Files
UI/packages/skin/ui/src/select.ts
T

180 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { effect, h, signal } from '@ui-arch/frame';
import type { SelectProps } from './types.js';
import { flip, trackShift } from './position.js';
import { bindOutside, pushOverlay } from './overlay.js';
/*** 下拉选择:自绘弹层替代原生 select,value 信号双向绑定;searchable 时弹层顶部带搜索框,支持 ↑↓/Home/End 导航与 Esc 关闭 ***/
export function select(props: SelectProps): HTMLElement {
// —— 状态集中声明 ——
const open = signal(false); // 弹层开合
const place = signal<'top' | 'bottom'>('bottom'); // 实际生效方位:打开时按视口空间自动翻转
const query = signal(''); // 搜索关键字(仅 searchable 时使用)
const value = props.value ?? signal(''); // 选中值;未传信号则内部持有
let releaseEsc: (() => void) | null = null; // Esc 栈注销函数:展开期间占一层,收起即释放
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);
open(false);
props.onChange?.(v);
};
/*** 清空:可选筛选项回到「未选」态,信号与回调同源写回 ***/
const clear = () => {
if (!value()) return; // 卫语句:本来就没选,不重复派发回调
value('');
props.onChange?.('');
};
// —— 触发器 ——
const trigger = h(
'button',
{
type: 'button',
class: `ua-select-trigger${props.size && props.size !== 'md' ? ` is-${props.size}` : ''}`,
'aria-haspopup': 'listbox',
'aria-expanded': () => String(open()),
'aria-label': props.ariaLabel ?? null, // 无可见 label 时由调用方给名字,null 让 h() 不落属性
disabled: props.disabled, // 布尔或信号禁用,h() 直接透传(原生 button 拦截点击)
onClick: () => open(!open()),
},
h('span', { class: () => `ua-select-value${value() ? '' : ' is-empty'}` }, () => current() || props.placeholder || ''),
h('span', { class: 'ua-select-arrow' }, '▾'),
);
// —— 选项弹层 ——
const options = props.options.map((opt) =>
h(
'button',
{
type: 'button',
class: () => `ua-option${opt.value === value() ? ' ua-option-selected' : ''}`,
role: 'option',
'aria-selected': () => String(value() === opt.value),
onClick: () => pick(opt.value),
},
opt.label,
),
);
// —— 分组:相邻同组选项合成一段,组内首项前插小标题;记下区间供过滤时整组显隐 ——
const nodes: HTMLElement[] = []; // 弹层内容序列(小标题与选项混排)
const runs: { el: HTMLElement; from: number; to: number }[] = []; // 每段标题及其首尾选项下标
props.options.forEach((opt, i) => {
let run = runs[runs.length - 1] ?? null;
if (opt.group && (!run || run.to !== i - 1)) { // 新起一段:标题插在本组首项之前
const el = h('div', { class: 'ua-option-group', role: 'presentation' }, opt.group);
run = { el, from: i, to: i };
runs.push(run);
nodes.push(el);
} else if (opt.group && run) run.to = i; // 同组续接:只拉长区间
nodes.push(options[i]);
});
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,
...nodes,
blank,
);
// —— 清空按钮:clearable 且有值时出现,绝对定位压在触发器右缘(与箭头错开)——
const clearBtn = h('button', {
type: 'button',
class: 'ua-select-clear',
'aria-label': '清空选择',
tabindex: '-1', // 不进 Tab 序:清空是鼠标 / 触屏便利,键盘用 Delete / Backspace
onClick: () => clear(),
}, '×');
effect(() => { clearBtn.hidden = !props.clearable || !value(); }); // 无值或未开启时藏起来(不进 Tab 序,隐藏即失效)
const root = h('div', { class: () => `ua-select${open() ? ' ua-open' : ''}` }, trigger, props.clearable ? clearBtn : null, 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 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) => {
const list = visibleIdx();
if (props.clearable && !open() && (e.key === 'Backspace' || e.key === 'Delete')) { e.preventDefault(); clear(); return; } // 键盘等价:收起态下 Delete / Backspace 清空
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault(); // 阻止页面滚动
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;
options[list[(at + dir + list.length) % list.length]]?.focus();
return;
}
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); // 可打印字符:键入快跳(搜索模式由输入框接管)
});
bindOutside(root, open, () => open(false)); // 点外部收起:仅展开期间挂监听,收起即摘
effect(() => {
const q = query();
let shown = 0;
options.forEach((el, i) => {
const on = match(i);
el.hidden = !on; // 不匹配即折叠,键盘只在可见项之间移动
if (on) shown += 1;
});
for (const run of runs) { // 整组都无命中时连小标题一起藏,不留孤零零的分组名
let any = false;
for (let i = run.from; i <= run.to; i++) if (match(i)) { any = true; break; }
run.el.hidden = !any;
}
blank.hidden = shown > 0 || !q; // 仅「有关键字且无命中」时才提示
});
effect(() => {
if (open() && props.searchable) {
query(''); // 每次打开重置关键字
search.value = '';
requestAnimationFrame(() => search.focus()); // 搜索模式:打开即聚焦输入框
}
});
effect(() => {
if (open()) {
releaseEsc ??= pushOverlay(() => { open(false); trigger.focus(); }); // 入 Esc 栈:与弹窗/抽屉共用同一套逐层关闭
} else {
releaseEsc?.();
releaseEsc = null;
}
});
let releaseShift: (() => void) | null = null; // 展开期间的位移跟随释放函数(滚动 / 缩放重算翻转)
effect(() => {
if (!open()) { // 收起即摘监听:弹层不在,重算方位没有意义
releaseShift?.();
releaseShift = null;
return;
}
place(flip(trigger, pop, 'bottom')); // 打开先算一次:空间不足自动翻转
releaseShift ??= trackShift(() => place(flip(trigger, pop, 'bottom'))); // 展开期间跟着滚动重算:视口空间会变
});
return root;
}