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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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; ?>
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
373
tests/Support/Helpers/TokenSelectFormHelperTest.php
Normal file
373
tests/Support/Helpers/TokenSelectFormHelperTest.php
Normal 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('<img src=x', $html);
|
||||
self::assertStringContainsString('<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);
|
||||
}
|
||||
}
|
||||
196
web/css/components/app-token-select.css
Normal file
196
web/css/components/app-token-select.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
455
web/js/components/app-token-select.js
Normal file
455
web/js/components/app-token-select.js
Normal 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;
|
||||
Reference in New Issue
Block a user