From b2982bdac7f1bda9b2ec79e073396a42171fb6be Mon Sep 17 00:00:00 2001 From: fs Date: Thu, 23 Apr 2026 23:52:32 +0200 Subject: [PATCH] feat(ui): token-select primitive for large multi-selects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- CLAUDE.md | 9 + core/Support/helpers/ui.php | 220 +++++++++ i18n/default_de.json | 14 +- i18n/default_en.json | 14 +- pages/admin/permissions/_form.phtml | 16 +- pages/admin/roles/_form.phtml | 32 +- .../FrontendRuntimeComponentContractTest.php | 5 + .../FrontendRuntimeEntrypointContractTest.php | 1 + .../FrontendRuntimeHostContractTest.php | 6 + .../Helpers/TokenSelectFormHelperTest.php | 373 ++++++++++++++ web/css/components/app-token-select.css | 196 ++++++++ web/js/components/app-token-select.js | 455 ++++++++++++++++++ 12 files changed, 1336 insertions(+), 5 deletions(-) create mode 100644 tests/Support/Helpers/TokenSelectFormHelperTest.php create mode 100644 web/css/components/app-token-select.css create mode 100644 web/js/components/app-token-select.js diff --git a/CLAUDE.md b/CLAUDE.md index 208634b..3cfc968 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -182,6 +182,15 @@ docker/ # Dockerfiles, Nginx configs, PHP configs - **`fetchUrl` signature:** write it as a single-expression arrow (not a block body) so the architecture test can statically locate the fragment path. Pattern: ``(uuid) => new URL(`path/${uuid}...`, appBase).toString()``. - **Architecture test** `DetailDrawerFragmentContractTest` enforces that every `initDetailDrawer({ fetchUrl })` has matching `*-fragment($id).php` + `*-fragment(none).phtml`. - **Filename rule:** URL parameters go only into the `.php` action filename (`*-fragment($id).php`). The `(none).phtml` view filename must NOT contain `($id)` — MintyPHP's router parses paren groups and breaks silently on nested ones. +- **Token-select field** (combobox + flat removable list): use `tokenSelectForm()` from `core/Support/helpers/ui.php` for any multi-select where the selected set is potentially large and a chip-header or full dropdown becomes unreadable. The primitive renders an inline typeahead input with popover suggestions and a flat alphabetical list of selected items with per-row remove. No drawer, no grouping — keep callers flat. + - **Server:** `tokenSelectForm($id, $name, $label, $placeholder, $items, $selected, $disabled, $labelKeys, $formId, $labelOverrides)`. Hidden `` as source of truth, syncs it on every add/remove, and dispatches a native `change` event that callers can listen to for dependent UI. + - **Remove button:** uses the shared `.app-icon-button` primitive + `` — never hand-roll a close button. + - **Coexistence:** `multiSelectForm()` (vendor MultiSelect) remains available for small closed lists where the chip-header is acceptable. Pick `tokenSelectForm()` when the selected set can grow beyond ~10 items or typeahead is the expected interaction. + - **Tests:** helper contract lives in `tests/Support/Helpers/TokenSelectFormHelperTest.php`; component lifecycle + entrypoint registration are enforced by `tests/Architecture/FrontendRuntime*ContractTest.php`. ### Never Do This diff --git a/core/Support/helpers/ui.php b/core/Support/helpers/ui.php index 56b0e66..8f36577 100644 --- a/core/Support/helpers/ui.php +++ b/core/Support/helpers/ui.php @@ -356,6 +356,226 @@ function multiSelectForm( []">` (source of truth for + * the form POST) plus a progressive-enhancement host that the `token-select` + * runtime component upgrades into an interactive typeahead. + * + * Generic core primitive. Data contract per item: + * ['id' => int, ...one of $labelKeys, 'key' => string (optional hidden search hint)] + * + * Call-sites may override the user-visible default strings via $labelOverrides. + * Supported keys (all optional): + * - 'emptyState' default: `token_select.empty_state_title` + * - 'noMatches' default: `token_select.no_matches` + * - 'removeTooltip' default: `token_select.remove` + * - 'clearAll' default: `token_select.clear_all` + * - 'countSuffix' default: `token_select.selected_count_suffix` + * - 'errorMessage' default: `token_select.error_invalid_items` + * Values are treated as already-translated strings (call `t(...)` at the call-site). + * + * @param string $id Element ID + * @param string $name Form field name (e.g. `permission_ids`) — posted as array + * @param string $label Field label (will be translated) + * @param string $placeholder Input placeholder (will be translated) + * @param array|null $items List of items; null/non-array renders an error card + * @param array $selected Array of selected item IDs + * @param bool $disabled Whether the field is readonly / disabled + * @param string|array $labelKeys Key(s) to use for the row label (tries in order, falls back to `key`) + * @param string|null $formId Form ID for the hidden select when rendered outside the form + * @param array $labelOverrides Per-call-site label overrides (already translated) + */ +function tokenSelectForm( + string $id, + string $name, + string $label, + string $placeholder, + mixed $items, + array $selected, + bool $disabled = false, + string|array $labelKeys = ['description', 'label', 'name'], + ?string $formId = null, + array $labelOverrides = [] +): void { + $normalizedName = preg_replace('/\[\]$/', '', $name) ?: $name; + $labelKeyList = is_array($labelKeys) ? $labelKeys : [$labelKeys]; + + // Label resolution: override wins over default i18n key. + $resolve = static function (string $overrideKey, string $i18nKey) use ($labelOverrides): string { + $override = $labelOverrides[$overrideKey] ?? null; + if (is_string($override) && $override !== '') { + return $override; + } + return t($i18nKey); + }; + $lblEmptyState = $resolve('emptyState', 'token_select.empty_state_title'); + $lblNoMatches = $resolve('noMatches', 'token_select.no_matches'); + $lblRemoveTooltip = $resolve('removeTooltip', 'token_select.remove'); + $lblClearAll = $resolve('clearAll', 'token_select.clear_all'); + $lblCountSuffix = $resolve('countSuffix', 'token_select.selected_count_suffix'); + $lblErrorMessage = $resolve('errorMessage', 'token_select.error_invalid_items'); + + // Guard: null or non-array $items renders a user-visible error card (SC-010). + if (!is_array($items)) { + ?> + +
+

