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([]); // 展开的 key 集合,外部不传则内部维护 const selected = props.selected ?? signal(''); // 当前选中 key const rows = new Map(); // key → 行元素,供响应式同步 const toggles = new Map(); // 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; }