Files
UI/packages/skin/ui/src/multiSelect.ts
T
2026-10-26 19:20:35 +08:00

125 lines
7.0 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 { Signal } from '@ui-arch/frame';
import { bindOutside, pushOverlay } from './overlay.js';
import { flip } from './position.js';
import type { MultiSelectProps } from './types.js';
/*** 多选下拉:标签式回显 + 可搜索弹层,value 数组信号双向绑定,到 max 未选项自动禁用 ***/
export function multiSelect(props: MultiSelectProps): HTMLElement {
// —— 状态集中声明 ——
const value: Signal<string[]> = props.value ?? signal<string[]>([]); // 选中值
const open = signal(false); // 弹层开合
const query = signal(''); // 搜索词
const active = signal(-1); // 键盘高亮下标
const place = signal<'top' | 'bottom'>('bottom'); // 实际方位
const searchable = props.searchable !== false; // 缺省可搜索
let releaseEsc: (() => void) | null = null; // Esc 栈注销函数
/*** 按搜索词过滤候选:禁用项仍展示(便于解释为何点不了) ***/
const visible = () => {
const q = query().trim().toLowerCase();
return q ? props.options.filter((o) => o.label.toLowerCase().includes(q)) : props.options;
};
/*** 提交变更:写信号 + 回调(上限由弹层禁用项兜住,这里不再拒绝) ***/
const update = (next: string[]): void => {
value(next);
props.onChange?.([...next]);
};
/*** 单项切换:已选则移除,未选且未到上限则加入 ***/
const toggle = (v: string): void => {
const cur = new Set(value());
if (cur.has(v)) cur.delete(v);
else if (props.max == null || cur.size < props.max) cur.add(v);
update([...cur]);
};
const tagWrap = h('span', { class: 'ua-multiselect-tags' }) as HTMLElement; // 标签区:每次重绘只换这里
const box = h('div', { class: 'ua-multiselect-box' }) as HTMLElement; // 标签 + 输入框容器
const input = h('input', {
type: 'text',
class: 'ua-multiselect-input',
placeholder: '',
'aria-label': props.placeholder ?? '多选下拉',
role: 'combobox',
'aria-expanded': () => String(open()),
disabled: props.disabled,
oninput: (e: Event) => {
query((e.target as HTMLInputElement).value);
active(-1);
open(true); // 输入即展开,便于边打边选
},
}) as HTMLInputElement;
box.append(tagWrap, input); // 标签区在前、输入框在后,横向排布
const list = h('ul', { class: 'ua-multiselect-list', role: 'listbox', 'aria-multiselectable': 'true' }) as HTMLElement;
const pop = h('div', { class: () => `ua-multiselect-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}` }, list) as HTMLElement;
const clearBtn = h('button', { type: 'button', class: 'ua-select-clear', 'aria-label': '清空所选', hidden: true, onClick: (e: Event) => { e.stopPropagation(); update([]); input.focus(); } }, '×') as HTMLElement; // 复用 select 清空钮样式;阻止冒泡免得清完又展开
const root = h('div', { class: () => `ua-multiselect${open() ? ' ua-open' : ''}` }, box, props.clearable ? clearBtn : null, pop) as HTMLElement;
effect(() => { clearBtn.hidden = !value().length; }); // 有值才显示(clearable 未开时按钮根本不挂载)
/*** 重绘标签区与候选:标签带删除钮,候选带勾选态与禁用态 ***/
const render = (): void => {
const cur = value();
const full = props.max != null && cur.length >= props.max; // 是否已达上限
const cut = props.maxTagCount != null && cur.length > props.maxTagCount ? cur.slice(0, props.maxTagCount) : cur; // 折叠后仍显示的标签
const labelOf = (v: string): string => props.options.find((o) => o.value === v)?.label ?? v;
const tags = cut.map((v) => {
const label = labelOf(v);
const del = h('button', { type: 'button', class: 'ua-multiselect-x', 'aria-label': `移除 ${label}`, onClick: () => toggle(v) }, '×');
return h('span', { class: 'ua-multiselect-tag' }, h('span', {}, label), del);
});
if (cut.length < cur.length) { // 溢出项折成一枚 +N,悬停给全量、点击展开弹层
const rest = cur.slice(cut.length).map(labelOf).join('、');
tags.push(h('span', { class: 'ua-multiselect-tag is-more', title: rest, 'aria-label': `另有 ${cur.length - cut.length} 项:${rest}`, onClick: () => open(true) }, `+${cur.length - cut.length}`));
}
tagWrap.replaceChildren(...tags); // 只换标签,输入框保持挂载(重建会丢焦点与输入内容)
input.placeholder = cur.length ? '' : props.placeholder ?? '请选择'; // 有标签时不挤占占位文案
const rows: HTMLElement[] = []; // 候选行:分组小标题与选项混排(小标题不可聚焦,不占 active 下标)
let prevGroup = ''; // 上一个候选的分组名,只在组首插一次标题
visible().forEach((o, i) => {
if (o.group && o.group !== prevGroup) rows.push(h('li', { class: 'ua-multiselect-group', role: 'presentation' }, o.group));
prevGroup = o.group ?? ''; // 无分组的候选打断连续段,后续同名组会重新起标题
const on = cur.includes(o.value);
const dead = o.disabled === true || (full && !on); // 到上限:未选项不可再点
rows.push(h(
'li',
{
class: () => `ua-multiselect-opt${on ? ' is-selected' : ''}${active() === i ? ' is-active' : ''}${dead ? ' is-disabled' : ''}`,
role: 'option',
'aria-selected': String(on),
onmousedown: (e: Event) => { e.preventDefault(); if (!dead) toggle(o.value); }, // 阻止默认:保住输入焦点,弹层不收起
},
h('span', { class: 'ua-multiselect-check', 'aria-hidden': 'true' }, on ? '✓' : ''),
h('span', {}, o.label),
));
});
if (!rows.length) rows.push(h('li', { class: 'ua-multiselect-empty' }, '无匹配项'));
list.replaceChildren(...rows);
};
box.addEventListener('click', () => { if (!(props.disabled === true)) { open(true); input.focus(); } }); // 点空白处展开并聚焦输入
root.addEventListener('keydown', (e: KeyboardEvent) => {
const opts = visible();
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (!open()) { 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(); toggle(opts[active()].value); return; }
if (e.key === 'Backspace' && !input.value && value().length) { update(value().slice(0, -1)); return; } // 空输入时退格删末个标签
if (e.key === 'Escape') { open(false); return; }
});
bindOutside(root, open, () => open(false)); // 点外部收起:仅展开期间挂监听,收起即摘
effect(render);
effect(() => {
if (open()) releaseEsc ??= pushOverlay(() => { open(false); input.focus(); }); // 入栈逐层关闭
else { releaseEsc?.(); releaseEsc = null; }
});
effect(() => { if (open()) place(flip(box, pop, 'bottom')); });
return root;
}