iter(g38): select 支持 searchable 搜索过滤

This commit is contained in:
HIsheR
2026-10-26 11:50:00 +08:00
parent 587ea92edb
commit 82909c5ed1
10 changed files with 110 additions and 26 deletions
+55 -17
View File
@@ -2,15 +2,19 @@ import { effect, h, signal } from '@ui-arch/frame';
import type { SelectProps } from './types.js';
import { flip } from './position.js';
/*** 下拉选择:自绘弹层替代原生 select,value 信号双向绑定;支持 ↑↓/Home/End 键盘导航,点选项 / 点外部 / Esc 关闭 ***/
/*** 下拉选择:自绘弹层替代原生 select,value 信号双向绑定;searchable 时弹层顶部带搜索框,支持 ↑↓/Home/End 导航与 Esc 关闭 ***/
export function select(props: SelectProps): HTMLElement {
// —— 状态集中声明 ——
const open = signal(false); // 弹层开合
const open = signal(false); // 弹层开合
const place = signal<'top' | 'bottom'>('bottom'); // 实际生效方位:打开时按视口空间自动翻转
const value = props.value ?? signal(''); // 选中值;未传信号则内部持有
const query = signal(''); // 搜索关键字(仅 searchable 时使用)
const value = props.value ?? signal(''); // 选中值;未传信号则内部持有
const labels = props.options.map((o) => o.label); // 文案缓存,供搜索与键入快跳共用
const labelOf = (v: string) => props.options.find((o) => o.value === v)?.label ?? '';
const current = () => labelOf(value()); // 触发器显示文案(响应式)
const match = (i: number) => !query() || labels[i].toLowerCase().includes(query().toLowerCase()); // 关键字包含匹配
const visibleIdx = () => props.options.map((_, i) => i).filter(match); // 当前可见选项的下标序列
const pick = (v: string) => {
value(v);
@@ -41,49 +45,83 @@ export function select(props: SelectProps): HTMLElement {
type: 'button',
class: () => `ua-option${opt.value === value() ? ' ua-option-selected' : ''}`,
role: 'option',
'aria-selected': () => String(opt.value === value()),
'aria-selected': () => String(value() === opt.value),
onClick: () => pick(opt.value),
},
opt.label,
),
);
const pop = h('div', { class: () => `ua-select-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}`, role: 'listbox' }, ...options);
const search = h('input', {
type: 'search',
class: 'ua-select-search',
placeholder: '搜索…',
'aria-label': '搜索选项',
oninput: (e: Event) => query((e.target as HTMLInputElement).value), // 输入即过滤
onkeydown: (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') { e.preventDefault(); options[visibleIdx()[0] ?? 0]?.focus(); } // ↓ 从搜索框进选项
},
}) as HTMLInputElement;
const blank = h('div', { class: 'ua-select-blank' }, '无匹配选项'); // 过滤空结果提示
const pop = h(
'div',
{ class: () => `ua-select-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}`, role: 'listbox' },
props.searchable ? search : null,
...options,
blank,
);
const root = h('div', { class: () => `ua-select${open() ? ' ua-open' : ''}` }, trigger, pop); // 根带开合类,供箭头旋转
// —— 键入快跳缓冲 ——
// —— 键入快跳缓冲(非 searchable 时生效,逻辑与 menu 一致)——
let typed = ''; // 最近键入的字母串,用于前缀匹配
let typedAt = 0; // 最近键入时间戳,超 1 秒重置缓冲
const jumpByTyped = (ch: string): void => {
const now = Date.now();
typed = now - typedAt > 1000 ? ch : typed + ch; // 超时重新起词,否则追加
typedAt = now;
const at = options.indexOf(document.activeElement as HTMLElement); // 当前焦点序号,作为环形搜索起点
const order = options.map((_, i) => (at + 1 + i) % options.length); // 从下一项开始环形排列的序号
const hit = order.find((i) => props.options[i].label.toLowerCase().startsWith(typed.toLowerCase())); // 前缀匹配(忽略大小写)
const list = visibleIdx();
const at = list.findIndex((i) => options[i] === document.activeElement); // 当前焦点在可见集中的序号
const order = list.length ? list.map((_, k) => list[(at + 1 + k) % list.length]) : []; // 从下一项开始环形排列
const hit = order.find((i) => labels[i].toLowerCase().startsWith(typed.toLowerCase())); // 前缀匹配(忽略大小写)
if (hit != null) options[hit]?.focus(); // 命中即聚焦;无命中留在原地
};
root.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.stopPropagation(); open(false); trigger.focus(); return; } // Esc 只关本层并回焦触发器
const list = visibleIdx();
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault(); // 阻止页面滚动
if (!open()) { open(true); options[0]?.focus(); return; } // 未展开:展开并聚焦首个选项
const idx = options.indexOf(document.activeElement as HTMLElement); // 展开中:在选项间环形移动
if (!open()) { open(true); options[list[0] ?? 0]?.focus(); return; } // 未展开:展开并聚焦首个可见选项
const at = list.findIndex((i) => options[i] === document.activeElement); // 展开中:在可见选项间环形移动
const dir = e.key === 'ArrowDown' ? 1 : -1;
const next = (idx + dir + options.length) % options.length;
options[next]?.focus();
options[list[(at + dir + list.length) % list.length]]?.focus();
return;
}
if (e.key === 'Home' && open()) { e.preventDefault(); options[0]?.focus(); return; } // Home 跳首项
if (e.key === 'End' && open()) { e.preventDefault(); options[options.length - 1]?.focus(); return; } // End 跳末项
if (open() && e.key.length === 1) jumpByTyped(e.key); // 可打印字符:键入快跳(Home/End/方向键已提前接管)
if (e.key === 'Home' && open()) { e.preventDefault(); options[list[0]]?.focus(); return; } // Home 跳首个可见项
if (e.key === 'End' && open()) { e.preventDefault(); options[list[list.length - 1]]?.focus(); return; } // End 跳末个可见项
if (open() && !props.searchable && e.key.length === 1) jumpByTyped(e.key); // 可打印字符:键入快跳(搜索模式由输入框接管)
});
document.addEventListener('click', (e) => {
if (root.contains(e.target as Node)) return; // 卫语句:组件内点击不处理
open(false); // 点外部关闭
});
effect(() => open()); // 建立依赖,保证开合信号生效
effect(() => {
const q = query();
let shown = 0;
options.forEach((el, i) => {
const on = match(i);
el.hidden = !on; // 不匹配即折叠,键盘只在可见项之间移动
if (on) shown += 1;
});
blank.hidden = shown > 0 || !q; // 仅「有关键字且无命中」时才提示
});
effect(() => {
if (open() && props.searchable) {
query(''); // 每次打开重置关键字
search.value = '';
requestAnimationFrame(() => search.focus()); // 搜索模式:打开即聚焦输入框
}
});
effect(() => { if (open()) place(flip(trigger, pop, 'bottom')); }); // 每次打开重算方位:空间不足自动翻转
return root;
}