Files
orbit/internal/transport/httpserver/console/console-select.js

148 lines
9.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 渐进增强原生 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 });
})();