iter(g310): multiSelect 补全选与半选

This commit is contained in:
HIsheR
2026-10-27 00:40:53 +08:00
parent 57d3520f82
commit be169b381b
8 changed files with 80 additions and 10 deletions
+9
View File
@@ -3176,6 +3176,15 @@
"motivation": "每轮收尾都要临时写 node 脚本改 backlog、手拼 iterations.log 的 JSON——历史踩过 ts 手写缺时区、字段顺序自创、状态忘记改、哈希抄错,lint-iterations 事后才拦。这条流程本该由 CLI 保证,不该每轮靠人记。", "motivation": "每轮收尾都要临时写 node 脚本改 backlog、手拼 iterations.log 的 JSON——历史踩过 ts 手写缺时区、字段顺序自创、状态忘记改、哈希抄错,lint-iterations 事后才拦。这条流程本该由 CLI 保证,不该每轮靠人记。",
"acceptance": "① 新增 ua iter done <id> <commit> <summary> [notes]:backlog 置 done、追加 {ts,id,result,commit,summary} 固定字段行,ts 由本机时钟生成带 +08:00 偏移,commit 校验 7-40 位哈希,条目不存在直接报错;② ua iter add <json>:五字段齐全 + id 唯一校验后追加;③ usage 串、dispatch、CLI 清单三处同步(doctor 通过);④ 临时脚本 .ua/log-one.mjs 与 .ua/add-entry.mjs 删除;⑤ npm run check 全绿。", "acceptance": "① 新增 ua iter done <id> <commit> <summary> [notes]:backlog 置 done、追加 {ts,id,result,commit,summary} 固定字段行,ts 由本机时钟生成带 +08:00 偏移,commit 校验 7-40 位哈希,条目不存在直接报错;② ua iter add <json>:五字段齐全 + id 唯一校验后追加;③ usage 串、dispatch、CLI 清单三处同步(doctor 通过);④ 临时脚本 .ua/log-one.mjs 与 .ua/add-entry.mjs 删除;⑤ npm run check 全绿。",
"notes": "" "notes": ""
},
{
"id": "g310",
"domain": "tune",
"status": "open",
"title": "multiSelect 补「全选 / 半选」:8 个候选逐个点太慢",
"motivation": "多选下拉只能逐项点或按退格删标签,选项一多就是体力活;transfer 早就有三态全选框,multiSelect 这个更常用的组件反而没有,用户只能「先搜再一个个点」。",
"acceptance": "① 新增 selectAll 入参:弹层顶部一行「全选(已选/可选)」,全选 ✓、部分选中显 –、再点即清空这批;② 只作用于当前过滤结果,先搜再全选=按条件批量选;③ 到 max 时补满即停,不静默超选;④ 键盘可达:全选行占 active -1 号位,↑↓ 能走到、Enter 触发;⑤ manifest 参数表与手把手、demo 同步,npm run check 全绿。",
"notes": ""
} }
] ]
} }
+9 -2
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "ui-arch-registry-index/0.1", "$schema": "ui-arch-registry-index/0.1",
"generatedAt": "2026-10-26T16:38:55.415Z", "generatedAt": "2026-10-26T16:40:39.175Z",
"repoRoot": ".", "repoRoot": ".",
"packages": [ "packages": [
{ {
@@ -3212,6 +3212,12 @@
"n": "clearable", "n": "clearable",
"t": "boolean", "t": "boolean",
"d": "开启后有选中值时在右缘显示一键清空按钮;缺省不渲染" "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 令牌' }] })", "example": "multiSelect({ value: tagPick, max: 3, placeholder: '选择技术栈', options: [{ value: 'ts', label: 'TypeScript' }, { value: 'css', label: 'CSS 令牌' }] })",
@@ -3222,7 +3228,8 @@
"↑↓ 环形移动高亮、Enter 切换当前项、Esc 收起(与其它弹层共用一个 Esc 栈)", "↑↓ 环形移动高亮、Enter 切换当前项、Esc 收起(与其它弹层共用一个 Esc 栈)",
"value 传数组信号即受控,外部写入会同步标签区", "value 传数组信号即受控,外部写入会同步标签区",
"选中项多时传 maxTagCount: 2 折叠:前 2 枚标签照常逐个删除,其余折成虚线「+N」,悬停 title 给出被折叠的项名,点它直接展开弹层;受控数组一变折叠数就重算", "选中项多时传 maxTagCount: 2 折叠:前 2 枚标签照常逐个删除,其余折成虚线「+N」,悬停 title 给出被折叠的项名,点它直接展开弹层;受控数组一变折叠数就重算",
"候选分组同样用 group 字段:小标题是不可点击的 li(不占键盘高亮下标),选中后的标签顺序仍按点击顺序而非分组顺序" "候选分组同样用 group 字段:小标题是不可点击的 li(不占键盘高亮下标),选中后的标签顺序仍按点击顺序而非分组顺序",
"候选多到「一个个点太慢」时开 selectAll:弹层顶部多一行「全选(3/8)」,半选显 –、全选再点即清空这批;它只作用于当前过滤结果,先搜再全选就是「按条件批量选」"
] ]
}, },
{ {
+2 -1
View File
@@ -997,7 +997,7 @@ const content = h(
section( section(
'sec-multiselect', 'sec-multiselect',
'多选下拉', '多选下拉',
'multiSelect:标签式回显 + 可搜索弹层,点选不收起弹层(连续多选),空输入退格删末个标签,max 到限后未选项禁用,maxTagCount 折叠溢出标签,clearable 一键清空。', 'multiSelect:标签式回显 + 可搜索弹层,点选不收起弹层(连续多选),空输入退格删末个标签,max 到限后未选项禁用,maxTagCount 折叠溢出标签,clearable 一键清空;selectAll 在弹层顶部给「全选(已选/可选)」,半选显 –,只作用于当前过滤结果。',
h( h(
'div', 'div',
{ class: 'row' }, { class: 'row' },
@@ -1019,6 +1019,7 @@ const content = h(
multiSelect({ multiSelect({
value: tagFold, value: tagFold,
maxTagCount: 2, maxTagCount: 2,
selectAll: true, // 全选行:先搜索再全选即「按条件批量选」
placeholder: '标签折叠演示', placeholder: '标签折叠演示',
options: [ options: [
{ value: 'ts', label: 'TypeScript' }, { value: 'ts', label: 'TypeScript' },
+3 -1
View File
@@ -1,6 +1,6 @@
# @ui-arch 组件库 Agent 文档(无图形版) # @ui-arch 组件库 Agent 文档(无图形版)
> 自动生成于 2026-10-26T16:38:55.415Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 > 自动生成于 2026-10-26T16:40:39.175Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。
> 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。 > 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。
## 快速上手 ## 快速上手
@@ -1658,6 +1658,7 @@ affix({ offset: 8 }, card({ title: '滚动时我会贴顶' }, h('p', null, '正
- disabled:boolean | Signal<boolean> —— 禁用整个下拉 - disabled:boolean | Signal<boolean> —— 禁用整个下拉
- onChange:(values: string[]) => void —— 变更回调(传副本) - onChange:(values: string[]) => void —— 变更回调(传副本)
- clearable:boolean —— 开启后有选中值时在右缘显示一键清空按钮;缺省不渲染 - clearable:boolean —— 开启后有选中值时在右缘显示一键清空按钮;缺省不渲染
- selectAll(默认 false):boolean —— 弹层顶部给「全选」行(只作用于当前过滤结果,半选显示 –,到 max 补满即停);缺省 false
示例: 示例:
@@ -1674,6 +1675,7 @@ multiSelect({ value: tagPick, max: 3, placeholder: '选择技术栈', options: [
5. value 传数组信号即受控,外部写入会同步标签区 5. value 传数组信号即受控,外部写入会同步标签区
6. 选中项多时传 maxTagCount: 2 折叠:前 2 枚标签照常逐个删除,其余折成虚线「+N」,悬停 title 给出被折叠的项名,点它直接展开弹层;受控数组一变折叠数就重算 6. 选中项多时传 maxTagCount: 2 折叠:前 2 枚标签照常逐个删除,其余折成虚线「+N」,悬停 title 给出被折叠的项名,点它直接展开弹层;受控数组一变折叠数就重算
7. 候选分组同样用 group 字段:小标题是不可点击的 li(不占键盘高亮下标),选中后的标签顺序仍按点击顺序而非分组顺序 7. 候选分组同样用 group 字段:小标题是不可点击的 li(不占键盘高亮下标),选中后的标签顺序仍按点击顺序而非分组顺序
8. 候选多到「一个个点太慢」时开 selectAll:弹层顶部多一行「全选(3/8)」,半选显 –、全选再点即清空这批;它只作用于当前过滤结果,先搜再全选就是「按条件批量选」
现场演示:apps/docs/demo.html#sec-multiselect 现场演示:apps/docs/demo.html#sec-multiselect
+40 -3
View File
@@ -35,6 +35,19 @@ export function multiSelect(props: MultiSelectProps): HTMLElement {
update([...cur]); 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 tagWrap = h('span', { class: 'ua-multiselect-tags' }) as HTMLElement; // 标签区:每次重绘只换这里
const box = h('div', { class: 'ua-multiselect-box' }) as HTMLElement; // 标签 + 输入框容器 const box = h('div', { class: 'ua-multiselect-box' }) as HTMLElement; // 标签 + 输入框容器
const input = h('input', { const input = h('input', {
@@ -76,6 +89,22 @@ export function multiSelect(props: MultiSelectProps): HTMLElement {
tagWrap.replaceChildren(...tags); // 只换标签,输入框保持挂载(重建会丢焦点与输入内容) tagWrap.replaceChildren(...tags); // 只换标签,输入框保持挂载(重建会丢焦点与输入内容)
input.placeholder = cur.length ? '' : props.placeholder ?? '请选择'; // 有标签时不挤占占位文案 input.placeholder = cur.length ? '' : props.placeholder ?? '请选择'; // 有标签时不挤占占位文案
const rows: HTMLElement[] = []; // 候选行:分组小标题与选项混排(小标题不可聚焦,不占 active 下标) 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 = ''; // 上一个候选的分组名,只在组首插一次标题 let prevGroup = ''; // 上一个候选的分组名,只在组首插一次标题
visible().forEach((o, i) => { visible().forEach((o, i) => {
if (o.group && o.group !== prevGroup) rows.push(h('li', { class: 'ua-multiselect-group', role: 'presentation' }, o.group)); 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(); } }); // 点空白处展开并聚焦输入 box.addEventListener('click', () => { if (!(props.disabled === true)) { open(true); input.focus(); } }); // 点空白处展开并聚焦输入
root.addEventListener('keydown', (e: KeyboardEvent) => { root.addEventListener('keydown', (e: KeyboardEvent) => {
const opts = visible(); 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') { if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault(); 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; 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; 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 === 'Backspace' && !input.value && value().length) { update(value().slice(0, -1)); return; } // 空输入时退格删末个标签
if (e.key === 'Escape') { open(false); return; } if (e.key === 'Escape') { open(false); return; }
}); });
+1
View File
@@ -796,6 +796,7 @@ export interface MultiSelectProps {
placeholder?: string; // 未选时占位文案;缺省「请选择」 placeholder?: string; // 未选时占位文案;缺省「请选择」
max?: number; // 最多选几项:到上限后未选项禁用 max?: number; // 最多选几项:到上限后未选项禁用
maxTagCount?: number; // 标签区最多显示几个,超出折成「+N」;缺省全显示 maxTagCount?: number; // 标签区最多显示几个,超出折成「+N」;缺省全显示
selectAll?: boolean; // 弹层顶部给「全选」行(只作用于当前过滤结果,半选显 –);缺省 false
searchable?: boolean; // 是否可输入过滤;缺省 true searchable?: boolean; // 是否可输入过滤;缺省 true
clearable?: boolean; // 开启后有选中值时显示一键清空按钮;缺省不渲染 clearable?: boolean; // 开启后有选中值时显示一键清空按钮;缺省不渲染
disabled?: boolean | Signal<boolean>; // 禁用整个下拉 disabled?: boolean | Signal<boolean>; // 禁用整个下拉
+8 -1
View File
@@ -3083,6 +3083,12 @@
"n": "clearable", "n": "clearable",
"t": "boolean", "t": "boolean",
"d": "开启后有选中值时在右缘显示一键清空按钮;缺省不渲染" "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 令牌' }] })", "example": "multiSelect({ value: tagPick, max: 3, placeholder: '选择技术栈', options: [{ value: 'ts', label: 'TypeScript' }, { value: 'css', label: 'CSS 令牌' }] })",
@@ -3093,7 +3099,8 @@
"↑↓ 环形移动高亮、Enter 切换当前项、Esc 收起(与其它弹层共用一个 Esc 栈)", "↑↓ 环形移动高亮、Enter 切换当前项、Esc 收起(与其它弹层共用一个 Esc 栈)",
"value 传数组信号即受控,外部写入会同步标签区", "value 传数组信号即受控,外部写入会同步标签区",
"选中项多时传 maxTagCount: 2 折叠:前 2 枚标签照常逐个删除,其余折成虚线「+N」,悬停 title 给出被折叠的项名,点它直接展开弹层;受控数组一变折叠数就重算", "选中项多时传 maxTagCount: 2 折叠:前 2 枚标签照常逐个删除,其余折成虚线「+N」,悬停 title 给出被折叠的项名,点它直接展开弹层;受控数组一变折叠数就重算",
"候选分组同样用 group 字段:小标题是不可点击的 li(不占键盘高亮下标),选中后的标签顺序仍按点击顺序而非分组顺序" "候选分组同样用 group 字段:小标题是不可点击的 li(不占键盘高亮下标),选中后的标签顺序仍按点击顺序而非分组顺序",
"候选多到「一个个点太慢」时开 selectAll:弹层顶部多一行「全选(3/8)」,半选显 –、全选再点即清空这批;它只作用于当前过滤结果,先搜再全选就是「按条件批量选」"
] ]
}, },
{ {
+6
View File
@@ -4078,6 +4078,12 @@ button.ua-step-dot:hover {
opacity: 0.45; opacity: 0.45;
cursor: not-allowed; 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 { .ua-multiselect-check {
width: 12px; width: 12px;
font-size: var(--ua-font-size-xs); font-size: var(--ua-font-size-xs);