@apollo-design/a11y 0.0.0-stage → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -2
- package/dist/combobox.mjs +37 -0
- package/dist/focus-restore.mjs +86 -0
- package/dist/index.d.mts +455 -0
- package/dist/index.d.ts +455 -0
- package/dist/index.mjs +8 -0
- package/dist/live-region.mjs +87 -0
- package/dist/roving.mjs +54 -0
- package/dist/use-active-descendant.mjs +24 -0
- package/dist/use-roving-focus.mjs +97 -0
- package/dist/use-typeahead.mjs +41 -0
- package/package.json +32 -3
package/README.md
CHANGED
|
@@ -1,3 +1,114 @@
|
|
|
1
|
-
|
|
1
|
+
> 规范范围:本文件保留实现说明与历史对照证据;上游同构、旧阶段与历史 Gate 要求不再生效。现行规则见 [项目规范](../../AGENTS.md),公共行为以 Apollo 组件 API 文档与测试契约为准。
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# @apollo-design/a11y
|
|
4
|
+
|
|
5
|
+
> **层**:L1 | **风险**:medium
|
|
6
|
+
>
|
|
7
|
+
> 本文档声明本包的**职责边界**。边界之外的事情不做 —— 如果发现需要做,说明架构需要调整,
|
|
8
|
+
> 应当先修改 `ARCHITECTURE.md` 并记录 ADR。
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 职责
|
|
13
|
+
|
|
14
|
+
运行时无障碍原语。antd 把焦点管理、roving tabindex、live region 等散落在各组件与 rc 包里,本项目把它们收敛为可复用的原语。
|
|
15
|
+
|
|
16
|
+
## 替代的 Ant Design 依赖
|
|
17
|
+
|
|
18
|
+
- `antd 内联 a11y 逻辑(本项目新增能力:antd 没有对应的独立包)`
|
|
19
|
+
|
|
20
|
+
## 公开 API
|
|
21
|
+
|
|
22
|
+
- lockFocus / useLockFocus / getFocusNodeList / triggerFocus / resetFocusLock —— 焦点陷阱(再导出自 utils)
|
|
23
|
+
- useFocusRestore —— 关闭后焦点恢复(Modal / Drawer / Image.Preview);多层保存返回路径,移除目标回退与卸载锁释放后恢复见 [当前契约](../../docs/foundation/a11y-contract.md#351-apollo-多层焦点返回2026-10-25)
|
|
24
|
+
|
|
25
|
+
`save(target?: Element | null)`省略参数保存当前外部焦点;已知封闭源根可传实际Element,null明确不保存。容器的焦点判断使用其实际根,包含判断跨ShadowRoot宿主;恢复仍保留多层返回路径、目标移除回退与后续开层优先。Image通过私有源节点getter使用此能力,没有新增Image公开prop。
|
|
26
|
+
- useRovingFocus + nextRovingIndex / moveRovingIndex / getRovingOffset / getRovingTabIndex —— roving tabindex 与方向键导航(Menu / Tabs / Radio.Group / Toolbar)
|
|
27
|
+
- useActiveDescendant + getListboxId / getOptionId —— aria-activedescendant 的 id 方案(Select / Tree / Listbox 类)
|
|
28
|
+
- useLiveRegion / announce / announceValues / formatLiveRegionText —— 屏幕阅读器播报(message / notification / upload / transfer)
|
|
29
|
+
- useTypeahead + pushTypeaheadChar / findTypeaheadIndex / isTypeaheadKey —— 键盘字符快速定位(Select / Tree / Menu)
|
|
30
|
+
|
|
31
|
+
## 明确不做(边界)
|
|
32
|
+
|
|
33
|
+
- ❌ 不做视觉样式(本包零 CSS;唯一例外是隐藏 live region 的内联样式,它是可达性语义的一部分)
|
|
34
|
+
- ❌ 不做 axe 扫描(那是 @apollo-design/test-utils 的 a11yDemoTest)
|
|
35
|
+
- ❌ 不重复实现 useId(在 utils)
|
|
36
|
+
- ❌ 不重写焦点陷阱(已在 utils,本包只再导出)
|
|
37
|
+
- ❌ 不渲染组件(列表 / 菜单 / 浮层的 DOM 结构属 ui 层)
|
|
38
|
+
|
|
39
|
+
## 当前 Vue 用法
|
|
40
|
+
|
|
41
|
+
组合函数的回调负责提出请求,组件是否接受由当前 ref 决定。`undefined` 撤控后保留最后接收值;返回的 `activeIndex` ref 可直接写入,也会经过同一请求出口。已有代码若只是要设置非受控初值,改用 `setActive(index, false)`,不要把静态 `activeIndex` 当作默认值。
|
|
42
|
+
|
|
43
|
+
```vue
|
|
44
|
+
<script setup lang="ts">
|
|
45
|
+
import { useRovingFocus } from '@apollo-design/a11y';
|
|
46
|
+
import { ref, useTemplateRef } from 'vue';
|
|
47
|
+
|
|
48
|
+
const count = ref(3);
|
|
49
|
+
const selected = ref(0);
|
|
50
|
+
const items = useTemplateRef<HTMLButtonElement[]>('items');
|
|
51
|
+
const { getTabIndex, onKeyDown } = useRovingFocus({
|
|
52
|
+
count,
|
|
53
|
+
activeIndex: selected,
|
|
54
|
+
onChange: (index) => { selected.value = index; },
|
|
55
|
+
getItem: (index) => items.value?.[index] ?? null,
|
|
56
|
+
});
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<template>
|
|
60
|
+
<div role="toolbar" aria-label="Actions" @keydown="onKeyDown">
|
|
61
|
+
<button v-for="index in count" :key="index" ref="items" :tabindex="getTabIndex(index - 1)">
|
|
62
|
+
Action {{ index }}
|
|
63
|
+
</button>
|
|
64
|
+
</div>
|
|
65
|
+
</template>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
导航仅在接受目标后移动焦点,Home/End 与方向键会阻止默认行为;已处理事件、输入法事件和空列表不消费。宿主提供逻辑项顺序及禁用过滤。本包不创建控件 DOM。
|
|
69
|
+
|
|
70
|
+
`useLiveRegion()` 可在 setup 的 `watchEffect` 中播报;挂载前保留最后文本,挂载后创建节点,卸载销毁。命令式 `announce()` 继续使用默认文档的共享节点。详细契约与历史证据见[当前增量](../../docs/foundation/a11y-contract.md#当前增量--2026-10-30)。
|
|
71
|
+
|
|
72
|
+
## 必须遵守的契约
|
|
73
|
+
|
|
74
|
+
- 所有原语必须可在 jsdom 中用键盘事件断言(不需要真实浏览器)
|
|
75
|
+
- 焦点恢复必须回到触发元素(Modal/Drawer 关闭后)
|
|
76
|
+
- live region 必须复用同一个 DOM 节点,避免播报丢失
|
|
77
|
+
|
|
78
|
+
## 依赖
|
|
79
|
+
|
|
80
|
+
### 运行时依赖
|
|
81
|
+
|
|
82
|
+
| `@apollo-design/utils` | `workspace:*` |
|
|
83
|
+
|
|
84
|
+
### peer 依赖
|
|
85
|
+
|
|
86
|
+
| `vue` | `catalog:` |
|
|
87
|
+
|
|
88
|
+
### 构建期 / 测试依赖(devDependencies,**不会**进入用户的依赖树)
|
|
89
|
+
|
|
90
|
+
(无)
|
|
91
|
+
|
|
92
|
+
## 依赖约束(ARCHITECTURE.md §3.1)
|
|
93
|
+
|
|
94
|
+
- **R1 单向**:只能依赖同层或更低层
|
|
95
|
+
- **R2 无环**:不得与其他包循环依赖
|
|
96
|
+
- **R3 地基纯净**(仅 L0):不得包含任何组件视觉语义
|
|
97
|
+
- **R4 引擎无视觉**(仅 L2):不得定义颜色/圆角/阴影,不得产出 CSS
|
|
98
|
+
- **R6 显式声明**:跨包导入必须在本文件的 `dependencies` 中声明(`.npmrc` 已设 `hoist=false`)
|
|
99
|
+
- **R7 零 Ant Design 运行时依赖**:发布包的 `dependencies` 不得出现任何 `@ant-design/*`。
|
|
100
|
+
Ant Design 生态包只允许出现在三处 —— ① 构建期数据源(`registry/tools/gen-*.mjs`)
|
|
101
|
+
② 测试 Oracle(`*.oracle.test.ts`)③ `devDependencies`。由 `registry:validate` 的 **E19** 强制。
|
|
102
|
+
|
|
103
|
+
本包的依赖已通过 `registry/tools/scaffold-packages.mjs` 的分层校验。
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
## 测试
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
pnpm --filter @apollo-design/a11y test
|
|
110
|
+
pnpm --filter @apollo-design/a11y lint
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
测试要求见 [`TESTING.md`](../../TESTING.md)。
|
|
114
|
+
覆盖率下限为 语句 95% / 分支 90% / 函数 95%(L1 档位,见 `vitest.config.ts` 的 `coverage.thresholds`)。
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
function getListboxId(id) {
|
|
2
|
+
return `${id}_list`;
|
|
3
|
+
}
|
|
4
|
+
function getOptionId(id, index) {
|
|
5
|
+
return `${id}_list_${index}`;
|
|
6
|
+
}
|
|
7
|
+
const DEFAULT_TYPEAHEAD_RESET_DELAY = 500;
|
|
8
|
+
const INITIAL_TYPEAHEAD_STATE = { buffer: "", lastAt: 0 };
|
|
9
|
+
function pushTypeaheadChar(state, char, now, resetDelay = DEFAULT_TYPEAHEAD_RESET_DELAY) {
|
|
10
|
+
const expired = now - state.lastAt > resetDelay;
|
|
11
|
+
return {
|
|
12
|
+
buffer: expired ? char : state.buffer + char,
|
|
13
|
+
lastAt: now
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function isTypeaheadKey(event) {
|
|
17
|
+
if (event.ctrlKey || event.metaKey || event.altKey || event.isComposing || event.defaultPrevented) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
return event.key.length === 1;
|
|
21
|
+
}
|
|
22
|
+
function findTypeaheadIndex(labels, buffer, fromIndex) {
|
|
23
|
+
const count = labels.length;
|
|
24
|
+
if (!buffer || count === 0) {
|
|
25
|
+
return -1;
|
|
26
|
+
}
|
|
27
|
+
const needle = buffer.toLowerCase();
|
|
28
|
+
for (let step = 0; step < count; step += 1) {
|
|
29
|
+
const index = ((fromIndex + 1 + step) % count + count) % count;
|
|
30
|
+
if (labels[index]?.toLowerCase().startsWith(needle)) {
|
|
31
|
+
return index;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return -1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export { DEFAULT_TYPEAHEAD_RESET_DELAY, INITIAL_TYPEAHEAD_STATE, findTypeaheadIndex, getListboxId, getOptionId, isTypeaheadKey, pushTypeaheadChar };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { getActiveElement, containsComposed } from '@apollo-design/utils';
|
|
2
|
+
import { onScopeDispose, toValue, nextTick } from 'vue';
|
|
3
|
+
|
|
4
|
+
function focusElement(element, options) {
|
|
5
|
+
if (element && "focus" in element && typeof element.focus === "function") element.focus(options);
|
|
6
|
+
}
|
|
7
|
+
const documentRestores = /* @__PURE__ */ new WeakMap();
|
|
8
|
+
function restoreConnectedTarget(targets) {
|
|
9
|
+
const target = targets.find((element) => element.isConnected);
|
|
10
|
+
if (!target) return null;
|
|
11
|
+
try {
|
|
12
|
+
focusElement(target, { preventScroll: true });
|
|
13
|
+
return target;
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function useFocusRestore(getContainer, options = {}) {
|
|
19
|
+
const entry = { getContainer, targets: [] };
|
|
20
|
+
let registered = null;
|
|
21
|
+
const clearSaved = () => {
|
|
22
|
+
registered?.entries.delete(entry);
|
|
23
|
+
registered = null;
|
|
24
|
+
entry.targets = [];
|
|
25
|
+
};
|
|
26
|
+
const getCurrentActiveElement = () => {
|
|
27
|
+
const container = getContainer();
|
|
28
|
+
const doc = container?.ownerDocument ?? (typeof document !== "undefined" ? document : null);
|
|
29
|
+
return getActiveElement(container ?? doc);
|
|
30
|
+
};
|
|
31
|
+
const save = (target) => {
|
|
32
|
+
const active = target === void 0 ? getCurrentActiveElement() : target;
|
|
33
|
+
if (containsComposed(getContainer(), active)) return;
|
|
34
|
+
clearSaved();
|
|
35
|
+
if (!active) return;
|
|
36
|
+
const doc = active.ownerDocument;
|
|
37
|
+
let state = documentRestores.get(doc);
|
|
38
|
+
if (!state) {
|
|
39
|
+
state = { entries: /* @__PURE__ */ new Set(), saveVersion: 0 };
|
|
40
|
+
documentRestores.set(doc, state);
|
|
41
|
+
}
|
|
42
|
+
state.saveVersion++;
|
|
43
|
+
if (!active.isConnected || active === doc.body || active === doc.documentElement) return;
|
|
44
|
+
let owner;
|
|
45
|
+
let ownerContainer = null;
|
|
46
|
+
for (const candidate of state.entries) {
|
|
47
|
+
const container = candidate.getContainer();
|
|
48
|
+
if (container && containsComposed(container, active) && (!ownerContainer || containsComposed(ownerContainer, container))) {
|
|
49
|
+
owner = candidate;
|
|
50
|
+
ownerContainer = container;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
entry.targets = [.../* @__PURE__ */ new Set([active, ...owner?.targets ?? []])];
|
|
54
|
+
registered = state;
|
|
55
|
+
state.entries.add(entry);
|
|
56
|
+
};
|
|
57
|
+
const focusContent = () => {
|
|
58
|
+
const active = getCurrentActiveElement();
|
|
59
|
+
if (!containsComposed(getContainer(), active)) {
|
|
60
|
+
focusElement(getContainer());
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const restore = () => {
|
|
64
|
+
const mask = toValue(options.mask) ?? true;
|
|
65
|
+
const enabled = toValue(options.enabled) ?? true;
|
|
66
|
+
if (!mask || !enabled || !entry.targets.length) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const targets = entry.targets;
|
|
70
|
+
const doc = targets[0]?.ownerDocument;
|
|
71
|
+
const state = registered;
|
|
72
|
+
const saveVersion = state?.saveVersion;
|
|
73
|
+
clearSaved();
|
|
74
|
+
const target = restoreConnectedTarget(targets);
|
|
75
|
+
if (!target || !doc || getActiveElement(target) === target) return;
|
|
76
|
+
void nextTick(() => {
|
|
77
|
+
if (getActiveElement(target) === doc.body && state?.saveVersion === saveVersion) {
|
|
78
|
+
restoreConnectedTarget(targets);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
onScopeDispose(clearSaved);
|
|
83
|
+
return { save, focusContent, restore };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export { useFocusRestore };
|