Files
UI/packages/skin/ui/src/multiSelect.ts
T

113 lines
5.6 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 root = h('div', { class: () => `ua-multiselect${open() ? ' ua-open' : ''}` }, box, pop) as HTMLElement;
/*** 重绘标签区与候选:标签带删除钮,候选带勾选态与禁用态 ***/
const render = (): void => {
const cur = value();
const full = props.max != null && cur.length >= props.max; // 是否已达上限
const tags = cur.map((v) => {
const label = props.options.find((o) => o.value === v)?.label ?? 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);
});
tagWrap.replaceChildren(...tags); // 只换标签,输入框保持挂载(重建会丢焦点与输入内容)
input.placeholder = cur.length ? '' : props.placeholder ?? '请选择'; // 有标签时不挤占占位文案
const rows = visible().map((o, i) => {
const on = cur.includes(o.value);
const dead = o.disabled === true || (full && !on); // 到上限:未选项不可再点
return 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;
}