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
+8
View File
@@ -1004,6 +1004,14 @@
"acceptance": "pointerenter / focusin 暂停并扣减已流逝时间,pointerleave / focusout 按剩余毫秒续计;duration 为 0 时行为不变;关闭仍先清计时器;文档与演示描述同步;npm run check 全绿", "acceptance": "pointerenter / focusin 暂停并扣减已流逝时间,pointerleave / focusout 按剩余毫秒续计;duration 为 0 时行为不变;关闭仍先清计时器;文档与演示描述同步;npm run check 全绿",
"notes": "自补:无障碍;不改签名,remaining 时间在组件内折算", "notes": "自补:无障碍;不改签名,remaining 时间在组件内折算",
"status": "done" "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"
} }
] ]
} }
+1 -1
View File
@@ -685,7 +685,7 @@ const content = h(
section( section(
'sec-tree', 'sec-tree',
'树形', '树形',
'tree:递归节点,箭头展开/收起、点标签选中;键盘 Enter 选中、→ 展开、← 收起,禁用节点不响应。', 'tree:递归节点,箭头展开/收起、点标签选中;键盘 Enter 选中、→ 展开、← 收起、↑↓/Home/End 行间移动,整棵树只占一个 Tab 停点(roving tabindex),禁用节点不响应。',
h( h(
'div', 'div',
{ class: 'row' }, { class: 'row' },
+19 -2
View File
@@ -13,6 +13,16 @@ export function tree(props: TreeProps): HTMLElement {
const boxes = new Map<string, HTMLInputElement>(); // key → 勾选框 const boxes = new Map<string, HTMLInputElement>(); // key → 勾选框
const selfOf = new Map<string, TreeNode>(); // key → 节点对象 const selfOf = new Map<string, TreeNode>(); // key → 节点对象
const parentOf = new Map<string, string | null>(); // key → 父 key(根为 null) 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 列表(含被禁用项,勾选时再按禁用过滤) ***/ /*** 子孙 key 列表(含被禁用项,勾选时再按禁用过滤) ***/
const descOf = (node: TreeNode): string[] => (node.children ?? []).flatMap((c) => [c.key, ...descOf(c)]); 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( const row = h(
'div', '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, box,
caret, caret,
h('span', { class: 'ua-tree-label' }, node.label), 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; 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) { if (props.defaultExpandAll) {
const all: string[] = []; const all: string[] = [];
const walk = (list: TreeNode[]) => list.forEach((n) => { if (n.children?.length) { all.push(n.key); walk(n.children); } }); 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[] => const visibleRows = (): HTMLElement[] =>
Array.from(root.querySelectorAll<HTMLElement>('.ua-tree-row')).filter((el) => !el.closest('.ua-tree-children[hidden]')); 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) => { root.addEventListener('keydown', (e: KeyboardEvent) => {
const row = (e.target as HTMLElement).closest('.ua-tree-row') as HTMLElement | null; const row = (e.target as HTMLElement).closest('.ua-tree-row') as HTMLElement | null;
if (!row) return; // 卫语句:焦点不在节点行 if (!row) return; // 卫语句:焦点不在节点行
@@ -125,7 +142,7 @@ export function tree(props: TreeProps): HTMLElement {
next?.focus(); // 行间移动只改焦点,不改变选中 next?.focus(); // 行间移动只改焦点,不改变选中
return; return;
} }
const key = [...rows.entries()].find(([, el]) => el === row)?.[0]; const key = keyOf(row);
const node = key ? selfOf.get(key) : null; const node = key ? selfOf.get(key) : null;
if (!node || node.disabled || !key) return; // 卫语句:找不到或禁用节点 if (!node || node.disabled || !key) return; // 卫语句:找不到或禁用节点
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {