@hypen-space/web 0.5.2 → 0.5.3
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/canvas/accessibility.d.ts +118 -11
- package/dist/canvas/accessibility.js +470 -108
- package/dist/canvas/accessibility.js.map +1 -1
- package/dist/canvas/dispatch.d.ts +24 -0
- package/dist/canvas/dispatch.js +62 -0
- package/dist/canvas/dispatch.js.map +1 -0
- package/dist/canvas/editing.d.ts +178 -0
- package/dist/canvas/editing.js +590 -0
- package/dist/canvas/editing.js.map +1 -0
- package/dist/canvas/events.d.ts +28 -23
- package/dist/canvas/events.js +76 -128
- package/dist/canvas/events.js.map +1 -1
- package/dist/canvas/focus.d.ts +78 -0
- package/dist/canvas/focus.js +182 -0
- package/dist/canvas/focus.js.map +1 -0
- package/dist/canvas/index.d.ts +3 -1
- package/dist/canvas/index.js +3 -1
- package/dist/canvas/index.js.map +1 -1
- package/dist/canvas/paint.d.ts +16 -3
- package/dist/canvas/paint.js +283 -41
- package/dist/canvas/paint.js.map +1 -1
- package/dist/canvas/renderer.d.ts +20 -1
- package/dist/canvas/renderer.js +181 -50
- package/dist/canvas/renderer.js.map +1 -1
- package/dist/canvas/selection.js +31 -177
- package/dist/canvas/selection.js.map +1 -1
- package/dist/canvas/text-geometry.d.ts +72 -0
- package/dist/canvas/text-geometry.js +244 -0
- package/dist/canvas/text-geometry.js.map +1 -0
- package/dist/canvas/text.js +42 -9
- package/dist/canvas/text.js.map +1 -1
- package/dist/canvas/types.d.ts +7 -1
- package/dist/canvas/variants.d.ts +4 -1
- package/dist/canvas/variants.js +22 -3
- package/dist/canvas/variants.js.map +1 -1
- package/dist/dom/a11y-styles.d.ts +20 -0
- package/dist/dom/a11y-styles.js +61 -0
- package/dist/dom/a11y-styles.js.map +1 -0
- package/dist/dom/applicators/aria.d.ts +19 -0
- package/dist/dom/applicators/aria.js +36 -0
- package/dist/dom/applicators/aria.js.map +1 -0
- package/dist/dom/applicators/events.d.ts +7 -0
- package/dist/dom/applicators/events.js +35 -8
- package/dist/dom/applicators/events.js.map +1 -1
- package/dist/dom/applicators/index.js +4 -0
- package/dist/dom/applicators/index.js.map +1 -1
- package/dist/dom/components/hypenapp.d.ts +25 -1
- package/dist/dom/components/hypenapp.js +213 -245
- package/dist/dom/components/hypenapp.js.map +1 -1
- package/dist/dom/components/index.js +7 -0
- package/dist/dom/components/index.js.map +1 -1
- package/dist/dom/components/spinner.js +24 -10
- package/dist/dom/components/spinner.js.map +1 -1
- package/dist/dom/components/tabs.d.ts +27 -0
- package/dist/dom/components/tabs.js +69 -0
- package/dist/dom/components/tabs.js.map +1 -0
- package/dist/dom/components/visuallyhidden.d.ts +9 -0
- package/dist/dom/components/visuallyhidden.js +26 -0
- package/dist/dom/components/visuallyhidden.js.map +1 -0
- package/dist/dom/operability.d.ts +100 -0
- package/dist/dom/operability.js +298 -0
- package/dist/dom/operability.js.map +1 -0
- package/dist/dom/renderer.d.ts +98 -5
- package/dist/dom/renderer.js +398 -63
- package/dist/dom/renderer.js.map +1 -1
- package/dist/dom/route-focus.d.ts +42 -0
- package/dist/dom/route-focus.js +88 -0
- package/dist/dom/route-focus.js.map +1 -0
- package/dist/dom/semantics.d.ts +35 -0
- package/dist/dom/semantics.js +184 -0
- package/dist/dom/semantics.js.map +1 -0
- package/package.json +2 -2
- package/src/canvas/QUICKSTART.md +4 -4
- package/src/canvas/README.md +55 -29
- package/src/canvas/accessibility.ts +507 -115
- package/src/canvas/dispatch.ts +78 -0
- package/src/canvas/editing.ts +697 -0
- package/src/canvas/events.ts +89 -142
- package/src/canvas/focus.ts +216 -0
- package/src/canvas/index.ts +8 -1
- package/src/canvas/paint.ts +339 -42
- package/src/canvas/renderer.ts +218 -70
- package/src/canvas/selection.ts +52 -210
- package/src/canvas/text-geometry.ts +311 -0
- package/src/canvas/text.ts +43 -9
- package/src/canvas/types.ts +14 -1
- package/src/canvas/variants.ts +24 -5
- package/src/dom/a11y-styles.ts +65 -0
- package/src/dom/applicators/aria.ts +41 -0
- package/src/dom/applicators/events.ts +38 -7
- package/src/dom/applicators/index.ts +5 -0
- package/src/dom/components/hypenapp.ts +244 -272
- package/src/dom/components/index.ts +7 -0
- package/src/dom/components/spinner.ts +31 -13
- package/src/dom/components/tabs.ts +76 -0
- package/src/dom/components/visuallyhidden.ts +30 -0
- package/src/dom/operability.ts +312 -0
- package/src/dom/renderer.ts +455 -63
- package/src/dom/route-focus.ts +98 -0
- package/src/dom/semantics.ts +199 -0
- package/dist/canvas/input.d.ts +0 -76
- package/dist/canvas/input.js +0 -207
- package/dist/canvas/input.js.map +0 -1
- package/src/canvas/input.ts +0 -251
|
@@ -4,153 +4,512 @@
|
|
|
4
4
|
* Maintain shadow DOM for screen readers
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import type { Semantics } from "@hypen-space/core/types";
|
|
7
8
|
import type { VirtualNode } from "./types.js";
|
|
8
|
-
import { walkTree } from "./utils.js";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
11
|
+
* Shadow tags that already convey their role natively, so we don't set an
|
|
12
|
+
* explicit `role` on them (it would be redundant). Generic `<div>`/`<span>`
|
|
13
|
+
* hosts are absent, so engine roles like `status`/`progressbar` land there.
|
|
14
|
+
*/
|
|
15
|
+
const NATIVE_SHADOW_TAGS = new Set([
|
|
16
|
+
"BUTTON",
|
|
17
|
+
"A",
|
|
18
|
+
"IMG",
|
|
19
|
+
"INPUT",
|
|
20
|
+
"TEXTAREA",
|
|
21
|
+
"SELECT",
|
|
22
|
+
"H1",
|
|
23
|
+
"H2",
|
|
24
|
+
"H3",
|
|
25
|
+
"H4",
|
|
26
|
+
"H5",
|
|
27
|
+
"H6",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Attributes this module set on a shadow element on its previous pass, so a
|
|
32
|
+
* re-apply (a reactive `setSemantics` patch) can clear exactly what it owns —
|
|
33
|
+
* a dropped `expanded` must remove its stale `aria-expanded`, a name going
|
|
34
|
+
* away its `aria-label`.
|
|
35
|
+
*/
|
|
36
|
+
const appliedShadowAttrs = new WeakMap<HTMLElement, string[]>();
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Apply engine-derived accessibility semantics to a shadow-tree element.
|
|
40
|
+
*
|
|
41
|
+
* Unlike the DOM renderer, the canvas paints pixels — the accessible name is
|
|
42
|
+
* *not* otherwise exposed — so the derived name is applied directly as
|
|
43
|
+
* `aria-label` (whether derived or explicit). Roles are set only on generic
|
|
44
|
+
* hosts that don't convey them natively. `hidden` is handled by the caller
|
|
45
|
+
* (the node is omitted from the shadow tree entirely).
|
|
46
|
+
*
|
|
47
|
+
* Called at shadow-node create and again on every reactive `setSemantics`
|
|
48
|
+
* re-apply. Idempotent and clearing: attributes set on a previous pass that
|
|
49
|
+
* the new block no longer produces are removed.
|
|
50
|
+
*/
|
|
51
|
+
export function applyShadowSemantics(element: HTMLElement, semantics?: Semantics): void {
|
|
52
|
+
if (!semantics && !appliedShadowAttrs.has(element)) return;
|
|
53
|
+
|
|
54
|
+
const next: Array<[string, string]> = [];
|
|
55
|
+
|
|
56
|
+
if (semantics) {
|
|
57
|
+
if (semantics.name) {
|
|
58
|
+
next.push(["aria-label", semantics.name]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const tag = element.tagName?.toUpperCase();
|
|
62
|
+
if (semantics.role && !NATIVE_SHADOW_TAGS.has(tag)) {
|
|
63
|
+
next.push(["role", semantics.role]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (semantics.description) {
|
|
67
|
+
next.push(["aria-description", semantics.description]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Self-state relationship attributes.
|
|
71
|
+
if (semantics.expanded !== undefined) {
|
|
72
|
+
next.push(["aria-expanded", String(semantics.expanded)]);
|
|
73
|
+
}
|
|
74
|
+
if (semantics.pressed !== undefined) {
|
|
75
|
+
next.push(["aria-pressed", String(semantics.pressed)]);
|
|
76
|
+
}
|
|
77
|
+
if (semantics.selected !== undefined) {
|
|
78
|
+
next.push(["aria-selected", String(semantics.selected)]);
|
|
79
|
+
}
|
|
80
|
+
if (semantics.current) {
|
|
81
|
+
next.push(["aria-current", semantics.current]);
|
|
82
|
+
}
|
|
83
|
+
if (semantics.checked !== undefined) {
|
|
84
|
+
next.push(["aria-checked", String(semantics.checked)]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (semantics.busy) {
|
|
88
|
+
next.push(["aria-busy", "true"]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (semantics.live) {
|
|
92
|
+
// Live-region politeness — engine-validated ("polite" | "assertive").
|
|
93
|
+
next.push(["aria-live", semantics.live]);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Clear what the previous pass set but this one doesn't.
|
|
98
|
+
const previous = appliedShadowAttrs.get(element);
|
|
99
|
+
if (previous) {
|
|
100
|
+
const keep = new Set(next.map(([name]) => name));
|
|
101
|
+
for (const name of previous) {
|
|
102
|
+
if (!keep.has(name)) {
|
|
103
|
+
element.removeAttribute(name);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const [name, value] of next) {
|
|
109
|
+
element.setAttribute(name, value);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (next.length > 0) {
|
|
113
|
+
appliedShadowAttrs.set(element, next.map(([name]) => name));
|
|
114
|
+
} else {
|
|
115
|
+
appliedShadowAttrs.delete(element);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Accessibility Mirror — positioned transparent semantics overlay
|
|
121
|
+
*
|
|
122
|
+
* A live DOM mirror of the virtual tree, rendered INVISIBLY over the canvas
|
|
123
|
+
* with every element absolutely positioned at its painted bounds (the
|
|
124
|
+
* Flutter-web / Google-Docs approach). Canvas *fallback content* was tried
|
|
125
|
+
* first and rejected on evidence: Chromium exposes fallback elements to the
|
|
126
|
+
* accessibility tree (names, roles, focus all work) but gives them ZERO
|
|
127
|
+
* geometry — and screen-reader browse modes (VoiceOver cursor, rotor, touch
|
|
128
|
+
* exploration) are geometry-driven, so they skip boundless elements. A
|
|
129
|
+
* rendered-but-transparent overlay gets real boxes, which makes browse mode
|
|
130
|
+
* work and puts native focus rings exactly over the painted controls.
|
|
131
|
+
*
|
|
132
|
+
* Real DOM focus on mirror elements is the single source of truth for
|
|
133
|
+
* `node.focused`; the overlay is `pointer-events: none` throughout so the
|
|
134
|
+
* canvas keeps all pointer interaction.
|
|
135
|
+
*
|
|
136
|
+
* The mirror is synced **incrementally** from the same patch stream that
|
|
137
|
+
* drives the canvas (`create/insert/move/remove/detach/attach/...`), so
|
|
138
|
+
* mirror elements keep their identity across re-renders — a screen reader's
|
|
139
|
+
* virtual cursor or a keyboard user's focus survives reactive updates
|
|
140
|
+
* instead of being destroyed by a rebuild. Element positions are refreshed
|
|
141
|
+
* after each canvas render via {@link syncPositions}.
|
|
12
142
|
*/
|
|
13
143
|
export class AccessibilityLayer {
|
|
14
|
-
private
|
|
144
|
+
private mirrorRoot: HTMLElement;
|
|
15
145
|
private nodeMap = new Map<string, HTMLElement>();
|
|
16
146
|
private enabled: boolean;
|
|
147
|
+
/** Whether the environment can host a mirror at all. */
|
|
148
|
+
private supported: boolean;
|
|
149
|
+
private canvas: HTMLElement | null;
|
|
17
150
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
151
|
+
/**
|
|
152
|
+
* Last box written to each mirror element, so the per-frame position sync
|
|
153
|
+
* only touches elements whose geometry actually changed — style writes on
|
|
154
|
+
* n absolutely-positioned elements per frame are what they cost.
|
|
155
|
+
*/
|
|
156
|
+
private lastSyncedBounds = new WeakMap<
|
|
157
|
+
HTMLElement,
|
|
158
|
+
{ x: number; y: number; width: number; height: number }
|
|
159
|
+
>();
|
|
160
|
+
private lastRootRect: { left: number; top: number; width: number; height: number } | null =
|
|
161
|
+
null;
|
|
162
|
+
|
|
163
|
+
private boundReposition = () => this.repositionRoot();
|
|
164
|
+
|
|
165
|
+
constructor(canvas: HTMLElement | null, enabled: boolean = true) {
|
|
166
|
+
this.canvas = canvas;
|
|
167
|
+
// The overlay mounts on document.body (positioned with page
|
|
168
|
+
// coordinates, like every canvas-adjacent helper here) — non-browser
|
|
169
|
+
// environments disable the mirror rather than crash.
|
|
170
|
+
this.supported =
|
|
171
|
+
typeof document !== "undefined" &&
|
|
172
|
+
!!(document as any).body &&
|
|
173
|
+
typeof (document as any).body.appendChild === "function" &&
|
|
174
|
+
!!canvas;
|
|
175
|
+
this.enabled = enabled && this.supported;
|
|
176
|
+
|
|
177
|
+
if (this.supported) {
|
|
178
|
+
// Plain container — deliberately NO role="application": that role
|
|
179
|
+
// switches screen readers out of virtual-cursor mode (arrow-key
|
|
180
|
+
// browsing stops working) and announces the container on every
|
|
181
|
+
// entry. The mirror is ordinary document content.
|
|
182
|
+
this.mirrorRoot = document.createElement("div");
|
|
183
|
+
this.mirrorRoot.setAttribute("data-hypen-a11y-overlay", "");
|
|
184
|
+
Object.assign(this.mirrorRoot.style, {
|
|
185
|
+
position: "absolute",
|
|
186
|
+
overflow: "hidden",
|
|
187
|
+
// The canvas owns ALL pointer interaction; the overlay is only for
|
|
188
|
+
// keyboard and assistive technology.
|
|
189
|
+
pointerEvents: "none",
|
|
190
|
+
});
|
|
191
|
+
if (this.enabled) {
|
|
192
|
+
this.mount();
|
|
34
193
|
}
|
|
35
194
|
} else {
|
|
36
|
-
//
|
|
37
|
-
this.
|
|
195
|
+
// Inert placeholder for non-browser environments
|
|
196
|
+
this.mirrorRoot = {} as HTMLElement;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private mount(): void {
|
|
201
|
+
// Pseudo-elements can't be styled inline: placeholders (and selection
|
|
202
|
+
// highlights) would otherwise paint through the transparent overlay.
|
|
203
|
+
if (!(document as any).getElementById?.("hypen-a11y-overlay-style")) {
|
|
204
|
+
const style = document.createElement("style");
|
|
205
|
+
style.id = "hypen-a11y-overlay-style";
|
|
206
|
+
style.textContent = [
|
|
207
|
+
"[data-hypen-a11y-overlay] input::placeholder,",
|
|
208
|
+
"[data-hypen-a11y-overlay] textarea::placeholder { color: transparent; opacity: 0; }",
|
|
209
|
+
"[data-hypen-a11y-overlay] ::selection { background: transparent; }",
|
|
210
|
+
"[data-hypen-a11y-overlay] input,",
|
|
211
|
+
"[data-hypen-a11y-overlay] textarea { resize: none; appearance: none; -webkit-appearance: none; }",
|
|
212
|
+
].join("\n");
|
|
213
|
+
(document as any).head?.appendChild?.(style);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
(document as any).body.appendChild(this.mirrorRoot);
|
|
217
|
+
// The overlay carries the semantics; the (now childless) canvas would
|
|
218
|
+
// otherwise surface as an anonymous element in browse mode.
|
|
219
|
+
(this.canvas as any)?.setAttribute?.("aria-hidden", "true");
|
|
220
|
+
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
|
|
221
|
+
window.addEventListener("resize", this.boundReposition);
|
|
222
|
+
window.addEventListener("scroll", this.boundReposition, true);
|
|
38
223
|
}
|
|
224
|
+
this.repositionRoot();
|
|
39
225
|
}
|
|
40
226
|
|
|
227
|
+
private unmount(): void {
|
|
228
|
+
this.mirrorRoot.remove();
|
|
229
|
+
(this.canvas as any)?.removeAttribute?.("aria-hidden");
|
|
230
|
+
if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
|
|
231
|
+
window.removeEventListener("resize", this.boundReposition);
|
|
232
|
+
window.removeEventListener("scroll", this.boundReposition, true);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Pin the overlay root to the canvas's page rect. */
|
|
237
|
+
private repositionRoot(): void {
|
|
238
|
+
if (!this.enabled) return;
|
|
239
|
+
const rect = (this.canvas as any)?.getBoundingClientRect?.();
|
|
240
|
+
if (!rect) return;
|
|
241
|
+
const sx = typeof window !== "undefined" ? window.scrollX ?? 0 : 0;
|
|
242
|
+
const sy = typeof window !== "undefined" ? window.scrollY ?? 0 : 0;
|
|
243
|
+
const left = rect.left + sx;
|
|
244
|
+
const top = rect.top + sy;
|
|
245
|
+
const last = this.lastRootRect;
|
|
246
|
+
if (
|
|
247
|
+
last &&
|
|
248
|
+
last.left === left &&
|
|
249
|
+
last.top === top &&
|
|
250
|
+
last.width === rect.width &&
|
|
251
|
+
last.height === rect.height
|
|
252
|
+
) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
this.lastRootRect = { left, top, width: rect.width, height: rect.height };
|
|
256
|
+
Object.assign(this.mirrorRoot.style, {
|
|
257
|
+
left: `${left}px`,
|
|
258
|
+
top: `${top}px`,
|
|
259
|
+
width: `${rect.width}px`,
|
|
260
|
+
height: `${rect.height}px`,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// -------------------------------------------------------------------------
|
|
265
|
+
// Incremental patch mirroring
|
|
266
|
+
// -------------------------------------------------------------------------
|
|
267
|
+
|
|
41
268
|
/**
|
|
42
|
-
*
|
|
269
|
+
* Mirror a `create` patch: build the element (detached — a subsequent
|
|
270
|
+
* `insert` places it). Elements mirror the virtual tree 1:1; visibility
|
|
271
|
+
* and decorative hiding are expressed with `display:none` (which removes
|
|
272
|
+
* the subtree from the AT tree) instead of omission, so sibling order
|
|
273
|
+
* always matches the virtual tree and `beforeId` mapping stays exact.
|
|
274
|
+
*
|
|
275
|
+
* The mirror's accessibility metadata (role, name, busy, hidden) comes
|
|
276
|
+
* from the engine-derived {@link Semantics} block — the single source of
|
|
277
|
+
* truth shared with every other renderer — not from ad-hoc prop sniffing.
|
|
278
|
+
* Form *values* (input value/placeholder) still come from props, as those
|
|
279
|
+
* aren't semantics.
|
|
43
280
|
*/
|
|
44
|
-
|
|
281
|
+
createNode(node: VirtualNode): void {
|
|
45
282
|
if (!this.enabled) return;
|
|
46
283
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
284
|
+
let element = this.nodeMap.get(node.id);
|
|
285
|
+
if (!element) {
|
|
286
|
+
element = this.createShadowElement(node);
|
|
287
|
+
element.setAttribute("data-hypen-id", node.id);
|
|
288
|
+
// Rendered but invisible: real boxes for AT geometry, zero visual
|
|
289
|
+
// presence — the canvas paints all pixels. `outline` is deliberately
|
|
290
|
+
// NOT suppressed: the native focus ring lands exactly over the
|
|
291
|
+
// painted control. Positions are written by syncPositions.
|
|
292
|
+
Object.assign(element.style, {
|
|
293
|
+
position: "absolute",
|
|
294
|
+
margin: "0",
|
|
295
|
+
padding: "0",
|
|
296
|
+
border: "0",
|
|
297
|
+
background: "transparent",
|
|
298
|
+
color: "transparent",
|
|
299
|
+
caretColor: "transparent",
|
|
300
|
+
overflow: "hidden",
|
|
301
|
+
});
|
|
302
|
+
this.nodeMap.set(node.id, element);
|
|
303
|
+
}
|
|
50
304
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (shadowNode) {
|
|
54
|
-
this.shadowRoot.appendChild(shadowNode);
|
|
305
|
+
if (node.focusable) {
|
|
306
|
+
element.tabIndex = 0;
|
|
55
307
|
}
|
|
308
|
+
|
|
309
|
+
applyShadowSemantics(element, node.semantics);
|
|
310
|
+
this.syncVisibility(element, node);
|
|
56
311
|
}
|
|
57
312
|
|
|
58
313
|
/**
|
|
59
|
-
*
|
|
314
|
+
* Mirror an `insert` or `attach` patch. Re-inserting an element that is
|
|
315
|
+
* already in the tree moves it (native `insertBefore` semantics), so
|
|
316
|
+
* `move` routes here too. An `attach` reinserts the exact element kept
|
|
317
|
+
* alive by {@link detachNode} — AT identity survives router navigation.
|
|
60
318
|
*/
|
|
61
|
-
|
|
62
|
-
if (!
|
|
319
|
+
insertNode(parentId: string, id: string, beforeId?: string): void {
|
|
320
|
+
if (!this.enabled) return;
|
|
63
321
|
|
|
64
|
-
|
|
322
|
+
const element = this.nodeMap.get(id);
|
|
323
|
+
if (!element) return;
|
|
65
324
|
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
break;
|
|
84
|
-
|
|
85
|
-
case "textarea":
|
|
86
|
-
element = document.createElement("textarea");
|
|
87
|
-
(element as HTMLTextAreaElement).value = node.props.value || "";
|
|
88
|
-
if (node.props.placeholder) {
|
|
89
|
-
(element as HTMLTextAreaElement).placeholder = node.props.placeholder;
|
|
90
|
-
}
|
|
91
|
-
break;
|
|
325
|
+
// Root mount: the engine addresses the root as its own parent (or under
|
|
326
|
+
// the reserved "root" id). Everything else hangs off its parent element.
|
|
327
|
+
const parent =
|
|
328
|
+
parentId === id
|
|
329
|
+
? this.mirrorRoot
|
|
330
|
+
: this.nodeMap.get(parentId) ?? (parentId === "root" ? this.mirrorRoot : null);
|
|
331
|
+
if (!parent) return;
|
|
332
|
+
|
|
333
|
+
const before = beforeId ? this.nodeMap.get(beforeId) : null;
|
|
334
|
+
if (before && before.parentNode === parent) {
|
|
335
|
+
parent.insertBefore(element, before);
|
|
336
|
+
} else {
|
|
337
|
+
// Unknown/foreign beforeId — same append fallback the virtual tree uses.
|
|
338
|
+
parent.appendChild(element);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
92
341
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
342
|
+
/**
|
|
343
|
+
* Mirror a `remove` patch: drop the element and purge the subtree's
|
|
344
|
+
* id mappings.
|
|
345
|
+
*/
|
|
346
|
+
removeNode(node: VirtualNode): void {
|
|
347
|
+
if (!this.enabled) return;
|
|
348
|
+
this.nodeMap.get(node.id)?.remove();
|
|
349
|
+
this.purge(node);
|
|
350
|
+
}
|
|
98
351
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
352
|
+
private purge(node: VirtualNode): void {
|
|
353
|
+
this.nodeMap.delete(node.id);
|
|
354
|
+
for (const child of node.children) {
|
|
355
|
+
this.purge(child);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
103
358
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
359
|
+
/**
|
|
360
|
+
* Mirror a `detach` patch: unlink the element but keep the subtree and
|
|
361
|
+
* all id mappings alive so a later `attach` reinserts the same elements
|
|
362
|
+
* (router subtree cache parity).
|
|
363
|
+
*/
|
|
364
|
+
detachNode(id: string): void {
|
|
365
|
+
if (!this.enabled) return;
|
|
366
|
+
this.nodeMap.get(id)?.remove();
|
|
367
|
+
}
|
|
111
368
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
369
|
+
/**
|
|
370
|
+
* Full rebuild from a virtual tree. Only used when re-enabling the mirror
|
|
371
|
+
* (`setEnabled(true)`) and by `clear()` — steady-state sync is incremental.
|
|
372
|
+
*/
|
|
373
|
+
rebuild(root: VirtualNode | null): void {
|
|
374
|
+
if (!this.enabled) return;
|
|
115
375
|
|
|
116
|
-
|
|
117
|
-
|
|
376
|
+
this.mirrorRoot.innerHTML = "";
|
|
377
|
+
this.nodeMap.clear();
|
|
118
378
|
|
|
119
|
-
if (
|
|
120
|
-
|
|
379
|
+
if (root) {
|
|
380
|
+
this.mountSubtree(root, this.mirrorRoot);
|
|
121
381
|
}
|
|
382
|
+
}
|
|
122
383
|
|
|
123
|
-
|
|
124
|
-
|
|
384
|
+
private mountSubtree(node: VirtualNode, parent: HTMLElement): void {
|
|
385
|
+
this.createNode(node);
|
|
386
|
+
const element = this.nodeMap.get(node.id);
|
|
387
|
+
if (!element) return;
|
|
388
|
+
this.updateNode(node);
|
|
389
|
+
parent.appendChild(element);
|
|
390
|
+
for (const child of node.children) {
|
|
391
|
+
this.mountSubtree(child, element);
|
|
125
392
|
}
|
|
393
|
+
}
|
|
126
394
|
|
|
127
|
-
|
|
128
|
-
|
|
395
|
+
/**
|
|
396
|
+
* `display:none` removes the subtree from the accessibility tree — used
|
|
397
|
+
* both for `visible: false` and for decorative (`semantics.hidden`) nodes.
|
|
398
|
+
*/
|
|
399
|
+
private syncVisibility(element: HTMLElement, node: VirtualNode): void {
|
|
400
|
+
element.style.display = node.visible && !node.semantics?.hidden ? "" : "none";
|
|
401
|
+
}
|
|
129
402
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
403
|
+
/**
|
|
404
|
+
* Write each mirror element's box to its node's painted (scroll-aware)
|
|
405
|
+
* bounds — called by the renderer after every canvas render, so AT
|
|
406
|
+
* geometry follows layout changes and container scrolling. Positions are
|
|
407
|
+
* parent-relative (every mirror element is absolutely positioned).
|
|
408
|
+
*/
|
|
409
|
+
syncPositions(root: VirtualNode | null): void {
|
|
410
|
+
if (!this.enabled) return;
|
|
411
|
+
this.repositionRoot();
|
|
412
|
+
if (root) {
|
|
413
|
+
this.syncNodePosition(root, 0, 0, 0, 0);
|
|
136
414
|
}
|
|
137
|
-
|
|
138
|
-
return element;
|
|
139
415
|
}
|
|
140
416
|
|
|
141
417
|
/**
|
|
142
|
-
*
|
|
418
|
+
* `scrollX`/`scrollY` accumulate the ancestors' scroll offsets down the
|
|
419
|
+
* recursion (the same subtraction `getScrollAwareBounds` derives by
|
|
420
|
+
* walking up), keeping the pass O(n) instead of O(n·depth). Writes are
|
|
421
|
+
* skipped when the element's box is unchanged since the last sync.
|
|
143
422
|
*/
|
|
144
|
-
private
|
|
145
|
-
|
|
146
|
-
|
|
423
|
+
private syncNodePosition(
|
|
424
|
+
node: VirtualNode,
|
|
425
|
+
parentX: number,
|
|
426
|
+
parentY: number,
|
|
427
|
+
scrollX: number,
|
|
428
|
+
scrollY: number,
|
|
429
|
+
): void {
|
|
430
|
+
const element = this.nodeMap.get(node.id);
|
|
431
|
+
if (!element || !node.layout) return;
|
|
432
|
+
|
|
433
|
+
const x = node.layout.x - scrollX;
|
|
434
|
+
const y = node.layout.y - scrollY;
|
|
435
|
+
const width = node.layout.width;
|
|
436
|
+
const height = node.layout.height;
|
|
437
|
+
|
|
438
|
+
const relX = x - parentX;
|
|
439
|
+
const relY = y - parentY;
|
|
440
|
+
const last = this.lastSyncedBounds.get(element);
|
|
441
|
+
if (!last || last.x !== relX || last.y !== relY || last.width !== width || last.height !== height) {
|
|
442
|
+
element.style.left = `${relX}px`;
|
|
443
|
+
element.style.top = `${relY}px`;
|
|
444
|
+
element.style.width = `${width}px`;
|
|
445
|
+
element.style.height = `${height}px`;
|
|
446
|
+
this.lastSyncedBounds.set(element, { x: relX, y: relY, width, height });
|
|
147
447
|
}
|
|
148
448
|
|
|
149
|
-
|
|
449
|
+
const childScrollX = scrollX + (node.scrollState?.scrollX ?? 0);
|
|
450
|
+
const childScrollY = scrollY + (node.scrollState?.scrollY ?? 0);
|
|
150
451
|
for (const child of node.children) {
|
|
151
|
-
|
|
452
|
+
this.syncNodePosition(child, x, y, childScrollX, childScrollY);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Choose the semantic HTML tag for a node's shadow element and set its
|
|
458
|
+
* non-accessibility content (text, input value/placeholder). Roles/names are
|
|
459
|
+
* applied separately from the semantics block.
|
|
460
|
+
*/
|
|
461
|
+
private createShadowElement(node: VirtualNode): HTMLElement {
|
|
462
|
+
switch (node.type.toLowerCase()) {
|
|
463
|
+
case "button":
|
|
464
|
+
return document.createElement("button");
|
|
465
|
+
|
|
466
|
+
case "link": {
|
|
467
|
+
const a = document.createElement("a");
|
|
468
|
+
a.setAttribute("href", "#");
|
|
469
|
+
return a;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
case "input": {
|
|
473
|
+
const input = document.createElement("input") as HTMLInputElement;
|
|
474
|
+
input.type = node.props.type || "text";
|
|
475
|
+
input.value = node.props.value || "";
|
|
476
|
+
if (node.props.placeholder) input.placeholder = node.props.placeholder;
|
|
477
|
+
return input;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
case "textarea": {
|
|
481
|
+
const textarea = document.createElement("textarea") as HTMLTextAreaElement;
|
|
482
|
+
textarea.value = node.props.value || "";
|
|
483
|
+
if (node.props.placeholder) textarea.placeholder = node.props.placeholder;
|
|
484
|
+
return textarea;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
case "image":
|
|
488
|
+
return document.createElement("img");
|
|
489
|
+
|
|
490
|
+
case "heading": {
|
|
491
|
+
// Use the derived level when known; fall back to a generic h2.
|
|
492
|
+
const level = Math.min(6, Math.max(1, node.semantics?.level ?? 2));
|
|
493
|
+
return document.createElement(`h${level}`);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
case "text": {
|
|
497
|
+
const span = document.createElement("span");
|
|
498
|
+
span.textContent = String(node.props[0] || node.props.text || "");
|
|
499
|
+
return span;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
case "column":
|
|
503
|
+
case "row":
|
|
504
|
+
case "container":
|
|
505
|
+
case "box":
|
|
506
|
+
// Plain div — role="group" made browse mode announce "group" on
|
|
507
|
+
// every layout container (the DOM renderer emits plain divs too).
|
|
508
|
+
return document.createElement("div");
|
|
509
|
+
|
|
510
|
+
default:
|
|
511
|
+
return document.createElement("div");
|
|
152
512
|
}
|
|
153
|
-
return text;
|
|
154
513
|
}
|
|
155
514
|
|
|
156
515
|
/**
|
|
@@ -174,18 +533,33 @@ export class AccessibilityLayer {
|
|
|
174
533
|
const element = this.nodeMap.get(node.id);
|
|
175
534
|
if (!element) return;
|
|
176
535
|
|
|
536
|
+
const type = node.type.toLowerCase();
|
|
537
|
+
|
|
177
538
|
// Update text content
|
|
178
|
-
if (
|
|
539
|
+
if (type === "text") {
|
|
179
540
|
element.textContent = String(node.props[0] || node.props.text || "");
|
|
180
541
|
}
|
|
181
542
|
|
|
182
|
-
// Update input value
|
|
183
|
-
|
|
184
|
-
|
|
543
|
+
// Update input value. Guarded — assigning even an identical value can
|
|
544
|
+
// collapse the browser's caret/selection in a focused mirror input.
|
|
545
|
+
if (type === "input" || type === "textarea") {
|
|
546
|
+
const input = element as HTMLInputElement | HTMLTextAreaElement;
|
|
547
|
+
const value = node.props.value || "";
|
|
548
|
+
if (input.value !== value) {
|
|
549
|
+
input.value = value;
|
|
550
|
+
}
|
|
551
|
+
if (node.props.placeholder != null) {
|
|
552
|
+
input.placeholder = node.props.placeholder;
|
|
553
|
+
}
|
|
185
554
|
}
|
|
186
555
|
|
|
187
|
-
//
|
|
188
|
-
|
|
556
|
+
// Re-apply semantics: painted text is invisible to AT, so a reactively
|
|
557
|
+
// changed accessible name/state only reaches screen readers through the
|
|
558
|
+
// shadow node's ARIA attributes. `applyShadowSemantics` clears anything
|
|
559
|
+
// it set previously that the current block no longer produces.
|
|
560
|
+
applyShadowSemantics(element, node.semantics);
|
|
561
|
+
|
|
562
|
+
this.syncVisibility(element, node);
|
|
189
563
|
}
|
|
190
564
|
|
|
191
565
|
/**
|
|
@@ -195,13 +569,31 @@ export class AccessibilityLayer {
|
|
|
195
569
|
return this.nodeMap.get(nodeId);
|
|
196
570
|
}
|
|
197
571
|
|
|
572
|
+
/** The fallback-content root element (for focus delegation wiring). */
|
|
573
|
+
getRoot(): HTMLElement | null {
|
|
574
|
+
return this.supported ? this.mirrorRoot : null;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
isEnabled(): boolean {
|
|
578
|
+
return this.enabled;
|
|
579
|
+
}
|
|
580
|
+
|
|
198
581
|
/**
|
|
199
|
-
* Enable or disable
|
|
582
|
+
* Enable or disable the mirror. Re-enabling rebuilds from the given
|
|
583
|
+
* virtual tree (incremental sync has no history to replay).
|
|
200
584
|
*/
|
|
201
|
-
setEnabled(enabled: boolean): void {
|
|
202
|
-
|
|
203
|
-
if (
|
|
204
|
-
|
|
585
|
+
setEnabled(enabled: boolean, root?: VirtualNode | null): void {
|
|
586
|
+
const next = enabled && this.supported;
|
|
587
|
+
if (next === this.enabled) return;
|
|
588
|
+
this.enabled = next;
|
|
589
|
+
|
|
590
|
+
if (!next) {
|
|
591
|
+
this.unmount();
|
|
592
|
+
this.nodeMap.clear();
|
|
593
|
+
} else {
|
|
594
|
+
this.mount();
|
|
595
|
+
this.rebuild(root ?? null);
|
|
596
|
+
this.syncPositions(root ?? null);
|
|
205
597
|
}
|
|
206
598
|
}
|
|
207
599
|
|
|
@@ -209,8 +601,8 @@ export class AccessibilityLayer {
|
|
|
209
601
|
* Cleanup
|
|
210
602
|
*/
|
|
211
603
|
destroy(): void {
|
|
212
|
-
if (this.
|
|
213
|
-
this.
|
|
604
|
+
if (this.supported) {
|
|
605
|
+
this.unmount();
|
|
214
606
|
}
|
|
215
607
|
this.nodeMap.clear();
|
|
216
608
|
}
|