import { effect, h, signal } from '@ui-arch/frame'; import type { Signal } from '@ui-arch/frame'; import { bindOutside, pushOverlay } from './overlay.js'; import { flip, trackShift } from './position.js'; import type { AutoCompleteOption, AutoCompleteProps } from './types.js'; let seq = 0; // 实例序号:给弹层列表一个稳定 id,供 aria-controls 关联 /*** 自动补全:输入即过滤候选项,↑↓ 高亮 / Enter 选中 / Esc 收起,value 信号双向绑定 ***/ export function autoComplete(props: AutoCompleteProps): HTMLElement { // —— 状态集中声明 —— const value: Signal = props.value ?? signal(''); // 输入值 const open = signal(false); // 弹层开合 const query = signal(''); // 当前过滤关键字(与 value 同步,独立信号便于受控写入) const active = signal(-1); // 键盘高亮下标,-1 表示无高亮 const place = signal<'top' | 'bottom'>('bottom'); // 实际方位 let releaseEsc: (() => void) | null = null; // Esc 栈注销函数 const max = props.maxItems ?? 8; /*** 按关键字取候选:函数式入参交给调用方计算(可远程联想),结果截断到 maxItems ***/ const candidates = (): AutoCompleteOption[] => { const all = typeof props.options === 'function' ? props.options(query()) : props.options; const q = query().trim().toLowerCase(); const hit = q ? all.filter((o) => o.value.toLowerCase().includes(q)) : all; // 包含匹配,大小写不敏感 return hit.slice(0, max); }; const input = h('input', { type: 'text', class: 'ua-autocomplete-input', placeholder: props.placeholder ?? '', 'aria-label': props.placeholder ?? '自动补全', role: 'combobox', 'aria-expanded': () => String(open()), 'aria-autocomplete': 'list', disabled: props.disabled, value: () => value(), // 外部写入信号时回填输入框 oninput: (e: Event) => { const v = (e.target as HTMLInputElement).value; value(v); query(v); active(-1); open(v.trim().length > 0); // 空串收起,避免开屏就弹 props.onInput?.(v); }, }) as HTMLInputElement; const list = h('ul', { class: 'ua-autocomplete-list', role: 'listbox', id: `ua-ac-${(seq += 1)}` }) as HTMLElement; input.setAttribute('aria-controls', list.id); const pop = h('div', { class: () => `ua-autocomplete-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}` }, list) as HTMLElement; const root = h('div', { class: 'ua-autocomplete' }, input, pop) as HTMLElement; /*** 选中候选:写值 + 收起 + 回调,焦点回到输入框便于继续输入 ***/ const pick = (opt: AutoCompleteOption): void => { value(opt.value); query(opt.value); open(false); active(-1); props.onSelect?.(opt); input.focus(); }; /*** 重绘候选行:高亮项由 active 决定,空结果渲染提示行 ***/ const render = (): void => { const opts = candidates(); if (!opts.length) { list.replaceChildren(h('li', { class: 'ua-autocomplete-empty' }, props.emptyText ?? '无匹配项')); input.removeAttribute('aria-activedescendant'); return; } const rows = opts.map((opt, i) => { const row = h( 'li', { class: () => `ua-autocomplete-opt${active() === i ? ' is-active' : ''}`, role: 'option', id: `${list.id}-${i}`, 'aria-selected': String(active() === i), onmousedown: (e: Event) => { e.preventDefault(); pick(opt); }, // 阻止默认:保住输入框焦点,blur 后仍能选中 }, h('span', { class: 'ua-autocomplete-value' }, opt.value), opt.hint ? h('span', { class: 'ua-autocomplete-hint' }, opt.hint) : null, ); return row; }); list.replaceChildren(...rows); const cur = rows[active()]; if (cur) input.setAttribute('aria-activedescendant', cur.id); // 读屏跟随高亮 else input.removeAttribute('aria-activedescendant'); }; root.addEventListener('keydown', (e: KeyboardEvent) => { const opts = candidates(); if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); // 阻止光标跳行 if (!open() && opts.length) { open(true); active(0); return; } // 未展开:先展开并高亮首项 const dir = e.key === 'ArrowDown' ? 1 : -1; active((active() + dir + opts.length) % opts.length); // 环形移动 return; } if (e.key === 'Enter' && open() && active() >= 0) { e.preventDefault(); pick(opts[active()]); return; } // 有高亮才回车选中 if (e.key === 'Tab') { open(false); return; } // 失焦前收起,避免残留浮层 }); bindOutside(root, open, () => open(false)); // 点外部收起:仅展开期间挂监听,收起即摘 effect(render); // 依赖 query / active / options,任一变化即重绘 effect(() => { if (open()) releaseEsc ??= pushOverlay(() => { open(false); input.focus(); }); // 入栈逐层关闭 else { releaseEsc?.(); releaseEsc = null; } }); let releaseShift: (() => void) | null = null; // 展开期间的位移跟随释放函数(滚动 / 缩放重算翻转) effect(() => { if (!open()) { // 收起即摘监听:弹层不在,重算方位没有意义 releaseShift?.(); releaseShift = null; return; } place(flip(input, pop, 'bottom')); // 打开先算一次:空间不足自动翻转 releaseShift ??= trackShift(() => place(flip(input, pop, 'bottom'))); // 展开期间跟着滚动重算:视口空间会变 }); // 空间不足自动翻转 return root; }