feat(console): refine filters and default resource pages to ten rows
This commit is contained in:
147
internal/transport/httpserver/console/console-select.js
Normal file
147
internal/transport/httpserver/console/console-select.js
Normal 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 });
|
||||
})();
|
||||
Reference in New Issue
Block a user