Files
breadcrumb-the-shire/web/js/components/app-token-select.js
fs b2982bdac7 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>
2026-04-23 23:52:32 +02:00

456 lines
14 KiB
JavaScript

/**
* 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;