iter(g310): multiSelect 补全选与半选
This commit is contained in:
@@ -3176,6 +3176,15 @@
|
||||
"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 全绿。",
|
||||
"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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "ui-arch-registry-index/0.1",
|
||||
"generatedAt": "2026-10-26T16:38:55.415Z",
|
||||
"generatedAt": "2026-10-26T16:40:39.175Z",
|
||||
"repoRoot": ".",
|
||||
"packages": [
|
||||
{
|
||||
@@ -3212,6 +3212,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 令牌' }] })",
|
||||
@@ -3222,7 +3228,8 @@
|
||||
"↑↓ 环形移动高亮、Enter 切换当前项、Esc 收起(与其它弹层共用一个 Esc 栈)",
|
||||
"value 传数组信号即受控,外部写入会同步标签区",
|
||||
"选中项多时传 maxTagCount: 2 折叠:前 2 枚标签照常逐个删除,其余折成虚线「+N」,悬停 title 给出被折叠的项名,点它直接展开弹层;受控数组一变折叠数就重算",
|
||||
"候选分组同样用 group 字段:小标题是不可点击的 li(不占键盘高亮下标),选中后的标签顺序仍按点击顺序而非分组顺序"
|
||||
"候选分组同样用 group 字段:小标题是不可点击的 li(不占键盘高亮下标),选中后的标签顺序仍按点击顺序而非分组顺序",
|
||||
"候选多到「一个个点太慢」时开 selectAll:弹层顶部多一行「全选(3/8)」,半选显 –、全选再点即清空这批;它只作用于当前过滤结果,先搜再全选就是「按条件批量选」"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+2
-1
@@ -997,7 +997,7 @@ const content = h(
|
||||
section(
|
||||
'sec-multiselect',
|
||||
'多选下拉',
|
||||
'multiSelect:标签式回显 + 可搜索弹层,点选不收起弹层(连续多选),空输入退格删末个标签,max 到限后未选项禁用,maxTagCount 折叠溢出标签,clearable 一键清空。',
|
||||
'multiSelect:标签式回显 + 可搜索弹层,点选不收起弹层(连续多选),空输入退格删末个标签,max 到限后未选项禁用,maxTagCount 折叠溢出标签,clearable 一键清空;selectAll 在弹层顶部给「全选(已选/可选)」,半选显 –,只作用于当前过滤结果。',
|
||||
h(
|
||||
'div',
|
||||
{ class: 'row' },
|
||||
@@ -1019,6 +1019,7 @@ const content = h(
|
||||
multiSelect({
|
||||
value: tagFold,
|
||||
maxTagCount: 2,
|
||||
selectAll: true, // 全选行:先搜索再全选即「按条件批量选」
|
||||
placeholder: '标签折叠演示',
|
||||
options: [
|
||||
{ value: 'ts', label: 'TypeScript' },
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
# @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)。
|
||||
|
||||
## 快速上手
|
||||
@@ -1658,6 +1658,7 @@ affix({ offset: 8 }, card({ title: '滚动时我会贴顶' }, h('p', null, '正
|
||||
- disabled:boolean | Signal<boolean> —— 禁用整个下拉
|
||||
- onChange:(values: string[]) => void —— 变更回调(传副本)
|
||||
- clearable:boolean —— 开启后有选中值时在右缘显示一键清空按钮;缺省不渲染
|
||||
- selectAll(默认 false):boolean —— 弹层顶部给「全选」行(只作用于当前过滤结果,半选显示 –,到 max 补满即停);缺省 false
|
||||
|
||||
示例:
|
||||
|
||||
@@ -1674,6 +1675,7 @@ multiSelect({ value: tagPick, max: 3, placeholder: '选择技术栈', options: [
|
||||
5. value 传数组信号即受控,外部写入会同步标签区
|
||||
6. 选中项多时传 maxTagCount: 2 折叠:前 2 枚标签照常逐个删除,其余折成虚线「+N」,悬停 title 给出被折叠的项名,点它直接展开弹层;受控数组一变折叠数就重算
|
||||
7. 候选分组同样用 group 字段:小标题是不可点击的 li(不占键盘高亮下标),选中后的标签顺序仍按点击顺序而非分组顺序
|
||||
8. 候选多到「一个个点太慢」时开 selectAll:弹层顶部多一行「全选(3/8)」,半选显 –、全选再点即清空这批;它只作用于当前过滤结果,先搜再全选就是「按条件批量选」
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-multiselect
|
||||
|
||||
|
||||
@@ -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)」,半选显 –、全选再点即清空这批;它只作用于当前过滤结果,先搜再全选就是「按条件批量选」"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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