@real-router/angular 0.11.1 → 0.11.2
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/package.json +5 -6
- package/src/components/NavigationAnnouncer.ts +0 -18
- package/src/components/RouteView.ts +0 -141
- package/src/components/RouterErrorBoundary.ts +0 -72
- package/src/directives/RealLink.ts +0 -144
- package/src/directives/RealLinkActive.ts +0 -77
- package/src/directives/RouteMatch.ts +0 -7
- package/src/directives/RouteNotFound.ts +0 -6
- package/src/directives/RouteSelf.ts +0 -6
- package/src/dom-utils/direction-tracker.ts +0 -70
- package/src/dom-utils/index.ts +0 -31
- package/src/dom-utils/link-utils.ts +0 -339
- package/src/dom-utils/route-announcer.ts +0 -215
- package/src/dom-utils/scroll-restore.ts +0 -511
- package/src/dom-utils/scroll-spy.ts +0 -688
- package/src/dom-utils/view-transitions.ts +0 -142
- package/src/functions/index.ts +0 -29
- package/src/functions/injectIsActiveRoute.ts +0 -31
- package/src/functions/injectNavigator.ts +0 -12
- package/src/functions/injectOrThrow.ts +0 -19
- package/src/functions/injectRoute.ts +0 -39
- package/src/functions/injectRouteEnter.ts +0 -117
- package/src/functions/injectRouteExit.ts +0 -118
- package/src/functions/injectRouteNode.ts +0 -19
- package/src/functions/injectRouteUtils.ts +0 -15
- package/src/functions/injectRouter.ts +0 -12
- package/src/functions/injectRouterTransition.ts +0 -17
- package/src/index.ts +0 -63
- package/src/internal/buildActiveRouteOptions.ts +0 -20
- package/src/internal/install.ts +0 -90
- package/src/internal/subscribeSourceToSignal.ts +0 -48
- package/src/providers.ts +0 -80
- package/src/providersFactory.ts +0 -316
- package/src/sourceToSignal.ts +0 -28
- package/src/types.ts +0 -13
|
@@ -1,339 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
NavigationOptions,
|
|
3
|
-
Params,
|
|
4
|
-
Router,
|
|
5
|
-
State,
|
|
6
|
-
} from "@real-router/core";
|
|
7
|
-
|
|
8
|
-
export function shouldNavigate(evt: MouseEvent): boolean {
|
|
9
|
-
return (
|
|
10
|
-
evt.button === 0 &&
|
|
11
|
-
!evt.metaKey &&
|
|
12
|
-
!evt.altKey &&
|
|
13
|
-
!evt.ctrlKey &&
|
|
14
|
-
!evt.shiftKey
|
|
15
|
-
);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// Matches a single percent-escape triple (`%` + two hex digits). Used as
|
|
19
|
-
// the "already-encoded" probe in `encodeFragmentInline` below — see the
|
|
20
|
-
// idempotency rationale there.
|
|
21
|
-
const PERCENT_ESCAPE_PROBE = /%[\dA-Fa-f]{2}/;
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* RFC 3986 fragment encoding: preserve sub-delims (`&`, `=`, `?`, `:`),
|
|
25
|
-
* encode space, `%`, control chars, non-ASCII via encodeURI; defensively
|
|
26
|
-
* escape `#` (encodeURI does not). Mirrors `encodeHashFragment` in
|
|
27
|
-
* `shared/browser-env/url-context.ts` — duplicated here because the
|
|
28
|
-
* shared/dom-utils symlink graph does not reach shared/browser-env.
|
|
29
|
-
*
|
|
30
|
-
* **Idempotency for pre-encoded input (audit-2026-05-17 §5 MEDIUM E.1).**
|
|
31
|
-
* The doc-comment on `<Link hash>` says the value is a "decoded fragment
|
|
32
|
-
* without leading #". But realistic consumers copy hashes out of
|
|
33
|
-
* `location.hash` (which is percent-encoded) and pass them back, so the
|
|
34
|
-
* naive `encodeURI("%20")` would double-encode into `"%2520"` and break
|
|
35
|
-
* anchor lookup. We detect a percent-escape triple in the input and, if
|
|
36
|
-
* present, decode + re-encode for idempotency. Malformed `%XX` (e.g.
|
|
37
|
-
* `"%2"` or `"%ZZ"`) makes `decodeURIComponent` throw — in that case we
|
|
38
|
-
* fall through to plain `encodeURI`, which never throws.
|
|
39
|
-
*/
|
|
40
|
-
function encodeFragmentInline(decoded: string): string {
|
|
41
|
-
if (PERCENT_ESCAPE_PROBE.test(decoded)) {
|
|
42
|
-
try {
|
|
43
|
-
const roundtrip = decodeURIComponent(decoded);
|
|
44
|
-
|
|
45
|
-
return encodeURI(roundtrip).replaceAll("#", "%23");
|
|
46
|
-
} catch {
|
|
47
|
-
// Malformed `%XX` — fall through to the plain encoding path.
|
|
48
|
-
// encodeURI does not throw on malformed escapes; it treats the
|
|
49
|
-
// `%` as a literal and percent-encodes it (`%2` → `%252`).
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
return encodeURI(decoded).replaceAll("#", "%23");
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
type BuildUrlFn = (
|
|
57
|
-
name: string,
|
|
58
|
-
params: Params,
|
|
59
|
-
options?: { hash?: string },
|
|
60
|
-
) => string | undefined;
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Builds an href for a `<Link>` element.
|
|
64
|
-
*
|
|
65
|
-
* - Prefers the URL plugin's `buildUrl` (browser-plugin, navigation-plugin,
|
|
66
|
-
* hash-plugin) when present.
|
|
67
|
-
* - Falls back to `router.buildPath` for runtimes without a URL plugin
|
|
68
|
-
* (memory-plugin, console UIs, NativeScript). In that fallback the hash
|
|
69
|
-
* is appended manually so the rendered href is still correct.
|
|
70
|
-
* - The optional 4th argument is an options object so the contract stays
|
|
71
|
-
* extensible. The `hash` option is a decoded fragment without leading "#";
|
|
72
|
-
* `<Link hash="#section">` is accepted defensively (leading "#" stripped).
|
|
73
|
-
* Frozen API: previous 3-arg call sites continue to work unchanged.
|
|
74
|
-
*/
|
|
75
|
-
export function buildHref(
|
|
76
|
-
router: Router,
|
|
77
|
-
routeName: string,
|
|
78
|
-
routeParams: Params,
|
|
79
|
-
options?: { hash?: string },
|
|
80
|
-
): string | undefined {
|
|
81
|
-
try {
|
|
82
|
-
const rawHash = options?.hash;
|
|
83
|
-
let normHash: string | undefined;
|
|
84
|
-
|
|
85
|
-
if (rawHash !== undefined) {
|
|
86
|
-
normHash = rawHash.startsWith("#") ? rawHash.slice(1) : rawHash;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const buildUrl = router.buildUrl as BuildUrlFn | undefined;
|
|
90
|
-
|
|
91
|
-
if (buildUrl) {
|
|
92
|
-
const url = buildUrl(
|
|
93
|
-
routeName,
|
|
94
|
-
routeParams,
|
|
95
|
-
normHash === undefined ? undefined : { hash: normHash },
|
|
96
|
-
);
|
|
97
|
-
|
|
98
|
-
// Accept only non-empty strings. The BuildUrlFn type contract is
|
|
99
|
-
// `string | undefined`, but defensive against:
|
|
100
|
-
// - `""` (empty string) → would render `<a href="">`, which resolves
|
|
101
|
-
// to the current page URL → silent self-navigation on click.
|
|
102
|
-
// - `null` (type-contract violation) → would render `<a href={null}>`,
|
|
103
|
-
// stringified to `"null"` in some renderers.
|
|
104
|
-
// Either case falls through to the `router.buildPath` fallback below.
|
|
105
|
-
if (typeof url === "string" && url.length > 0) {
|
|
106
|
-
return url;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const path = router.buildPath(routeName, routeParams);
|
|
111
|
-
|
|
112
|
-
// Symmetric to the buildUrl guard above (#S1 audit, Invariant 12).
|
|
113
|
-
// `router.buildPath` is typed `string`, but defends against:
|
|
114
|
-
// - `""` (empty string) — would render `<a href="">`, which resolves
|
|
115
|
-
// to the current page URL → silent self-navigation on click.
|
|
116
|
-
// - non-string type-contract violations from custom path-matchers.
|
|
117
|
-
// Both yield `undefined` (renderer drops the attribute) with a warning.
|
|
118
|
-
if (typeof path !== "string" || path.length === 0) {
|
|
119
|
-
console.error(
|
|
120
|
-
`[real-router] Route "${routeName}" yielded an empty path. The element will render without an href attribute.`,
|
|
121
|
-
);
|
|
122
|
-
|
|
123
|
-
return undefined;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
return normHash ? `${path}#${encodeFragmentInline(normHash)}` : path;
|
|
127
|
-
} catch {
|
|
128
|
-
console.error(
|
|
129
|
-
`[real-router] Route "${routeName}" is not defined. The element will render without an href attribute.`,
|
|
130
|
-
);
|
|
131
|
-
|
|
132
|
-
return undefined;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* `<Link>` click-handler navigation helper (#532).
|
|
138
|
-
*
|
|
139
|
-
* Wraps `router.navigate(name, params, opts)` with same-route different-hash
|
|
140
|
-
* detection: when the consumer clicks a hash-bearing Link that targets the
|
|
141
|
-
* current route with the same params but a different fragment, core's
|
|
142
|
-
* SAME_STATES check would otherwise reject the navigation. The helper adds
|
|
143
|
-
* `force: true` and `hashChange: true` automatically — subscribers can then
|
|
144
|
-
* disambiguate via `state.context.url.hashChanged`.
|
|
145
|
-
*
|
|
146
|
-
* For pure programmatic same-route hash-only navigation, callers are
|
|
147
|
-
* documented to pass `{ force: true }` themselves; the auto-bypass here is
|
|
148
|
-
* a UX convenience for `<Link hash>` that all 6 framework adapters share.
|
|
149
|
-
*/
|
|
150
|
-
/**
|
|
151
|
-
* Local extended-options type. Adapters that depend only on `@real-router/core`
|
|
152
|
-
* (without a URL plugin) do not see the `NavigationOptions` augmentation that
|
|
153
|
-
* declares `hash` / `hashChange`. Casting to this widened type inside the
|
|
154
|
-
* helper keeps shared/dom-utils self-contained — adapters do not need to
|
|
155
|
-
* augment NavigationOptions themselves to consume `<Link hash>`.
|
|
156
|
-
*/
|
|
157
|
-
type HashAwareNavigationOptions = NavigationOptions & {
|
|
158
|
-
hash?: string;
|
|
159
|
-
hashChange?: boolean;
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
export function navigateWithHash(
|
|
163
|
-
router: Router,
|
|
164
|
-
routeName: string,
|
|
165
|
-
routeParams: Params,
|
|
166
|
-
hash: string | undefined,
|
|
167
|
-
extraOptions?: NavigationOptions,
|
|
168
|
-
): Promise<State> {
|
|
169
|
-
const opts: HashAwareNavigationOptions = { ...extraOptions };
|
|
170
|
-
|
|
171
|
-
if (hash !== undefined) {
|
|
172
|
-
opts.hash = hash;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const current = router.getState();
|
|
176
|
-
|
|
177
|
-
if (
|
|
178
|
-
current?.name === routeName &&
|
|
179
|
-
shallowEqual(current.params, routeParams)
|
|
180
|
-
) {
|
|
181
|
-
const currentHash =
|
|
182
|
-
(current.context as { url?: { hash?: string } } | undefined)?.url?.hash ??
|
|
183
|
-
"";
|
|
184
|
-
const newHash = hash ?? currentHash;
|
|
185
|
-
|
|
186
|
-
if (currentHash !== newHash) {
|
|
187
|
-
opts.force = true;
|
|
188
|
-
opts.hashChange = true;
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
return router.navigate(routeName, routeParams, opts);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Match-any-whitespace regex shared across calls. RegExp literals at
|
|
196
|
-
// call-site recompile in some engines; lifting it avoids that microcost
|
|
197
|
-
// for the slow-path branch.
|
|
198
|
-
const WHITESPACE_PROBE = /\s/;
|
|
199
|
-
const WHITESPACE_SPLIT = /\S+/g;
|
|
200
|
-
|
|
201
|
-
function parseTokens(value: string | undefined): string[] {
|
|
202
|
-
if (!value) {
|
|
203
|
-
return [];
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Hot-path fast-path (audit-2026-05-17 §8b #1): >99% of active-class
|
|
207
|
-
// inputs at `<Link>` emit are single-token strings like `"active"` or
|
|
208
|
-
// `"is-current"` — no whitespace, no leading/trailing pad. Skip the
|
|
209
|
-
// regex match and Array result allocation: a literal `[value]` works
|
|
210
|
-
// because the slow-path `match(/\S+/g)` would return exactly `[value]`
|
|
211
|
-
// for the same input. PBT lock: linkUtils.properties.ts Invariant 13.
|
|
212
|
-
if (!WHITESPACE_PROBE.test(value)) {
|
|
213
|
-
return [value];
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
return value.match(WHITESPACE_SPLIT) ?? [];
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
export function buildActiveClassName(
|
|
220
|
-
isActive: boolean,
|
|
221
|
-
activeClassName: string | undefined,
|
|
222
|
-
baseClassName: string | undefined,
|
|
223
|
-
): string | undefined {
|
|
224
|
-
if (isActive && activeClassName) {
|
|
225
|
-
const activeTokens = parseTokens(activeClassName);
|
|
226
|
-
|
|
227
|
-
if (activeTokens.length === 0) {
|
|
228
|
-
return baseClassName ?? undefined;
|
|
229
|
-
}
|
|
230
|
-
if (!baseClassName) {
|
|
231
|
-
return activeTokens.join(" ");
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
const baseTokens = parseTokens(baseClassName);
|
|
235
|
-
const seen = new Set(baseTokens);
|
|
236
|
-
|
|
237
|
-
for (const token of activeTokens) {
|
|
238
|
-
if (!seen.has(token)) {
|
|
239
|
-
seen.add(token);
|
|
240
|
-
baseTokens.push(token);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
return baseTokens.join(" ");
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
return baseClassName ?? undefined;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* One-level structural equality using `Object.is` per key.
|
|
252
|
-
*
|
|
253
|
-
* **String-keyed properties only (Mini-sprint E.3 — audit-5 §4.2 #3).**
|
|
254
|
-
* Implementation walks `Object.keys()` which by spec returns only
|
|
255
|
-
* enumerable own STRING keys. Symbol-keyed properties — created via
|
|
256
|
-
* `obj[Symbol("brand")] = value` or `{ [Symbol(...)]: value }` — are
|
|
257
|
-
* NOT compared. Two records that differ only in a Symbol-keyed value
|
|
258
|
-
* will compare as equal.
|
|
259
|
-
*
|
|
260
|
-
* This is intentional: route params and Link options are documented as
|
|
261
|
-
* string-keyed primitives (string | number | boolean) — Symbol-keyed
|
|
262
|
-
* metadata (e.g. brand markers, private state) doesn't belong in a
|
|
263
|
-
* cache-key comparison. Switching to `Reflect.ownKeys()` would extend
|
|
264
|
-
* the contract to symbols at the cost of one extra allocation per call
|
|
265
|
-
* (Reflect.ownKeys composes string-keys + symbol-keys arrays). If a
|
|
266
|
-
* consumer relies on symbol-keyed metadata for navigation
|
|
267
|
-
* disambiguation, they should encode it into a string key instead.
|
|
268
|
-
*
|
|
269
|
-
* Mirrors React's `shallowEqual` (packages/shared/shallowEqual.js) in
|
|
270
|
-
* both the string-keys-only semantics and the `hasOwnProperty` guard
|
|
271
|
-
* below.
|
|
272
|
-
*/
|
|
273
|
-
export function shallowEqual(
|
|
274
|
-
prev: object | undefined,
|
|
275
|
-
next: object | undefined,
|
|
276
|
-
): boolean {
|
|
277
|
-
if (Object.is(prev, next)) {
|
|
278
|
-
return true;
|
|
279
|
-
}
|
|
280
|
-
if (!prev || !next) {
|
|
281
|
-
return false;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
const prevKeys = Object.keys(prev);
|
|
285
|
-
|
|
286
|
-
if (prevKeys.length !== Object.keys(next).length) {
|
|
287
|
-
return false;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
const prevRecord = prev as Record<string, unknown>;
|
|
291
|
-
const nextRecord = next as Record<string, unknown>;
|
|
292
|
-
|
|
293
|
-
for (const key of prevKeys) {
|
|
294
|
-
// hasOwnProperty guard: without it, a key missing in `next` reads as
|
|
295
|
-
// `undefined` and falsely matches `prev[key] === undefined`. Same shape
|
|
296
|
-
// as React's shallowEqual (packages/shared/shallowEqual.js).
|
|
297
|
-
if (
|
|
298
|
-
!Object.prototype.hasOwnProperty.call(next, key) ||
|
|
299
|
-
!Object.is(prevRecord[key], nextRecord[key])
|
|
300
|
-
) {
|
|
301
|
-
return false;
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
return true;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
export function applyLinkA11y(element: HTMLElement | null | undefined): void {
|
|
309
|
-
if (!element) {
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// Cross-realm safety (audit-2026-05-17 §5 HIGH #4):
|
|
314
|
-
// `instanceof HTMLAnchorElement` compares against the constructor from
|
|
315
|
-
// the CURRENT realm. An element created in a different window (iframe
|
|
316
|
-
// contentDocument, micro-frontend, embedded widget) fails the check
|
|
317
|
-
// even when it IS a real anchor — the helper would then inject
|
|
318
|
-
// role="link" + tabindex="0" on top of native anchor semantics,
|
|
319
|
-
// breaking screen reader output ("link link") and focus order.
|
|
320
|
-
//
|
|
321
|
-
// tagName is realm-agnostic and is uppercase for HTML-namespaced
|
|
322
|
-
// elements in any document. SVG `<a>` has lowercase tagName plus a
|
|
323
|
-
// different prototype (SVGAElement) — skipping it here is wrong by
|
|
324
|
-
// accident: SVG anchors don't have keyboard activation semantics the
|
|
325
|
-
// helper would add. But they also don't reach this helper in
|
|
326
|
-
// practice (router Link components emit HTML anchors). Lock the
|
|
327
|
-
// uppercase compare to keep the contract narrow.
|
|
328
|
-
const tag = element.tagName;
|
|
329
|
-
|
|
330
|
-
if (tag === "A" || tag === "BUTTON") {
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
if (!element.hasAttribute("role")) {
|
|
334
|
-
element.setAttribute("role", "link");
|
|
335
|
-
}
|
|
336
|
-
if (!element.hasAttribute("tabindex")) {
|
|
337
|
-
element.setAttribute("tabindex", "0");
|
|
338
|
-
}
|
|
339
|
-
}
|
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
import type { Router, State } from "@real-router/core";
|
|
2
|
-
|
|
3
|
-
const CLEAR_DELAY = 7000;
|
|
4
|
-
const SAFARI_READY_DELAY = 100;
|
|
5
|
-
const ANNOUNCER_ATTR = "data-real-router-announcer";
|
|
6
|
-
const INTERNAL_ROUTE_PREFIX = "@@";
|
|
7
|
-
const VISUALLY_HIDDEN =
|
|
8
|
-
"position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);clip-path:inset(50%);white-space:nowrap;border:0";
|
|
9
|
-
|
|
10
|
-
export interface RouteAnnouncerOptions {
|
|
11
|
-
prefix?: string;
|
|
12
|
-
getAnnouncementText?: (route: State) => string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const NOOP_INSTANCE: { destroy: () => void } = Object.freeze({
|
|
16
|
-
destroy: () => {
|
|
17
|
-
/* no-op */
|
|
18
|
-
},
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
export function createRouteAnnouncer(
|
|
22
|
-
router: Router,
|
|
23
|
-
options?: RouteAnnouncerOptions,
|
|
24
|
-
): { destroy: () => void } {
|
|
25
|
-
// Defensive SSR / non-browser guard: in SSR (Node.js) or non-DOM
|
|
26
|
-
// environments, `document` is undefined and the announcer cannot
|
|
27
|
-
// attach its aria-live region. Return a frozen NOOP_INSTANCE — same
|
|
28
|
-
// pattern as `createDirectionTracker`, `createScrollRestoration`, and
|
|
29
|
-
// `createViewTransitions`. Without this guard, `NavigationAnnouncer`
|
|
30
|
-
// component construction would throw `ReferenceError: document is not
|
|
31
|
-
// defined` under `@angular/ssr` rendering, tearing down the whole SSR
|
|
32
|
-
// bootstrap. Closes review-2026-05-10 §5.10 ⛔ "NavigationAnnouncer
|
|
33
|
-
// SSR mode" MED.
|
|
34
|
-
if (typeof document === "undefined") {
|
|
35
|
-
return NOOP_INSTANCE;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const prefix = options?.prefix ?? "Navigated to ";
|
|
39
|
-
const getCustomText = options?.getAnnouncementText;
|
|
40
|
-
|
|
41
|
-
let isInitialNavigation = true;
|
|
42
|
-
let isReady = false;
|
|
43
|
-
let isDestroyed = false;
|
|
44
|
-
let lastAnnouncedText = "";
|
|
45
|
-
let pendingText: string | null = null;
|
|
46
|
-
let clearTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
47
|
-
|
|
48
|
-
const announcer = getOrCreateAnnouncer();
|
|
49
|
-
|
|
50
|
-
const doAnnounce = (text: string, h1: HTMLElement | null): void => {
|
|
51
|
-
lastAnnouncedText = text;
|
|
52
|
-
clearTimeout(clearTimeoutId);
|
|
53
|
-
announcer.textContent = text;
|
|
54
|
-
clearTimeoutId = setTimeout(() => {
|
|
55
|
-
announcer.textContent = "";
|
|
56
|
-
lastAnnouncedText = "";
|
|
57
|
-
}, CLEAR_DELAY);
|
|
58
|
-
|
|
59
|
-
manageFocus(h1);
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
// Safari-ready delay: announcing before VoiceOver wires up the aria-live region
|
|
63
|
-
// causes the first announcement to be silently dropped. Wait SAFARI_READY_DELAY ms
|
|
64
|
-
// before marking the announcer "ready" — any navigation during that window is
|
|
65
|
-
// buffered in pendingText and flushed once the delay expires.
|
|
66
|
-
const safariTimeoutId = setTimeout(() => {
|
|
67
|
-
isReady = true;
|
|
68
|
-
|
|
69
|
-
if (pendingText !== null && !isDestroyed) {
|
|
70
|
-
const text = pendingText;
|
|
71
|
-
|
|
72
|
-
pendingText = null;
|
|
73
|
-
doAnnounce(text, document.querySelector<HTMLElement>("h1"));
|
|
74
|
-
}
|
|
75
|
-
}, SAFARI_READY_DELAY);
|
|
76
|
-
|
|
77
|
-
const unsubscribe = router.subscribe(({ route }) => {
|
|
78
|
-
if (isInitialNavigation) {
|
|
79
|
-
isInitialNavigation = false;
|
|
80
|
-
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Double rAF: waits for two paint frames so the incoming route's DOM
|
|
85
|
-
// (including the new <h1>) is fully rendered before resolveText reads it.
|
|
86
|
-
// Single rAF fires before the new route's template has been attached,
|
|
87
|
-
// which would cause resolveText to pick up the OLD h1 or fall back to
|
|
88
|
-
// document.title / route.name prematurely.
|
|
89
|
-
requestAnimationFrame(() => {
|
|
90
|
-
requestAnimationFrame(() => {
|
|
91
|
-
if (isDestroyed) {
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const h1 = document.querySelector<HTMLElement>("h1");
|
|
96
|
-
const text = resolveText(route, prefix, getCustomText, h1);
|
|
97
|
-
|
|
98
|
-
if (!text || text === lastAnnouncedText) {
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if (!isReady) {
|
|
103
|
-
// Defer announcement until Safari-ready window elapses (see safariTimeoutId).
|
|
104
|
-
pendingText = text;
|
|
105
|
-
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
doAnnounce(text, h1);
|
|
110
|
-
});
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
return {
|
|
115
|
-
destroy() {
|
|
116
|
-
isDestroyed = true;
|
|
117
|
-
unsubscribe();
|
|
118
|
-
clearTimeout(clearTimeoutId);
|
|
119
|
-
clearTimeout(safariTimeoutId);
|
|
120
|
-
removeAnnouncer();
|
|
121
|
-
},
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function getOrCreateAnnouncer(): HTMLElement {
|
|
126
|
-
const existing = document.querySelector<HTMLElement>(`[${ANNOUNCER_ATTR}]`);
|
|
127
|
-
|
|
128
|
-
if (existing) {
|
|
129
|
-
return existing;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const element = document.createElement("div");
|
|
133
|
-
|
|
134
|
-
element.setAttribute("style", VISUALLY_HIDDEN);
|
|
135
|
-
element.setAttribute("aria-live", "assertive");
|
|
136
|
-
element.setAttribute("aria-atomic", "true");
|
|
137
|
-
element.setAttribute(ANNOUNCER_ATTR, "");
|
|
138
|
-
|
|
139
|
-
// Defensive SSR / pre-`<body>` guard: in some environments (early
|
|
140
|
-
// injection, deferred-body documents, certain SSR rehydration paths)
|
|
141
|
-
// `document.body` can be null when the announcer is constructed.
|
|
142
|
-
// `document.body.prepend(...)` would throw `TypeError: Cannot read
|
|
143
|
-
// properties of null`, tearing down the consumer's RouterProvider /
|
|
144
|
-
// NavigationAnnouncer mount. Fallback to `documentElement` keeps the
|
|
145
|
-
// announcer working for SR users; visual-hidden styling means there is
|
|
146
|
-
// no visible artifact regardless of mount point.
|
|
147
|
-
//
|
|
148
|
-
// TS dom lib types `document.body` as `HTMLElement` (non-null), but
|
|
149
|
-
// runtime can return null per spec. The `as` cast narrows the type to
|
|
150
|
-
// include null so the `??` short-circuit is type-safe.
|
|
151
|
-
((document.body as HTMLElement | null) ?? document.documentElement).prepend(
|
|
152
|
-
element,
|
|
153
|
-
);
|
|
154
|
-
|
|
155
|
-
return element;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function removeAnnouncer(): void {
|
|
159
|
-
document.querySelector(`[${ANNOUNCER_ATTR}]`)?.remove();
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function resolveText(
|
|
163
|
-
route: State,
|
|
164
|
-
prefix: string,
|
|
165
|
-
getCustomText: ((route: State) => string) | undefined,
|
|
166
|
-
h1: HTMLElement | null,
|
|
167
|
-
): string {
|
|
168
|
-
if (getCustomText) {
|
|
169
|
-
try {
|
|
170
|
-
const customText = getCustomText(route);
|
|
171
|
-
|
|
172
|
-
// Mini-sprint E.4 (audit-5 §4.2 #4) — empty-string fallback.
|
|
173
|
-
// A consumer pattern like
|
|
174
|
-
// getAnnouncementText: (route) => myMap[route.name] ?? ""
|
|
175
|
-
// returns `""` for routes outside the map. The subscribe loop
|
|
176
|
-
// then sees an empty text and silently no-announces — screen
|
|
177
|
-
// readers stay quiet without any signal to the developer. Treat
|
|
178
|
-
// a falsy custom result (`""` / `null` / `undefined`) as
|
|
179
|
-
// "consumer doesn't have a name for this route" and fall through
|
|
180
|
-
// to the default resolution chain (h1 → title → route name).
|
|
181
|
-
if (customText) {
|
|
182
|
-
return customText;
|
|
183
|
-
}
|
|
184
|
-
} catch (error) {
|
|
185
|
-
// A throwing consumer callback inside the router's subscribe loop
|
|
186
|
-
// would tear down sibling listeners — log and fall through to the
|
|
187
|
-
// built-in resolution chain so the announcer keeps working.
|
|
188
|
-
console.error(
|
|
189
|
-
"[real-router] getAnnouncementText threw; falling back to default resolution.",
|
|
190
|
-
error,
|
|
191
|
-
);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const h1Text = (h1?.textContent ?? "").trim();
|
|
196
|
-
const routeName = route.name.startsWith(INTERNAL_ROUTE_PREFIX)
|
|
197
|
-
? ""
|
|
198
|
-
: route.name;
|
|
199
|
-
const rawText =
|
|
200
|
-
h1Text || document.title || routeName || globalThis.location.pathname;
|
|
201
|
-
|
|
202
|
-
return `${prefix}${rawText}`;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function manageFocus(h1: HTMLElement | null): void {
|
|
206
|
-
if (!h1) {
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
if (!h1.hasAttribute("tabindex")) {
|
|
211
|
-
h1.setAttribute("tabindex", "-1");
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
h1.focus({ preventScroll: true });
|
|
215
|
-
}
|