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

@@ -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.
*