/** * Token-select host component — combobox lookup + flat removable list. * * Reads a hidden 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;