1
0

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

@@ -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 `<select multiple name="<name>[]">` is the form submission contract — the consuming action reads it verbatim via `$request->body('<name>', [])`, no parsing required.
- **Data contract:** each item is `['id' => int]` plus ONE of the keys listed in `$labelKeys` (default: `['description', 'label', 'name']`). Optional `'key' => string` is a hidden search hint that participates in fuzzy matching but is NOT displayed to the user.
- **Label overrides:** pass `$labelOverrides` (10th arg) with any of `emptyState`, `noMatches`, `removeTooltip`, `clearAll`, `countSuffix`, `errorMessage` to replace the generic defaults with domain-specific text. Values are already-translated strings (call `t(...)` at the call-site).
- **Readonly:** pass `$disabled = true` — combobox, clear-all and remove buttons are suppressed; the list renders as pure presentation.
- **Client:** `initTokenSelect` from `/js/components/app-token-select.js` is auto-registered as `token-select` in `app-init.js`. The component reads the hidden `<select>` 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 + `<i class="bi bi-x-lg">` — 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

View File

@@ -356,6 +356,226 @@ function multiSelectForm(
<?php
}
/**
* Render a token-select form field — combobox lookup on top, flat removable list
* below. Emits a hidden `<select multiple name="<name>[]">` (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<string,string> $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)) {
?>
<label>
<span><?php e(t($label)); ?></span>
</label>
<div class="app-token-select app-token-select--error" data-app-component="token-select-error">
<p class="app-token-select-error-message"><?php e($lblErrorMessage); ?></p>
</div>
<?php
return;
}
$readonlyFlag = $disabled ? '1' : '0';
$selectedIds = [];
foreach ($selected as $selId) {
$selectedIds[(int) $selId] = true;
}
// Build normalized row list (flat, alphabetically sorted). Invalid ids (<= 0) are dropped.
$rows = [];
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$itemId = (int) ($item['id'] ?? 0);
if ($itemId <= 0) {
continue;
}
$key = (string) ($item['key'] ?? '');
$rowLabel = '';
foreach ($labelKeyList as $labelKey) {
if (isset($item[$labelKey]) && (string) $item[$labelKey] !== '') {
$rowLabel = (string) $item[$labelKey];
break;
}
}
if ($rowLabel === '') {
$rowLabel = $key !== '' ? $key : ('#' . $itemId);
}
$rows[] = [
'id' => $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';
?>
<label for="<?php e($id); ?>">
<span><?php e(t($label)); ?></span>
</label>
<?php $wrapperClass = 'app-token-select' . ($disabled ? ' app-token-select--readonly' : ''); ?>
<div
class="<?php e($wrapperClass); ?>"
data-app-component="token-select"
data-token-select-id="<?php e($id); ?>"
data-token-select-field="<?php e($normalizedName); ?>"
data-readonly="<?php e($readonlyFlag); ?>"
data-label-remove="<?php e($lblRemoveTooltip); ?>"
data-label-empty-state="<?php e($lblEmptyState); ?>"
data-label-no-matches="<?php e($lblNoMatches); ?>"
>
<select
id="<?php e($id); ?>"
name="<?php e($normalizedName); ?>[]"
<?php if ($formId !== null): ?>form="<?php e($formId); ?>"<?php endif; ?>
multiple
data-token-select-hidden="1"
hidden
aria-hidden="true"
tabindex="-1"
<?php if ($disabled): ?>disabled<?php endif; ?>
>
<?php foreach ($rows as $row): ?>
<option
value="<?php e((string) $row['id']); ?>"
data-token-key="<?php e($row['key']); ?>"
data-token-label="<?php e($row['label']); ?>"
<?php if ($row['selected']): ?>selected<?php endif; ?>
><?php e($row['label']); ?></option>
<?php endforeach; ?>
</select>
<?php if (!$disabled): ?>
<div class="app-token-select-combobox">
<input
type="text"
class="app-token-select-input"
role="combobox"
aria-autocomplete="list"
aria-expanded="false"
aria-controls="<?php e($listboxId); ?>"
placeholder="<?php e(t($placeholder)); ?>"
autocomplete="off"
data-token-select-input
>
<ul
class="app-token-select-listbox"
role="listbox"
id="<?php e($listboxId); ?>"
data-token-select-listbox
hidden
></ul>
</div>
<?php endif; ?>
<div class="app-token-select-card">
<div class="app-token-select-meta">
<div class="app-token-select-meta-count">
<span class="app-token-select-meta-count-value" data-token-select-count><?php e((string) $selectedTotal); ?></span>
<span class="app-token-select-meta-count-label"><?php e($lblCountSuffix); ?></span>
</div>
<?php if (!$disabled): ?>
<button
type="button"
class="secondary outline small app-token-select-clear"
data-token-select-clear
<?php if (!$hasSelection): ?>hidden<?php endif; ?>
><?php e($lblClearAll); ?></button>
<?php endif; ?>
</div>
<ul class="app-token-select-rows" data-token-select-selected>
<?php if (!$hasSelection): ?>
<li
class="app-empty-state app-token-select-empty"
data-size="compact"
data-align="center"
data-token-select-empty
>
<p class="app-empty-state-message"><?php e($lblEmptyState); ?></p>
</li>
<?php else: ?>
<?php foreach ($selectedRows as $row): ?>
<li class="app-token-select-row" data-token-select-row="<?php e((string) $row['id']); ?>">
<span class="app-token-select-row-label"><?php e($row['label']); ?></span>
<?php if (!$disabled): ?>
<button
type="button"
class="app-icon-button app-token-select-row-remove"
data-token-select-remove="<?php e((string) $row['id']); ?>"
data-tooltip="<?php e($lblRemoveTooltip); ?>"
aria-label="<?php e($lblRemoveTooltip); ?>"
><i class="bi bi-x-lg" aria-hidden="true"></i></button>
<?php endif; ?>
</li>
<?php endforeach; ?>
<?php endif; ?>
</ul>
</div>
</div>
<?php
}
/**
* Render arbitrary HTML attributes from a key-value map.
*

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"
}

View File

@@ -60,7 +60,21 @@ $keyReadonlyAttr = ($isReadOnly || !empty($values['is_system'])) ? 'readonly' :
<?php if ($roles): ?>
<div data-tab-panel="roles">
<?php multiSelectForm('permission-roles', 'role_ids', 'Assigned roles', 'Select roles', $roles, $selectedRoleIds, $isReadOnly, 'description'); ?>
<?php tokenSelectForm(
'permission-roles',
'role_ids',
'Assigned roles',
'Search roles…',
$roles,
$selectedRoleIds,
$isReadOnly,
'description',
null,
[
'emptyState' => t('No roles selected'),
'removeTooltip' => t('Remove role'),
]
); ?>
</div>
<?php endif; ?>

View File

@@ -59,7 +59,21 @@ $selectedAssignableRoleIds = is_array($selectedAssignableRoleIds ?? null) ? $sel
<div data-tab-panel="permissions">
<?php if ($permissions): ?>
<?php multiSelectForm('role-permissions', 'permission_ids', 'Assigned permissions', 'Select permissions', $permissions, $selectedPermissionIds, $isReadOnly, ['description', 'key']); ?>
<?php tokenSelectForm(
'role-permissions',
'permission_ids',
'Assigned permissions',
'Search permissions…',
$permissions,
$selectedPermissionIds,
$isReadOnly,
['description', 'key'],
null,
[
'emptyState' => t('No permissions selected'),
'removeTooltip' => t('Remove permission'),
]
); ?>
<?php else: ?>
<?php
$emptyState = [
@@ -74,7 +88,21 @@ $selectedAssignableRoleIds = is_array($selectedAssignableRoleIds ?? null) ? $sel
<div data-tab-panel="assignable-roles">
<?php if ($allRolesForAssignable): ?>
<?php multiSelectForm('role-assignable-roles', 'assignable_role_ids', 'Assignable roles', 'Select roles', $allRolesForAssignable, $selectedAssignableRoleIds, $isReadOnly, 'description'); ?>
<?php tokenSelectForm(
'role-assignable-roles',
'assignable_role_ids',
'Assignable roles',
'Search roles…',
$allRolesForAssignable,
$selectedAssignableRoleIds,
$isReadOnly,
'description',
null,
[
'emptyState' => t('No roles selected'),
'removeTooltip' => t('Remove role'),
]
); ?>
<?php else: ?>
<?php
$emptyState = [

View File

@@ -48,6 +48,11 @@ class FrontendRuntimeComponentContractTest extends TestCase
$this->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

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -0,0 +1,373 @@
<?php
namespace MintyPHP\Tests\Support\Helpers;
use PHPUnit\Framework\TestCase;
/**
* Contract tests for the `tokenSelectForm()` PHP helper (GR-TEST-001).
*
* The helper emits a hidden <select multiple> (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<array{id: int, key: string, description: string}>
*/
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('<select', $html);
self::assertStringContainsString('name="permission_ids[]"', $html);
self::assertStringContainsString('multiple', $html);
self::assertStringContainsString('value="1"', $html);
self::assertStringContainsString('value="2"', $html);
self::assertStringContainsString('value="3"', $html);
// Only ids 1 and 3 must carry `selected`.
self::assertMatchesRegularExpression('/value="1"[^>]*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 <option>s), so its positions reflect the rendered row order.
$posAlpha = strpos($html, 'data-token-select-row="2"');
$posMike = strpos($html, 'data-token-select-row="3"');
$posZeta = strpos($html, 'data-token-select-row="1"');
self::assertIsInt($posAlpha);
self::assertIsInt($posMike);
self::assertIsInt($posZeta);
self::assertLessThan($posMike, $posAlpha);
self::assertLessThan($posZeta, $posMike);
// No grouping markers in output.
self::assertStringNotContainsString('app-token-select-group', $html);
self::assertStringNotContainsString('data-token-select-group', $html);
}
public function testLabelFallbackChainUsesKeyWhenDescriptionMissing(): void
{
$items = [
['id' => 7, 'key' => 'admin.users.view', 'description' => ''],
];
$html = $this->render(function () use ($items): void {
tokenSelectForm(
'role-permissions',
'permission_ids',
'Assigned permissions',
'Select permissions',
$items,
[7],
false,
['description', 'key']
);
});
// The row should display the key, since description is empty.
self::assertStringContainsString('admin.users.view', $html);
}
public function testInvalidItemsRendersErrorCard(): void
{
$html = $this->render(function (): void {
tokenSelectForm(
'role-permissions',
'permission_ids',
'Assigned permissions',
'Select permissions',
null,
[],
false,
['description', 'key']
);
});
self::assertStringContainsString('app-token-select--error', $html);
// t() returns the input key when no translation is loaded.
self::assertStringContainsString('token_select.error_invalid_items', $html);
self::assertStringNotContainsString('data-app-component="token-select"', $html);
}
public function testLabelOverridesReplaceDefaultI18nStrings(): void
{
$html = $this->render(function (): void {
tokenSelectForm(
'custom',
'tag_ids',
'Assigned tags',
'Search tags…',
$this->itemsFixture(),
[1],
false,
['description', 'key'],
null,
[
'emptyState' => 'No tags selected',
'noMatches' => 'No matching tags',
'removeTooltip' => 'Remove tag',
'clearAll' => 'Drop all tags',
'countSuffix' => 'tags applied',
]
);
});
self::assertStringContainsString('data-label-remove="Remove tag"', $html);
self::assertStringContainsString('data-label-empty-state="No tags selected"', $html);
self::assertStringContainsString('data-label-no-matches="No matching tags"', $html);
self::assertStringContainsString('>Drop all tags<', $html);
self::assertStringContainsString('tags applied', $html);
// Default i18n key must NOT leak through when an override is provided.
self::assertStringNotContainsString('token_select.remove', $html);
self::assertStringNotContainsString('token_select.empty_state_title', $html);
}
public function testLabelOverridesForErrorCardReplaceDefault(): void
{
$html = $this->render(function (): void {
tokenSelectForm(
'custom',
'tag_ids',
'Assigned tags',
'Search tags…',
null,
[],
false,
['description', 'key'],
null,
['errorMessage' => 'Tags could not be loaded.']
);
});
self::assertStringContainsString('Tags could not be loaded.', $html);
self::assertStringNotContainsString('token_select.error_invalid_items', $html);
}
public function testAcceptsMinimalItemShape(): void
{
// Generic data contract: id + one labelKey is enough; `key` is optional.
$items = [
['id' => 10, 'label' => 'First item'],
['id' => 20, 'label' => 'Second item'],
];
$html = $this->render(function () use ($items): void {
tokenSelectForm(
'generic',
'item_ids',
'Items',
'Search…',
$items,
[10],
false,
['label']
);
});
self::assertStringContainsString('value="10"', $html);
self::assertStringContainsString('value="20"', $html);
self::assertMatchesRegularExpression('/value="10"[^>]*selected/', $html);
self::assertStringContainsString('First item', $html);
// Without a `key`, hidden options carry an empty `data-token-key` attribute.
self::assertMatchesRegularExpression('/value="10"[^>]*data-token-key=""/', $html);
}
public function testEscapesHostileLabelsAndKeys(): void
{
// Protection against GR-SEC-010: every user-facing value must flow through e().
$items = [
['id' => 1, 'key' => '<img src=x onerror=alert(1)>', 'description' => '"><svg onload=alert(1)>'],
];
$html = $this->render(function () use ($items): void {
tokenSelectForm(
'hostile',
'ids',
'Things',
'Search…',
$items,
[1],
false,
['description']
);
});
// Raw attack payloads must NOT appear unescaped.
self::assertStringNotContainsString('<img src=x', $html);
self::assertStringNotContainsString('<svg onload', $html);
// Escaped forms must be present.
self::assertStringContainsString('&lt;img src=x', $html);
self::assertStringContainsString('&lt;svg onload', $html);
}
public function testTwoInstancesOnSamePageProduceUniqueListboxIds(): void
{
$html = $this->render(function (): void {
tokenSelectForm('perms', 'permission_ids', 'Permissions', 'Search…', $this->itemsFixture(), [1], false, ['description']);
tokenSelectForm('roles', 'role_ids', 'Roles', 'Search…', $this->itemsFixture(), [2], false, ['description']);
});
self::assertStringContainsString('id="perms-listbox"', $html);
self::assertStringContainsString('id="roles-listbox"', $html);
self::assertStringContainsString('aria-controls="perms-listbox"', $html);
self::assertStringContainsString('aria-controls="roles-listbox"', $html);
self::assertStringContainsString('data-token-select-id="perms"', $html);
self::assertStringContainsString('data-token-select-id="roles"', $html);
}
public function testServerRendersTranslatedLabelsOnHost(): void
{
// The `data-label-*` attributes must carry server-translated strings so
// JS re-renders (remove, empty-state, no-matches) read them back verbatim
// instead of rendering i18n key literals.
$html = $this->render(function (): void {
tokenSelectForm(
'token',
'ids',
'Assigned things',
'Search things…',
$this->itemsFixture(),
[1],
false,
['description'],
null,
[
'emptyState' => 'No things here',
'removeTooltip' => 'Drop this thing',
'noMatches' => 'Nothing matches',
]
);
});
self::assertStringContainsString('data-label-remove="Drop this thing"', $html);
self::assertStringContainsString('data-label-empty-state="No things here"', $html);
self::assertStringContainsString('data-label-no-matches="Nothing matches"', $html);
}
public function testEmptyItemsRendersEmptyState(): void
{
$html = $this->render(function (): void {
tokenSelectForm(
'role-permissions',
'permission_ids',
'Assigned permissions',
'Select permissions',
[],
[],
false,
['description', 'key']
);
});
self::assertStringContainsString('data-token-select-empty', $html);
self::assertStringContainsString('data-app-component="token-select"', $html);
// Combobox input is still rendered so the user can add items from the empty state.
self::assertStringContainsString('data-token-select-input', $html);
}
}

