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:
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user