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>
456 lines
14 KiB
JavaScript
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;
|