feat(ui): token-select primitive for large multi-selects

Adds tokenSelectForm() in core/Support/helpers/ui.php: an inline
typeahead combobox + flat alphabetical removable list, designed for
selections that outgrow the chip-header of the vendor MultiSelect
(roles, permissions, and similar admin pickers).

Contract:
- Hidden <select multiple name="<name>[]"> is the form submission source —
  consumers read via $request->body() verbatim, no parsing
- Items are ['id' => int, <labelKey> => string, 'key' => string?]
  where labelKeys default to ['description', 'label', 'name']; 'key' is
  an invisible fuzzy-match hint
- $labelOverrides lets callers swap emptyState / removeTooltip /
  noMatches / clearAll / countSuffix / errorMessage with domain copy
- $disabled renders pure-presentation list (no combobox, no remove)

Runtime:
- initTokenSelect in web/js/components/app-token-select.js is registered
  as 'token-select' in app-init.js; destroy()/cleanupFns contract
- Syncs the hidden <select> on every mutation and dispatches 'change'
  so dependent UI can react

Wired up: pages/admin/permissions/_form.phtml (assigned roles),
pages/admin/roles/_form.phtml (permissions + assignable roles).
Helper contract lives in tests/Support/Helpers/TokenSelectFormHelperTest.php;
runtime contract + entrypoint registration + host usage are enforced by
FrontendRuntime*ContractTest.php.

Coexists with multiSelectForm() — pick tokenSelectForm() when the
selected set can grow beyond ~10 items or typeahead is expected.
Docs in CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 23:52:32 +02:00
parent cfba3f40ed
commit b2982bdac7
12 changed files with 1336 additions and 5 deletions

View File

@@ -145,6 +145,9 @@
"Assigned permissions": "Zugewiesene Berechtigungen",
"Select permissions": "Berechtigungen auswählen",
"No permissions available": "Keine Berechtigungen verfügbar",
"No permissions selected": "Keine Berechtigungen ausgewählt",
"Remove permission": "Berechtigung entfernen",
"Search permissions…": "Berechtigungen suchen…",
"No active permissions are configured yet.": "Es sind noch keine aktiven Berechtigungen konfiguriert.",
"No active roles are configured yet.": "Es sind noch keine aktiven Rollen konfiguriert.",
"Permission denied": "Keine Berechtigung",
@@ -489,6 +492,9 @@
"Role": "Rolle",
"Roles": "Rollen",
"No roles available": "Keine Rollen verfügbar",
"No roles selected": "Keine Rollen ausgewählt",
"Remove role": "Rolle entfernen",
"Search roles…": "Rollen suchen…",
"Assignable roles": "Zuweisbare Rollen",
"Create role": "Rolle anlegen",
"Edit role": "Rolle bearbeiten",
@@ -1272,5 +1278,11 @@
"Hired": "Eingestellt",
"Primary": "Primär",
"Send email": "E-Mail senden",
"Summary": "Zusammenfassung"
"Summary": "Zusammenfassung",
"token_select.clear_all": "Alle entfernen",
"token_select.empty_state_title": "Keine Elemente ausgewählt",
"token_select.error_invalid_items": "Elemente konnten nicht geladen werden.",
"token_select.no_matches": "Keine Treffer",
"token_select.remove": "Entfernen",
"token_select.selected_count_suffix": "ausgewählt"
}

View File

@@ -145,6 +145,9 @@
"Assigned permissions": "Assigned permissions",
"Select permissions": "Select permissions",
"No permissions available": "No permissions available",
"No permissions selected": "No permissions selected",
"Remove permission": "Remove permission",
"Search permissions…": "Search permissions…",
"No active permissions are configured yet.": "No active permissions are configured yet.",
"No active roles are configured yet.": "No active roles are configured yet.",
"Permission denied": "Permission denied",
@@ -489,6 +492,9 @@
"Role": "Role",
"Roles": "Roles",
"No roles available": "No roles available",
"No roles selected": "No roles selected",
"Remove role": "Remove role",
"Search roles…": "Search roles…",
"Assignable roles": "Assignable roles",
"Create role": "Create role",
"Edit role": "Edit role",
@@ -1272,5 +1278,11 @@
"Hired": "Hired",
"Primary": "Primary",
"Send email": "Send email",
"Summary": "Summary"
"Summary": "Summary",
"token_select.clear_all": "Clear all",
"token_select.empty_state_title": "No items selected",
"token_select.error_invalid_items": "Items could not be loaded.",
"token_select.no_matches": "No matches",
"token_select.remove": "Remove",
"token_select.selected_count_suffix": "selected"
}