@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
package/src/dom/renderer.ts
CHANGED
|
@@ -8,10 +8,42 @@ import type { Patch } from "@hypen-space/core/types";
|
|
|
8
8
|
import type { HypenModuleInstance } from "@hypen-space/core/app";
|
|
9
9
|
import type { HypenRouter } from "@hypen-space/core/router";
|
|
10
10
|
import type { HypenGlobalContext } from "@hypen-space/core/context";
|
|
11
|
-
import { frameworkLoggers } from "@hypen-space/core/logger";
|
|
11
|
+
import { frameworkLoggers, isDebugMode } from "@hypen-space/core/logger";
|
|
12
12
|
|
|
13
13
|
const log = frameworkLoggers.renderer;
|
|
14
14
|
|
|
15
|
+
/** A compiled piece of a text template: literal text, or an `@{...}` binding. */
|
|
16
|
+
type TemplateSegment = string | { match: string; keys: string[] };
|
|
17
|
+
|
|
18
|
+
interface TextBinding {
|
|
19
|
+
element: HTMLElement;
|
|
20
|
+
template: string;
|
|
21
|
+
segments: TemplateSegment[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Split a text template into literal and `@{state.path}` binding segments,
|
|
26
|
+
* so per-update interpolation walks pre-split paths instead of re-running
|
|
27
|
+
* the regex over the whole template.
|
|
28
|
+
*/
|
|
29
|
+
function compileTextTemplate(template: string): TemplateSegment[] {
|
|
30
|
+
const bindingPattern = /@\{([^}]+)\}/g;
|
|
31
|
+
const segments: TemplateSegment[] = [];
|
|
32
|
+
let last = 0;
|
|
33
|
+
let match: RegExpExecArray | null;
|
|
34
|
+
while ((match = bindingPattern.exec(template)) !== null) {
|
|
35
|
+
if (match.index > last) {
|
|
36
|
+
segments.push(template.slice(last, match.index));
|
|
37
|
+
}
|
|
38
|
+
segments.push({ match: match[0], keys: match[1]!.split(".") });
|
|
39
|
+
last = match.index + match[0].length;
|
|
40
|
+
}
|
|
41
|
+
if (last < template.length) {
|
|
42
|
+
segments.push(template.slice(last));
|
|
43
|
+
}
|
|
44
|
+
return segments;
|
|
45
|
+
}
|
|
46
|
+
|
|
15
47
|
/** Element types that treat the "action" prop as an onClick handler */
|
|
16
48
|
const ACTIONABLE_TYPES = new Set(["button", "link", "card"]);
|
|
17
49
|
|
|
@@ -31,20 +63,48 @@ const COMPONENT_HTML_ATTRS: Record<string, Set<string>> = {
|
|
|
31
63
|
checkbox: new Set(["checked", "disabled", "name"]),
|
|
32
64
|
radio: new Set(["checked", "disabled", "name", "value"]),
|
|
33
65
|
link: new Set(["href", "target", "rel"]),
|
|
66
|
+
// Route URL changes to the handler so it reconnects the embedded app
|
|
67
|
+
// instead of the generic text branch overwriting the subtree.
|
|
68
|
+
hypenapp: new Set(["0", "url"]),
|
|
34
69
|
};
|
|
35
70
|
|
|
36
71
|
import { ComponentRegistry } from "./components/index.js";
|
|
37
72
|
import { ApplicatorRegistry } from "./applicators/index.js";
|
|
73
|
+
import { applySemantics } from "./semantics.js";
|
|
74
|
+
import {
|
|
75
|
+
makeKeyboardActivatable,
|
|
76
|
+
makeFocusTrap,
|
|
77
|
+
makeRovingTablist,
|
|
78
|
+
makeRovingListbox,
|
|
79
|
+
focusDialogOnOpen,
|
|
80
|
+
restoreDialogFocus,
|
|
81
|
+
installDialogEscape,
|
|
82
|
+
} from "./operability.js";
|
|
83
|
+
import { findRouteFocusTarget, focusRouteTarget } from "./route-focus.js";
|
|
84
|
+
import type { Semantics } from "@hypen-space/core/types";
|
|
38
85
|
import { canvasHandler, canvasApplicators } from "./canvas/index.js";
|
|
39
86
|
import { CanvasRenderer } from "../canvas/renderer.js";
|
|
40
87
|
import { RerenderTracker, type DebugConfig, defaultDebugConfig } from "./debug.js";
|
|
41
88
|
import { setEngine, disposeHypenElement } from "./element-data.js";
|
|
89
|
+
import { ensureA11yStyles } from "./a11y-styles.js";
|
|
42
90
|
|
|
43
91
|
// Interface for the engine that renderer needs
|
|
44
92
|
interface IEngine {
|
|
45
93
|
dispatchAction(name: string, payload?: any): void;
|
|
46
94
|
}
|
|
47
95
|
|
|
96
|
+
export interface DOMRendererOptions {
|
|
97
|
+
/**
|
|
98
|
+
* Route-change focus contract (route-focus.ts). `"auto"` (default) moves
|
|
99
|
+
* focus to the incoming route's first heading / `main` landmark / subtree
|
|
100
|
+
* root on every navigation. `"off"` skips `focusIncomingRoute` entirely —
|
|
101
|
+
* the escape hatch for authors who own focus management themselves (custom
|
|
102
|
+
* transition choreography, embedded shells) and would otherwise fight the
|
|
103
|
+
* renderer for focus on every navigation.
|
|
104
|
+
*/
|
|
105
|
+
routeFocus?: "auto" | "off";
|
|
106
|
+
}
|
|
107
|
+
|
|
48
108
|
export class DOMRenderer {
|
|
49
109
|
private container: HTMLElement;
|
|
50
110
|
private nodes: Map<string, HTMLElement> = new Map();
|
|
@@ -58,20 +118,68 @@ export class DOMRenderer {
|
|
|
58
118
|
private componentInstances = new Map<string, HypenModuleInstance>();
|
|
59
119
|
private debugTracker: RerenderTracker;
|
|
60
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Text elements whose template contains an `@{...}` binding, with the
|
|
123
|
+
* template pre-compiled into segments. Only these nodes are visited on
|
|
124
|
+
* state changes — static text never re-interpolates.
|
|
125
|
+
*/
|
|
126
|
+
private textBindings = new Map<string, TextBinding>();
|
|
127
|
+
|
|
61
128
|
// Canvas subtree routing — Canvas components get their own CanvasRenderer
|
|
62
129
|
// and all descendant patches are forwarded to it instead of the DOM.
|
|
63
130
|
private canvasRenderers = new Map<string, CanvasRenderer>();
|
|
64
131
|
private canvasSubtreeMap = new Map<string, string>(); // nodeId → canvasRootId
|
|
132
|
+
// Parent/child links within canvas subtrees, so removes can prune the
|
|
133
|
+
// routing maps without scanning every entry ever recorded.
|
|
134
|
+
private canvasNodeChildren = new Map<string, Set<string>>();
|
|
135
|
+
private canvasNodeParent = new Map<string, string>();
|
|
65
136
|
private canvasElements = new Map<string, HTMLCanvasElement>();
|
|
66
137
|
/** Props stashed at create-time so we can forward them to CanvasRenderer */
|
|
67
138
|
private pendingCreateProps = new Map<string, { elementType: string; props: Record<string, any> }>();
|
|
68
139
|
|
|
69
|
-
|
|
140
|
+
/**
|
|
141
|
+
* Focus-restore memory for the Router subtree cache, keyed by detached
|
|
142
|
+
* subtree root id: the element that held focus when the route was
|
|
143
|
+
* detached, restored on cached re-`attach`. Entries are dropped on
|
|
144
|
+
* `remove` (Router LRU eviction) so focus restore can never target an
|
|
145
|
+
* evicted NodeId — the route-focus contract (see route-focus.ts).
|
|
146
|
+
*/
|
|
147
|
+
private routeFocusMemory = new Map<string, HTMLElement>();
|
|
148
|
+
|
|
149
|
+
/** See `DOMRendererOptions.routeFocus` — `"off"` disables `focusIncomingRoute`. */
|
|
150
|
+
private routeFocus: "auto" | "off";
|
|
151
|
+
|
|
152
|
+
/** Ids created with `semantics.role === "dialog"`, so insert/remove hooks can act. */
|
|
153
|
+
private dialogIds = new Set<string>();
|
|
154
|
+
/**
|
|
155
|
+
* Open dialog id → the element that held focus before the dialog took it
|
|
156
|
+
* (the trigger), restored on dialog remove/detach when still connected.
|
|
157
|
+
* Presence of an entry means "already focused" — re-inserts (moves) of an
|
|
158
|
+
* open dialog never re-run the mount focus.
|
|
159
|
+
*/
|
|
160
|
+
private dialogOpeners = new Map<string, HTMLElement | null>();
|
|
161
|
+
/**
|
|
162
|
+
* Dialog ids inserted during the current batch. Mount focus runs after the
|
|
163
|
+
* whole batch, because a dialog's focusable children may be inserted after
|
|
164
|
+
* the dialog's own insert patch.
|
|
165
|
+
*/
|
|
166
|
+
private pendingDialogMounts: string[] = [];
|
|
167
|
+
|
|
168
|
+
constructor(
|
|
169
|
+
container: HTMLElement,
|
|
170
|
+
engine: IEngine,
|
|
171
|
+
debugConfig?: Partial<DebugConfig>,
|
|
172
|
+
options?: DOMRendererOptions,
|
|
173
|
+
) {
|
|
70
174
|
this.container = container;
|
|
71
175
|
this.engine = engine;
|
|
72
176
|
this.components = new ComponentRegistry();
|
|
73
177
|
this.applicators = new ApplicatorRegistry();
|
|
74
178
|
this.debugTracker = new RerenderTracker({ ...defaultDebugConfig, ...debugConfig });
|
|
179
|
+
this.routeFocus = options?.routeFocus ?? "auto";
|
|
180
|
+
|
|
181
|
+
// Inject the global reduced-motion + focus-visible stylesheet once.
|
|
182
|
+
ensureA11yStyles();
|
|
75
183
|
|
|
76
184
|
// Match the canvas/iOS/Android renderers: text without an explicit
|
|
77
185
|
// `.color()` defaults to black. Without this, DOM text would inherit
|
|
@@ -104,35 +212,76 @@ export class DOMRenderer {
|
|
|
104
212
|
* the DOMRenderer or the owning CanvasRenderer.
|
|
105
213
|
*/
|
|
106
214
|
applyPatches(patches: Patch[]): void {
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
215
|
+
// Canvas routing only matters once a canvas root exists; the common
|
|
216
|
+
// no-canvas case applies the batch directly with no extra passes.
|
|
217
|
+
let canvasBatches: Map<string, Patch[]> | null = null;
|
|
218
|
+
let domPatches = patches;
|
|
219
|
+
|
|
220
|
+
if (this.canvasRenderers.size > 0 || this.canvasSubtreeMap.size > 0) {
|
|
221
|
+
// Phase 1: scan inserts to discover new canvas-subtree members
|
|
222
|
+
for (const patch of patches) {
|
|
223
|
+
if (patch.type === "insert" || patch.type === "move") {
|
|
224
|
+
const parentId = patch.parentId;
|
|
225
|
+
const childId = patch.id;
|
|
226
|
+
if (!parentId || !childId) continue;
|
|
227
|
+
|
|
228
|
+
// Is the parent a canvas root or inside a canvas subtree?
|
|
229
|
+
const canvasRootId =
|
|
230
|
+
this.canvasRenderers.has(parentId) ? parentId
|
|
231
|
+
: this.canvasSubtreeMap.get(parentId);
|
|
232
|
+
if (canvasRootId) {
|
|
233
|
+
this.registerCanvasMember(childId, parentId, canvasRootId);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Phase 2: route patches
|
|
239
|
+
canvasBatches = new Map<string, Patch[]>();
|
|
240
|
+
domPatches = [];
|
|
241
|
+
|
|
242
|
+
for (const patch of patches) {
|
|
243
|
+
const canvasRootId = this.getCanvasRouteTarget(patch);
|
|
118
244
|
if (canvasRootId) {
|
|
119
|
-
|
|
245
|
+
let batch = canvasBatches.get(canvasRootId);
|
|
246
|
+
if (!batch) { batch = []; canvasBatches.set(canvasRootId, batch); }
|
|
247
|
+
batch.push(patch);
|
|
248
|
+
if (patch.type === "remove" && patch.id) {
|
|
249
|
+
// Removed canvas nodes never come back (engine node ids are
|
|
250
|
+
// never reused), so drop their routing entries now.
|
|
251
|
+
this.pruneCanvasSubtree(patch.id);
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
domPatches.push(patch);
|
|
120
255
|
}
|
|
121
256
|
}
|
|
122
257
|
}
|
|
123
258
|
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
259
|
+
// Route-change detection for the focus contract: a batch with a detach
|
|
260
|
+
// (route leaving) plus incoming subtree roots (attach of a cached route,
|
|
261
|
+
// or a fresh top-level create+insert) is a navigation. Initial renders
|
|
262
|
+
// have no detach, so they never steal focus.
|
|
263
|
+
const isNavigation = domPatches.some((p) => p.type === "detach");
|
|
264
|
+
const incomingRoots: string[] = [];
|
|
265
|
+
if (isNavigation) {
|
|
266
|
+
const createdInBatch = new Set<string>();
|
|
267
|
+
for (const p of domPatches) {
|
|
268
|
+
if (p.type === "create" && p.id) {
|
|
269
|
+
createdInBatch.add(p.id);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
for (const p of domPatches) {
|
|
273
|
+
if (p.type === "attach" && p.id) {
|
|
274
|
+
incomingRoots.push(p.id);
|
|
275
|
+
} else if (
|
|
276
|
+
p.type === "insert" &&
|
|
277
|
+
p.id &&
|
|
278
|
+
createdInBatch.has(p.id) &&
|
|
279
|
+
(!p.parentId || !createdInBatch.has(p.parentId))
|
|
280
|
+
) {
|
|
281
|
+
// A freshly-built subtree root: created in this batch, inserted
|
|
282
|
+
// under a parent that predates the batch (the router's slot).
|
|
283
|
+
incomingRoots.push(p.id);
|
|
284
|
+
}
|
|
136
285
|
}
|
|
137
286
|
}
|
|
138
287
|
|
|
@@ -142,12 +291,59 @@ export class DOMRenderer {
|
|
|
142
291
|
}
|
|
143
292
|
|
|
144
293
|
// Forward canvas-subtree patches to their CanvasRenderer instances
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
renderer
|
|
294
|
+
if (canvasBatches) {
|
|
295
|
+
for (const [rootId, batch] of canvasBatches) {
|
|
296
|
+
const renderer = this.canvasRenderers.get(rootId);
|
|
297
|
+
if (renderer) {
|
|
298
|
+
renderer.applyPatches(batch);
|
|
299
|
+
}
|
|
149
300
|
}
|
|
150
301
|
}
|
|
302
|
+
|
|
303
|
+
if (isNavigation && incomingRoots.length > 0 && this.routeFocus !== "off") {
|
|
304
|
+
this.focusIncomingRoute(incomingRoots[0]!);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Dialog mount focus runs last: a dialog opened by this batch takes
|
|
308
|
+
// focus even when the batch was also a navigation (modal wins).
|
|
309
|
+
this.flushDialogMounts();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Apply the dialog focus contract to dialogs inserted by the finished
|
|
314
|
+
* batch: focus the first focusable descendant, else the dialog itself, and
|
|
315
|
+
* remember the previously-focused element (the trigger) for restore on
|
|
316
|
+
* close. Deferred to batch end so descendants inserted after the dialog's
|
|
317
|
+
* own insert patch are visible to the focus search.
|
|
318
|
+
*/
|
|
319
|
+
private flushDialogMounts(): void {
|
|
320
|
+
if (this.pendingDialogMounts.length === 0) return;
|
|
321
|
+
const pending = this.pendingDialogMounts;
|
|
322
|
+
this.pendingDialogMounts = [];
|
|
323
|
+
for (const id of pending) {
|
|
324
|
+
if (this.dialogOpeners.has(id)) continue; // already open — a move, not a mount
|
|
325
|
+
const dialog = this.nodes.get(id);
|
|
326
|
+
if (!dialog) continue;
|
|
327
|
+
this.dialogOpeners.set(id, focusDialogOnOpen(dialog));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Apply the route-focus contract to a freshly-shown route subtree: restore
|
|
333
|
+
* the remembered focus of a cached route when it is still inside the
|
|
334
|
+
* subtree, otherwise land on the first heading / main landmark / the
|
|
335
|
+
* subtree root (see route-focus.ts for the full contract).
|
|
336
|
+
*/
|
|
337
|
+
private focusIncomingRoute(rootId: string): void {
|
|
338
|
+
const root = this.nodes.get(rootId);
|
|
339
|
+
if (!root) return;
|
|
340
|
+
|
|
341
|
+
const remembered = this.routeFocusMemory.get(rootId);
|
|
342
|
+
const target =
|
|
343
|
+
remembered && (root === remembered || root.contains?.(remembered))
|
|
344
|
+
? remembered
|
|
345
|
+
: findRouteFocusTarget(root);
|
|
346
|
+
focusRouteTarget(target);
|
|
151
347
|
}
|
|
152
348
|
|
|
153
349
|
/**
|
|
@@ -182,6 +378,46 @@ export class DOMRenderer {
|
|
|
182
378
|
}
|
|
183
379
|
}
|
|
184
380
|
|
|
381
|
+
/**
|
|
382
|
+
* Record a node as a member of a canvas subtree, tracking its parent link
|
|
383
|
+
* so a later `remove` can prune the whole subtree from the routing maps.
|
|
384
|
+
*/
|
|
385
|
+
private registerCanvasMember(childId: string, parentId: string, canvasRootId: string): void {
|
|
386
|
+
this.canvasSubtreeMap.set(childId, canvasRootId);
|
|
387
|
+
const prevParent = this.canvasNodeParent.get(childId);
|
|
388
|
+
if (prevParent !== undefined && prevParent !== parentId) {
|
|
389
|
+
this.canvasNodeChildren.get(prevParent)?.delete(childId);
|
|
390
|
+
}
|
|
391
|
+
this.canvasNodeParent.set(childId, parentId);
|
|
392
|
+
let siblings = this.canvasNodeChildren.get(parentId);
|
|
393
|
+
if (!siblings) {
|
|
394
|
+
siblings = new Set();
|
|
395
|
+
this.canvasNodeChildren.set(parentId, siblings);
|
|
396
|
+
}
|
|
397
|
+
siblings.add(childId);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Drop a removed canvas node and all of its descendants from the canvas
|
|
402
|
+
* routing maps. The engine emits `remove` only for the subtree root, so
|
|
403
|
+
* descendants are walked via the tracked child links.
|
|
404
|
+
*/
|
|
405
|
+
private pruneCanvasSubtree(id: string): void {
|
|
406
|
+
const children = this.canvasNodeChildren.get(id);
|
|
407
|
+
if (children) {
|
|
408
|
+
this.canvasNodeChildren.delete(id);
|
|
409
|
+
for (const childId of children) {
|
|
410
|
+
this.pruneCanvasSubtree(childId);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
this.canvasSubtreeMap.delete(id);
|
|
414
|
+
const parentId = this.canvasNodeParent.get(id);
|
|
415
|
+
if (parentId !== undefined) {
|
|
416
|
+
this.canvasNodeParent.delete(id);
|
|
417
|
+
this.canvasNodeChildren.get(parentId)?.delete(id);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
185
421
|
/**
|
|
186
422
|
* Update state and interpolate text content
|
|
187
423
|
*/
|
|
@@ -201,42 +437,64 @@ export class DOMRenderer {
|
|
|
201
437
|
}
|
|
202
438
|
|
|
203
439
|
/**
|
|
204
|
-
* Interpolate state values in all text elements
|
|
440
|
+
* Interpolate state values in all binding-bearing text elements
|
|
205
441
|
*/
|
|
206
442
|
private interpolateAllText(): void {
|
|
207
|
-
for (const [id,
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
// Track re-render if text actually changed
|
|
213
|
-
const currentText = element.textContent;
|
|
214
|
-
if (currentText !== interpolated) {
|
|
215
|
-
this.debugTracker.trackRerender(id, element, "interpolate");
|
|
216
|
-
}
|
|
217
|
-
|
|
443
|
+
for (const [id, binding] of this.textBindings) {
|
|
444
|
+
const interpolated = this.interpolateSegments(binding.segments, this.currentState);
|
|
445
|
+
const element = binding.element;
|
|
446
|
+
if (element.textContent !== interpolated) {
|
|
447
|
+
this.debugTracker.trackRerender(id, element, "interpolate");
|
|
218
448
|
element.textContent = interpolated;
|
|
219
449
|
}
|
|
220
450
|
}
|
|
221
451
|
}
|
|
222
452
|
|
|
223
453
|
/**
|
|
224
|
-
*
|
|
454
|
+
* Resolve a compiled template against the current state.
|
|
225
455
|
*
|
|
226
|
-
* Bindings use the `@{state.path}` syntax (matching the engine's parser)
|
|
456
|
+
* Bindings use the `@{state.path}` syntax (matching the engine's parser);
|
|
457
|
+
* unresolvable bindings render as their original `@{...}` text.
|
|
227
458
|
*/
|
|
228
|
-
private
|
|
229
|
-
|
|
459
|
+
private interpolateSegments(segments: TemplateSegment[], state: Record<string, any>): string {
|
|
460
|
+
let result = "";
|
|
461
|
+
for (const segment of segments) {
|
|
462
|
+
if (typeof segment === "string") {
|
|
463
|
+
result += segment;
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
230
466
|
try {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
}
|
|
235
|
-
|
|
467
|
+
let value: any = state;
|
|
468
|
+
for (const key of segment.keys) {
|
|
469
|
+
value = key === "state" ? state : value?.[key];
|
|
470
|
+
}
|
|
471
|
+
result += value !== undefined ? String(value) : segment.match;
|
|
236
472
|
} catch {
|
|
237
|
-
|
|
473
|
+
result += segment.match;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return result;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Keep the binding index in sync with the element's stored template:
|
|
481
|
+
* register text elements whose template contains an `@{...}` binding,
|
|
482
|
+
* drop everything else.
|
|
483
|
+
*/
|
|
484
|
+
private syncTextBinding(id: string, element: HTMLElement): void {
|
|
485
|
+
const template = element.dataset.textTemplate;
|
|
486
|
+
if (element.dataset.hypenType === "text" && template && template.includes("@{")) {
|
|
487
|
+
const existing = this.textBindings.get(id);
|
|
488
|
+
if (!existing || existing.template !== template || existing.element !== element) {
|
|
489
|
+
this.textBindings.set(id, {
|
|
490
|
+
element,
|
|
491
|
+
template,
|
|
492
|
+
segments: compileTextTemplate(template),
|
|
493
|
+
});
|
|
238
494
|
}
|
|
239
|
-
}
|
|
495
|
+
} else {
|
|
496
|
+
this.textBindings.delete(id);
|
|
497
|
+
}
|
|
240
498
|
}
|
|
241
499
|
|
|
242
500
|
/**
|
|
@@ -250,7 +508,7 @@ export class DOMRenderer {
|
|
|
250
508
|
|
|
251
509
|
switch (patch.type) {
|
|
252
510
|
case "create":
|
|
253
|
-
this.onCreate(id!, elementType ?? "container", patch.props || {});
|
|
511
|
+
this.onCreate(id!, elementType ?? "container", patch.props || {}, patch.semantics);
|
|
254
512
|
break;
|
|
255
513
|
case "setProp":
|
|
256
514
|
this.onSetProp(id!, patch.name!, patch.value);
|
|
@@ -276,13 +534,33 @@ export class DOMRenderer {
|
|
|
276
534
|
case "attach":
|
|
277
535
|
this.onInsert(parentId!, id!, beforeId);
|
|
278
536
|
break;
|
|
537
|
+
case "setSemantics":
|
|
538
|
+
this.onSetSemantics(id!, patch.semantics);
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Reactive accessibility update: re-run the create-time semantics
|
|
545
|
+
* translation on the live element with the node's complete re-resolved
|
|
546
|
+
* block. `applySemantics` is idempotent and clears attributes it set on a
|
|
547
|
+
* previous pass that the new block no longer produces. This is the sole
|
|
548
|
+
* ARIA writer for bound self-state — the SetProp that accompanies a bound
|
|
549
|
+
* `.expanded(@state.open)` change does not touch ARIA attributes.
|
|
550
|
+
*/
|
|
551
|
+
private onSetSemantics(id: string, semantics?: Semantics): void {
|
|
552
|
+
const element = this.nodes.get(id);
|
|
553
|
+
if (!element) {
|
|
554
|
+
log.warn(`setSemantics: element ${id} not found`);
|
|
555
|
+
return;
|
|
279
556
|
}
|
|
557
|
+
applySemantics(element, semantics);
|
|
280
558
|
}
|
|
281
559
|
|
|
282
560
|
/**
|
|
283
561
|
* Create a new element
|
|
284
562
|
*/
|
|
285
|
-
private onCreate(id: string, elementType: string, props: Record<string, any> | Map<string, any
|
|
563
|
+
private onCreate(id: string, elementType: string, props: Record<string, any> | Map<string, any>, semantics?: Semantics): void {
|
|
286
564
|
const propsObj = props instanceof Map ? Object.fromEntries(props) : props;
|
|
287
565
|
|
|
288
566
|
let element = this.components.createElement(elementType, propsObj);
|
|
@@ -302,14 +580,60 @@ export class DOMRenderer {
|
|
|
302
580
|
element.dataset.hypenId = id;
|
|
303
581
|
setEngine(element, this.engine);
|
|
304
582
|
|
|
583
|
+
// Slot marker: `.slot("name")` lowers to the `slot.0` prop. The engine
|
|
584
|
+
// resolves slots during component expansion, but container components
|
|
585
|
+
// that own their children natively (HypenApp's loading/error slots)
|
|
586
|
+
// need to identify slotted children at the DOM level.
|
|
587
|
+
const slotName = propsObj["slot.0"] ?? propsObj.slot;
|
|
588
|
+
if (typeof slotName === "string" && slotName) {
|
|
589
|
+
element.dataset.hypenSlot = slotName;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Apply engine-derived accessibility semantics (role, …) before styling
|
|
593
|
+
// props, so a redundant-role check sees the final host tag.
|
|
594
|
+
applySemantics(element, semantics);
|
|
595
|
+
|
|
596
|
+
// Dialog-like containers trap keyboard focus so Tab cycles within the
|
|
597
|
+
// dialog rather than escaping to the page behind it. Mount focus and
|
|
598
|
+
// restore-to-trigger run on insert/remove (tracked via dialogIds) — the
|
|
599
|
+
// element isn't in the document yet at create time. Escape closes only
|
|
600
|
+
// when the author declared an onClose action; without one, closing is
|
|
601
|
+
// app state and Escape does nothing.
|
|
602
|
+
if (semantics?.role === "dialog") {
|
|
603
|
+
makeFocusTrap(element);
|
|
604
|
+
this.dialogIds.add(id);
|
|
605
|
+
const onClose = propsObj.onClose ?? propsObj["onClose.0"];
|
|
606
|
+
if (onClose) {
|
|
607
|
+
installDialogEscape(element, onClose);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Tablists get the WAI-ARIA roving-tabindex keyboard contract: arrows
|
|
612
|
+
// move focus among the role="tab" children, Home/End jump to the ends,
|
|
613
|
+
// only the focused tab stays in the page Tab order, and printable
|
|
614
|
+
// characters typeahead to the next matching tab.
|
|
615
|
+
if (semantics?.role === "tablist") {
|
|
616
|
+
makeRovingTablist(element);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Listboxes get the same roving + typeahead contract over their
|
|
620
|
+
// role="option" children (no-op on a native <select> host, where the
|
|
621
|
+
// browser owns the keyboard behaviour).
|
|
622
|
+
if (semantics?.role === "listbox") {
|
|
623
|
+
makeRovingListbox(element);
|
|
624
|
+
}
|
|
625
|
+
|
|
305
626
|
this.applicators.applyAll(element, propsObj);
|
|
306
627
|
|
|
307
|
-
// Actionable components: wire "action" prop as onClick
|
|
628
|
+
// Actionable components: wire "action" prop as onClick, and make
|
|
629
|
+
// non-native hosts (e.g. an actionable Card div) keyboard-operable.
|
|
308
630
|
if (propsObj.action && ACTIONABLE_TYPES.has(elementType.toLowerCase())) {
|
|
309
631
|
this.applicators.apply(element, "onClick", propsObj.action);
|
|
632
|
+
makeKeyboardActivatable(element, propsObj.action);
|
|
310
633
|
}
|
|
311
634
|
|
|
312
635
|
this.nodes.set(id, element);
|
|
636
|
+
this.syncTextBinding(id, element);
|
|
313
637
|
this.debugTracker.trackRerender(id, element, `create:${elementType}`);
|
|
314
638
|
|
|
315
639
|
// Canvas component: create a CanvasRenderer for its subtree
|
|
@@ -391,6 +715,7 @@ export class DOMRenderer {
|
|
|
391
715
|
// If current template isn't a template, keep it in sync.
|
|
392
716
|
element.dataset.textTemplate = nextText;
|
|
393
717
|
}
|
|
718
|
+
this.syncTextBinding(id, element);
|
|
394
719
|
log.debug(`Updated text content: "${value}"`);
|
|
395
720
|
return;
|
|
396
721
|
}
|
|
@@ -398,6 +723,7 @@ export class DOMRenderer {
|
|
|
398
723
|
// Actionable components: wire "action" prop as onClick
|
|
399
724
|
if (name === "action" && ACTIONABLE_TYPES.has(element.dataset.hypenType || "")) {
|
|
400
725
|
this.applicators.apply(element, "onClick", value);
|
|
726
|
+
makeKeyboardActivatable(element, value);
|
|
401
727
|
return;
|
|
402
728
|
}
|
|
403
729
|
|
|
@@ -455,11 +781,16 @@ export class DOMRenderer {
|
|
|
455
781
|
const parent = parentId === "root" ? this.container : this.nodes.get(parentId);
|
|
456
782
|
const child = this.nodes.get(id);
|
|
457
783
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
784
|
+
// Gated up front: building the payload reads `textContent`, which
|
|
785
|
+
// serializes the whole subtree's text — too costly to pay when debug
|
|
786
|
+
// logging is off.
|
|
787
|
+
if (isDebugMode()) {
|
|
788
|
+
log.debug(`Inserting ${id} into ${parentId}`, {
|
|
789
|
+
parent: parent ? `${parent.tagName}#${parent.id || 'no-id'}` : 'null',
|
|
790
|
+
child: child ? `${child.tagName}#${child.id || 'no-id'}` : 'null',
|
|
791
|
+
childText: child?.textContent?.substring(0, 20)
|
|
792
|
+
});
|
|
793
|
+
}
|
|
463
794
|
|
|
464
795
|
if (!parent || !child) return;
|
|
465
796
|
|
|
@@ -479,6 +810,13 @@ export class DOMRenderer {
|
|
|
479
810
|
parent.appendChild(child);
|
|
480
811
|
}
|
|
481
812
|
}
|
|
813
|
+
|
|
814
|
+
// Dialog entering the document (insert or cached re-attach): queue mount
|
|
815
|
+
// focus for the end of the batch (flushDialogMounts). Already-open
|
|
816
|
+
// dialogs are filtered there, so moves never re-focus.
|
|
817
|
+
if (this.dialogIds.has(id)) {
|
|
818
|
+
this.pendingDialogMounts.push(id);
|
|
819
|
+
}
|
|
482
820
|
}
|
|
483
821
|
|
|
484
822
|
/**
|
|
@@ -509,10 +847,26 @@ export class DOMRenderer {
|
|
|
509
847
|
const element = this.nodes.get(id);
|
|
510
848
|
if (!element) return;
|
|
511
849
|
|
|
850
|
+
// Remember where focus was inside the leaving route, so a cached
|
|
851
|
+
// re-`attach` of this subtree can restore it (route-focus contract).
|
|
852
|
+
const active = (element.ownerDocument?.activeElement ??
|
|
853
|
+
(typeof document !== "undefined" ? document.activeElement : null)) as HTMLElement | null;
|
|
854
|
+
if (active && (element === active || element.contains?.(active))) {
|
|
855
|
+
this.routeFocusMemory.set(id, active);
|
|
856
|
+
}
|
|
857
|
+
|
|
512
858
|
if (element.parentNode) {
|
|
513
859
|
element.parentNode.removeChild(element);
|
|
514
860
|
}
|
|
515
861
|
|
|
862
|
+
// Any open dialog inside the detached subtree just left the document:
|
|
863
|
+
// restore its trigger (a trigger detached along with it fails the
|
|
864
|
+
// connectivity check and is skipped — the route-focus contract owns
|
|
865
|
+
// focus for the incoming route). Runs after the DOM unlink so the
|
|
866
|
+
// connectivity check reflects the detach. dialogIds keeps the id — a
|
|
867
|
+
// cached re-attach re-runs mount focus.
|
|
868
|
+
this.restoreDialogsWithin(element);
|
|
869
|
+
|
|
516
870
|
// If the root gets detached (unlikely but defensible), clear
|
|
517
871
|
// rootId so a later attach can re-root cleanly.
|
|
518
872
|
if (this.rootId === id) {
|
|
@@ -520,6 +874,23 @@ export class DOMRenderer {
|
|
|
520
874
|
}
|
|
521
875
|
}
|
|
522
876
|
|
|
877
|
+
/**
|
|
878
|
+
* Close-side of the dialog focus contract: every open dialog at-or-under
|
|
879
|
+
* `subtree` has left the document — restore focus to its remembered
|
|
880
|
+
* trigger (when still connected) and drop the open entry so a later
|
|
881
|
+
* insert/attach counts as a fresh mount.
|
|
882
|
+
*/
|
|
883
|
+
private restoreDialogsWithin(subtree: HTMLElement): void {
|
|
884
|
+
if (this.dialogOpeners.size === 0) return;
|
|
885
|
+
for (const [dialogId, opener] of this.dialogOpeners) {
|
|
886
|
+
const dialog = this.nodes.get(dialogId);
|
|
887
|
+
if (!dialog || subtree === dialog || subtree.contains?.(dialog)) {
|
|
888
|
+
this.dialogOpeners.delete(dialogId);
|
|
889
|
+
if (dialog) restoreDialogFocus(dialog, opener);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
523
894
|
/**
|
|
524
895
|
* Remove an element from the tree
|
|
525
896
|
*/
|
|
@@ -533,8 +904,12 @@ export class DOMRenderer {
|
|
|
533
904
|
this.canvasRenderers.delete(id);
|
|
534
905
|
this.canvasElements.delete(id);
|
|
535
906
|
// Remove all subtree entries pointing to this canvas root
|
|
536
|
-
|
|
537
|
-
|
|
907
|
+
const members = this.canvasNodeChildren.get(id);
|
|
908
|
+
if (members) {
|
|
909
|
+
this.canvasNodeChildren.delete(id);
|
|
910
|
+
for (const childId of members) {
|
|
911
|
+
this.pruneCanvasSubtree(childId);
|
|
912
|
+
}
|
|
538
913
|
}
|
|
539
914
|
}
|
|
540
915
|
|
|
@@ -545,7 +920,17 @@ export class DOMRenderer {
|
|
|
545
920
|
element.parentNode.removeChild(element);
|
|
546
921
|
}
|
|
547
922
|
|
|
923
|
+
// Restore-to-trigger for any open dialog in the removed subtree, before
|
|
924
|
+
// the node map forgets it. The removed subtree is gone for good, so the
|
|
925
|
+
// dialog id is dropped too.
|
|
926
|
+
this.restoreDialogsWithin(element);
|
|
927
|
+
|
|
548
928
|
this.nodes.delete(id);
|
|
929
|
+
this.textBindings.delete(id);
|
|
930
|
+
this.dialogIds.delete(id);
|
|
931
|
+
// Router LRU eviction: this subtree is gone for good — focus restore
|
|
932
|
+
// must never target it again (route-focus contract).
|
|
933
|
+
this.routeFocusMemory.delete(id);
|
|
549
934
|
|
|
550
935
|
if (this.rootId === id) {
|
|
551
936
|
this.rootId = null;
|
|
@@ -570,6 +955,8 @@ export class DOMRenderer {
|
|
|
570
955
|
this.canvasRenderers.clear();
|
|
571
956
|
this.canvasElements.clear();
|
|
572
957
|
this.canvasSubtreeMap.clear();
|
|
958
|
+
this.canvasNodeChildren.clear();
|
|
959
|
+
this.canvasNodeParent.clear();
|
|
573
960
|
|
|
574
961
|
// Dispose all element resources before clearing
|
|
575
962
|
for (const element of this.nodes.values()) {
|
|
@@ -577,7 +964,12 @@ export class DOMRenderer {
|
|
|
577
964
|
}
|
|
578
965
|
this.container.innerHTML = "";
|
|
579
966
|
this.nodes.clear();
|
|
967
|
+
this.textBindings.clear();
|
|
580
968
|
this.rootId = null;
|
|
969
|
+
this.dialogIds.clear();
|
|
970
|
+
this.dialogOpeners.clear();
|
|
971
|
+
this.pendingDialogMounts = [];
|
|
972
|
+
this.routeFocusMemory.clear();
|
|
581
973
|
}
|
|
582
974
|
|
|
583
975
|
/**
|