iter(g61): tree 支持勾选多选与父子级联
This commit is contained in:
@@ -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<string[]>([]); // 展开的 key 集合,外部不传则内部维护
|
||||
const selected = props.selected ?? signal(''); // 当前选中 key
|
||||
const rows = new Map<string, HTMLElement>(); // key → 行元素,供响应式同步
|
||||
const toggles = new Map<string, HTMLElement>(); // key → 展开箭头,供响应式同步
|
||||
const selected = props.selected ?? signal(''); // 当前选中 key
|
||||
const checked: Signal<string[]> = props.checked ?? signal<string[]>([]); // 勾选 key 集合(multi 生效)
|
||||
const rows = new Map<string, HTMLElement>(); // key → 行元素,供响应式同步
|
||||
const toggles = new Map<string, HTMLElement>(); // key → 展开箭头,供响应式同步
|
||||
const boxes = new Map<string, HTMLInputElement>(); // key → 勾选框
|
||||
const selfOf = new Map<string, TreeNode>(); // key → 节点对象
|
||||
const parentOf = new Map<string, string | null>(); // 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;
|
||||
}
|
||||
|
||||
@@ -466,7 +466,10 @@ export interface TreeProps {
|
||||
expanded?: Signal<string[]>; // 展开的 key 集合;不传则内部维护
|
||||
selected?: Signal<string>; // 选中 key 信号;不传则内部维护
|
||||
defaultExpandAll?: boolean; // 首屏展开全部分支;缺省 false
|
||||
multi?: boolean; // 是否显示勾选框并开启多选;缺省 false
|
||||
checked?: Signal<string[]>; // 勾选 key 集合(multi 时生效);不传则内部维护
|
||||
onSelect?: (node: TreeNode) => void; // 选中回调(点击或 Enter/空格)
|
||||
onCheck?: (keys: string[]) => void; // 勾选变更回调(传副本,父子已级联)
|
||||
}
|
||||
|
||||
/*** 轮播入参 ***/
|
||||
|
||||
Reference in New Issue
Block a user