@real-router/angular 0.6.1 → 0.8.0

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.
@@ -1,4 +1,9 @@
1
- import type { Router, Params } from "@real-router/core";
1
+ import type {
2
+ NavigationOptions,
3
+ Params,
4
+ Router,
5
+ State,
6
+ } from "@real-router/core";
2
7
 
3
8
  export function shouldNavigate(evt: MouseEvent): boolean {
4
9
  return (
@@ -10,25 +15,67 @@ export function shouldNavigate(evt: MouseEvent): boolean {
10
15
  );
11
16
  }
12
17
 
13
- type BuildUrlFn = (name: string, params: Params) => string | undefined;
18
+ /**
19
+ * RFC 3986 fragment encoding: preserve sub-delims (`&`, `=`, `?`, `:`),
20
+ * encode space, `%`, control chars, non-ASCII via encodeURI; defensively
21
+ * escape `#` (encodeURI does not). Mirrors `encodeHashFragment` in
22
+ * `shared/browser-env/url-context.ts` — duplicated here because the
23
+ * shared/dom-utils symlink graph does not reach shared/browser-env.
24
+ */
25
+ function encodeFragmentInline(decoded: string): string {
26
+ return encodeURI(decoded).replaceAll("#", "%23");
27
+ }
14
28
 
29
+ type BuildUrlFn = (
30
+ name: string,
31
+ params: Params,
32
+ options?: { hash?: string },
33
+ ) => string | undefined;
34
+
35
+ /**
36
+ * Builds an href for a `<Link>` element.
37
+ *
38
+ * - Prefers the URL plugin's `buildUrl` (browser-plugin, navigation-plugin,
39
+ * hash-plugin) when present.
40
+ * - Falls back to `router.buildPath` for runtimes without a URL plugin
41
+ * (memory-plugin, console UIs, NativeScript). In that fallback the hash
42
+ * is appended manually so the rendered href is still correct.
43
+ * - The optional 4th argument is an options object so the contract stays
44
+ * extensible. The `hash` option is a decoded fragment without leading "#";
45
+ * `<Link hash="#section">` is accepted defensively (leading "#" stripped).
46
+ * Frozen API: previous 3-arg call sites continue to work unchanged.
47
+ */
15
48
  export function buildHref(
16
49
  router: Router,
17
50
  routeName: string,
18
51
  routeParams: Params,
52
+ options?: { hash?: string },
19
53
  ): string | undefined {
20
54
  try {
55
+ const rawHash = options?.hash;
56
+ let normHash: string | undefined;
57
+
58
+ if (rawHash !== undefined) {
59
+ normHash = rawHash.startsWith("#") ? rawHash.slice(1) : rawHash;
60
+ }
61
+
21
62
  const buildUrl = router.buildUrl as BuildUrlFn | undefined;
22
63
 
23
64
  if (buildUrl) {
24
- const url = buildUrl(routeName, routeParams);
65
+ const url = buildUrl(
66
+ routeName,
67
+ routeParams,
68
+ normHash === undefined ? undefined : { hash: normHash },
69
+ );
25
70
 
26
71
  if (url !== undefined) {
27
72
  return url;
28
73
  }
29
74
  }
30
75
 
31
- return router.buildPath(routeName, routeParams);
76
+ const path = router.buildPath(routeName, routeParams);
77
+
78
+ return normHash ? `${path}#${encodeFragmentInline(normHash)}` : path;
32
79
  } catch {
33
80
  console.error(
34
81
  `[real-router] Route "${routeName}" is not defined. The element will render without an href attribute.`,
@@ -38,6 +85,65 @@ export function buildHref(
38
85
  }
39
86
  }
40
87
 
88
+ /**
89
+ * `<Link>` click-handler navigation helper (#532).
90
+ *
91
+ * Wraps `router.navigate(name, params, opts)` with same-route different-hash
92
+ * detection: when the consumer clicks a hash-bearing Link that targets the
93
+ * current route with the same params but a different fragment, core's
94
+ * SAME_STATES check would otherwise reject the navigation. The helper adds
95
+ * `force: true` and `hashChange: true` automatically — subscribers can then
96
+ * disambiguate via `state.context.url.hashChanged`.
97
+ *
98
+ * For pure programmatic same-route hash-only navigation, callers are
99
+ * documented to pass `{ force: true }` themselves; the auto-bypass here is
100
+ * a UX convenience for `<Link hash>` that all 6 framework adapters share.
101
+ */
102
+ /**
103
+ * Local extended-options type. Adapters that depend only on `@real-router/core`
104
+ * (without a URL plugin) do not see the `NavigationOptions` augmentation that
105
+ * declares `hash` / `hashChange`. Casting to this widened type inside the
106
+ * helper keeps shared/dom-utils self-contained — adapters do not need to
107
+ * augment NavigationOptions themselves to consume `<Link hash>`.
108
+ */
109
+ type HashAwareNavigationOptions = NavigationOptions & {
110
+ hash?: string;
111
+ hashChange?: boolean;
112
+ };
113
+
114
+ export function navigateWithHash(
115
+ router: Router,
116
+ routeName: string,
117
+ routeParams: Params,
118
+ hash: string | undefined,
119
+ extraOptions?: NavigationOptions,
120
+ ): Promise<State> {
121
+ const opts: HashAwareNavigationOptions = { ...extraOptions };
122
+
123
+ if (hash !== undefined) {
124
+ opts.hash = hash;
125
+ }
126
+
127
+ const current = router.getState();
128
+
129
+ if (
130
+ current?.name === routeName &&
131
+ shallowEqual(current.params, routeParams)
132
+ ) {
133
+ const currentHash =
134
+ (current.context as { url?: { hash?: string } } | undefined)?.url?.hash ??
135
+ "";
136
+ const newHash = hash ?? currentHash;
137
+
138
+ if (currentHash !== newHash) {
139
+ opts.force = true;
140
+ opts.hashChange = true;
141
+ }
142
+ }
143
+
144
+ return router.navigate(routeName, routeParams, opts);
145
+ }
146
+
41
147
  function parseTokens(value: string | undefined): string[] {
42
148
  return value ? (value.match(/\S+/g) ?? []) : [];
43
149
  }
@@ -1,6 +1,6 @@
1
1
  import type { Router, State } from "@real-router/core";
2
2
 
3
- const STORAGE_KEY = "real-router:scroll";
3
+ const DEFAULT_STORAGE_KEY = "real-router:scroll";
4
4
 
5
5
  const NOOP_INSTANCE: { destroy: () => void } = Object.freeze({
6
6
  destroy: () => {
@@ -8,12 +8,33 @@ const NOOP_INSTANCE: { destroy: () => void } = Object.freeze({
8
8
  },
9
9
  });
10
10
 
11
- export type ScrollRestorationMode = "restore" | "top" | "manual";
11
+ export type ScrollRestorationMode = "restore" | "top" | "native";
12
12
 
13
13
  export interface ScrollRestorationOptions {
14
14
  mode?: ScrollRestorationMode | undefined;
15
15
  anchorScrolling?: boolean | undefined;
16
16
  scrollContainer?: (() => HTMLElement | null) | undefined;
17
+ /**
18
+ * Scroll behavior passed to `scrollTo({ behavior })` and
19
+ * `scrollIntoView({ behavior })`.
20
+ *
21
+ * - `"auto"` (default) — browser-defined, usually instant.
22
+ * - `"instant"` — explicit instant jump (no animation).
23
+ * - `"smooth"` — animated transition. Note: smooth restore on back/traverse
24
+ * can feel disorienting if the user expects to land at the saved position
25
+ * immediately. Recommended for `mode: "top"` or anchor scroll only.
26
+ *
27
+ * See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/ScrollToOptions/behavior).
28
+ */
29
+ behavior?: ScrollBehavior | undefined;
30
+ /**
31
+ * sessionStorage key used to persist saved scroll positions. Default:
32
+ * `"real-router:scroll"`. Override only when multiple independent
33
+ * `RouterProvider` instances share the same document and you need to
34
+ * isolate their scroll stores (e.g. micro-frontends, embedded widgets,
35
+ * or testing). For a single app with one provider the default is fine.
36
+ */
37
+ storageKey?: string | undefined;
17
38
  }
18
39
 
19
40
  interface NavigationContext {
@@ -31,15 +52,41 @@ export function createScrollRestoration(
31
52
 
32
53
  const mode = options?.mode ?? "restore";
33
54
 
34
- // mode "manual" = utility does nothing. Don't flip history.scrollRestoration,
35
- // don't subscribe, don't register pagehide — leave the browser's native
36
- // auto-restore intact for the app to override if it wants to.
37
- if (mode === "manual") {
55
+ // mode "native" = utility does nothing. Don't flip history.scrollRestoration,
56
+ // don't subscribe, don't register pagehide — `history.scrollRestoration`
57
+ // stays at the browser default ("auto") so the browser handles scroll
58
+ // restore natively. (Note: this is the OPPOSITE of `history.scrollRestoration
59
+ // === "manual"` — utility's "native" leaves the DOM property at "auto" so
60
+ // the browser is in charge.)
61
+ if (mode === "native") {
38
62
  return NOOP_INSTANCE;
39
63
  }
40
64
 
41
65
  const anchorEnabled = options?.anchorScrolling ?? true;
42
66
  const getContainer = options?.scrollContainer;
67
+ const behavior: ScrollBehavior = options?.behavior ?? "auto";
68
+ const storageKey = options?.storageKey ?? DEFAULT_STORAGE_KEY;
69
+
70
+ const loadStore = (): Record<string, number> => {
71
+ try {
72
+ const raw = sessionStorage.getItem(storageKey);
73
+
74
+ return raw ? (JSON.parse(raw) as Record<string, number>) : {};
75
+ } catch {
76
+ return {};
77
+ }
78
+ };
79
+
80
+ const putPos = (key: string, pos: number): void => {
81
+ try {
82
+ const store = loadStore();
83
+
84
+ store[key] = pos;
85
+ sessionStorage.setItem(storageKey, JSON.stringify(store));
86
+ } catch {
87
+ // Ignore quota / security errors.
88
+ }
89
+ };
43
90
 
44
91
  const prevScrollRestoration = history.scrollRestoration;
45
92
 
@@ -62,19 +109,44 @@ export function createScrollRestoration(
62
109
  const element = getContainer?.();
63
110
 
64
111
  if (element) {
65
- element.scrollTop = top;
112
+ element.scrollTo({ top, left: 0, behavior });
66
113
  } else {
67
- globalThis.scrollTo(0, top);
114
+ globalThis.scrollTo({ top, left: 0, behavior });
68
115
  }
69
116
  };
70
117
 
71
- const scrollToHashOrTop = (): void => {
118
+ const scrollToHashOrTop = (route: State): void => {
119
+ // URL plugin path (#532): `state.context.url.hash` is the source of truth
120
+ // when one of the URL plugins (browser-plugin / navigation-plugin) is
121
+ // installed. The value is already DECODED — feeding it through
122
+ // `decodeURIComponent` again would throw on a bare `%`.
123
+ const ctxHash = (route.context as { url?: { hash?: string } } | undefined)
124
+ ?.url?.hash;
125
+
126
+ if (ctxHash !== undefined) {
127
+ if (anchorEnabled && ctxHash.length > 0) {
128
+ // eslint-disable-next-line unicorn/prefer-query-selector -- ids may contain CSS-unsafe chars
129
+ const element = document.getElementById(ctxHash);
130
+
131
+ if (element) {
132
+ element.scrollIntoView({ behavior });
133
+
134
+ return;
135
+ }
136
+ }
137
+
138
+ writePos(0);
139
+
140
+ return;
141
+ }
142
+
143
+ // Fallback path: no URL plugin, read the DOM. `location.hash` is
144
+ // percent-encoded; ids in the DOM are the raw string, so decode for the
145
+ // match. Fall back to the raw slice if the hash contains a malformed
146
+ // escape sequence (decodeURIComponent throws on those).
72
147
  const hash = globalThis.location.hash;
73
148
 
74
149
  if (anchorEnabled && hash.length > 1) {
75
- // location.hash is percent-encoded; ids in the DOM are the raw string.
76
- // Decode for the match. Fall back to the raw slice if the hash contains
77
- // a malformed escape sequence (decodeURIComponent throws on those).
78
150
  let id: string;
79
151
 
80
152
  try {
@@ -87,7 +159,7 @@ export function createScrollRestoration(
87
159
  const element = document.getElementById(id);
88
160
 
89
161
  if (element) {
90
- element.scrollIntoView();
162
+ element.scrollIntoView({ behavior });
91
163
 
92
164
  return;
93
165
  }
@@ -117,7 +189,7 @@ export function createScrollRestoration(
117
189
  }
118
190
 
119
191
  if (mode === "top" || !nav) {
120
- scrollToHashOrTop();
192
+ scrollToHashOrTop(route);
121
193
 
122
194
  return;
123
195
  }
@@ -136,7 +208,7 @@ export function createScrollRestoration(
136
208
  return;
137
209
  }
138
210
 
139
- scrollToHashOrTop();
211
+ scrollToHashOrTop(route);
140
212
  });
141
213
  });
142
214
 
@@ -173,27 +245,6 @@ function keyOf(state: State): string {
173
245
  return `${state.name}:${canonicalJson(state.params)}`;
174
246
  }
175
247
 
176
- function loadStore(): Record<string, number> {
177
- try {
178
- const raw = sessionStorage.getItem(STORAGE_KEY);
179
-
180
- return raw ? (JSON.parse(raw) as Record<string, number>) : {};
181
- } catch {
182
- return {};
183
- }
184
- }
185
-
186
- function putPos(key: string, pos: number): void {
187
- try {
188
- const store = loadStore();
189
-
190
- store[key] = pos;
191
- sessionStorage.setItem(STORAGE_KEY, JSON.stringify(store));
192
- } catch {
193
- // Ignore quota / security errors.
194
- }
195
- }
196
-
197
248
  function canonicalJson(value: unknown): string {
198
249
  return JSON.stringify(value, canonicalReplacer);
199
250
  }
@@ -9,13 +9,22 @@ import type { Params } from "@real-router/core";
9
9
  export function injectIsActiveRoute(
10
10
  routeName: string,
11
11
  params?: Params,
12
- options?: { strict?: boolean; ignoreQueryParams?: boolean },
12
+ options?: { strict?: boolean; ignoreQueryParams?: boolean; hash?: string },
13
13
  ): Signal<boolean> {
14
14
  const router = injectRouter();
15
- const source = createActiveRouteSource(router, routeName, params, {
16
- strict: options?.strict ?? false,
17
- ignoreQueryParams: options?.ignoreQueryParams ?? true,
18
- });
15
+ const strict = options?.strict ?? false;
16
+ const ignoreQueryParams = options?.ignoreQueryParams ?? true;
17
+ const hash = options?.hash;
18
+ // exactOptionalPropertyTypes forbids `{ hash: undefined }` literally — pass
19
+ // the field only when a value was provided. (#532)
20
+ const source = createActiveRouteSource(
21
+ router,
22
+ routeName,
23
+ params,
24
+ hash === undefined
25
+ ? { strict, ignoreQueryParams }
26
+ : { strict, ignoreQueryParams, hash },
27
+ );
19
28
 
20
29
  return sourceToSignal(source);
21
30
  }