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 = props.value ?? signal([]); // 选中值 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; }