diff --git a/.ua/index.json b/.ua/index.json index 6a39aa7..694d5c4 100644 --- a/.ua/index.json +++ b/.ua/index.json @@ -1,6 +1,6 @@ { "$schema": "ui-arch-registry-index/0.1", - "generatedAt": "2026-10-26T04:24:59.738Z", + "generatedAt": "2026-10-26T04:26:29.979Z", "repoRoot": ".", "packages": [ { @@ -1936,6 +1936,21 @@ "t": "(node: TreeNode) => void", "k": 1, "d": "选中回调:点击标签或 Enter/空格触发" + }, + { + "n": "multi", + "t": "boolean", + "d": "是否显示勾选框并开启多选;缺省 false" + }, + { + "n": "checked", + "t": "Signal", + "d": "勾选 key 集合(multi 时生效);不传则内部维护" + }, + { + "n": "onCheck", + "t": "(keys: string[]) => void", + "d": "勾选变更回调(父子已级联,传副本)" } ], "example": "const sel = signal(''); tree({ nodes: [{ key: 'src', label: 'src', children: [{ key: 'ui', label: 'ui' }] }], selected: sel, onSelect: (n) => console.log(n.key) })", @@ -1944,7 +1959,9 @@ "传 selected 信号即可外部受控;不传则组件内部维护选中态", "点箭头只展开/收起,点标签才选中;叶子节点箭头位置留空以对齐", "键盘:↑↓ 在可见节点间移动、Home/End 到首末行(只移动焦点),Enter/空格 选中、→ 展开、← 收起;disabled 节点点击与键盘都不响应", - "defaultExpandAll 首屏展开全部分支(外部已给 expanded 初值时以外部为准)" + "defaultExpandAll 首屏展开全部分支(外部已给 expanded 初值时以外部为准)", + "multi: true 时每行前置勾选框:勾父级下钻全选子孙(跳过禁用项),子孙全选后父级自动上勾、部分勾选显示半选", + "勾选框点击不触发选中(stopPropagation),键盘在多选模式下 Enter/空格改为切换勾选" ] }, { diff --git a/apps/demo/main.ts b/apps/demo/main.ts index ac409ef..27e3814 100644 --- a/apps/demo/main.ts +++ b/apps/demo/main.ts @@ -200,6 +200,7 @@ const permPick = signal(['read']); // 多选组选中值(外部受 const colorVal = signal('var(--ua-color-accent)'); // 颜色选择值(外部受控演示) const dateVal = signal('2026-10-26'); // 日期选择值(外部受控演示) const acValue = signal(''); // 自动补全输入值(外部受控演示) +const treeChecked = signal(['core']); // 多选树勾选集合(外部受控演示) const treePick = signal('core'); // 树形选中节点 key const transferKeys = signal(['a11y']); // 穿梭框右侧集合(外部受控演示) const cityPick = signal(''); // 可搜索下拉的选中城市 @@ -673,6 +674,25 @@ const content = h( onSelect: (n) => treePick(n.key), }), h('span', { class: 'out' }, () => (treePick() ? `已选节点:${treePick()}` : '未选中节点')), + h( + 'div', + { class: 'col' }, + tree({ + multi: true, + checked: treeChecked, + defaultExpandAll: true, + nodes: [ + { key: 'perm', label: '权限包', children: [ + { key: 'read', label: '读取' }, + { key: 'write', label: '写入' }, + { key: 'admin', label: '管理(不可授予)', disabled: true }, + ] }, + { key: 'bill', label: '计费模块', children: [{ key: 'bill-view', label: '查看账单' }] }, + ], + onCheck: (keys) => toast.info(`已勾选 ${keys.length} 项`), + }), + h('span', { class: 'out' }, () => `已勾选:${treeChecked().join(' / ') || '无'}`), + ), ), ), section( diff --git a/llms-full.txt b/llms-full.txt index 8111a17..4182790 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # @ui-arch 组件库 Agent 文档(无图形版) -> 自动生成于 2026-10-26T04:24:59.738Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 +> 自动生成于 2026-10-26T04:26:29.979Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 > 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。 ## 快速上手 @@ -1082,6 +1082,9 @@ const i = signal(0); carousel({ items: [h('div', null, 'A'), h('div', null, 'B') - selected:Signal —— 选中 key 信号;不传则内部维护 - defaultExpandAll:boolean —— 首屏展开全部分支;缺省 false - onSelect(回调):(node: TreeNode) => void —— 选中回调:点击标签或 Enter/空格触发 +- multi:boolean —— 是否显示勾选框并开启多选;缺省 false +- checked:Signal —— 勾选 key 集合(multi 时生效);不传则内部维护 +- onCheck:(keys: string[]) => void —— 勾选变更回调(父子已级联,传副本) 示例: @@ -1096,6 +1099,8 @@ const sel = signal(''); tree({ nodes: [{ key: 'src', label: 'src', children: [{ 3. 点箭头只展开/收起,点标签才选中;叶子节点箭头位置留空以对齐 4. 键盘:↑↓ 在可见节点间移动、Home/End 到首末行(只移动焦点),Enter/空格 选中、→ 展开、← 收起;disabled 节点点击与键盘都不响应 5. defaultExpandAll 首屏展开全部分支(外部已给 expanded 初值时以外部为准) +6. multi: true 时每行前置勾选框:勾父级下钻全选子孙(跳过禁用项),子孙全选后父级自动上勾、部分勾选显示半选 +7. 勾选框点击不触发选中(stopPropagation),键盘在多选模式下 Enter/空格改为切换勾选 现场演示:apps/docs/demo.html#sec-tree diff --git a/packages/skin/ui/src/tree.ts b/packages/skin/ui/src/tree.ts index e0b4050..af041b4 100644 --- a/packages/skin/ui/src/tree.ts +++ b/packages/skin/ui/src/tree.ts @@ -1,55 +1,85 @@ import { effect, h, signal } from '@ui-arch/frame'; +import type { Signal } from '@ui-arch/frame'; import type { TreeProps, TreeNode } from './types.js'; -/*** 树形:递归渲染节点,箭头展开/收起,点击标签选中;键盘 Enter 选中、→ 展开、← 收起,被禁用的节点不响应 ***/ +/*** 树形:递归渲染节点,箭头展开/收起、点标签选中;multi 时带勾选框且父子级联,键盘 Enter/空格选中或勾选 ***/ export function tree(props: TreeProps): HTMLElement { // —— 状态集中声明 —— const expanded = props.expanded ?? signal([]); // 展开的 key 集合,外部不传则内部维护 - const selected = props.selected ?? signal(''); // 当前选中 key - const rows = new Map(); // key → 行元素,供响应式同步 - const toggles = new Map(); // key → 展开箭头,供响应式同步 + const selected = props.selected ?? signal(''); // 当前选中 key + const checked: Signal = props.checked ?? signal([]); // 勾选 key 集合(multi 生效) + const rows = new Map(); // key → 行元素,供响应式同步 + const toggles = new Map(); // key → 展开箭头,供响应式同步 + const boxes = new Map(); // key → 勾选框 + const selfOf = new Map(); // key → 节点对象 + const parentOf = new Map(); // key → 父 key(根为 null) + + /*** 子孙 key 列表(含被禁用项,勾选时再按禁用过滤) ***/ + const descOf = (node: TreeNode): string[] => (node.children ?? []).flatMap((c) => [c.key, ...descOf(c)]); /*** 展开/收起一个节点(写回信号后由 effect 统一刷新 DOM) ***/ - const toggle = (key: string) => { + const toggle = (key: string): void => { const cur = expanded(); expanded(cur.includes(key) ? cur.filter((k) => k !== key) : [...cur, key]); }; /*** 选中节点:写信号并回调 ***/ - const pick = (node: TreeNode) => { + const pick = (node: TreeNode): void => { selected(node.key); props.onSelect?.(node); }; - /*** 递归构建节点行:父行 + 子容器(子容器缩进一级) ***/ - const build = (node: TreeNode, depth: number): HTMLElement => { + /*** 勾选级联:向下整套写入,向上回算父级(子全选才勾上父) ***/ + const setCheck = (node: TreeNode, on: boolean): void => { + const next = new Set(checked()); + for (const key of [node.key, ...descOf(node)]) { + if (selfOf.get(key)?.disabled) continue; // 禁用项不参与级联 + on ? next.add(key) : next.delete(key); + } + let up = parentOf.get(node.key) ?? null; + while (up) { + const kids = (selfOf.get(up)?.children ?? []).filter((c) => !c.disabled); + if (on && kids.length && kids.every((c) => next.has(c.key))) next.add(up); // 子全选才上勾 + else if (!on) next.delete(up); // 子有未选:父必取消 + up = parentOf.get(up) ?? null; + } + checked([...next]); + props.onCheck?.([...next]); + }; + + /*** 递归构建节点行:父行 + 子容器(子容器缩进一级),multi 时行首插入勾选框 ***/ + const build = (node: TreeNode, depth: number, parent: string | null): HTMLElement => { const on = expanded().includes(node.key); const caret = h('span', { class: 'ua-tree-caret', 'aria-hidden': 'true' }, '›'); + const box = props.multi + ? (h('input', { type: 'checkbox', class: 'ua-checkbox ua-tree-box', 'aria-label': node.label, disabled: node.disabled }) as HTMLInputElement) + : null; + if (box) { + box.addEventListener('click', (e) => { e.stopPropagation(); setCheck(node, box.checked); }); // 勾选不触发选中 + boxes.set(node.key, box); + } const row = h( 'div', - { - class: 'ua-tree-row', - role: 'treeitem', - 'aria-level': String(depth + 1), - 'aria-expanded': String(on), - tabindex: '0', - onClick: (e: Event) => { - if (node.disabled) return; // 卫语句:禁用节点不响应 - if ((e.target as HTMLElement) === caret) toggle(node.key); // 点箭头只展开/收起 - else pick(node); - }, - }, + { class: 'ua-tree-row', role: 'treeitem', 'aria-level': String(depth + 1), 'aria-expanded': String(on), tabindex: '0' }, + box, caret, h('span', { class: 'ua-tree-label' }, node.label), ); + row.addEventListener('click', (e: Event) => { + if (node.disabled) return; // 卫语句:禁用节点不响应 + if ((e.target as HTMLElement) === caret) toggle(node.key); // 点箭头只展开/收起 + else pick(node); + }); rows.set(node.key, row); toggles.set(node.key, caret); - if (!node.children?.length) caret.classList.add('ua-tree-caret-leaf'); // 叶子节点箭头置空位占位 - const box = h('div', { class: 'ua-tree-children', role: 'group' }, ...(node.children ?? []).map((c) => build(c, depth + 1))); - return h('div', { class: 'ua-tree-node' }, row, box); + selfOf.set(node.key, node); + parentOf.set(node.key, parent); + if (!node.children?.length) caret.classList.add('ua-tree-caret-leaf'); // 叶子箭头置空位占位 + const kids = h('div', { class: 'ua-tree-children', role: 'group' }, ...(node.children ?? []).map((c) => build(c, depth + 1, node.key))); + return h('div', { class: 'ua-tree-node' }, row, kids) as HTMLElement; }; - const root = h('div', { class: 'ua-tree', role: 'tree' }, ...props.nodes.map((n) => build(n, 0))) as HTMLElement; + const root = h('div', { class: 'ua-tree', role: 'tree' }, ...props.nodes.map((n) => build(n, 0, null))) as HTMLElement; if (props.defaultExpandAll) { const all: string[] = []; const walk = (list: TreeNode[]) => list.forEach((n) => { if (n.children?.length) { all.push(n.key); walk(n.children); } }); @@ -60,6 +90,7 @@ export function tree(props: TreeProps): HTMLElement { effect(() => { const open = expanded(); const sel = selected(); + const picked = checked(); for (const [key, row] of rows) { const on = open.includes(key); row.classList.toggle('ua-tree-open', on); @@ -67,8 +98,14 @@ export function tree(props: TreeProps): HTMLElement { row.setAttribute('aria-selected', String(key === sel)); row.setAttribute('aria-expanded', String(on)); toggles.get(key)?.classList.toggle('ua-tree-caret-open', on); // 箭头随展开态旋转 - const box = row.nextElementSibling as HTMLElement | null; - if (box) box.hidden = !on; // 收起即隐藏子容器 + const kids = row.nextElementSibling as HTMLElement | null; + if (kids) kids.hidden = !on; // 收起即隐藏子容器 + const box = boxes.get(key); + if (!box) continue; // 卫语句:非多选模式没有勾选框 + const self = selfOf.get(key); + const hit = picked.includes(key); + box.checked = hit; + box.indeterminate = !hit && !!self && descOf(self).some((k) => picked.includes(k)); // 半选:子级部分勾选 } }); @@ -89,23 +126,17 @@ export function tree(props: TreeProps): HTMLElement { return; } const key = [...rows.entries()].find(([, el]) => el === row)?.[0]; - if (!key) return; // 卫语句:找不到对应节点 - const node = findNode(props.nodes, key); - if (!node || node.disabled) return; // 卫语句:禁用节点不接管键盘 - if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(node); return; } + const node = key ? selfOf.get(key) : null; + if (!node || node.disabled || !key) return; // 卫语句:找不到或禁用节点 + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + if (props.multi) setCheck(node, !checked().includes(key)); // 多选:回车/空格切换勾选 + else pick(node); + return; + } if (e.key === 'ArrowRight' && !expanded().includes(key)) { e.preventDefault(); toggle(key); return; } // → 展开 if (e.key === 'ArrowLeft' && expanded().includes(key)) { e.preventDefault(); toggle(key); } // ← 收起 }); return root; } - -/*** 按 key 深度查找节点(键盘操作需要拿到原始节点对象) ***/ -function findNode(nodes: TreeNode[], key: string): TreeNode | null { - for (const n of nodes) { - if (n.key === key) return n; - const hit = n.children ? findNode(n.children, key) : null; - if (hit) return hit; - } - return null; -} diff --git a/packages/skin/ui/src/types.ts b/packages/skin/ui/src/types.ts index 56f04dc..453ebab 100644 --- a/packages/skin/ui/src/types.ts +++ b/packages/skin/ui/src/types.ts @@ -466,7 +466,10 @@ export interface TreeProps { expanded?: Signal; // 展开的 key 集合;不传则内部维护 selected?: Signal; // 选中 key 信号;不传则内部维护 defaultExpandAll?: boolean; // 首屏展开全部分支;缺省 false + multi?: boolean; // 是否显示勾选框并开启多选;缺省 false + checked?: Signal; // 勾选 key 集合(multi 时生效);不传则内部维护 onSelect?: (node: TreeNode) => void; // 选中回调(点击或 Enter/空格) + onCheck?: (keys: string[]) => void; // 勾选变更回调(传副本,父子已级联) } /*** 轮播入参 ***/ diff --git a/packages/skin/ui/ua.manifest.json b/packages/skin/ui/ua.manifest.json index 4404bee..152a21b 100644 --- a/packages/skin/ui/ua.manifest.json +++ b/packages/skin/ui/ua.manifest.json @@ -1807,6 +1807,21 @@ "t": "(node: TreeNode) => void", "k": 1, "d": "选中回调:点击标签或 Enter/空格触发" + }, + { + "n": "multi", + "t": "boolean", + "d": "是否显示勾选框并开启多选;缺省 false" + }, + { + "n": "checked", + "t": "Signal", + "d": "勾选 key 集合(multi 时生效);不传则内部维护" + }, + { + "n": "onCheck", + "t": "(keys: string[]) => void", + "d": "勾选变更回调(父子已级联,传副本)" } ], "example": "const sel = signal(''); tree({ nodes: [{ key: 'src', label: 'src', children: [{ key: 'ui', label: 'ui' }] }], selected: sel, onSelect: (n) => console.log(n.key) })", @@ -1815,7 +1830,9 @@ "传 selected 信号即可外部受控;不传则组件内部维护选中态", "点箭头只展开/收起,点标签才选中;叶子节点箭头位置留空以对齐", "键盘:↑↓ 在可见节点间移动、Home/End 到首末行(只移动焦点),Enter/空格 选中、→ 展开、← 收起;disabled 节点点击与键盘都不响应", - "defaultExpandAll 首屏展开全部分支(外部已给 expanded 初值时以外部为准)" + "defaultExpandAll 首屏展开全部分支(外部已给 expanded 初值时以外部为准)", + "multi: true 时每行前置勾选框:勾父级下钻全选子孙(跳过禁用项),子孙全选后父级自动上勾、部分勾选显示半选", + "勾选框点击不触发选中(stopPropagation),键盘在多选模式下 Enter/空格改为切换勾选" ] }, { diff --git a/packages/skin/ui/ui.css b/packages/skin/ui/ui.css index dfcaf83..0404fc5 100644 --- a/packages/skin/ui/ui.css +++ b/packages/skin/ui/ui.css @@ -3391,3 +3391,6 @@ button.ua-step-dot:hover { .ua-multiselect-box, .ua-multiselect-opt { transition: none; } } +.ua-tree-row .ua-tree-box { + margin-right: 2px; +}