iter(g49): 新增 autoComplete 自动补全组件
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import { effect, h, signal } from '@ui-arch/frame';
|
||||
import type { Signal } from '@ui-arch/frame';
|
||||
import { pushOverlay } from './overlay.js';
|
||||
import { flip } 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<string> = 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; } // 失焦前收起,避免残留浮层
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (root.contains(e.target as Node)) return; // 卫语句:组件内点击不处理
|
||||
open(false);
|
||||
});
|
||||
effect(render); // 依赖 query / active / options,任一变化即重绘
|
||||
effect(() => {
|
||||
if (open()) releaseEsc ??= pushOverlay(() => { open(false); input.focus(); }); // 入栈逐层关闭
|
||||
else { releaseEsc?.(); releaseEsc = null; }
|
||||
});
|
||||
effect(() => { if (open()) place(flip(input, pop, 'bottom')); }); // 空间不足自动翻转
|
||||
return root;
|
||||
}
|
||||
@@ -47,3 +47,4 @@ export { dataTable } from './dataTable.js';
|
||||
export { transfer } from './transfer.js';
|
||||
export { cascader } from './cascader.js';
|
||||
export { splitter } from './splitter.js';
|
||||
export { autoComplete } from './autoComplete.js';
|
||||
|
||||
@@ -523,4 +523,22 @@ export interface SplitterProps {
|
||||
onChange?: (sizes: number[]) => void; // 拖拽/键盘调整后回调(传副本)
|
||||
}
|
||||
|
||||
/*** 自动补全候选 ***/
|
||||
export interface AutoCompleteOption {
|
||||
value: string; // 选中后写入输入框的值
|
||||
hint?: string; // 右侧补充说明(如分类 / 拼音);缺省不渲染
|
||||
}
|
||||
|
||||
/*** 自动补全入参 ***/
|
||||
export interface AutoCompleteProps {
|
||||
options: AutoCompleteOption[] | ((query: string) => AutoCompleteOption[]); // 候选:静态列表或按关键字计算
|
||||
value?: Signal<string>; // 输入值信号;不传则内部维护
|
||||
placeholder?: string; // 占位文案
|
||||
emptyText?: string; // 无匹配提示;缺省「无匹配项」
|
||||
maxItems?: number; // 最多展示条数;缺省 8
|
||||
disabled?: boolean | Signal<boolean>; // 禁用输入与弹层
|
||||
onInput?: (query: string) => void; // 每次输入回调(可用于远程联想)
|
||||
onSelect?: (option: AutoCompleteOption) => void; // 选中回调(点击或 Enter)
|
||||
}
|
||||
|
||||
export type { ColumnDef, RowKey }; // 再导出常用内核类型,方便调用方
|
||||
|
||||
Reference in New Issue
Block a user