iter(g81): 树组件改用 roving tabindex

This commit is contained in:
HIsheR
2026-10-26 12:58:56 +08:00
parent ba196b8f3f
commit f3c83e9552
3 changed files with 28 additions and 3 deletions
+19 -2
View File
@@ -13,6 +13,16 @@ export function tree(props: TreeProps): HTMLElement {
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 停点
/*** 切换 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)]);
@@ -60,7 +70,7 @@ export function tree(props: TreeProps): HTMLElement {
}
const row = h(
'div',
{ class: 'ua-tree-row', role: 'treeitem', 'aria-level': String(depth + 1), 'aria-expanded': String(on), tabindex: '0' },
{ 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),
@@ -80,6 +90,7 @@ export function tree(props: TreeProps): HTMLElement {
};
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); } });
@@ -113,6 +124,12 @@ export function tree(props: TreeProps): HTMLElement {
const visibleRows = (): HTMLElement[] =>
Array.from(root.querySelectorAll<HTMLElement>('.ua-tree-row')).filter((el) => !el.closest('.ua-tree-children[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; // 卫语句:焦点不在节点行
@@ -125,7 +142,7 @@ export function tree(props: TreeProps): HTMLElement {
next?.focus(); // 行间移动只改焦点,不改变选中
return;
}
const key = [...rows.entries()].find(([, el]) => el === row)?.[0];
const key = keyOf(row);
const node = key ? selfOf.get(key) : null;
if (!node || node.disabled || !key) return; // 卫语句:找不到或禁用节点
if (e.key === 'Enter' || e.key === ' ') {