const serverTimers = new WeakMap();
const serverControllers = new WeakMap();
function parentOptionsBase() {
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>${escapeAttr(label)}</option>`;
}
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 = `${parentOptionsBase()}?field=${encodeURIComponent(field)}&q=${encodeURIComponent(needle)}`;
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.value === '') {
if (lowered === '') rows.push({ ...opt });
continue;
}
if (lowered !== '' && !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) {
const options = Array.from(select.querySelectorAll('option')).map((opt) => ({
value: opt.value,
label: (opt.textContent || '').trim(),
selected: opt.selected,
}));
return matchingOptions(options, needle, MAX_LIST_ITEMS);
}
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 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 }) {
if (!combo || !filter || !list || !select) return;
const needle = filter.value.trim();
const rows = optionRows(select, needle);
if (rows.length === 0) {
messageRow(
{ combo, filter, list, select },
needle === '' ? 'No options' : 'No matching options',
);
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 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 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');
}
}
function applyHiddenSelects(root) {
(root || document).querySelectorAll('[data-options-filter]').forEach((filter) => {
hideNativeSelect(partsOf(filter));
});
}
function install() {
document.addEventListener('input', (e) => {
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, '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;
renderList(partsOf(parts.filter));
}, 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.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 (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' });
} else if (e.key === 'Escape') {
closeList(parts.combo);
}
});
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) => {
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,
matchingOptions,
preservedOption,
shouldHideNativeSelect,
};
}