import { effect, h, signal } from '@ui-arch/frame'; import type { Signal } from '@ui-arch/frame'; import { bindOutside, pushOverlay } from './overlay.js'; import { flip, trackShift } 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]); }; /*** 全选 / 反选:只作用于当前过滤后的候选,上限时按顺序补到 max 为止 ***/ const toggleAll = (): void => { const opts = visible().filter((o) => !o.disabled); const cur = new Set(value()); const allOn = opts.length > 0 && opts.every((o) => cur.has(o.value)); if (allOn) opts.forEach((o) => cur.delete(o.value)); // 全选态再点即清空这批 else for (const o of opts) { if (props.max != null && cur.size >= props.max) break; // 到上限就停,不静默超选 cur.add(o.value); } 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 下标) if (props.selectAll) { // 全选行:占 active 下标 -1,键盘 ↑↓ 能走到,不用鼠标也能全选 const opts = visible(); const picked = opts.filter((o) => cur.includes(o.value)).length; const allOn = opts.length > 0 && picked === opts.length; rows.push(h( 'li', { class: () => `ua-multiselect-opt ua-multiselect-all${open() && active() === -1 ? ' is-active' : ''}${opts.length ? '' : ' is-disabled'}`, role: 'option', 'aria-selected': String(allOn), onmousedown: (e: Event) => { e.preventDefault(); if (opts.length) toggleAll(); }, // 阻止默认:保住输入焦点 }, h('span', { class: 'ua-multiselect-check', 'aria-hidden': 'true' }, allOn ? '✓' : picked ? '–' : ''), // – 为半选:部分可选 h('span', {}, opts.length ? `全选(${picked}/${opts.length})` : '全选'), )); } 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(); const base = props.selectAll ? -1 : 0; // 全选行占 -1 号位:环形导航的起点随之左移一格 const slots = opts.length + (props.selectAll ? 1 : 0); // 可停留的位置总数(含全选行) if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); if (!open()) { open(true); active(base); return; } if (!slots) return; // 无候选也没全选行:不动 const dir = e.key === 'ArrowDown' ? 1 : -1; active(base + (((active() - base) + dir + slots) % slots)); // 环形移动,越界回绕 return; } if (e.key === 'Enter' && open() && slots && active() >= base) { e.preventDefault(); if (active() === -1) toggleAll(); // 全选行 else 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; } }); let releaseShift: (() => void) | null = null; // 展开期间的位移跟随释放函数(滚动 / 缩放重算翻转) effect(() => { if (!open()) { // 收起即摘监听:弹层不在,重算方位没有意义 releaseShift?.(); releaseShift = null; return; } place(flip(box, pop, 'bottom')); // 打开先算一次:空间不足自动翻转 releaseShift ??= trackShift(() => place(flip(box, pop, 'bottom'))); // 展开期间跟着滚动重算:视口空间会变 }); return root; }