iter(g49): 新增 autoComplete 自动补全组件

This commit is contained in:
HIsheR
2026-10-26 12:12:52 +08:00
parent 1fa593317d
commit cb8a9ea357
12 changed files with 405 additions and 10 deletions
+116
View File
@@ -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;
}
+1
View File
@@ -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';
+18
View File
@@ -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 }; // 再导出常用内核类型,方便调用方
+58 -1
View File
@@ -61,7 +61,8 @@
"result(props):结果页,status 四档语义色 + 标题 + 说明 + 操作区,用于提交完成/校验失败等终态",
"transfer(props):穿梭框,左右两栏勾选后整体搬运,targetKeys 信号双向绑定,两栏各自搜索与全选(含半选),禁用条目不参与搬运也不可勾选",
"cascader(props):级联选择,多列联动面板(悬停下钻 / 点击提交),value 为路径数组信号,changeOnSelect 允许停在中间层,全键盘可操作",
"splitter(props):可拖拽分栏容器,分隔条支持指针拖拽 / 方向键微调 / 双击复位,sizes 为百分比数组信号"
"splitter(props):可拖拽分栏容器,分隔条支持指针拖拽 / 方向键微调 / 双击复位,sizes 为百分比数组信号",
"autoComplete(props):输入联想,输入即过滤候选、↑↓ 环形高亮 / Enter 选中 / Esc 收起,options 可传函数做远程联想"
],
"keyTypes": [
"ButtonProps",
@@ -2066,6 +2067,62 @@
"direction 传 vertical 变为上下分栏,键盘方向键与光标同步切换",
"传入 sizes 信号即受控,外部写入可做「预设布局」切换(如 1:1 / 2:1)"
]
},
{
"id": "autoComplete",
"title": "自动补全 AutoComplete",
"demo": "sec-autocomplete",
"props": [
{
"n": "options",
"t": "AutoCompleteOption[] | ((q: string) => AutoCompleteOption[])",
"r": 1,
"d": "候选项:静态列表或按关键字计算的函数(可远程联想)"
},
{
"n": "value",
"t": "Signal<string>",
"d": "输入值信号;不传则内部维护,传了即外部受控"
},
{
"n": "placeholder",
"t": "string",
"d": "占位文案,同时作为无障碍名称"
},
{
"n": "emptyText",
"t": "string",
"d": "无匹配时的提示文案;缺省「无匹配项」"
},
{
"n": "maxItems",
"t": "number",
"d": "最多展示条数;缺省 8"
},
{
"n": "disabled",
"t": "boolean | Signal<boolean>",
"d": "禁用输入与弹层"
},
{
"n": "onInput",
"t": "(query: string) => void",
"d": "每次输入回调,适合触发远程请求"
},
{
"n": "onSelect",
"t": "(option: AutoCompleteOption) => void",
"d": "选中回调(点击或 Enter)"
}
],
"example": "autoComplete({ value: acValue, placeholder: '输入组件名', options: [{ value: 'dataTable', hint: '表格' }], onSelect: (o) => toast.success(o.value) })",
"steps": [
"输入任何字符即按包含匹配(忽略大小写)过滤候选,空串时弹层不展开",
"↑↓ 在候选间环形移动并高亮,Enter 选中,Esc 收起,Tab 失焦也会收起",
"候选行支持右侧 hint(分类 / 拼音),选中后值写回输入框并触发 onSelect",
"options 传函数时每次输入都会用当前关键字调用,可在此发起远程请求并返回新数组",
"value 传信号即受控:外部写入会同步回填输入框内容"
]
}
]
}
+88
View File
@@ -2721,3 +2721,91 @@ button.ua-step-dot:hover {
transition: none;
}
}
.ua-autocomplete {
position: relative;
display: inline-block;
width: 100%;
max-width: 320px;
}
.ua-autocomplete-input {
width: 100%;
padding: 8px var(--ua-space-2);
font-size: var(--ua-font-size-sm);
color: var(--ua-color-text);
background: var(--ua-color-surface);
border: 1px solid var(--ua-color-border);
border-radius: var(--ua-radius-sm);
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.ua-autocomplete-input:focus {
border-color: var(--ua-color-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 18%, transparent);
}
.ua-autocomplete-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ua-autocomplete-pop {
position: absolute;
z-index: 40;
left: 0;
right: 0;
display: none;
margin-top: 4px;
border: 1px solid var(--ua-color-border);
border-radius: var(--ua-radius-md);
background: var(--ua-color-surface);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.18);
overflow: hidden;
}
.ua-autocomplete-pop.ua-open {
display: block;
animation: ua-ac-in 0.15s ease both;
}
.ua-autocomplete-pop.is-flip {
margin-top: 0;
margin-bottom: 4px;
}
.ua-autocomplete-list {
max-height: 220px;
margin: 0;
padding: var(--ua-space-1);
overflow: auto;
list-style: none;
}
.ua-autocomplete-opt {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ua-space-1);
padding: 6px var(--ua-space-1);
border-radius: var(--ua-radius-sm);
font-size: var(--ua-font-size-sm);
color: var(--ua-color-text);
cursor: pointer;
transition: background 0.15s ease;
}
.ua-autocomplete-opt.is-active,
.ua-autocomplete-opt:hover {
background: color-mix(in srgb, var(--ua-color-accent) 10%, transparent);
}
.ua-autocomplete-hint {
font-size: var(--ua-font-size-sm);
color: var(--ua-color-text-muted);
}
.ua-autocomplete-empty {
padding: var(--ua-space-2) 0;
text-align: center;
font-size: var(--ua-font-size-sm);
color: var(--ua-color-text-muted);
}
@keyframes ua-ac-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.ua-autocomplete-pop.ua-open { animation: none; }
.ua-autocomplete-input,
.ua-autocomplete-opt { transition: none; }
}