iter(g310): multiSelect 补全选与半选
This commit is contained in:
@@ -35,6 +35,19 @@ export function multiSelect(props: MultiSelectProps): HTMLElement {
|
||||
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', {
|
||||
@@ -76,6 +89,22 @@ export function multiSelect(props: MultiSelectProps): HTMLElement {
|
||||
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));
|
||||
@@ -101,14 +130,22 @@ export function multiSelect(props: MultiSelectProps): HTMLElement {
|
||||
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(0); return; }
|
||||
if (!open()) { open(true); active(base); return; }
|
||||
if (!slots) return; // 无候选也没全选行:不动
|
||||
const dir = e.key === 'ArrowDown' ? 1 : -1;
|
||||
active((active() + dir + opts.length) % opts.length); // 环形移动
|
||||
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 === '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; }
|
||||
});
|
||||
@@ -120,7 +157,7 @@ export function multiSelect(props: MultiSelectProps): HTMLElement {
|
||||
else { releaseEsc?.(); releaseEsc = null; }
|
||||
});
|
||||
let releaseShift: (() => void) | null = null; // 展开期间的位移跟随释放函数(滚动 / 缩放重算翻转)
|
||||
effect(() => {
|
||||
effect(() => {
|
||||
if (!open()) { // 收起即摘监听:弹层不在,重算方位没有意义
|
||||
releaseShift?.();
|
||||
releaseShift = null;
|
||||
|
||||
@@ -796,6 +796,7 @@ export interface MultiSelectProps {
|
||||
placeholder?: string; // 未选时占位文案;缺省「请选择」
|
||||
max?: number; // 最多选几项:到上限后未选项禁用
|
||||
maxTagCount?: number; // 标签区最多显示几个,超出折成「+N」;缺省全显示
|
||||
selectAll?: boolean; // 弹层顶部给「全选」行(只作用于当前过滤结果,半选显 –);缺省 false
|
||||
searchable?: boolean; // 是否可输入过滤;缺省 true
|
||||
clearable?: boolean; // 开启后有选中值时显示一键清空按钮;缺省不渲染
|
||||
disabled?: boolean | Signal<boolean>; // 禁用整个下拉
|
||||
|
||||
@@ -3083,6 +3083,12 @@
|
||||
"n": "clearable",
|
||||
"t": "boolean",
|
||||
"d": "开启后有选中值时在右缘显示一键清空按钮;缺省不渲染"
|
||||
},
|
||||
{
|
||||
"n": "selectAll",
|
||||
"t": "boolean",
|
||||
"d": "弹层顶部给「全选」行(只作用于当前过滤结果,半选显示 –,到 max 补满即停);缺省 false",
|
||||
"df": "false"
|
||||
}
|
||||
],
|
||||
"example": "multiSelect({ value: tagPick, max: 3, placeholder: '选择技术栈', options: [{ value: 'ts', label: 'TypeScript' }, { value: 'css', label: 'CSS 令牌' }] })",
|
||||
@@ -3093,7 +3099,8 @@
|
||||
"↑↓ 环形移动高亮、Enter 切换当前项、Esc 收起(与其它弹层共用一个 Esc 栈)",
|
||||
"value 传数组信号即受控,外部写入会同步标签区",
|
||||
"选中项多时传 maxTagCount: 2 折叠:前 2 枚标签照常逐个删除,其余折成虚线「+N」,悬停 title 给出被折叠的项名,点它直接展开弹层;受控数组一变折叠数就重算",
|
||||
"候选分组同样用 group 字段:小标题是不可点击的 li(不占键盘高亮下标),选中后的标签顺序仍按点击顺序而非分组顺序"
|
||||
"候选分组同样用 group 字段:小标题是不可点击的 li(不占键盘高亮下标),选中后的标签顺序仍按点击顺序而非分组顺序",
|
||||
"候选多到「一个个点太慢」时开 selectAll:弹层顶部多一行「全选(3/8)」,半选显 –、全选再点即清空这批;它只作用于当前过滤结果,先搜再全选就是「按条件批量选」"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -4611,4 +4618,4 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4078,6 +4078,12 @@ button.ua-step-dot:hover {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ua-multiselect-all {
|
||||
border-bottom: 1px solid var(--ua-border-subtle); /* 与下方候选分开:全选整批、不是某一项 */
|
||||
border-radius: 0; /* 贴顶整行,圆角会把分隔线切出缺口 */
|
||||
color: var(--ua-color-text-secondary);
|
||||
font-weight: var(--ua-weight-medium);
|
||||
}
|
||||
.ua-multiselect-check {
|
||||
width: 12px;
|
||||
font-size: var(--ua-font-size-xs);
|
||||
|
||||
Reference in New Issue
Block a user