feat(console): refine filters and default resource pages to ten rows

This commit is contained in:
2026-09-29 23:00:53 +08:00
parent 96d5a3032a
commit df76dcb9c0
11 changed files with 251 additions and 14 deletions

View File

@@ -0,0 +1,147 @@
/* 渐进增强原生 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 });
})();