iter(g81): 树组件改用 roving tabindex
This commit is contained in:
@@ -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
@@ -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' },
|
||||||
|
|||||||
@@ -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 === ' ') {
|
||||||
|
|||||||
Reference in New Issue
Block a user