const serverTimers = new WeakMap();
const serverControllers = new WeakMap();
function optionsBase(wrap) {
const named = wrap.getAttribute('data-options-url');
if (named) return named;
const path = window.location.pathname.replace(/\/$/, '');
const base = path
.replace(/\/-\/create$/, '')
.replace(/\/[^\/]+\/edit$/, '');
return `${base}/-/options`;
}
function escapeAttr(s) {
return String(s)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function preservedOption(current, label, html) {
if (current === '') return null;
const escCurrent = escapeAttr(current);
if (html.includes(`value="${escCurrent}"`)) return null;
return `<option value="${escCurrent}" selected data-options-kept="">${escapeAttr(label)}</option>`;
}
function parentParam(wrap) {
if (!wrap.hasAttribute('data-options-parent')) return '';
return `&parent=${encodeURIComponent(wrap.getAttribute('data-options-parent-value') || '')}`;
}
function postedValue(control) {
if (control.type === 'checkbox') return control.checked ? 'true' : 'false';
return (control.value || '').trim();
}
const dependentControllers = new WeakMap();
async function refreshDependent(wrap, parentValue) {
const select = wrap.querySelector('select');
const field = wrap.getAttribute('data-options-field');
if (!select || !field) return;
wrap.setAttribute('data-options-parent-value', parentValue);
const prev = dependentControllers.get(wrap);
if (prev) prev.abort();
const filter = wrap.querySelector('[data-options-filter]');
if (filter) {
const timer = serverTimers.get(filter);
if (timer) clearTimeout(timer);
serverTimers.delete(filter);
const search = serverControllers.get(filter);
if (search) search.abort();
serverControllers.delete(filter);
delete wrap.dataset.optionsSearching;
}
let html = '';
let overflow = false;
if (parentValue !== '') {
const controller = new AbortController();
dependentControllers.set(wrap, controller);
const url = `${optionsBase(wrap)}?field=${encodeURIComponent(field)}${parentParam(wrap)}`;
try {
const res = await fetch(url, {
headers: { Accept: 'text/html' },
signal: controller.signal,
});
if (res.ok) {
overflow = res.headers.get('x-options-overflow') === 'true';
html = await res.text();
}
} catch (err) {
if (err && err.name === 'AbortError') return;
} finally {
if (dependentControllers.get(wrap) === controller) {
dependentControllers.delete(wrap);
}
}
} else {
dependentControllers.delete(wrap);
}
if (overflow && !filter) html = '';
const current = select.value;
const placeholder = select.querySelector('option[value=""]');
const placeholderHtml = placeholder ? placeholder.outerHTML : '<option value="">-- Select --</option>';
select.innerHTML = `${placeholderHtml}${html}`;
const kept = current !== '' && optionFor(select, current) !== null;
select.value = kept ? current : '';
if (overflow && filter) {
wrap.setAttribute('data-options-server', 'true');
} else {
wrap.removeAttribute('data-options-server');
}
const hint = wrap.querySelector('[data-options-hint]');
if (hint) hint.hidden = !(overflow && filter);
if (filter && !kept) filter.value = '';
if (!kept && current !== '') {
select.dispatchEvent(new Event('change', { bubbles: true }));
}
}
function refreshDependents(control) {
if (!control || !control.name || !control.closest) return;
const scope = control.form || control.closest('form') || document;
scope.querySelectorAll('[data-options-parent]').forEach((wrap) => {
if (wrap.getAttribute('data-options-parent') !== control.name) return;
if (wrap.contains(control)) return;
refreshDependent(wrap, postedValue(control));
});
}
async function serverSearch(filter, wrap, select, field, needle) {
const prev = serverControllers.get(filter);
if (prev) prev.abort();
const controller = new AbortController();
serverControllers.set(filter, controller);
const current = select.value;
const url = `${optionsBase(wrap)}?field=${encodeURIComponent(field)}&q=${encodeURIComponent(needle)}${parentParam(wrap)}`;
let html;
try {
const res = await fetch(url, {
headers: { Accept: 'text/html' },
signal: controller.signal,
});
if (!res.ok) return;
html = await res.text();
} catch (err) {
if (err && err.name === 'AbortError') return;
return;
} finally {
if (serverControllers.get(filter) === controller) {
serverControllers.delete(filter);
}
}
const placeholder = select.querySelector('option[value=""]');
const placeholderHtml = placeholder ? placeholder.outerHTML : '<option value="">-- Select --</option>';
const currentOption = optionFor(select, current);
const currentLabel = currentOption
? (currentOption.textContent || '').trim()
: current;
const preserved = preservedOption(current, currentLabel, html) || '';
const escCurrent = escapeAttr(current);
const hasCurrent = current !== '' && html.includes(`value="${escCurrent}"`);
if (hasCurrent) {
html = html.split(`value="${escCurrent}"`).join(`value="${escCurrent}" selected`);
}
select.innerHTML = `${placeholderHtml}${preserved}${html}`;
}
const MAX_LIST_ITEMS = 50;
function matchingOptions(options, needle, limit) {
const lowered = needle.trim().toLowerCase();
const rows = [];
let choices = 0;
for (const opt of options) {
if (opt.disabled) continue;
if (opt.value === '') {
if (lowered === '') rows.push({ ...opt });
continue;
}
if (lowered !== '' && !opt.matched && !opt.label.toLowerCase().includes(lowered)) continue;
if (choices >= limit) break;
choices += 1;
rows.push({ ...opt });
}
return rows;
}
function partsOf(node) {
const combo = node.closest('[data-options-combobox]');
const wrap = node.closest('[data-select-filterable]');
return {
combo,
wrap,
filter: combo && combo.querySelector('[data-options-filter]'),
list: combo && combo.querySelector('[data-options-list]'),
select: wrap && wrap.querySelector('select'),
};
}
function optionRows(select, needle, server) {
const options = Array.from(select.querySelectorAll('option')).map((opt) => ({
value: opt.value,
label: (opt.textContent || '').trim(),
selected: opt.selected,
disabled: opt.disabled,
matched: server && !opt.hasAttribute('data-options-kept'),
}));
return matchingOptions(options, needle, MAX_LIST_ITEMS);
}
function statusOption(select) {
const status = Array.from(select.querySelectorAll('option')).find((opt) => opt.disabled);
return status ? (status.textContent || '').trim() : null;
}
function closeList(combo) {
const list = combo.querySelector('[data-options-list]');
if (!list) return;
list.hidden = true;
list.replaceChildren();
const filter = combo.querySelector('[data-options-filter]');
setExpanded(filter, false);
setActiveDescendant(filter, null);
}
function wording(wrap, name) {
return (wrap && wrap.getAttribute(`data-options-${name}-text`)) || '';
}
function messageRow(parts, text) {
const { list, filter } = parts;
const item = document.createElement('li');
item.className = 'px-2 py-1.5 text-muted-foreground';
item.textContent = text;
list.replaceChildren(item);
list.hidden = false;
setExpanded(filter, true);
setActiveDescendant(filter, null);
}
function optionRowId(list, index) {
return `${list.id || 'options-list'}-option-${index}`;
}
function setExpanded(filter, expanded) {
if (filter && filter.setAttribute) {
filter.setAttribute('aria-expanded', expanded ? 'true' : 'false');
}
}
function setActiveDescendant(filter, item) {
if (!filter || !filter.setAttribute) return;
if (item && item.id) {
filter.setAttribute('aria-activedescendant', item.id);
} else if (filter.removeAttribute) {
filter.removeAttribute('aria-activedescendant');
}
}
function renderList({ combo, filter, list, select, wrap }) {
if (!combo || !filter || !list || !select) return;
const needle = listNeedle(filter, select);
const server = Boolean(wrap) && wrap.getAttribute('data-options-server') === 'true';
const rows = optionRows(select, needle, server);
if (rows.length === 0) {
messageRow(
{ combo, filter, list, select },
(server && statusOption(select))
|| wording(wrap, needle === '' ? 'empty' : 'unmatched'),
);
return;
}
list.replaceChildren();
rows.forEach((row, index) => {
const item = document.createElement('li');
item.setAttribute('role', 'option');
item.setAttribute('aria-selected', row.selected ? 'true' : 'false');
item.dataset.value = row.value;
item.id = optionRowId(list, index);
item.className =
'cursor-pointer rounded-md px-2 py-1.5 hover:bg-foreground/5'
+ (row.selected ? ' font-medium' : '');
item.textContent = row.label;
list.appendChild(item);
});
list.hidden = false;
setExpanded(filter, true);
setActiveDescendant(filter, activeItem(list));
}
function listNeedle(filter, select) {
const needle = filter.value.trim();
const current = select.value === '' ? null : optionFor(select, select.value);
return current && needle === (current.textContent || '').trim() ? '' : needle;
}
function optionFor(select, value) {
return Array.from(select.options).find((opt) => opt.value === value) || null;
}
function chooseOption({ combo, filter, select }, option) {
if (!option) return;
select.value = option.value;
if (filter) filter.value = (option.textContent || '').trim();
select.dispatchEvent(new Event('change', { bubbles: true }));
closeList(combo);
}
function activeItem(list) {
return (
list.querySelector('[role="option"][aria-selected="true"]')
|| list.querySelector('[role="option"]')
);
}
function choiceMatches(label, needle) {
const lowered = needle.trim().toLowerCase();
return lowered === '' || label.toLowerCase().includes(lowered);
}
function filterChoices(field, needle) {
field.querySelectorAll('[data-choice]').forEach((row) => {
row.hidden = !choiceMatches((row.textContent || '').trim(), needle);
});
}
function shouldHideNativeSelect({ combo, filter, list, select }) {
return Boolean(combo && filter && list && select);
}
function nativeControl({ wrap, select }) {
const parent = select.parentElement;
return parent && parent !== wrap ? parent : select;
}
function hideNativeSelect(parts) {
if (!shouldHideNativeSelect(parts)) return;
nativeControl(parts).hidden = true;
if (parts.filter.value === '' && parts.select.value !== '') {
const current = optionFor(parts.select, parts.select.value);
if (current) parts.filter.value = (current.textContent || '').trim();
}
if (parts.select.required) {
parts.select.required = false;
parts.filter.setAttribute('aria-required', 'true');
}
['aria-invalid', 'aria-describedby'].forEach((name) => {
const value = parts.select.getAttribute(name);
if (value !== null) parts.filter.setAttribute(name, value);
});
}
function applyHiddenSelects(root) {
(root || document).querySelectorAll('[data-options-filter]').forEach((filter) => {
hideNativeSelect(partsOf(filter));
});
}
function install() {
document.addEventListener('input', (e) => {
const choices = e.target.closest('[data-choices-filter]');
if (choices) {
const field = choices.closest('[data-choices]');
if (field) filterChoices(field, choices.value);
return;
}
if (!e.target.closest('[data-options-filter]')) return;
const parts = partsOf(e.target);
if (!parts.select) return;
const needle = parts.filter.value.trim();
const field = parts.wrap.getAttribute('data-options-field');
const server = parts.wrap.getAttribute('data-options-server') === 'true';
if (server && field) {
messageRow(parts, wording(parts.wrap, 'searching'));
parts.wrap.dataset.optionsSearching = 'true';
const prevTimer = serverTimers.get(parts.filter);
if (prevTimer) clearTimeout(prevTimer);
const timer = setTimeout(async () => {
serverTimers.delete(parts.filter);
await serverSearch(parts.filter, parts.wrap, parts.select, field, needle);
delete parts.wrap.dataset.optionsSearching;
const landed = partsOf(parts.filter);
if (landed.list && !landed.list.hidden) renderList(landed);
}, 200);
serverTimers.set(parts.filter, timer);
return;
}
renderList(parts);
});
document.addEventListener('focusin', (e) => {
if (!e.target.closest('[data-options-filter]')) return;
renderList(partsOf(e.target));
});
document.addEventListener('mousedown', (e) => {
const item = e.target.closest('[data-options-list] [role="option"]');
if (!item) return;
e.preventDefault();
const parts = partsOf(item);
if (!parts.select) return;
chooseOption(parts, optionFor(parts.select, item.dataset.value));
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && e.target.closest('[data-choices-filter]')) {
e.preventDefault();
return;
}
if (!e.target.closest('[data-options-filter]')) return;
const parts = partsOf(e.target);
if (!parts.select || !parts.list) return;
if (e.key === 'Enter') {
e.preventDefault();
if (!parts.list.hidden) {
const item = activeItem(parts.list);
chooseOption(parts, item && optionFor(parts.select, item.dataset.value));
}
return;
}
if (e.key === 'Escape') {
if (!parts.list.hidden) {
e.preventDefault();
closeList(parts.combo);
}
return;
}
if (parts.list.hidden) {
if (e.key === 'ArrowDown') {
e.preventDefault();
renderList(parts);
}
return;
}
const items = Array.from(parts.list.querySelectorAll('[role="option"]'));
if (items.length === 0) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const current = items.indexOf(activeItem(parts.list));
const step = e.key === 'ArrowDown' ? 1 : -1;
const next = items[(current + step + items.length) % items.length];
items.forEach((item) => item.setAttribute('aria-selected', 'false'));
next.setAttribute('aria-selected', 'true');
setActiveDescendant(parts.filter, next);
next.scrollIntoView({ block: 'nearest' });
}
});
document.addEventListener(
'focusout',
(e) => {
const combo = e.target.closest && e.target.closest('[data-options-combobox]');
if (!combo || combo.contains(e.relatedTarget)) return;
closeList(combo);
},
true,
);
document.addEventListener('click', (e) => {
document.querySelectorAll('[data-options-combobox]').forEach((combo) => {
if (!combo.contains(e.target)) closeList(combo);
});
});
document.addEventListener(
'change',
(e) => {
refreshDependents(e.target);
const select = e.target.closest && e.target.closest('[data-select-filterable] select');
if (!select) return;
const wrap = select.closest('[data-select-filterable]');
if (!wrap || wrap.dataset.optionsSearching === 'true') return;
const parts = partsOf(select);
if (parts.filter && document.activeElement === parts.filter) {
renderList(parts);
}
},
true,
);
applyHiddenSelects();
if (typeof MutationObserver !== 'undefined' && document.documentElement) {
new MutationObserver((records) => {
records.forEach((record) => {
record.addedNodes.forEach((node) => {
if (node.nodeType === 1) applyHiddenSelects(node);
});
});
}).observe(document.documentElement, { childList: true, subtree: true });
}
}
if (typeof document !== 'undefined') install();
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
MAX_LIST_ITEMS,
choiceMatches,
matchingOptions,
preservedOption,
shouldHideNativeSelect,
};
}