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