180 lines
8.7 KiB
TypeScript
180 lines
8.7 KiB
TypeScript
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;
|
||
}
|