@flighthq/accessibility 0.1.0
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/dist/accessibility.d.ts +10 -0
- package/dist/accessibility.d.ts.map +1 -0
- package/dist/accessibility.js +234 -0
- package/dist/accessibility.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/package.json +37 -0
- package/src/accessibility.test.ts +285 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AccessibilityBackend, AccessibilityLiveness, AccessibilityNode } from '@flighthq/types';
|
|
2
|
+
export declare function announceAccessibility(message: string, liveness?: AccessibilityLiveness): void;
|
|
3
|
+
export declare function clearAccessibilityTree(): void;
|
|
4
|
+
export declare function createWebAccessibilityBackend(container?: HTMLElement): AccessibilityBackend;
|
|
5
|
+
export declare function getAccessibilityBackend(): AccessibilityBackend;
|
|
6
|
+
export declare function removeAccessibilityNode(id: string): void;
|
|
7
|
+
export declare function setAccessibilityBackend(backend: AccessibilityBackend | null): void;
|
|
8
|
+
export declare function setAccessibilityFocus(id: string): boolean;
|
|
9
|
+
export declare function setAccessibilityNode(node: Readonly<AccessibilityNode>): void;
|
|
10
|
+
//# sourceMappingURL=accessibility.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"accessibility.d.ts","sourceRoot":"","sources":["../src/accessibility.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,oBAAoB,EACpB,qBAAqB,EACrB,iBAAiB,EAElB,MAAM,iBAAiB,CAAC;AAKzB,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,GAAE,qBAAgC,GAAG,IAAI,CAEvG;AAID,wBAAgB,sBAAsB,IAAI,IAAI,CAE7C;AAQD,wBAAgB,6BAA6B,CAAC,SAAS,CAAC,EAAE,WAAW,GAAG,oBAAoB,CAgE3F;AAID,wBAAgB,uBAAuB,IAAI,oBAAoB,CAG9D;AAGD,wBAAgB,uBAAuB,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAExD;AAGD,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,oBAAoB,GAAG,IAAI,GAAG,IAAI,CAElF;AAID,wBAAgB,qBAAqB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAEzD;AAID,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,QAAQ,CAAC,iBAAiB,CAAC,GAAG,IAAI,CAE5E"}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Speaks a transient message through the platform's live region. `liveness` picks urgency: 'polite'
|
|
2
|
+
// waits for current speech, 'assertive' interrupts. Defaults to 'polite'. No-op when no backend can
|
|
3
|
+
// speak.
|
|
4
|
+
export function announceAccessibility(message, liveness = 'polite') {
|
|
5
|
+
getAccessibilityBackend().announce(message, liveness);
|
|
6
|
+
}
|
|
7
|
+
// Empties the mirrored accessibility tree, removing every published node. Announcement live regions
|
|
8
|
+
// are recreated lazily on the next announce.
|
|
9
|
+
export function clearAccessibilityTree() {
|
|
10
|
+
getAccessibilityBackend().clear();
|
|
11
|
+
}
|
|
12
|
+
// Builds the default web backend: a visually-hidden ARIA DOM overlay. Created lazily by
|
|
13
|
+
// getAccessibilityBackend — nothing touches the DOM at import time, so importing the package has no
|
|
14
|
+
// side effect. Each published node becomes an element carrying `role` + `aria-*` attributes, nested
|
|
15
|
+
// under its `parentId`'s element (or the container root). By default a hidden container is appended
|
|
16
|
+
// to document.body on first use; pass `container` to host the overlay elsewhere. With no DOM present
|
|
17
|
+
// every method is a sentinel no-op (setFocus returns false) rather than throwing.
|
|
18
|
+
export function createWebAccessibilityBackend(container) {
|
|
19
|
+
const elements = new Map();
|
|
20
|
+
const liveRegions = new Map();
|
|
21
|
+
let root = container ?? null;
|
|
22
|
+
let rootResolved = container !== undefined;
|
|
23
|
+
// Resolves the overlay root, lazily creating and appending a hidden container on first use. Returns
|
|
24
|
+
// null when no DOM is available, which flips every method into a no-op.
|
|
25
|
+
function getRoot() {
|
|
26
|
+
if (rootResolved)
|
|
27
|
+
return root;
|
|
28
|
+
rootResolved = true;
|
|
29
|
+
if (typeof document === 'undefined' || document.body === null) {
|
|
30
|
+
root = null;
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
root = _createHiddenAccessibilityContainer(document);
|
|
34
|
+
document.body.appendChild(root);
|
|
35
|
+
return root;
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
setNode(node) {
|
|
39
|
+
const overlayRoot = getRoot();
|
|
40
|
+
if (overlayRoot === null)
|
|
41
|
+
return;
|
|
42
|
+
let element = elements.get(node.id);
|
|
43
|
+
if (element === undefined) {
|
|
44
|
+
element = overlayRoot.ownerDocument.createElement('div');
|
|
45
|
+
element.setAttribute('data-flight-accessibility-id', node.id);
|
|
46
|
+
elements.set(node.id, element);
|
|
47
|
+
}
|
|
48
|
+
_applyAccessibilityElementAttributes(element, node);
|
|
49
|
+
_reparentAccessibilityElement(element, node.parentId, elements, overlayRoot);
|
|
50
|
+
},
|
|
51
|
+
removeNode(id) {
|
|
52
|
+
const element = elements.get(id);
|
|
53
|
+
if (element === undefined)
|
|
54
|
+
return;
|
|
55
|
+
// Drop the node and its entire descendant subtree (Node.contains is true for the element
|
|
56
|
+
// itself and every DOM descendant, matching the nested overlay layout).
|
|
57
|
+
for (const [key, other] of elements) {
|
|
58
|
+
if (element.contains(other))
|
|
59
|
+
elements.delete(key);
|
|
60
|
+
}
|
|
61
|
+
if (element.parentNode !== null)
|
|
62
|
+
element.parentNode.removeChild(element);
|
|
63
|
+
},
|
|
64
|
+
clear() {
|
|
65
|
+
const overlayRoot = getRoot();
|
|
66
|
+
elements.clear();
|
|
67
|
+
liveRegions.clear();
|
|
68
|
+
if (overlayRoot !== null)
|
|
69
|
+
overlayRoot.replaceChildren();
|
|
70
|
+
},
|
|
71
|
+
setFocus(id) {
|
|
72
|
+
const overlayRoot = getRoot();
|
|
73
|
+
if (overlayRoot === null)
|
|
74
|
+
return false;
|
|
75
|
+
const element = elements.get(id);
|
|
76
|
+
if (element === undefined)
|
|
77
|
+
return false;
|
|
78
|
+
element.focus();
|
|
79
|
+
return overlayRoot.ownerDocument.activeElement === element;
|
|
80
|
+
},
|
|
81
|
+
announce(message, liveness) {
|
|
82
|
+
const overlayRoot = getRoot();
|
|
83
|
+
if (overlayRoot === null)
|
|
84
|
+
return;
|
|
85
|
+
const region = _getAccessibilityLiveRegion(overlayRoot, liveRegions, liveness);
|
|
86
|
+
region.textContent = message;
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// The active accessibility backend, lazily defaulting to the web ARIA overlay. There is always a
|
|
91
|
+
// backend.
|
|
92
|
+
export function getAccessibilityBackend() {
|
|
93
|
+
if (_backend === null)
|
|
94
|
+
_backend = createWebAccessibilityBackend();
|
|
95
|
+
return _backend;
|
|
96
|
+
}
|
|
97
|
+
// Removes a node and its entire descendant subtree from the mirrored tree.
|
|
98
|
+
export function removeAccessibilityNode(id) {
|
|
99
|
+
getAccessibilityBackend().removeNode(id);
|
|
100
|
+
}
|
|
101
|
+
// Installs a native host accessibility backend; pass null to fall back to a fresh lazy web default.
|
|
102
|
+
export function setAccessibilityBackend(backend) {
|
|
103
|
+
_backend = backend;
|
|
104
|
+
}
|
|
105
|
+
// Moves platform focus to the published node. Returns false when the node is missing or the platform
|
|
106
|
+
// could not focus it — a sentinel, never a throw.
|
|
107
|
+
export function setAccessibilityFocus(id) {
|
|
108
|
+
return getAccessibilityBackend().setFocus(id);
|
|
109
|
+
}
|
|
110
|
+
// Registers or updates a node in the mirrored tree, keyed by `node.id` and parented by
|
|
111
|
+
// `node.parentId`. Re-issuing with the same id updates the existing node in place.
|
|
112
|
+
export function setAccessibilityNode(node) {
|
|
113
|
+
getAccessibilityBackend().setNode(node);
|
|
114
|
+
}
|
|
115
|
+
let _backend = null;
|
|
116
|
+
const _TEXT_NODE = 3;
|
|
117
|
+
// Reflects a node's semantics onto its overlay element: role, the label/description/value text and
|
|
118
|
+
// their ARIA attributes, focusability, and the mapped state attributes. Attributes are cleared when
|
|
119
|
+
// their source field is absent, so an update that drops a field clears the stale attribute.
|
|
120
|
+
function _applyAccessibilityElementAttributes(element, node) {
|
|
121
|
+
element.setAttribute('role', node.role);
|
|
122
|
+
_reflectAccessibilityAttribute(element, 'aria-label', node.label);
|
|
123
|
+
_reflectAccessibilityAttribute(element, 'aria-description', node.description);
|
|
124
|
+
_reflectAccessibilityAttribute(element, 'title', node.description);
|
|
125
|
+
_reflectAccessibilityAttribute(element, 'aria-valuetext', node.value);
|
|
126
|
+
_setAccessibilityElementValueText(element, node.value);
|
|
127
|
+
// Every node element is focusable so setFocus can move platform focus to it, including
|
|
128
|
+
// non-interactive roles (heading/region) a caller may want to move a screen reader to.
|
|
129
|
+
element.setAttribute('tabindex', '-1');
|
|
130
|
+
_applyAccessibilityStateAttributes(element, node.states ?? _EMPTY_STATE);
|
|
131
|
+
}
|
|
132
|
+
// Maps each AccessibilityState field to its ARIA attribute. Absent fields clear the attribute so a
|
|
133
|
+
// state that was set then dropped does not linger.
|
|
134
|
+
function _applyAccessibilityStateAttributes(element, state) {
|
|
135
|
+
_reflectAccessibilityBoolean(element, 'aria-disabled', state.disabled);
|
|
136
|
+
_reflectAccessibilityBoolean(element, 'aria-checked', state.checked);
|
|
137
|
+
_reflectAccessibilityBoolean(element, 'aria-expanded', state.expanded);
|
|
138
|
+
_reflectAccessibilityBoolean(element, 'aria-selected', state.selected);
|
|
139
|
+
_reflectAccessibilityBoolean(element, 'aria-pressed', state.pressed);
|
|
140
|
+
_reflectAccessibilityBoolean(element, 'aria-busy', state.busy);
|
|
141
|
+
_reflectAccessibilityBoolean(element, 'aria-hidden', state.hidden);
|
|
142
|
+
_reflectAccessibilityBoolean(element, 'aria-readonly', state.readonly);
|
|
143
|
+
_reflectAccessibilityBoolean(element, 'aria-required', state.required);
|
|
144
|
+
_reflectAccessibilityNumber(element, 'aria-level', state.level);
|
|
145
|
+
_reflectAccessibilityNumber(element, 'aria-valuemin', state.valueMin);
|
|
146
|
+
_reflectAccessibilityNumber(element, 'aria-valuemax', state.valueMax);
|
|
147
|
+
_reflectAccessibilityNumber(element, 'aria-valuenow', state.valueNow);
|
|
148
|
+
}
|
|
149
|
+
// Builds the visually-hidden overlay container. Uses the standard clip-rect "sr-only" inline styles
|
|
150
|
+
// so the elements stay in the accessibility tree while drawing nothing on screen.
|
|
151
|
+
function _createHiddenAccessibilityContainer(doc) {
|
|
152
|
+
const container = doc.createElement('div');
|
|
153
|
+
container.setAttribute('data-flight-accessibility', 'true');
|
|
154
|
+
const style = container.style;
|
|
155
|
+
style.position = 'absolute';
|
|
156
|
+
style.width = '1px';
|
|
157
|
+
style.height = '1px';
|
|
158
|
+
style.margin = '-1px';
|
|
159
|
+
style.padding = '0';
|
|
160
|
+
style.border = '0';
|
|
161
|
+
style.overflow = 'hidden';
|
|
162
|
+
style.clip = 'rect(0 0 0 0)';
|
|
163
|
+
style.clipPath = 'inset(50%)';
|
|
164
|
+
style.whiteSpace = 'nowrap';
|
|
165
|
+
return container;
|
|
166
|
+
}
|
|
167
|
+
// Resolves the persistent aria-live region for the given urgency, creating and appending it under
|
|
168
|
+
// the container on first use (or when a prior clear removed it).
|
|
169
|
+
function _getAccessibilityLiveRegion(root, liveRegions, liveness) {
|
|
170
|
+
let region = liveRegions.get(liveness);
|
|
171
|
+
if (region === undefined || region.parentNode === null) {
|
|
172
|
+
region = root.ownerDocument.createElement('div');
|
|
173
|
+
region.setAttribute('aria-live', liveness);
|
|
174
|
+
region.setAttribute('aria-atomic', 'true');
|
|
175
|
+
region.setAttribute('data-flight-accessibility-live', liveness);
|
|
176
|
+
liveRegions.set(liveness, region);
|
|
177
|
+
root.appendChild(region);
|
|
178
|
+
}
|
|
179
|
+
return region;
|
|
180
|
+
}
|
|
181
|
+
// Sets a string attribute, or removes it when the value is absent.
|
|
182
|
+
function _reflectAccessibilityAttribute(element, attribute, value) {
|
|
183
|
+
if (value === undefined) {
|
|
184
|
+
element.removeAttribute(attribute);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
element.setAttribute(attribute, value);
|
|
188
|
+
}
|
|
189
|
+
// Sets a boolean attribute as 'true'/'false', or removes it when the value is absent.
|
|
190
|
+
function _reflectAccessibilityBoolean(element, attribute, value) {
|
|
191
|
+
if (value === undefined) {
|
|
192
|
+
element.removeAttribute(attribute);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
element.setAttribute(attribute, value ? 'true' : 'false');
|
|
196
|
+
}
|
|
197
|
+
// Sets a numeric attribute as its decimal string, or removes it when the value is absent.
|
|
198
|
+
function _reflectAccessibilityNumber(element, attribute, value) {
|
|
199
|
+
if (value === undefined) {
|
|
200
|
+
element.removeAttribute(attribute);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
element.setAttribute(attribute, String(value));
|
|
204
|
+
}
|
|
205
|
+
// Moves the element under the element of `parentId` (or the container root when the parent is unknown
|
|
206
|
+
// or unset). Only touches the DOM when the parent actually changes, so re-issuing an unchanged node
|
|
207
|
+
// causes no reparenting churn.
|
|
208
|
+
function _reparentAccessibilityElement(element, parentId, elements, root) {
|
|
209
|
+
let parent = root;
|
|
210
|
+
if (parentId !== undefined) {
|
|
211
|
+
const found = elements.get(parentId);
|
|
212
|
+
if (found !== undefined)
|
|
213
|
+
parent = found;
|
|
214
|
+
}
|
|
215
|
+
if (element.parentNode !== parent)
|
|
216
|
+
parent.appendChild(element);
|
|
217
|
+
}
|
|
218
|
+
// Maintains the node's value as a leading text node so nested child elements survive value updates
|
|
219
|
+
// (setting element.textContent would wipe the appended child nodes).
|
|
220
|
+
function _setAccessibilityElementValueText(element, value) {
|
|
221
|
+
const first = element.firstChild;
|
|
222
|
+
if (value === undefined) {
|
|
223
|
+
if (first !== null && first.nodeType === _TEXT_NODE)
|
|
224
|
+
element.removeChild(first);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (first !== null && first.nodeType === _TEXT_NODE) {
|
|
228
|
+
first.nodeValue = value;
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
element.insertBefore(element.ownerDocument.createTextNode(value), first);
|
|
232
|
+
}
|
|
233
|
+
const _EMPTY_STATE = {};
|
|
234
|
+
//# sourceMappingURL=accessibility.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"accessibility.js","sourceRoot":"","sources":["../src/accessibility.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flighthq/accessibility",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"src/**/*.test.ts",
|
|
16
|
+
"!dist/**/*.test.js",
|
|
17
|
+
"!dist/**/*.test.d.ts",
|
|
18
|
+
"!dist/**/*.test.js.map",
|
|
19
|
+
"!dist/**/*.test.d.ts.map"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc -b",
|
|
23
|
+
"clean": "tsc -b --clean",
|
|
24
|
+
"test": "vitest run --config vitest.config.ts",
|
|
25
|
+
"test:watch": "vitest --watch --config vitest.config.ts",
|
|
26
|
+
"prepack": "npm run clean && npm run clean:dist && npm run build",
|
|
27
|
+
"clean:dist": "tsx ../../scripts/clean-package-dist.ts"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@flighthq/types": "0.1.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"typescript": "^5.3.0"
|
|
34
|
+
},
|
|
35
|
+
"description": "Assistive-technology (ARIA) bridge over a swappable web/native backend (a visually-hidden ARIA DOM overlay by default)",
|
|
36
|
+
"sideEffects": false
|
|
37
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import type { AccessibilityBackend, AccessibilityLiveness, AccessibilityNode } from '@flighthq/types';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
announceAccessibility,
|
|
6
|
+
clearAccessibilityTree,
|
|
7
|
+
createWebAccessibilityBackend,
|
|
8
|
+
getAccessibilityBackend,
|
|
9
|
+
removeAccessibilityNode,
|
|
10
|
+
setAccessibilityBackend,
|
|
11
|
+
setAccessibilityFocus,
|
|
12
|
+
setAccessibilityNode,
|
|
13
|
+
} from './accessibility';
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
setAccessibilityBackend(null);
|
|
17
|
+
vi.unstubAllGlobals();
|
|
18
|
+
document.body.replaceChildren();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('announceAccessibility', () => {
|
|
22
|
+
it('dispatches the message and liveness to the backend', () => {
|
|
23
|
+
const mock = createMockAccessibilityBackend();
|
|
24
|
+
setAccessibilityBackend(mock.backend);
|
|
25
|
+
announceAccessibility('saved', 'assertive');
|
|
26
|
+
expect(mock.calls.announce).toEqual([['saved', 'assertive']]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('defaults liveness to polite', () => {
|
|
30
|
+
const mock = createMockAccessibilityBackend();
|
|
31
|
+
setAccessibilityBackend(mock.backend);
|
|
32
|
+
announceAccessibility('loading');
|
|
33
|
+
expect(mock.calls.announce).toEqual([['loading', 'polite']]);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('clearAccessibilityTree', () => {
|
|
38
|
+
it('dispatches a clear to the backend', () => {
|
|
39
|
+
const mock = createMockAccessibilityBackend();
|
|
40
|
+
setAccessibilityBackend(mock.backend);
|
|
41
|
+
clearAccessibilityTree();
|
|
42
|
+
expect(mock.calls.clear).toBe(1);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('createWebAccessibilityBackend', () => {
|
|
47
|
+
it('creates an element carrying role, aria-label, and mapped state attributes', () => {
|
|
48
|
+
const container = document.createElement('div');
|
|
49
|
+
const backend = createWebAccessibilityBackend(container);
|
|
50
|
+
backend.setNode(node('play', 'button', { label: 'Play', states: { disabled: true, pressed: false } }));
|
|
51
|
+
const element = container.querySelector('[data-flight-accessibility-id="play"]');
|
|
52
|
+
expect(element).not.toBeNull();
|
|
53
|
+
expect(element?.getAttribute('role')).toBe('button');
|
|
54
|
+
expect(element?.getAttribute('aria-label')).toBe('Play');
|
|
55
|
+
expect(element?.getAttribute('aria-disabled')).toBe('true');
|
|
56
|
+
expect(element?.getAttribute('aria-pressed')).toBe('false');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('maps description to aria-description and title, and value to text and aria-valuetext', () => {
|
|
60
|
+
const container = document.createElement('div');
|
|
61
|
+
const backend = createWebAccessibilityBackend(container);
|
|
62
|
+
backend.setNode(node('vol', 'slider', { description: 'Volume level', value: '70%' }));
|
|
63
|
+
const element = container.querySelector('[data-flight-accessibility-id="vol"]');
|
|
64
|
+
expect(element?.getAttribute('aria-description')).toBe('Volume level');
|
|
65
|
+
expect(element?.getAttribute('title')).toBe('Volume level');
|
|
66
|
+
expect(element?.getAttribute('aria-valuetext')).toBe('70%');
|
|
67
|
+
expect(element?.textContent).toBe('70%');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('maps range and level numerics to their aria attributes', () => {
|
|
71
|
+
const container = document.createElement('div');
|
|
72
|
+
const backend = createWebAccessibilityBackend(container);
|
|
73
|
+
backend.setNode(node('bar', 'progressbar', { states: { valueMin: 0, valueMax: 100, valueNow: 42, level: 2 } }));
|
|
74
|
+
const element = container.querySelector('[data-flight-accessibility-id="bar"]');
|
|
75
|
+
expect(element?.getAttribute('aria-valuemin')).toBe('0');
|
|
76
|
+
expect(element?.getAttribute('aria-valuemax')).toBe('100');
|
|
77
|
+
expect(element?.getAttribute('aria-valuenow')).toBe('42');
|
|
78
|
+
expect(element?.getAttribute('aria-level')).toBe('2');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('updates an existing node in place without duplicating the element', () => {
|
|
82
|
+
const container = document.createElement('div');
|
|
83
|
+
const backend = createWebAccessibilityBackend(container);
|
|
84
|
+
backend.setNode(node('cb', 'checkbox', { label: 'Mute', states: { checked: false } }));
|
|
85
|
+
backend.setNode(node('cb', 'checkbox', { label: 'Muted', states: { checked: true } }));
|
|
86
|
+
const matches = container.querySelectorAll('[data-flight-accessibility-id="cb"]');
|
|
87
|
+
expect(matches.length).toBe(1);
|
|
88
|
+
expect(matches[0].getAttribute('aria-label')).toBe('Muted');
|
|
89
|
+
expect(matches[0].getAttribute('aria-checked')).toBe('true');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('clears an attribute when an updated node drops the field', () => {
|
|
93
|
+
const container = document.createElement('div');
|
|
94
|
+
const backend = createWebAccessibilityBackend(container);
|
|
95
|
+
backend.setNode(node('cb', 'checkbox', { label: 'Mute', states: { checked: true } }));
|
|
96
|
+
backend.setNode(node('cb', 'checkbox', {}));
|
|
97
|
+
const element = container.querySelector('[data-flight-accessibility-id="cb"]');
|
|
98
|
+
expect(element?.hasAttribute('aria-label')).toBe(false);
|
|
99
|
+
expect(element?.hasAttribute('aria-checked')).toBe(false);
|
|
100
|
+
expect(element?.textContent).toBe('');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('nests a child element under its parentId element', () => {
|
|
104
|
+
const container = document.createElement('div');
|
|
105
|
+
const backend = createWebAccessibilityBackend(container);
|
|
106
|
+
backend.setNode(node('menu', 'menu', { label: 'File' }));
|
|
107
|
+
backend.setNode(node('item', 'menuitem', { label: 'Open', parentId: 'menu' }));
|
|
108
|
+
const parent = container.querySelector('[data-flight-accessibility-id="menu"]');
|
|
109
|
+
const child = container.querySelector('[data-flight-accessibility-id="item"]');
|
|
110
|
+
expect(child?.parentElement).toBe(parent);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('re-parents an element when parentId changes', () => {
|
|
114
|
+
const container = document.createElement('div');
|
|
115
|
+
const backend = createWebAccessibilityBackend(container);
|
|
116
|
+
backend.setNode(node('a', 'group', {}));
|
|
117
|
+
backend.setNode(node('b', 'group', {}));
|
|
118
|
+
backend.setNode(node('leaf', 'button', { parentId: 'a' }));
|
|
119
|
+
const child = () => container.querySelector('[data-flight-accessibility-id="leaf"]');
|
|
120
|
+
expect(child()?.parentElement?.getAttribute('data-flight-accessibility-id')).toBe('a');
|
|
121
|
+
backend.setNode(node('leaf', 'button', { parentId: 'b' }));
|
|
122
|
+
expect(child()?.parentElement?.getAttribute('data-flight-accessibility-id')).toBe('b');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('removes a node and its descendant subtree', () => {
|
|
126
|
+
const container = document.createElement('div');
|
|
127
|
+
const backend = createWebAccessibilityBackend(container);
|
|
128
|
+
backend.setNode(node('menu', 'menu', {}));
|
|
129
|
+
backend.setNode(node('item', 'menuitem', { parentId: 'menu' }));
|
|
130
|
+
backend.removeNode('menu');
|
|
131
|
+
expect(container.querySelector('[data-flight-accessibility-id="menu"]')).toBeNull();
|
|
132
|
+
expect(container.querySelector('[data-flight-accessibility-id="item"]')).toBeNull();
|
|
133
|
+
// The descendant id is dropped from tracking too, so re-adding it lands at the root.
|
|
134
|
+
backend.setNode(node('item', 'menuitem', { parentId: 'menu' }));
|
|
135
|
+
expect(container.querySelector('[data-flight-accessibility-id="item"]')?.parentElement).toBe(container);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('focuses a node element and reports missing ids as false', () => {
|
|
139
|
+
const container = document.createElement('div');
|
|
140
|
+
document.body.appendChild(container);
|
|
141
|
+
const backend = createWebAccessibilityBackend(container);
|
|
142
|
+
backend.setNode(node('field', 'textbox', { label: 'Name' }));
|
|
143
|
+
expect(backend.setFocus('field')).toBe(true);
|
|
144
|
+
const element = container.querySelector('[data-flight-accessibility-id="field"]');
|
|
145
|
+
expect(document.activeElement).toBe(element);
|
|
146
|
+
expect(backend.setFocus('missing')).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('writes announcements into the polite and assertive live regions', () => {
|
|
150
|
+
const container = document.createElement('div');
|
|
151
|
+
const backend = createWebAccessibilityBackend(container);
|
|
152
|
+
backend.announce('level complete', 'polite');
|
|
153
|
+
backend.announce('warning', 'assertive');
|
|
154
|
+
const polite = container.querySelector('[data-flight-accessibility-live="polite"]');
|
|
155
|
+
const assertive = container.querySelector('[data-flight-accessibility-live="assertive"]');
|
|
156
|
+
expect(polite?.getAttribute('aria-live')).toBe('polite');
|
|
157
|
+
expect(polite?.textContent).toBe('level complete');
|
|
158
|
+
expect(assertive?.getAttribute('aria-live')).toBe('assertive');
|
|
159
|
+
expect(assertive?.textContent).toBe('warning');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('empties the container on clear', () => {
|
|
163
|
+
const container = document.createElement('div');
|
|
164
|
+
const backend = createWebAccessibilityBackend(container);
|
|
165
|
+
backend.setNode(node('a', 'button', {}));
|
|
166
|
+
backend.announce('hi', 'polite');
|
|
167
|
+
expect(container.childElementCount).toBeGreaterThan(0);
|
|
168
|
+
backend.clear();
|
|
169
|
+
expect(container.childElementCount).toBe(0);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('defaults its container into document.body', () => {
|
|
173
|
+
const backend = createWebAccessibilityBackend();
|
|
174
|
+
backend.setNode(node('a', 'button', {}));
|
|
175
|
+
expect(document.querySelector('[data-flight-accessibility-id="a"]')).not.toBeNull();
|
|
176
|
+
expect(document.body.querySelector('[data-flight-accessibility]')).not.toBeNull();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('is a sentinel no-op when no DOM is available', () => {
|
|
180
|
+
vi.stubGlobal('document', undefined);
|
|
181
|
+
const backend = createWebAccessibilityBackend();
|
|
182
|
+
expect(() => backend.setNode(node('a', 'button', {}))).not.toThrow();
|
|
183
|
+
expect(() => backend.removeNode('a')).not.toThrow();
|
|
184
|
+
expect(() => backend.announce('hi', 'polite')).not.toThrow();
|
|
185
|
+
expect(() => backend.clear()).not.toThrow();
|
|
186
|
+
expect(backend.setFocus('a')).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('getAccessibilityBackend', () => {
|
|
191
|
+
it('lazily returns a stable web default', () => {
|
|
192
|
+
const first = getAccessibilityBackend();
|
|
193
|
+
expect(first).toBe(getAccessibilityBackend());
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('returns the installed backend', () => {
|
|
197
|
+
const mock = createMockAccessibilityBackend();
|
|
198
|
+
setAccessibilityBackend(mock.backend);
|
|
199
|
+
expect(getAccessibilityBackend()).toBe(mock.backend);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe('removeAccessibilityNode', () => {
|
|
204
|
+
it('dispatches the id to the backend', () => {
|
|
205
|
+
const mock = createMockAccessibilityBackend();
|
|
206
|
+
setAccessibilityBackend(mock.backend);
|
|
207
|
+
removeAccessibilityNode('gone');
|
|
208
|
+
expect(mock.calls.removeNode).toEqual(['gone']);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe('setAccessibilityBackend', () => {
|
|
213
|
+
it('installs a backend that later commands dispatch through', () => {
|
|
214
|
+
const mock = createMockAccessibilityBackend();
|
|
215
|
+
setAccessibilityBackend(mock.backend);
|
|
216
|
+
setAccessibilityNode(node('a', 'button', {}));
|
|
217
|
+
expect(mock.calls.setNode.length).toBe(1);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('restores a fresh, distinct web default when passed null', () => {
|
|
221
|
+
const original = getAccessibilityBackend();
|
|
222
|
+
setAccessibilityBackend(null);
|
|
223
|
+
const restored = getAccessibilityBackend();
|
|
224
|
+
expect(restored).not.toBe(original);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe('setAccessibilityFocus', () => {
|
|
229
|
+
it('dispatches the id and returns the backend result', () => {
|
|
230
|
+
const mock = createMockAccessibilityBackend();
|
|
231
|
+
setAccessibilityBackend(mock.backend);
|
|
232
|
+
expect(setAccessibilityFocus('ok')).toBe(true);
|
|
233
|
+
expect(setAccessibilityFocus('nope')).toBe(false);
|
|
234
|
+
expect(mock.calls.setFocus).toEqual(['ok', 'nope']);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe('setAccessibilityNode', () => {
|
|
239
|
+
it('dispatches the node to the backend', () => {
|
|
240
|
+
const mock = createMockAccessibilityBackend();
|
|
241
|
+
setAccessibilityBackend(mock.backend);
|
|
242
|
+
const target = node('a', 'button', { label: 'Go' });
|
|
243
|
+
setAccessibilityNode(target);
|
|
244
|
+
expect(mock.calls.setNode).toEqual([target]);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
function node(
|
|
249
|
+
id: string,
|
|
250
|
+
role: AccessibilityNode['role'],
|
|
251
|
+
rest: Readonly<Omit<AccessibilityNode, 'id' | 'role'>>,
|
|
252
|
+
): AccessibilityNode {
|
|
253
|
+
return { id, role, ...rest };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
interface MockAccessibilityCalls {
|
|
257
|
+
setNode: AccessibilityNode[];
|
|
258
|
+
removeNode: string[];
|
|
259
|
+
clear: number;
|
|
260
|
+
setFocus: string[];
|
|
261
|
+
announce: (readonly [string, AccessibilityLiveness])[];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function createMockAccessibilityBackend(): { backend: AccessibilityBackend; calls: MockAccessibilityCalls } {
|
|
265
|
+
const calls: MockAccessibilityCalls = { setNode: [], removeNode: [], clear: 0, setFocus: [], announce: [] };
|
|
266
|
+
const backend: AccessibilityBackend = {
|
|
267
|
+
setNode(target) {
|
|
268
|
+
calls.setNode.push(target as AccessibilityNode);
|
|
269
|
+
},
|
|
270
|
+
removeNode(id) {
|
|
271
|
+
calls.removeNode.push(id);
|
|
272
|
+
},
|
|
273
|
+
clear() {
|
|
274
|
+
calls.clear += 1;
|
|
275
|
+
},
|
|
276
|
+
setFocus(id) {
|
|
277
|
+
calls.setFocus.push(id);
|
|
278
|
+
return id === 'ok';
|
|
279
|
+
},
|
|
280
|
+
announce(message, liveness) {
|
|
281
|
+
calls.announce.push([message, liveness]);
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
return { backend, calls };
|
|
285
|
+
}
|