forked from fa/breadcrumb-the-shire
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:
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