Files
UI/packages/skin/ui/src/tree.ts
T

112 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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; // 收起即隐藏子容器
}
});
/*** 当前可见的节点行(收起分支的子节点不参与 ↑↓ 遍历) ***/
const visibleRows = (): HTMLElement[] =>
Array.from(root.querySelectorAll<HTMLElement>('.ua-tree-row')).filter((el) => !el.closest('.ua-tree-children[hidden]'));
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 = [...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;
}