@real-router/angular 0.11.0 → 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.
Files changed (35) hide show
  1. package/package.json +6 -7
  2. package/src/components/NavigationAnnouncer.ts +0 -18
  3. package/src/components/RouteView.ts +0 -141
  4. package/src/components/RouterErrorBoundary.ts +0 -72
  5. package/src/directives/RealLink.ts +0 -144
  6. package/src/directives/RealLinkActive.ts +0 -77
  7. package/src/directives/RouteMatch.ts +0 -7
  8. package/src/directives/RouteNotFound.ts +0 -6
  9. package/src/directives/RouteSelf.ts +0 -6
  10. package/src/dom-utils/direction-tracker.ts +0 -70
  11. package/src/dom-utils/index.ts +0 -31
  12. package/src/dom-utils/link-utils.ts +0 -339
  13. package/src/dom-utils/route-announcer.ts +0 -215
  14. package/src/dom-utils/scroll-restore.ts +0 -511
  15. package/src/dom-utils/scroll-spy.ts +0 -688
  16. package/src/dom-utils/view-transitions.ts +0 -142
  17. package/src/functions/index.ts +0 -29
  18. package/src/functions/injectIsActiveRoute.ts +0 -31
  19. package/src/functions/injectNavigator.ts +0 -12
  20. package/src/functions/injectOrThrow.ts +0 -19
  21. package/src/functions/injectRoute.ts +0 -39
  22. package/src/functions/injectRouteEnter.ts +0 -117
  23. package/src/functions/injectRouteExit.ts +0 -118
  24. package/src/functions/injectRouteNode.ts +0 -19
  25. package/src/functions/injectRouteUtils.ts +0 -15
  26. package/src/functions/injectRouter.ts +0 -12
  27. package/src/functions/injectRouterTransition.ts +0 -17
  28. package/src/index.ts +0 -63
  29. package/src/internal/buildActiveRouteOptions.ts +0 -20
  30. package/src/internal/install.ts +0 -90
  31. package/src/internal/subscribeSourceToSignal.ts +0 -48
  32. package/src/providers.ts +0 -80
  33. package/src/providersFactory.ts +0 -316
  34. package/src/sourceToSignal.ts +0 -28
  35. 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
- }