iter(g31): 新增 tree 树形组件

This commit is contained in:
HIsheR
2026-10-26 11:42:25 +08:00
parent 4f512b0d6b
commit 4b0dbfecec
13 changed files with 324 additions and 9 deletions
+98
View File
@@ -0,0 +1,98 @@
import { effect, h, signal } from '@ui-arch/frame';
import type { TreeProps, TreeNode } from './types.js';
/*** 树形:递归渲染节点,箭头展开/收起,点击标签选中;键盘 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 → 展开箭头,供响应式同步
/*** 展开/收起一个节点(写回信号后由 effect 统一刷新 DOM) ***/
const toggle = (key: string) => {
const cur = expanded();
expanded(cur.includes(key) ? cur.filter((k) => k !== key) : [...cur, key]);
};
/*** 选中节点:写信号并回调 ***/
const pick = (node: TreeNode) => {
selected(node.key);
props.onSelect?.(node);
};
/*** 递归构建节点行:父行 + 子容器(子容器缩进一级) ***/
const build = (node: TreeNode, depth: number): HTMLElement => {
const on = expanded().includes(node.key);
const caret = h('span', { class: 'ua-tree-caret', 'aria-hidden': 'true' }, '›');
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);
},
},
caret,
h('span', { class: 'ua-tree-label' }, node.label),
);
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);
};
const root = h('div', { class: 'ua-tree', role: 'tree' }, ...props.nodes.map((n) => build(n, 0))) 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); } });
walk(props.nodes);
if (!expanded().length) expanded(all); // 仅在外部未给初值时展开全部
}
effect(() => {
const open = expanded();
const sel = selected();
for (const [key, row] of rows) {
const on = open.includes(key);
row.classList.toggle('ua-tree-open', on);
row.classList.toggle('ua-tree-selected', key === sel);
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; // 收起即隐藏子容器
}
});
root.addEventListener('keydown', (e: KeyboardEvent) => {
const row = (e.target as HTMLElement).closest('.ua-tree-row') as HTMLElement | null;
if (!row) 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; }
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;
}