import { effect, h, signal } from '@ui-arch/frame'; import type { Signal } from '@ui-arch/frame'; import type { TreeProps, TreeNode } from './types.js'; /*** 树形:递归渲染节点,箭头展开/收起、点标签选中;multi 时带勾选框且父子级联,键盘 Enter/空格选中或勾选 ***/ export function tree(props: TreeProps): HTMLElement { // —— 状态集中声明 —— const expanded = props.expanded ?? signal([]); // 展开的 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) let tabStop: string | null = null; // roving tabindex:整棵树只保留一个 Tab 停点 const keyword = signal(''); // 搜索词:非空时按标签过滤,命中节点保留父链 const nodeEls = new Map(); // key → 节点根(行 + 子容器),整枝显隐用 /*** 切换 Tab 停点:只有当前行 tabindex=0,其余 -1,Tab 进出树只停一次 ***/ const syncTabStop = (key: string | null): void => { tabStop = key; for (const [k, row] of rows) row.tabIndex = k === key ? 0 : -1; }; /*** 行元素 → key(键盘导航与焦点同步共用) ***/ const keyOf = (row: HTMLElement): string | null => [...rows.entries()].find(([, el]) => el === row)?.[0] ?? null; /*** 子孙 key 列表(含被禁用项,勾选时再按禁用过滤) ***/ const descOf = (node: TreeNode): string[] => (node.children ?? []).flatMap((c) => [c.key, ...descOf(c)]); /*** 展开/收起一个节点(写回信号后由 effect 统一刷新 DOM) ***/ const toggle = (key: string): void => { const cur = expanded(); expanded(cur.includes(key) ? cur.filter((k) => k !== key) : [...cur, key]); }; /*** 选中节点:写信号并回调 ***/ const pick = (node: TreeNode): void => { selected(node.key); props.onSelect?.(node); }; /*** 勾选级联:向下整套写入,向上回算父级(子全选才勾上父) ***/ 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: '-1' }, // 由 roving tabStop 决定谁可 Tab 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); 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))); const nodeEl = h('div', { class: 'ua-tree-node' }, row, kids) as HTMLElement; nodeEls.set(node.key, nodeEl); return nodeEl; }; /*** 命中集合:自身标签含关键词或子孙含关键词(父链整条保留,避免结果断在中间) ***/ const hitting = (): Set => { const q = keyword().trim().toLowerCase(); const set = new Set(); if (!q) return set; // 卫语句:无关键词不过滤 const walk = (node: TreeNode): boolean => { let hit = node.label.toLowerCase().includes(q); for (const c of node.children ?? []) if (walk(c)) hit = true; // 子孙命中同样保留本节点 if (hit) set.add(node.key); return hit; }; props.nodes.forEach(walk); return set; }; const root = h('div', { class: 'ua-tree', role: 'tree' }, ...props.nodes.map((n) => build(n, 0, null))) as HTMLElement; syncTabStop(props.nodes.find((n) => !n.disabled)?.key ?? null); // 初始停点:首个可用根节点 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(); const picked = checked(); const hits = hitting(); // 搜索命中集合;空集合表示未过滤 const filtering = keyword().trim().length > 0; for (const [key, row] of rows) { const on = open.includes(key); const shown = !filtering || hits.has(key); const nodeEl = nodeEls.get(key); if (nodeEl) nodeEl.hidden = !shown; // 不命中整枝隐藏,连带子节点退出键盘遍历 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 kids = row.nextElementSibling as HTMLElement | null; if (kids) kids.hidden = filtering ? !shown : !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)); // 半选:子级部分勾选 } }); /*** 当前可见的节点行(收起分支的子节点不参与 ↑↓ 遍历) ***/ const visibleRows = (): HTMLElement[] => Array.from(root.querySelectorAll('.ua-tree-row')).filter((el) => !el.closest('[hidden]')); // 收起或搜索隐藏的枝都不参与 ↑↓ 遍历 root.addEventListener('focusin', (e: FocusEvent) => { const row = (e.target as HTMLElement).closest('.ua-tree-row') as HTMLElement | null; const key = row ? keyOf(row) : null; if (key && key !== tabStop) syncTabStop(key); // 焦点跟随:下次 Tab 直接回到这行 }); root.addEventListener('keydown', (e: KeyboardEvent) => { const row = (e.target as HTMLElement).closest('.ua-tree-row') as HTMLElement | null; if (!row) return; // 卫语句:焦点不在节点行 if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Home' || e.key === 'End') { const list = visibleRows(); const at = list.indexOf(row); if (at < 0) return; // 卫语句:行已不在可见集 e.preventDefault(); const next = e.key === 'Home' ? list[0] : e.key === 'End' ? list[list.length - 1] : list[at + (e.key === 'ArrowDown' ? 1 : -1)]; next?.focus(); // 行间移动只改焦点,不改变选中 return; } const key = keyOf(row); 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); } // ← 收起 }); if (!props.searchable) return root; const search = h('input', { type: 'search', class: 'ua-tree-search', placeholder: '搜索节点…', 'aria-label': '搜索节点', oninput: (e: Event) => keyword((e.target as HTMLInputElement).value), }) as HTMLInputElement; return h('div', { class: 'ua-tree-box' }, search, root); // 搜索框放在 role=tree 之外,语义不混 }