Files
UI/packages/skin/ui/src/tree.ts
T
2026-10-26 13:12:28 +08:00

192 lines
9.6 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 { 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<string[]>([]); // 展开的 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)
let tabStop: string | null = null; // roving tabindex:整棵树只保留一个 Tab 停点
const keyword = signal(''); // 搜索词:非空时按标签过滤,命中节点保留父链
const nodeEls = new Map<string, HTMLElement>(); // 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<string> => {
const q = keyword().trim().toLowerCase();
const set = new Set<string>();
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<HTMLElement>('.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 之外,语义不混
}