import { effect, h, signal } from '@ui-arch/frame'; import type { CommandPaletteHandle, CommandPaletteItem, CommandPaletteProps } from './types.js'; import { lockScroll, pushOverlay } from './overlay.js'; let seq = 0; // 实例序号:选项与列表的 id 靠它唯一 /*** 作用:条目是否命中搜索词——主文案 / 辅助说明 / 额外关键词任一包含即命中(大小写不敏感) ***/ function matches(item: CommandPaletteItem, q: string): boolean { if (!q) return true; // 空词不过滤 const hay = [item.label, item.hint ?? '', ...(item.keywords ?? [])].join(' ').toLowerCase(); return hay.includes(q); } /*** 命令面板:⌘K 唤起,输入即过滤,↑↓ 选择、回车执行、Esc 收起;返回 { el, open, close } 句柄 ***/ export function commandPalette(props: CommandPaletteProps): CommandPaletteHandle { // —— 状态集中声明 —— const uid = `ua-cmdk-${++seq}`; // 实例 id 前缀 const query = signal(''); // 搜索词:输入事件写入,供过滤 const active = signal(0); // 当前高亮项在「过滤后序列」中的下标 let visible: CommandPaletteItem[] = []; // 过滤后的可见条目 let nodes: HTMLElement[] = []; // 与 visible 同序的节点,供高亮就地切换(不整树重绘) let lastFocus: HTMLElement | null = null; // 打开前焦点,关闭归还 let release: (() => void) | null = null; // Esc 栈注销函数 let releaseLock: (() => void) | null = null; // 滚动锁解锁函数 // —— DOM 骨架:遮罩 → 面板 → 输入框 + 列表 —— const input = h('input', { class: 'ua-cmdk-input', type: 'text', placeholder: props.placeholder ?? '搜索命令…', 'aria-controls': `${uid}-list`, role: 'combobox', 'aria-expanded': 'true', }) as HTMLInputElement; const list = h('div', { class: 'ua-cmdk-list', id: `${uid}-list`, role: 'listbox', 'aria-label': '命令列表' }); const panel = h('div', { class: 'ua-cmdk-panel', role: 'dialog', 'aria-modal': 'true', 'aria-label': '命令面板' }, input, list); const root = h('div', { class: 'ua-cmdk', onClick: (e: Event) => { if (e.target === root) close(); } }, panel); /*** 选中即执行:先收起(归还焦点)再回调,避免回调里再开弹层时焦点打转 ***/ const run = (item: CommandPaletteItem): void => { close(); props.onSelect(item); }; /*** 生成一行的节点:label 左、hint 右,点击与悬停都能选中 ***/ const row = (item: CommandPaletteItem, idx: number): HTMLElement => h('div', { class: 'ua-cmdk-item', id: `${uid}-opt-${idx}`, role: 'option', 'aria-selected': 'false', onClick: () => run(item), onPointermove: () => setActive(idx), // 鼠标划过即高亮,点之前就知道会选中谁 }, h('span', { class: 'ua-cmdk-label' }, item.label), item.hint ? h('span', { class: 'ua-cmdk-hint' }, item.hint) : null); /*** 高亮第 idx 项(环形):只切 class + scrollIntoView,不重建列表 ***/ function setActive(idx: number): void { if (!visible.length) return; // 卫语句:空列表无需高亮 const next = (idx + visible.length) % visible.length; // 环形:首尾相接 active(next); nodes.forEach((el, i) => { const on = i === next; el.classList.toggle('is-active', on); el.setAttribute('aria-selected', String(on)); }); nodes[next].scrollIntoView({ block: 'nearest' }); // 窄容器内跟随滚动 input.setAttribute('aria-activedescendant', `${uid}-opt-${next}`); } /*** 按当前搜索词重绘列表:同组连续,组间插标题;无命中给空状态 ***/ function paint(): void { const q = query().trim().toLowerCase(); visible = props.items.filter((it) => matches(it, q)); nodes = []; const parts: HTMLElement[] = []; let group = ''; // 上一个已画过的组名,用于判定是否该插标题 visible.forEach((item, idx) => { if (item.group && item.group !== group) { group = item.group; parts.push(h('div', { class: 'ua-cmdk-group' }, group)); } const el = row(item, idx); nodes.push(el); parts.push(el); }); list.replaceChildren(...(parts.length ? parts : [h('div', { class: 'ua-cmdk-empty' }, props.emptyText ?? '没有匹配的命令')])); input.removeAttribute('aria-activedescendant'); if (visible.length) setActive(0); } /*** 打开:清搜索词、重绘、抢焦点(写回信号,由下面的 effect 统一处理副作用) ***/ function open(): void { if (props.open()) return; // 卫语句:已打开不重复触发 props.open(true); } /*** 关闭:写回信号即可,副作用(出栈 / 解锁 / 还焦点)由 effect 收口 ***/ function close(): void { if (!props.open()) return; // 卫语句:已关闭不重复触发 props.open(false); } input.addEventListener('input', () => { query(input.value); paint(); }); // —— 键盘:↑↓ 环形选择、Home/End 跳首尾、回车执行(Esc 交给弹层栈,保证只关最上层) —— panel.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault(); setActive(active() + 1); return; } if (e.key === 'ArrowUp') { e.preventDefault(); setActive(active() - 1); return; } if (e.key === 'Home') { e.preventDefault(); setActive(0); return; } if (e.key === 'End') { e.preventDefault(); setActive(visible.length - 1); return; } if (e.key === 'Enter') { e.preventDefault(); const item = visible[active()]; if (item) run(item); // 无命中时回车不做事 } }); // —— 显隐副作用收口:焦点、Esc 栈、背景滚动锁 —— effect(() => { const on = props.open(); root.classList.toggle('ua-open', on); if (on) { lastFocus = document.activeElement as HTMLElement | null; query(''); input.value = ''; paint(); release ??= pushOverlay(close); releaseLock ??= lockScroll(); input.focus(); } else { release?.(); release = null; releaseLock?.(); releaseLock = null; lastFocus?.focus(); // 归还焦点:键盘用户不会被困在隐藏层里 lastFocus = null; } }); // —— 全局快捷键:⌘K / Ctrl+K 切换;hotkey=false 时不注册(多实例场景由调用方自己绑) —— if (props.hotkey !== false) { document.addEventListener('keydown', (e: KeyboardEvent) => { if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'k') return; // 卫语句:只认 ⌘K / Ctrl+K e.preventDefault(); if (props.open()) close(); else open(); // 再按一次收起 }); } return { el: root, open, close }; }