+
+ $itemId, + 'key' => $key, + 'label' => $rowLabel, + 'selected' => isset($selectedIds[$itemId]), + ]; + } + + $selectedRows = array_values(array_filter($rows, static fn (array $row): bool => $row['selected'])); + usort($selectedRows, static fn (array $a, array $b): int => strcasecmp($a['label'], $b['label'])); + $hasSelection = $selectedRows !== []; + $selectedTotal = count($selectedRows); + $listboxId = $id . '-listbox'; + ?> + + +
+ + + +
+ + +
+ + +
+
+
+ + +
+ + + +
+ +
    + +
  • +

    +
  • + + +
  • + + + + +
  • + + +
+
+
+
- + t('No roles selected'), + 'removeTooltip' => t('Remove role'), + ] + ); ?>
diff --git a/pages/admin/roles/_form.phtml b/pages/admin/roles/_form.phtml index 5742d18..568feb9 100644 --- a/pages/admin/roles/_form.phtml +++ b/pages/admin/roles/_form.phtml @@ -59,7 +59,21 @@ $selectedAssignableRoleIds = is_array($selectedAssignableRoleIds ?? null) ? $sel
- + t('No permissions selected'), + 'removeTooltip' => t('Remove permission'), + ] + ); ?> - + t('No roles selected'), + 'removeTooltip' => t('Remove role'), + ] + ); ?> assertStringContainsString('return {', $multiSelect); $this->assertStringContainsString('destroy: () => {', $multiSelect); + + $tokenSelect = $this->readProjectFile('web/js/components/app-token-select.js'); + + $this->assertStringContainsString('const destroy = () => {', $tokenSelect); + $this->assertStringContainsString('cleanupFns.push(', $tokenSelect); } public function testRuntimeStorageContractUsesSharedUiStorageHelper(): void diff --git a/tests/Architecture/FrontendRuntimeEntrypointContractTest.php b/tests/Architecture/FrontendRuntimeEntrypointContractTest.php index a97181a..6eebdb7 100644 --- a/tests/Architecture/FrontendRuntimeEntrypointContractTest.php +++ b/tests/Architecture/FrontendRuntimeEntrypointContractTest.php @@ -20,6 +20,7 @@ class FrontendRuntimeEntrypointContractTest extends TestCase $this->assertStringContainsString("runtime.register('sidebar-toggle', initSidebarToggle", $defaultEntry); $this->assertStringContainsString("runtime.register('global-search', initGlobalSearch", $defaultEntry); + $this->assertStringContainsString("runtime.register('token-select', initTokenSelect", $defaultEntry); $this->assertStringContainsString("await mountModuleComponentsByPhase('late');", $defaultEntry); $this->assertStringContainsString('moduleRuntimeComponents', $defaultEntry); $this->assertStringContainsString("selector: '[data-app-component=\"tabs\"]'", $defaultEntry); diff --git a/tests/Architecture/FrontendRuntimeHostContractTest.php b/tests/Architecture/FrontendRuntimeHostContractTest.php index 6776782..b040b2a 100644 --- a/tests/Architecture/FrontendRuntimeHostContractTest.php +++ b/tests/Architecture/FrontendRuntimeHostContractTest.php @@ -57,4 +57,10 @@ class FrontendRuntimeHostContractTest extends TestCase $this->assertStringContainsString('data-app-component="tabs"', $content, $file); } } + + public function testRolesFormUsesTokenSelectHost(): void + { + $rolesForm = $this->readProjectFile('pages/admin/roles/_form.phtml'); + $this->assertStringContainsString('tokenSelectForm(', $rolesForm); + } } diff --git a/tests/Support/Helpers/TokenSelectFormHelperTest.php b/tests/Support/Helpers/TokenSelectFormHelperTest.php new file mode 100644 index 0000000..5999a1b --- /dev/null +++ b/tests/Support/Helpers/TokenSelectFormHelperTest.php @@ -0,0 +1,373 @@ + (source of truth for POST) plus + * a progressive-enhancement host that the `token-select` runtime component + * upgrades into an interactive combobox lookup with a flat removable list. + */ +class TokenSelectFormHelperTest extends TestCase +{ + /** + * @return list + */ + private function itemsFixture(): array + { + return [ + ['id' => 1, 'key' => 'admin.users.view', 'description' => 'View users'], + ['id' => 2, 'key' => 'admin.users.edit', 'description' => 'Edit users'], + ['id' => 3, 'key' => 'billing.invoices.read', 'description' => 'Read invoices'], + ]; + } + + private function render(callable $call): string + { + ob_start(); + try { + $call(); + } finally { + $output = ob_get_clean(); + } + + return $output === false ? '' : $output; + } + + public function testRendersHiddenSelectWithSelectedOptions(): void + { + $items = $this->itemsFixture(); + $html = $this->render(function () use ($items): void { + tokenSelectForm( + 'role-permissions', + 'permission_ids', + 'Assigned permissions', + 'Select permissions', + $items, + [1, 3], + false, + ['description', 'key'] + ); + }); + + self::assertStringContainsString(']*selected/', $html); + self::assertMatchesRegularExpression('/value="3"[^>]*selected/', $html); + self::assertDoesNotMatchRegularExpression('/value="2"[^>]*selected/', $html); + } + + public function testRendersHostDataAttributeAndCombobox(): void + { + $html = $this->render(function (): void { + tokenSelectForm( + 'role-permissions', + 'permission_ids', + 'Assigned permissions', + 'Select permissions', + $this->itemsFixture(), + [1], + false, + ['description', 'key'] + ); + }); + + self::assertStringContainsString('data-app-component="token-select"', $html); + self::assertStringContainsString('data-readonly="0"', $html); + self::assertStringContainsString('data-token-select-id="role-permissions"', $html); + self::assertStringContainsString('data-token-select-field="permission_ids"', $html); + // Combobox input + listbox must be present in non-readonly mode. + self::assertStringContainsString('role="combobox"', $html); + self::assertStringContainsString('data-token-select-input', $html); + self::assertStringContainsString('data-token-select-listbox', $html); + self::assertStringContainsString('id="role-permissions-listbox"', $html); + self::assertStringContainsString('aria-controls="role-permissions-listbox"', $html); + } + + public function testReadonlyModeHidesInteractiveAffordances(): void + { + $html = $this->render(function (): void { + tokenSelectForm( + 'role-permissions', + 'permission_ids', + 'Assigned permissions', + 'Select permissions', + $this->itemsFixture(), + [1, 2], + true, + ['description', 'key'] + ); + }); + + self::assertStringContainsString('data-readonly="1"', $html); + self::assertStringContainsString('app-token-select--readonly', $html); + // No combobox input, no clear, no remove in readonly. + self::assertStringNotContainsString('data-token-select-input', $html); + self::assertStringNotContainsString('data-token-select-clear', $html); + self::assertStringNotContainsString('data-token-select-remove', $html); + self::assertStringNotContainsString('role="combobox"', $html); + } + + public function testSelectedRowsAreFlatAndAlphabeticallySorted(): void + { + $items = [ + ['id' => 1, 'key' => 'zulu.task', 'description' => 'Zeta action'], + ['id' => 2, 'key' => 'alpha.task', 'description' => 'Alpha action'], + ['id' => 3, 'key' => 'mike.task', 'description' => 'Mike action'], + ]; + $html = $this->render(function () use ($items): void { + tokenSelectForm( + 'role-permissions', + 'permission_ids', + 'Assigned permissions', + 'Select permissions', + $items, + [1, 2, 3], + false, + ['description', 'key'] + ); + }); + + // Alphabetical by label: Alpha (id=2) < Mike (id=3) < Zeta (id=1). + // The `data-token-select-row` attribute only appears on the visible selected rows + // (not on the hidden