View File

@@ -0,0 +1,196 @@
@layer components {
/*
* Token-select primitive — combobox lookup + flat removable list rendered
* inside a details-card-style bordered wrapper. Reusable core UI component
* for multi-selects with large selectable sets. Rendered via
* `tokenSelectForm()` (core/Support/helpers/ui.php) + initialized by
* `initTokenSelect` (web/js/components/app-token-select.js).
* Coexists with the legacy vendor `app-multiselect-*` primitive.
*/
.app-token-select {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.app-token-select--error .app-token-select-error-message {
color: var(--app-color-danger, #b91c1c);
font-size: var(--text-sm);
margin: 0;
}
/* Combobox ------------------------------------------------------------ */
.app-token-select-combobox {
position: relative;
}
.app-token-select-input {
width: 100%;
margin-bottom: 0;
font-size: var(--text-sm);
}
.app-token-select-listbox {
list-style: none;
margin: 0.25rem 0 0;
padding: 0.25rem;
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 10;
max-height: 18rem;
overflow-y: auto;
background: var(--app-card-background-color, #fff);
border: 1px solid var(--app-muted-border-color, rgba(148, 163, 184, 0.35));
border-radius: var(--app-border-radius, 0.375rem);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.1);
}
.app-token-select-listbox[hidden] {
display: none;
}
.app-token-select-listbox-option {
padding: 0.4rem 0.5rem;
border-radius: calc(var(--app-border-radius, 0.375rem) - 2px);
cursor: pointer;
}
.app-token-select-listbox-option.is-active,
.app-token-select-listbox-option:hover {
background: var(
--app-card-sectioning-background-color,
rgba(148, 163, 184, 0.1)
);
}
.app-token-select-listbox-label {
font-size: var(--text-sm);
font-weight: var(--font-regular);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-token-select-listbox-empty {
/* inherits from .app-empty-state — keep margin tight inside the popover */
margin: 0.25rem 0;
}
/* Selection card — details-card-style bordered wrapper around meta + rows */
.app-token-select-card {
border: 1px solid var(--app-card-border-color, var(--app-muted-border-color, rgba(148, 163, 184, 0.3)));
border-radius: var(--app-border-radius, 0.375rem);
background: var(--app-card-background-color, #fff);
overflow: hidden;
}
.app-token-select-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: calc(var(--app-spacing) * 0.55) calc(var(--app-spacing) * 0.85);
background: var(--app-card-sectioning-background-color, rgba(148, 163, 184, 0.06));
border-bottom: 1px solid var(--app-card-border-color, var(--app-muted-border-color, rgba(148, 163, 184, 0.25)));
}
.app-token-select-meta-count {
display: inline-flex;
align-items: baseline;
gap: 0.3rem;
font-size: var(--text-xs);
color: var(--app-color-muted, #6b7280);
letter-spacing: var(--tracking-wide);
}
.app-token-select-meta-count-value {
font-variant-numeric: tabular-nums;
font-weight: var(--font-medium);
color: var(--app-color, inherit);
font-size: var(--text-sm);
}
.app-token-select-meta-count-label {
text-transform: uppercase;
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider);
}
.app-token-select-clear {
margin-bottom: 0;
}
/* Selected list (flat, alphabetical, inside the card body) ----------- */
.app-token-select-rows {
list-style: none;
margin: 0;
padding: 0 calc(var(--app-spacing) * 0.85);
display: flex;
flex-direction: column;
}
.app-token-select-empty {
/* inherits from .app-empty-state — breathing room inside the card body */
margin: calc(var(--app-spacing) * 0.5) 0;
}
.app-token-select-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.75rem;
padding: 0;
border-bottom: 1px solid
var(--app-muted-border-color, rgba(148, 163, 184, 0.12));
margin: 0;
}
.app-token-select-row:last-child {
border-bottom: none;
}
.app-token-select-row-label {
font-size: var(--text-sm);
font-weight: var(--font-regular);
line-height: var(--leading-snug);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--app-color, inherit);
}
.app-token-select-row-remove {
margin-bottom: 0;
opacity: 0;
transition: opacity 0.12s ease;
}
.app-token-select-row:hover .app-token-select-row-remove,
.app-token-select-row:focus-within .app-token-select-row-remove,
.app-token-select-row-remove:focus-visible {
opacity: 1;
}
.app-token-select--readonly .app-token-select-row-remove {
display: none;
}
.app-token-select--readonly .app-token-select-row {
grid-template-columns: minmax(0, 1fr);
}
@media (max-width: 720px) {
/* Touch devices: keep remove visible since hover doesn't fire reliably */
.app-token-select-row-remove {
opacity: 1;
}
}
}

View File

@@ -0,0 +1,455 @@
/**
* Token-select host component — combobox lookup + flat removable list.
*
* Reads a hidden <select multiple> as source of truth. Shows an inline typeahead
* popover (no drawer, no grouping). Selected items render as a flat alphabetical
* list below with a per-row remove button.
*/
import { resolveHost, warnOnce } from '../core/app-dom.js';
// Last-resort fallback labels for JS-rendered text. Normally the PHP helper
// ships translated strings via `data-label-*` attributes on the host — these
// defaults only surface if the host is mounted without server-rendered labels.
const LABEL_KEYS = {
remove: 'token_select.remove',
emptyState: 'token_select.empty_state_title',
noMatches: 'token_select.no_matches',
};
const MAX_RESULTS = 50;
const readItems = (select) => {
if (!(select instanceof HTMLSelectElement)) {
return [];
}
return Array.from(select.options)
.map((option) => ({
id: option.value,
key: option.dataset.tokenKey || '',
label: option.dataset.tokenLabel || option.textContent || option.value,
selected: option.selected === true,
}))
.filter((item) => item.id !== '');
};
export function initTokenSelect(root = document, options = {}) {
const host = resolveHost(root);
if (!(host instanceof HTMLElement)) {
return { destroy: () => {} };
}
const hostEl = host.matches?.('[data-app-component="token-select"]')
? host
: host.querySelector('[data-app-component="token-select"]');
if (!(hostEl instanceof HTMLElement)) {
return { destroy: () => {} };
}
if (hostEl.dataset.tokenSelectBound === '1') {
return { destroy: () => {} };
}
hostEl.dataset.tokenSelectBound = '1';
const select = hostEl.querySelector('select[data-token-select-hidden]');
if (!(select instanceof HTMLSelectElement)) {
warnOnce('UI_EL_MISSING', 'token-select hidden <select> missing', { module: 'token-select' });
return { destroy: () => {} };
}
const isReadOnly = hostEl.dataset.readonly === '1' || select.disabled === true;
const input = hostEl.querySelector('[data-token-select-input]');
const listbox = hostEl.querySelector('[data-token-select-listbox]');
const listContainer = hostEl.querySelector('[data-token-select-selected]');
const countEl = hostEl.querySelector('[data-token-select-count]');
const clearBtn = hostEl.querySelector('[data-token-select-clear]');
if (!(listContainer instanceof HTMLElement)) {
return { destroy: () => {} };
}
const labels = (options && typeof options === 'object' && options.labels) || {};
const datasetLabels = {
remove: hostEl.dataset.labelRemove,
emptyState: hostEl.dataset.labelEmptyState,
noMatches: hostEl.dataset.labelNoMatches,
};
const resolveLabel = (name) => {
const fromDataset = datasetLabels[name];
if (typeof fromDataset === 'string' && fromDataset.length > 0) {
return fromDataset;
}
const override = labels[name];
if (typeof override === 'string' && override.length > 0) {
return override;
}
return LABEL_KEYS[name] || name;
};
const cleanupFns = [];
const bind = (target, eventName, handler, opts) => {
target.addEventListener(eventName, handler, opts);
cleanupFns.push(() => target.removeEventListener(eventName, handler, opts));
};
let activeIndex = -1;
let currentMatches = [];
const fireChange = () => {
select.dispatchEvent(new Event('change', { bubbles: true }));
};
const setSelected = (id, next) => {
const option = select.querySelector(`option[value="${CSS.escape(String(id))}"]`);
if (option instanceof HTMLOptionElement && option.selected !== next) {
option.selected = next;
}
};
const sortByLabel = (a, b) => String(a.label).localeCompare(String(b.label));
const filterItems = (query) => {
const items = readItems(select);
const unselected = items.filter((it) => !it.selected).sort(sortByLabel);
const q = String(query || '').trim().toLowerCase();
if (q === '') {
return unselected.slice(0, MAX_RESULTS);
}
return unselected
.filter((it) => (it.label + ' ' + it.key).toLowerCase().indexOf(q) !== -1)
.slice(0, MAX_RESULTS);
};
const buildListboxOption = (item, index) => {
const li = document.createElement('li');
li.className = 'app-token-select-listbox-option';
li.setAttribute('role', 'option');
li.setAttribute('data-token-select-option', item.id);
li.id = hostEl.dataset.tokenSelectId + '-option-' + index;
const labelSpan = document.createElement('span');
labelSpan.className = 'app-token-select-listbox-label';
labelSpan.textContent = item.label;
li.appendChild(labelSpan);
return li;
};
const renderListbox = () => {
if (!(listbox instanceof HTMLElement)) {
return;
}
const query = input instanceof HTMLInputElement ? input.value : '';
currentMatches = filterItems(query);
activeIndex = currentMatches.length > 0 ? 0 : -1;
listbox.textContent = '';
if (currentMatches.length === 0) {
const emptyLi = document.createElement('li');
emptyLi.className = 'app-empty-state app-token-select-listbox-empty';
emptyLi.setAttribute('data-size', 'small');
emptyLi.setAttribute('data-align', 'center');
emptyLi.setAttribute('role', 'option');
emptyLi.setAttribute('aria-disabled', 'true');
const msg = document.createElement('p');
msg.className = 'app-empty-state-message';
msg.textContent = resolveLabel('noMatches');
emptyLi.appendChild(msg);
listbox.appendChild(emptyLi);
return;
}
currentMatches.forEach((item, index) => {
const option = buildListboxOption(item, index);
if (index === activeIndex) {
option.classList.add('is-active');
option.setAttribute('aria-selected', 'true');
if (input instanceof HTMLInputElement) {
input.setAttribute('aria-activedescendant', option.id);
}
} else {
option.setAttribute('aria-selected', 'false');
}
listbox.appendChild(option);
});
};
const isListboxOpen = () => listbox instanceof HTMLElement && !listbox.hasAttribute('hidden');
const openListbox = () => {
if (!(listbox instanceof HTMLElement) || !(input instanceof HTMLInputElement)) {
return;
}
renderListbox();
listbox.removeAttribute('hidden');
input.setAttribute('aria-expanded', 'true');
};
const closeListbox = () => {
if (!(listbox instanceof HTMLElement) || !(input instanceof HTMLInputElement)) {
return;
}
listbox.setAttribute('hidden', '');
input.setAttribute('aria-expanded', 'false');
input.removeAttribute('aria-activedescendant');
activeIndex = -1;
};
const setActive = (next) => {
if (!(listbox instanceof HTMLElement)) {
return;
}
const opts = listbox.querySelectorAll('[data-token-select-option]');
if (opts.length === 0) {
return;
}
const normalized = ((next % opts.length) + opts.length) % opts.length;
activeIndex = normalized;
opts.forEach((opt, index) => {
if (index === normalized) {
opt.classList.add('is-active');
opt.setAttribute('aria-selected', 'true');
opt.scrollIntoView({ block: 'nearest' });
if (input instanceof HTMLInputElement) {
input.setAttribute('aria-activedescendant', opt.id);
}
} else {
opt.classList.remove('is-active');
opt.setAttribute('aria-selected', 'false');
}
});
};
const addById = (id) => {
if (typeof id !== 'string' || id === '') {
return;
}
setSelected(id, true);
if (input instanceof HTMLInputElement) {
input.value = '';
input.focus();
}
fireChange();
};
const addActive = () => {
if (activeIndex < 0 || activeIndex >= currentMatches.length) {
return;
}
addById(String(currentMatches[activeIndex].id));
};
const removeById = (id) => {
setSelected(id, false);
fireChange();
};
const clearAll = () => {
Array.from(select.options).forEach((opt) => {
opt.selected = false;
});
fireChange();
};
const renderSelected = () => {
const items = readItems(select).filter((it) => it.selected).sort(sortByLabel);
listContainer.textContent = '';
if (items.length === 0) {
const emptyLi = document.createElement('li');
emptyLi.className = 'app-empty-state app-token-select-empty';
emptyLi.setAttribute('data-size', 'compact');
emptyLi.setAttribute('data-align', 'center');
emptyLi.setAttribute('data-token-select-empty', '');
const msg = document.createElement('p');
msg.className = 'app-empty-state-message';
msg.textContent = resolveLabel('emptyState');
emptyLi.appendChild(msg);
listContainer.appendChild(emptyLi);
} else {
items.forEach((item) => {
const li = document.createElement('li');
li.className = 'app-token-select-row';
li.setAttribute('data-token-select-row', String(item.id));
const labelSpan = document.createElement('span');
labelSpan.className = 'app-token-select-row-label';
labelSpan.textContent = item.label;
li.appendChild(labelSpan);
if (!isReadOnly) {
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'app-icon-button app-token-select-row-remove';
removeBtn.setAttribute('data-token-select-remove', String(item.id));
const removeLabel = resolveLabel('remove');
removeBtn.setAttribute('aria-label', removeLabel);
removeBtn.setAttribute('data-tooltip', removeLabel);
const icon = document.createElement('i');
icon.className = 'bi bi-x-lg';
icon.setAttribute('aria-hidden', 'true');
removeBtn.appendChild(icon);
li.appendChild(removeBtn);
}
listContainer.appendChild(li);
});
}
if (countEl instanceof HTMLElement) {
countEl.textContent = String(items.length);
}
if (clearBtn instanceof HTMLElement) {
if (items.length > 0) {
clearBtn.removeAttribute('hidden');
} else {
clearBtn.setAttribute('hidden', '');
}
}
};
const render = () => {
renderSelected();
if (isListboxOpen()) {
renderListbox();
}
};
// Combobox wiring (skipped in readonly mode where no input is rendered).
if (!isReadOnly && input instanceof HTMLInputElement && listbox instanceof HTMLElement) {
bind(input, 'focus', () => openListbox());
bind(input, 'input', () => {
if (!isListboxOpen()) {
openListbox();
} else {
renderListbox();
}
});
bind(input, 'keydown', (event) => {
if (event.key === 'ArrowDown') {
event.preventDefault();
if (!isListboxOpen()) {
openListbox();
} else {
setActive(activeIndex + 1);
}
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
if (!isListboxOpen()) {
openListbox();
setActive(currentMatches.length - 1);
} else {
setActive(activeIndex - 1);
}
return;
}
if (event.key === 'Enter') {
if (isListboxOpen() && activeIndex >= 0) {
event.preventDefault();
addActive();
}
return;
}
if (event.key === 'Escape') {
if (isListboxOpen()) {
event.preventDefault();
closeListbox();
}
return;
}
if (event.key === 'Backspace' && input.value === '') {
const selectedItems = readItems(select).filter((it) => it.selected).sort(sortByLabel);
if (selectedItems.length === 0) {
return;
}
event.preventDefault();
removeById(String(selectedItems[selectedItems.length - 1].id));
}
});
// Keep input focused when clicking a listbox option.
bind(listbox, 'mousedown', (event) => {
const target = event.target;
if (target instanceof Element && target.closest('[data-token-select-option]')) {
event.preventDefault();
}
});
bind(listbox, 'click', (event) => {
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const option = target.closest('[data-token-select-option]');
if (option instanceof HTMLElement) {
event.preventDefault();
addById(option.getAttribute('data-token-select-option') || '');
}
});
bind(listbox, 'mousemove', (event) => {
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const option = target.closest('[data-token-select-option]');
if (!(option instanceof HTMLElement)) {
return;
}
const opts = Array.from(listbox.querySelectorAll('[data-token-select-option]'));
const nextIndex = opts.indexOf(option);
if (nextIndex !== -1 && nextIndex !== activeIndex) {
setActive(nextIndex);
}
});
// Close on outside pointer-down (fires before blur → keeps focus when clicking inside).
const onDocumentPointerDown = (event) => {
if (!isListboxOpen()) {
return;
}
const target = event.target;
if (target instanceof Node && !hostEl.contains(target)) {
closeListbox();
}
};
bind(document, 'mousedown', onDocumentPointerDown);
bind(document, 'touchstart', onDocumentPointerDown, { passive: true });
}
// Host-level clicks for remove + clear (also works when only these exist, e.g. readonly has neither).
bind(hostEl, 'click', (event) => {
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const removeBtn = target.closest('[data-token-select-remove]');
if (removeBtn instanceof HTMLElement) {
event.preventDefault();
removeById(removeBtn.getAttribute('data-token-select-remove') || '');
return;
}
if (target.closest('[data-token-select-clear]')) {
event.preventDefault();
clearAll();
}
});
bind(select, 'change', render);
render();
const destroy = () => {
cleanupFns.forEach((fn) => {
try { fn(); } catch (_error) { /* swallow */ }
});
cleanupFns.length = 0;
delete hostEl.dataset.tokenSelectBound;
};
return { destroy };
}
export default initTokenSelect;