diff --git a/.ua/backlog.json b/.ua/backlog.json index 402bb71..d0aafc9 100644 --- a/.ua/backlog.json +++ b/.ua/backlog.json @@ -1004,6 +1004,14 @@ "acceptance": "pointerenter / focusin 暂停并扣减已流逝时间,pointerleave / focusout 按剩余毫秒续计;duration 为 0 时行为不变;关闭仍先清计时器;文档与演示描述同步;npm run check 全绿", "notes": "自补:无障碍;不改签名,remaining 时间在组件内折算", "status": "done" + }, + { + "id": "g81", + "title": "树形组件改用 roving tabindex", + "motivation": "每行都写死 tabindex=0,键盘用户从树里穿过去要按 N 次 Tab(20 个节点就是 20 次),违反 WAI-ARIA 树模式「整棵树一个 Tab 停点、行间用方向键」的要求", + "acceptance": "只有当前行 tabindex=0 其余 -1;焦点落到哪行停点就跟到哪行(focusin 同步),下次 Tab 直接回到该行;↑↓/Home/End 移动后停点随焦点迁移;初始停点为首个未禁用根节点;tree ≤ 200 行;npm run check 全绿", + "notes": "自补:无障碍;keyOf 抽出复用,去掉 keydown 里的内联查找", + "status": "open" } ] } diff --git a/apps/demo/main.ts b/apps/demo/main.ts index ac67ae2..c358fa6 100644 --- a/apps/demo/main.ts +++ b/apps/demo/main.ts @@ -685,7 +685,7 @@ const content = h( section( 'sec-tree', '树形', - 'tree:递归节点,箭头展开/收起、点标签选中;键盘 Enter 选中、→ 展开、← 收起,禁用节点不响应。', + 'tree:递归节点,箭头展开/收起、点标签选中;键盘 Enter 选中、→ 展开、← 收起、↑↓/Home/End 行间移动,整棵树只占一个 Tab 停点(roving tabindex),禁用节点不响应。', h( 'div', { class: 'row' }, diff --git a/packages/skin/ui/src/tree.ts b/packages/skin/ui/src/tree.ts index af041b4..1236f95 100644 --- a/packages/skin/ui/src/tree.ts +++ b/packages/skin/ui/src/tree.ts @@ -13,6 +13,16 @@ export function tree(props: TreeProps): HTMLElement { 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 停点 + + /*** 切换 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('.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 === ' ') {