/* 渐进增强原生 select:表单值和 change 事件仍由原控件提供。 */ (() => { const controls = new Map(); let opened = null; let serial = 0; function enhance(select) { const searchable = select.hasAttribute('data-search'); const name = select.getAttribute('aria-label') || '选择选项'; const id = `orbit-select-${++serial}`; const button = document.createElement('button'); button.type = 'button'; button.className = 'select-trigger'; button.setAttribute('aria-label', name); button.setAttribute('aria-haspopup', 'listbox'); button.setAttribute('aria-controls', id); button.setAttribute('aria-expanded', 'false'); const caption = document.createElement('span'); caption.className = 'select-caption'; const chevron = document.createElement('span'); chevron.className = 'select-chevron'; chevron.setAttribute('aria-hidden', 'true'); button.append(caption, chevron); const popup = document.createElement('div'); popup.className = 'select-popover'; popup.hidden = true; const search = document.createElement('input'); search.type = 'search'; search.placeholder = select.dataset.search || '搜索已加载选项'; search.setAttribute('aria-label', search.placeholder); search.setAttribute('role', 'combobox'); search.setAttribute('aria-autocomplete', 'list'); search.setAttribute('aria-controls', id); search.setAttribute('aria-expanded', 'false'); if (searchable) { const head = document.createElement('div'); head.className = 'select-search'; head.append(search); popup.append(head); } const list = document.createElement('div'); list.id = id; list.className = 'select-options'; list.setAttribute('role', 'listbox'); list.setAttribute('aria-label', name); list.tabIndex = -1; const empty = document.createElement('p'); empty.className = 'select-empty'; empty.setAttribute('role', 'status'); empty.textContent = '没有匹配项,请更换关键词'; empty.hidden = true; popup.append(list, empty); document.body.append(popup); select.after(button); select.hidden = true; let rows = [], active = -1; const focusOwner = () => searchable ? search : list; function position() { if (popup.hidden) return; const rect = button.getBoundingClientRect(); if (!rect.width || rect.bottom < 0 || rect.top > innerHeight) { close(); return; } const width = Math.min(Math.max(rect.width, searchable ? 280 : 150), innerWidth - 24); popup.style.width = `${width}px`; popup.style.left = `${Math.max(12, Math.min(rect.left, innerWidth - width - 12))}px`; const below = innerHeight - rect.bottom - 16, above = rect.top - 16; const up = below < Math.min(popup.scrollHeight, 300) && above > below; const room = Math.max(80, up ? above : below); list.style.maxHeight = `${Math.min(280, room - (searchable ? 61 : 12))}px`; popup.style.top = `${up ? Math.max(8, rect.top - popup.getBoundingClientRect().height - 6) : rect.bottom + 6}px`; } function highlight(index, scroll = false) { active = index; rows.forEach((row, i) => row.node.classList.toggle('is-active', i === index)); if (index < 0) focusOwner().removeAttribute('aria-activedescendant'); else { focusOwner().setAttribute('aria-activedescendant', rows[index].node.id); if (scroll) rows[index].node.scrollIntoView({ block: 'nearest' }); } } function choose(option) { if (option.disabled || select.disabled) return; const changed = select.value !== option.value; select.value = option.value; close(true); if (changed) select.dispatchEvent(new Event('change', { bubbles: true })); // 范围切换可能被未提交表单拦截,以业务监听器确认后的值为准。 sync(); } function render() { const query = search.value.toLocaleLowerCase(); list.replaceChildren(); rows = []; [...select.options].forEach((option, index) => { if (option.hidden || (query && option.value !== '__more__' && !option.textContent.toLocaleLowerCase().includes(query))) return; const node = document.createElement('div'); node.className = 'select-option'; node.id = `${id}-${index}`; node.setAttribute('role', 'option'); node.setAttribute('aria-selected', String(option.selected)); node.setAttribute('aria-disabled', String(option.disabled)); const copy = document.createElement('span'); copy.className = 'select-option-copy'; const title = document.createElement('b'); title.textContent = option.dataset.title || option.textContent; copy.append(title); if (option.dataset.subtitle) { const sub = document.createElement('small'); sub.textContent = option.dataset.subtitle; copy.append(sub); } const check = document.createElement('span'); check.className = 'select-check'; check.textContent = option.selected ? '✓' : ''; check.setAttribute('aria-hidden', 'true'); node.append(copy, check); const rowIndex = rows.length; node.addEventListener('pointermove', () => { if (!option.disabled) highlight(rowIndex); }); node.addEventListener('mousedown', event => event.preventDefault()); node.addEventListener('click', () => choose(option)); rows.push({ option, node }); list.append(node); }); empty.hidden = rows.length > 0; const selected = rows.findIndex(row => row.option.selected && !row.option.disabled); highlight(selected >= 0 ? selected : rows.findIndex(row => !row.option.disabled)); position(); } function sync() { caption.textContent = select.selectedOptions[0]?.textContent || '请选择'; button.title = caption.textContent; button.disabled = select.disabled; if (select.disabled) close(); else if (!popup.hidden) render(); } function close(focus = false) { if (popup.hidden) return; popup.hidden = true; button.setAttribute('aria-expanded', 'false'); search.setAttribute('aria-expanded', 'false'); list.removeAttribute('aria-activedescendant'); search.removeAttribute('aria-activedescendant'); if (opened?.button === button) opened = null; if (focus && !button.disabled) button.focus(); } function open() { if (select.disabled) return; opened?.close(); sync(); search.value = ''; popup.hidden = false; opened = { button, popup, close, position }; button.setAttribute('aria-expanded', 'true'); search.setAttribute('aria-expanded', 'true'); render(); focusOwner().focus(); highlight(active, true); } function keydown(event) { if (event.key === 'Escape' && !popup.hidden) { event.preventDefault(); event.stopPropagation(); close(true); return; } if (event.key === 'Tab' && !popup.hidden) { close(true); return; } if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) { // 搜索输入的 Home/End 保留文本编辑语义。 if (event.target === search && ['Home', 'End'].includes(event.key)) return; event.preventDefault(); if (popup.hidden) { open(); return; } const enabled = rows.map((row, index) => row.option.disabled ? -1 : index).filter(index => index >= 0); if (!enabled.length) return; const current = enabled.indexOf(active), last = enabled.length - 1; const next = event.key === 'Home' ? 0 : event.key === 'End' ? last : Math.max(0, Math.min(last, current + (event.key === 'ArrowDown' ? 1 : -1))); highlight(enabled[next], true); } else if (event.key === 'Enter' || (event.key === ' ' && event.target !== search)) { event.preventDefault(); if (popup.hidden) open(); else if (active >= 0) choose(rows[active].option); } else if (event.key.length === 1 && searchable && event.target === button && !event.ctrlKey && !event.metaKey && !event.altKey) { event.preventDefault(); open(); search.value = event.key; render(); } } button.addEventListener('click', event => { event.preventDefault(); popup.hidden ? open() : close(); }); button.addEventListener('keydown', keydown); search.addEventListener('keydown', keydown); list.addEventListener('keydown', keydown); search.addEventListener('input', render); select.addEventListener('change', () => queueMicrotask(sync)); // reset 事件先于浏览器恢复默认值;下一个任务再同步显示。 select.form?.addEventListener('reset', () => setTimeout(sync, 0)); new MutationObserver(sync).observe(select, { childList: true, subtree: true, attributes: true, characterData: true }); controls.set(select, sync); sync(); } document.querySelectorAll('select[data-select]').forEach(enhance); document.addEventListener('pointerdown', event => { if (opened && !opened.popup.contains(event.target) && !opened.button.contains(event.target)) opened.close(); }); document.addEventListener('focusin', event => { if (opened && !opened.popup.contains(event.target) && !opened.button.contains(event.target)) opened.close(); }); document.addEventListener('scroll', event => { if (opened && !opened.popup.contains(event.target)) opened.position(); }, true); window.addEventListener('resize', () => opened?.position()); window.addEventListener('hashchange', () => opened?.close()); window.OrbitSelect = Object.freeze({ sync: select => controls.get(select)?.(), isOpen: () => !!opened }); })();