125 lines
7.0 KiB
TypeScript
125 lines
7.0 KiB
TypeScript
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;
|
||
}
|