475 lines
18 KiB
JavaScript
475 lines
18 KiB
JavaScript
'use strict';
|
|
|
|
const customSelectRegistry = new WeakMap();
|
|
let customSelectOpenState = null;
|
|
let customSelectSequence = 0;
|
|
let customSelectObserver = null;
|
|
let customSelectPropertiesPatched = false;
|
|
|
|
function patchCustomSelectProperties() {
|
|
if (customSelectPropertiesPatched) return;
|
|
customSelectPropertiesPatched = true;
|
|
['value', 'selectedIndex'].forEach(property => {
|
|
const descriptor = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, property);
|
|
if (!descriptor?.get || !descriptor?.set || descriptor.configurable === false) return;
|
|
try {
|
|
Object.defineProperty(HTMLSelectElement.prototype, property, {
|
|
configurable: descriptor.configurable,
|
|
enumerable: descriptor.enumerable,
|
|
get: descriptor.get,
|
|
set(nextValue) {
|
|
descriptor.set.call(this, nextValue);
|
|
queueMicrotask(() => refreshCustomSelect(this));
|
|
},
|
|
});
|
|
} catch (_) { }
|
|
});
|
|
}
|
|
|
|
function customSelectSelectedOption(select) {
|
|
return select.options?.[select.selectedIndex] || null;
|
|
}
|
|
|
|
function customSelectLabel(select) {
|
|
const explicit = select.getAttribute('aria-label');
|
|
if (explicit) return explicit;
|
|
const labelledBy = select.getAttribute('aria-labelledby');
|
|
if (labelledBy) {
|
|
const text = labelledBy.split(/\s+/).map(id => document.getElementById(id)?.textContent?.trim()).filter(Boolean).join(' ');
|
|
if (text) return text;
|
|
}
|
|
const label = select.closest('label');
|
|
const labelSpan = label?.querySelector(':scope > span');
|
|
return labelSpan?.textContent?.trim() || select.name || select.id || '';
|
|
}
|
|
|
|
function customSelectMenuHost(select) {
|
|
// A modal <dialog> makes everything outside its DOM subtree inert. Keep
|
|
// the popover menu inside the same dialog so pointer hover/click continues
|
|
// to work while the dialog is shown with showModal().
|
|
return select.closest('dialog') || document.body;
|
|
}
|
|
|
|
function ensureCustomSelectMenuHost(select, state) {
|
|
const host = customSelectMenuHost(select);
|
|
if (state.menu.parentElement !== host) host.appendChild(state.menu);
|
|
}
|
|
|
|
function customSelectOptionRows(select) {
|
|
const rows = [];
|
|
[...select.children].forEach(child => {
|
|
if (child instanceof HTMLOptGroupElement) {
|
|
rows.push({ type: 'group', label: child.label });
|
|
[...child.children].forEach(option => {
|
|
if (option instanceof HTMLOptionElement) rows.push({ type: 'option', option, index: [...select.options].indexOf(option) });
|
|
});
|
|
} else if (child instanceof HTMLOptionElement) {
|
|
rows.push({ type: 'option', option: child, index: [...select.options].indexOf(child) });
|
|
}
|
|
});
|
|
return rows;
|
|
}
|
|
|
|
function positionCustomSelectMenu(state) {
|
|
if (!state?.open || !state.trigger?.isConnected || !state.menu?.isConnected) return;
|
|
const rect = state.trigger.getBoundingClientRect();
|
|
const margin = 8;
|
|
const gap = 6;
|
|
const toolbar = state.toolbar;
|
|
const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
|
|
const viewportHeight = document.documentElement.clientHeight || window.innerHeight;
|
|
const width = Math.min(Math.max(rect.width, toolbar ? 210 : 180), Math.max(180, viewportWidth - margin * 2));
|
|
const below = Math.max(0, viewportHeight - rect.bottom - gap - margin);
|
|
const above = Math.max(0, rect.top - gap - margin);
|
|
|
|
state.menu.style.width = `${Math.round(width)}px`;
|
|
state.menu.style.maxHeight = '320px';
|
|
|
|
// Once the popover is visible, scrollHeight is the natural menu height.
|
|
// Use that height to choose the side and, especially, to place short menus
|
|
// directly above the trigger instead of at the top of a 320px allowance.
|
|
const naturalHeight = Math.min(320, Math.max(0, state.menu.scrollHeight || state.menu.getBoundingClientRect().height || 0));
|
|
const desiredHeight = naturalHeight || 180;
|
|
const useBelow = below >= desiredHeight || (below >= above && above < desiredHeight);
|
|
const available = Math.max(1, Math.min(320, useBelow ? below : above));
|
|
state.menu.style.maxHeight = `${Math.round(available)}px`;
|
|
|
|
const renderedHeight = naturalHeight ? Math.min(naturalHeight, available) : available;
|
|
const left = Math.min(Math.max(margin, rect.left), Math.max(margin, viewportWidth - width - margin));
|
|
const rawTop = useBelow ? rect.bottom + gap : rect.top - gap - renderedHeight;
|
|
const maxTop = Math.max(margin, viewportHeight - renderedHeight - margin);
|
|
const top = Math.min(Math.max(margin, rawTop), maxTop);
|
|
|
|
state.menu.style.left = `${Math.round(left)}px`;
|
|
state.menu.style.top = `${Math.round(top)}px`;
|
|
state.menu.dataset.placement = useBelow ? 'bottom' : 'top';
|
|
}
|
|
|
|
|
|
function rebuildCustomSelectMenu(select, state) {
|
|
const rows = customSelectOptionRows(select);
|
|
state.menu.replaceChildren();
|
|
|
|
if (!rows.length) {
|
|
const empty = document.createElement('div');
|
|
empty.className = 'custom-select-empty';
|
|
empty.textContent = '—';
|
|
state.menu.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
rows.forEach(row => {
|
|
if (row.type === 'group') {
|
|
const group = document.createElement('div');
|
|
group.className = 'custom-select-group';
|
|
group.textContent = row.label;
|
|
state.menu.appendChild(group);
|
|
return;
|
|
}
|
|
|
|
const { option, index } = row;
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = 'custom-select-option';
|
|
button.dataset.index = String(index);
|
|
button.setAttribute('role', 'option');
|
|
button.setAttribute('aria-selected', option.selected ? 'true' : 'false');
|
|
button.disabled = option.disabled || option.parentElement?.disabled || select.disabled;
|
|
|
|
const text = document.createElement('span');
|
|
text.textContent = option.textContent || option.label || option.value || '—';
|
|
const check = document.createElement('b');
|
|
check.setAttribute('aria-hidden', 'true');
|
|
check.innerHTML = option.selected ? uiIcon('check') : '';
|
|
button.append(text, check);
|
|
|
|
button.addEventListener('click', event => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (button.disabled) return;
|
|
const nextIndex = Number(button.dataset.index);
|
|
if (!Number.isInteger(nextIndex) || !select.options[nextIndex]) return;
|
|
const changed = select.selectedIndex !== nextIndex;
|
|
select.selectedIndex = nextIndex;
|
|
refreshCustomSelect(select);
|
|
closeCustomSelect();
|
|
if (changed) {
|
|
select.dispatchEvent(new Event('input', { bubbles: true }));
|
|
select.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
state.trigger.focus({ preventScroll: true });
|
|
});
|
|
|
|
state.menu.appendChild(button);
|
|
});
|
|
}
|
|
|
|
function refreshCustomSelect(select) {
|
|
const state = customSelectRegistry.get(select);
|
|
if (!state) return;
|
|
const selected = customSelectSelectedOption(select);
|
|
const text = selected?.textContent?.trim() || selected?.label || selected?.value || '—';
|
|
const label = customSelectLabel(select);
|
|
const disabled = !!select.disabled;
|
|
|
|
if (state.toolbar) {
|
|
state.trigger.classList.toggle('custom-select-disabled', disabled);
|
|
state.trigger.setAttribute('aria-disabled', disabled ? 'true' : 'false');
|
|
state.trigger.setAttribute('aria-label', label ? `${label}: ${text}` : text);
|
|
state.trigger.tabIndex = disabled ? -1 : 0;
|
|
} else {
|
|
state.value.textContent = text;
|
|
state.trigger.disabled = disabled;
|
|
state.trigger.setAttribute('aria-label', label ? `${label}: ${text}` : text);
|
|
state.wrapper.hidden = !!select.hidden;
|
|
}
|
|
|
|
if (state.open) {
|
|
rebuildCustomSelectMenu(select, state);
|
|
requestAnimationFrame(() => positionCustomSelectMenu(state));
|
|
}
|
|
}
|
|
|
|
function closeCustomSelect(state = customSelectOpenState) {
|
|
if (!state?.open) return;
|
|
state.open = false;
|
|
state.trigger.setAttribute('aria-expanded', 'false');
|
|
state.trigger.classList.remove('custom-select-open');
|
|
state.wrapper?.classList.remove('custom-select-open');
|
|
|
|
if (typeof state.menu.hidePopover === 'function') {
|
|
try {
|
|
if (state.menu.matches(':popover-open')) state.menu.hidePopover();
|
|
} catch (_) { }
|
|
}
|
|
state.menu.classList.remove('custom-select-menu-fallback-open');
|
|
state.menu.hidden = true;
|
|
if (customSelectOpenState === state) customSelectOpenState = null;
|
|
}
|
|
|
|
function openCustomSelect(select) {
|
|
const state = customSelectRegistry.get(select);
|
|
if (!state || select.disabled) return;
|
|
ensureCustomSelectMenuHost(select, state);
|
|
if (customSelectOpenState === state && state.open) {
|
|
closeCustomSelect(state);
|
|
return;
|
|
}
|
|
closeCustomSelect();
|
|
refreshCustomSelect(select);
|
|
rebuildCustomSelectMenu(select, state);
|
|
|
|
state.open = true;
|
|
customSelectOpenState = state;
|
|
state.trigger.setAttribute('aria-expanded', 'true');
|
|
state.trigger.classList.add('custom-select-open');
|
|
state.wrapper?.classList.add('custom-select-open');
|
|
state.menu.hidden = false;
|
|
positionCustomSelectMenu(state);
|
|
|
|
if (typeof state.menu.showPopover === 'function') {
|
|
try { state.menu.showPopover(); }
|
|
catch (_) { state.menu.classList.add('custom-select-menu-fallback-open'); }
|
|
} else {
|
|
state.menu.classList.add('custom-select-menu-fallback-open');
|
|
}
|
|
|
|
positionCustomSelectMenu(state);
|
|
requestAnimationFrame(() => {
|
|
positionCustomSelectMenu(state);
|
|
state.menu.querySelector('.custom-select-option[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' });
|
|
});
|
|
}
|
|
|
|
function customSelectKeyboard(event, select) {
|
|
if (select.disabled) return;
|
|
const state = customSelectRegistry.get(select);
|
|
if (!state) return;
|
|
|
|
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
event.preventDefault();
|
|
if (!state.open) {
|
|
openCustomSelect(select);
|
|
return;
|
|
}
|
|
const enabled = [...state.menu.querySelectorAll('.custom-select-option:not(:disabled)')];
|
|
if (!enabled.length) return;
|
|
const active = document.activeElement;
|
|
const current = enabled.indexOf(active);
|
|
const delta = event.key === 'ArrowUp' ? -1 : 1;
|
|
enabled[(current + delta + enabled.length) % enabled.length].focus();
|
|
} else if (event.key === 'Escape' && state.open) {
|
|
event.preventDefault();
|
|
closeCustomSelect(state);
|
|
state.trigger.focus({ preventScroll: true });
|
|
} else if (event.key === 'Home' && state.open) {
|
|
event.preventDefault();
|
|
state.menu.querySelector('.custom-select-option:not(:disabled)')?.focus();
|
|
} else if (event.key === 'End' && state.open) {
|
|
event.preventDefault();
|
|
[...state.menu.querySelectorAll('.custom-select-option:not(:disabled)')].at(-1)?.focus();
|
|
}
|
|
}
|
|
|
|
function enhanceCustomSelect(select) {
|
|
if (!(select instanceof HTMLSelectElement) || customSelectRegistry.has(select) || select.multiple || select.size > 1 || select.dataset.nativeSelect === 'true') return;
|
|
|
|
const toolbarHost = select.classList.contains('toolbar-picker-select') ? select.closest('.toolbar-picker') : null;
|
|
const originalSelectState = {
|
|
tabIndex: select.getAttribute('tabindex'),
|
|
ariaHidden: select.getAttribute('aria-hidden'),
|
|
};
|
|
const originalToolbarState = toolbarHost ? {
|
|
role: toolbarHost.getAttribute('role'),
|
|
ariaHaspopup: toolbarHost.getAttribute('aria-haspopup'),
|
|
ariaControls: toolbarHost.getAttribute('aria-controls'),
|
|
ariaExpanded: toolbarHost.getAttribute('aria-expanded'),
|
|
ariaDisabled: toolbarHost.getAttribute('aria-disabled'),
|
|
} : null;
|
|
const menu = document.createElement('div');
|
|
const menuId = `customSelectMenu${++customSelectSequence}`;
|
|
menu.id = menuId;
|
|
menu.className = 'custom-select-menu';
|
|
menu.setAttribute('role', 'listbox');
|
|
menu.setAttribute('popover', 'manual');
|
|
menu.hidden = true;
|
|
customSelectMenuHost(select).appendChild(menu);
|
|
|
|
let wrapper = null;
|
|
let trigger = toolbarHost;
|
|
let value = null;
|
|
|
|
select.classList.add('select-native-proxy');
|
|
select.tabIndex = -1;
|
|
select.setAttribute('aria-hidden', 'true');
|
|
|
|
if (toolbarHost) {
|
|
toolbarHost.classList.add('custom-select-toolbar');
|
|
toolbarHost.setAttribute('role', 'button');
|
|
toolbarHost.setAttribute('aria-haspopup', 'listbox');
|
|
toolbarHost.setAttribute('aria-controls', menuId);
|
|
toolbarHost.setAttribute('aria-expanded', 'false');
|
|
} else {
|
|
wrapper = document.createElement('div');
|
|
wrapper.className = 'custom-select';
|
|
trigger = document.createElement('button');
|
|
trigger.type = 'button';
|
|
trigger.className = 'custom-select-trigger';
|
|
trigger.setAttribute('aria-haspopup', 'listbox');
|
|
trigger.setAttribute('aria-controls', menuId);
|
|
trigger.setAttribute('aria-expanded', 'false');
|
|
value = document.createElement('span');
|
|
value.className = 'custom-select-value';
|
|
const chevron = document.createElement('span');
|
|
chevron.className = 'custom-select-chevron';
|
|
chevron.setAttribute('aria-hidden', 'true');
|
|
chevron.innerHTML = uiIcon('chevron-down');
|
|
trigger.append(value, chevron);
|
|
wrapper.appendChild(trigger);
|
|
select.insertAdjacentElement('afterend', wrapper);
|
|
}
|
|
|
|
const state = {
|
|
select,
|
|
wrapper,
|
|
trigger,
|
|
value,
|
|
menu,
|
|
toolbar: !!toolbarHost,
|
|
open: false,
|
|
originalSelectState,
|
|
originalToolbarState,
|
|
};
|
|
customSelectRegistry.set(select, state);
|
|
|
|
trigger.addEventListener('click', event => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
openCustomSelect(select);
|
|
});
|
|
trigger.addEventListener('keydown', event => customSelectKeyboard(event, select));
|
|
menu.addEventListener('keydown', event => customSelectKeyboard(event, select));
|
|
|
|
// Clicking an implicit <label> may synthesize a click on the original select.
|
|
// Redirect that trusted interaction to the custom popup instead of opening the OS picker.
|
|
select.addEventListener('click', event => {
|
|
if (!event.isTrusted) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
openCustomSelect(select);
|
|
});
|
|
select.addEventListener('change', () => refreshCustomSelect(select));
|
|
select.addEventListener('input', () => refreshCustomSelect(select));
|
|
|
|
refreshCustomSelect(select);
|
|
}
|
|
|
|
function cleanupCustomSelect(select) {
|
|
const state = customSelectRegistry.get(select);
|
|
if (!state) return;
|
|
if (state.open) closeCustomSelect(state);
|
|
state.menu.remove();
|
|
state.wrapper?.remove();
|
|
select.classList.remove('select-native-proxy');
|
|
if (state.originalSelectState.tabIndex == null) select.removeAttribute('tabindex');
|
|
else select.setAttribute('tabindex', state.originalSelectState.tabIndex);
|
|
if (state.originalSelectState.ariaHidden == null) select.removeAttribute('aria-hidden');
|
|
else select.setAttribute('aria-hidden', state.originalSelectState.ariaHidden);
|
|
if (state.toolbar && state.trigger) {
|
|
state.trigger.classList.remove('custom-select-toolbar', 'custom-select-open', 'custom-select-disabled');
|
|
const restoreAttribute = (name, value) => value == null ? state.trigger.removeAttribute(name) : state.trigger.setAttribute(name, value);
|
|
restoreAttribute('role', state.originalToolbarState?.role);
|
|
restoreAttribute('aria-haspopup', state.originalToolbarState?.ariaHaspopup);
|
|
restoreAttribute('aria-controls', state.originalToolbarState?.ariaControls);
|
|
restoreAttribute('aria-expanded', state.originalToolbarState?.ariaExpanded);
|
|
restoreAttribute('aria-disabled', state.originalToolbarState?.ariaDisabled);
|
|
}
|
|
customSelectRegistry.delete(select);
|
|
}
|
|
|
|
function refreshCustomSelects(root = document) {
|
|
if (root instanceof HTMLSelectElement) refreshCustomSelect(root);
|
|
root.querySelectorAll?.('select').forEach(select => refreshCustomSelect(select));
|
|
}
|
|
|
|
function enhanceCustomSelects(root = document) {
|
|
if (root instanceof HTMLSelectElement) enhanceCustomSelect(root);
|
|
root.querySelectorAll?.('select').forEach(enhanceCustomSelect);
|
|
}
|
|
|
|
function initCustomSelects() {
|
|
patchCustomSelectProperties();
|
|
enhanceCustomSelects(document);
|
|
if (customSelectObserver) return;
|
|
|
|
customSelectObserver = new MutationObserver(records => {
|
|
const toRefresh = new Set();
|
|
records.forEach(record => {
|
|
record.addedNodes.forEach(node => {
|
|
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
|
enhanceCustomSelects(node);
|
|
const select = node.closest?.('select');
|
|
if (select) toRefresh.add(select);
|
|
});
|
|
record.removedNodes.forEach(node => {
|
|
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
|
if (node instanceof HTMLSelectElement && !node.isConnected) cleanupCustomSelect(node);
|
|
node.querySelectorAll?.('select').forEach(select => {
|
|
// A DOM move is reported as a removal followed by an addition. Keep
|
|
// the existing custom-select state when the select is still attached
|
|
// to the document; otherwise every move would leave an old wrapper
|
|
// behind and create another one on the next enhancement pass.
|
|
if (!select.isConnected) cleanupCustomSelect(select);
|
|
});
|
|
});
|
|
const targetElement = record.target.nodeType === Node.ELEMENT_NODE ? record.target : record.target.parentElement;
|
|
const select = targetElement?.closest?.('select');
|
|
if (select) toRefresh.add(select);
|
|
});
|
|
toRefresh.forEach(select => {
|
|
if (!select.isConnected) {
|
|
cleanupCustomSelect(select);
|
|
return;
|
|
}
|
|
if (!customSelectRegistry.has(select)) enhanceCustomSelect(select);
|
|
refreshCustomSelect(select);
|
|
});
|
|
});
|
|
|
|
customSelectObserver.observe(document.documentElement, {
|
|
childList: true,
|
|
subtree: true,
|
|
characterData: true,
|
|
attributes: true,
|
|
attributeFilter: ['disabled', 'hidden', 'selected', 'label', 'value', 'aria-label', 'aria-labelledby'],
|
|
});
|
|
|
|
document.addEventListener('pointerdown', event => {
|
|
const state = customSelectOpenState;
|
|
if (!state?.open) return;
|
|
if (state.menu.contains(event.target) || state.trigger.contains(event.target)) return;
|
|
closeCustomSelect(state);
|
|
}, true);
|
|
|
|
document.addEventListener('reset', event => {
|
|
requestAnimationFrame(() => refreshCustomSelects(event.target));
|
|
}, true);
|
|
|
|
document.addEventListener('invalid', event => {
|
|
const select = event.target;
|
|
if (!(select instanceof HTMLSelectElement)) return;
|
|
const state = customSelectRegistry.get(select);
|
|
if (!state) return;
|
|
requestAnimationFrame(() => state.trigger.focus({ preventScroll: true }));
|
|
}, true);
|
|
|
|
window.addEventListener('resize', () => positionCustomSelectMenu(customSelectOpenState));
|
|
document.addEventListener('scroll', event => {
|
|
const state = customSelectOpenState;
|
|
if (!state?.open || state.menu.contains(event.target)) return;
|
|
closeCustomSelect(state);
|
|
}, true);
|
|
}
|
|
|
|
initCustomSelects();
|