@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.
package/README.md CHANGED
@@ -320,6 +320,16 @@ Navigation directive for `<a>` elements. Handles click events, sets `href`, and
320
320
  | `activeClassName` | `string` | `"active"` | CSS class applied when route is active |
321
321
  | `activeStrict` | `boolean` | `false` | Exact match only (no ancestor match) |
322
322
  | `ignoreQueryParams` | `boolean` | `true` | Query params don't affect active state |
323
+ | `hash` | `string` | `undefined`| URL fragment (decoded). Tri-state: undefined preserves, `""` clears, value sets. (#532) |
324
+
325
+ #### `hash` input — URL fragment / tab-style UIs
326
+
327
+ ```html
328
+ <a [realLink]="'settings'" [hash]="'profile'">Profile</a>
329
+ <a [realLink]="'settings'" [hash]="'account'">Account</a>
330
+ ```
331
+
332
+ Active class is hash-aware — only the matching tab lights up. Live demo: [`examples/web/react/link-hash/`](../../examples/web/react/link-hash/) — behavior is identical across adapters, only template syntax differs. See the [Hash Fragment Support](https://github.com/greydragon888/real-router/wiki/Hash) wiki page for the full surface.
323
333
 
324
334
  ### `[realLinkActive]`
325
335
 
@@ -404,7 +414,7 @@ interface RealRouterOptions {
404
414
  }
405
415
  ```
406
416
 
407
- Restores scroll on back/forward, scrolls to top (or `#hash`) on push. Three modes: `"restore"` (default), `"top"`, `"manual"`. Custom containers via `scrollContainer: () => HTMLElement | null`. The utility is created by `provideEnvironmentInitializer` and torn down via `inject(DestroyRef)`. Options are a snapshot at bootstrap — not reactive to runtime changes. See [Scroll Restoration guide](https://github.com/greydragon888/real-router/wiki/Scroll-Restoration) for details.
417
+ Restores scroll on back/forward, scrolls to top (or `#hash`) on push. Three modes: `"restore"` (default), `"top"`, `"native"`. Custom containers via `scrollContainer: () => HTMLElement | null`. The utility is created by `provideEnvironmentInitializer` and torn down via `inject(DestroyRef)`. Options are a snapshot at bootstrap — not reactive to runtime changes. See [Scroll Restoration guide](https://github.com/greydragon888/real-router/wiki/Scroll-Restoration) for details.
408
418
 
409
419
  ## View Transitions
410
420
 
package/dist/README.md CHANGED
@@ -320,6 +320,16 @@ Navigation directive for `<a>` elements. Handles click events, sets `href`, and
320
320
  | `activeClassName` | `string` | `"active"` | CSS class applied when route is active |
321
321
  | `activeStrict` | `boolean` | `false` | Exact match only (no ancestor match) |
322
322
  | `ignoreQueryParams` | `boolean` | `true` | Query params don't affect active state |
323
+ | `hash` | `string` | `undefined`| URL fragment (decoded). Tri-state: undefined preserves, `""` clears, value sets. (#532) |
324
+
325
+ #### `hash` input — URL fragment / tab-style UIs
326
+
327
+ ```html
328
+ <a [realLink]="'settings'" [hash]="'profile'">Profile</a>
329
+ <a [realLink]="'settings'" [hash]="'account'">Account</a>
330
+ ```
331
+
332
+ Active class is hash-aware — only the matching tab lights up. Live demo: [`examples/web/react/link-hash/`](../../examples/web/react/link-hash/) — behavior is identical across adapters, only template syntax differs. See the [Hash Fragment Support](https://github.com/greydragon888/real-router/wiki/Hash) wiki page for the full surface.
323
333
 
324
334
  ### `[realLinkActive]`
325
335
 
@@ -404,7 +414,7 @@ interface RealRouterOptions {
404
414
  }
405
415
  ```
406
416
 
407
- Restores scroll on back/forward, scrolls to top (or `#hash`) on push. Three modes: `"restore"` (default), `"top"`, `"manual"`. Custom containers via `scrollContainer: () => HTMLElement | null`. The utility is created by `provideEnvironmentInitializer` and torn down via `inject(DestroyRef)`. Options are a snapshot at bootstrap — not reactive to runtime changes. See [Scroll Restoration guide](https://github.com/greydragon888/real-router/wiki/Scroll-Restoration) for details.
417
+ Restores scroll on back/forward, scrolls to top (or `#hash`) on push. Three modes: `"restore"` (default), `"top"`, `"native"`. Custom containers via `scrollContainer: () => HTMLElement | null`. The utility is created by `provideEnvironmentInitializer` and torn down via `inject(DestroyRef)`. Options are a snapshot at bootstrap — not reactive to runtime changes. See [Scroll Restoration guide](https://github.com/greydragon888/real-router/wiki/Scroll-Restoration) for details.
408
418
 
409
419
  ## View Transitions
410
420
 
@@ -177,7 +177,7 @@ function manageFocus(h1) {
177
177
  h1.focus({ preventScroll: true });
178
178
  }
179
179
 
180
- const STORAGE_KEY = "real-router:scroll";
180
+ const DEFAULT_STORAGE_KEY = "real-router:scroll";
181
181
  const NOOP_INSTANCE$1 = Object.freeze({
182
182
  destroy: () => {
183
183
  /* no-op */
@@ -188,14 +188,38 @@ function createScrollRestoration(router, options) {
188
188
  return NOOP_INSTANCE$1;
189
189
  }
190
190
  const mode = options?.mode ?? "restore";
191
- // mode "manual" = utility does nothing. Don't flip history.scrollRestoration,
192
- // don't subscribe, don't register pagehide — leave the browser's native
193
- // auto-restore intact for the app to override if it wants to.
194
- if (mode === "manual") {
191
+ // mode "native" = utility does nothing. Don't flip history.scrollRestoration,
192
+ // don't subscribe, don't register pagehide — `history.scrollRestoration`
193
+ // stays at the browser default ("auto") so the browser handles scroll
194
+ // restore natively. (Note: this is the OPPOSITE of `history.scrollRestoration
195
+ // === "manual"` — utility's "native" leaves the DOM property at "auto" so
196
+ // the browser is in charge.)
197
+ if (mode === "native") {
195
198
  return NOOP_INSTANCE$1;
196
199
  }
197
200
  const anchorEnabled = options?.anchorScrolling ?? true;
198
201
  const getContainer = options?.scrollContainer;
202
+ const behavior = options?.behavior ?? "auto";
203
+ const storageKey = options?.storageKey ?? DEFAULT_STORAGE_KEY;
204
+ const loadStore = () => {
205
+ try {
206
+ const raw = sessionStorage.getItem(storageKey);
207
+ return raw ? JSON.parse(raw) : {};
208
+ }
209
+ catch {
210
+ return {};
211
+ }
212
+ };
213
+ const putPos = (key, pos) => {
214
+ try {
215
+ const store = loadStore();
216
+ store[key] = pos;
217
+ sessionStorage.setItem(storageKey, JSON.stringify(store));
218
+ }
219
+ catch {
220
+ // Ignore quota / security errors.
221
+ }
222
+ };
199
223
  const prevScrollRestoration = history.scrollRestoration;
200
224
  try {
201
225
  history.scrollRestoration = "manual";
@@ -213,18 +237,37 @@ function createScrollRestoration(router, options) {
213
237
  const writePos = (top) => {
214
238
  const element = getContainer?.();
215
239
  if (element) {
216
- element.scrollTop = top;
240
+ element.scrollTo({ top, left: 0, behavior });
217
241
  }
218
242
  else {
219
- globalThis.scrollTo(0, top);
243
+ globalThis.scrollTo({ top, left: 0, behavior });
220
244
  }
221
245
  };
222
- const scrollToHashOrTop = () => {
246
+ const scrollToHashOrTop = (route) => {
247
+ // URL plugin path (#532): `state.context.url.hash` is the source of truth
248
+ // when one of the URL plugins (browser-plugin / navigation-plugin) is
249
+ // installed. The value is already DECODED — feeding it through
250
+ // `decodeURIComponent` again would throw on a bare `%`.
251
+ const ctxHash = route.context
252
+ ?.url?.hash;
253
+ if (ctxHash !== undefined) {
254
+ if (anchorEnabled && ctxHash.length > 0) {
255
+ // eslint-disable-next-line unicorn/prefer-query-selector -- ids may contain CSS-unsafe chars
256
+ const element = document.getElementById(ctxHash);
257
+ if (element) {
258
+ element.scrollIntoView({ behavior });
259
+ return;
260
+ }
261
+ }
262
+ writePos(0);
263
+ return;
264
+ }
265
+ // Fallback path: no URL plugin, read the DOM. `location.hash` is
266
+ // percent-encoded; ids in the DOM are the raw string, so decode for the
267
+ // match. Fall back to the raw slice if the hash contains a malformed
268
+ // escape sequence (decodeURIComponent throws on those).
223
269
  const hash = globalThis.location.hash;
224
270
  if (anchorEnabled && hash.length > 1) {
225
- // location.hash is percent-encoded; ids in the DOM are the raw string.
226
- // Decode for the match. Fall back to the raw slice if the hash contains
227
- // a malformed escape sequence (decodeURIComponent throws on those).
228
271
  let id;
229
272
  try {
230
273
  id = decodeURIComponent(hash.slice(1));
@@ -235,7 +278,7 @@ function createScrollRestoration(router, options) {
235
278
  // eslint-disable-next-line unicorn/prefer-query-selector -- ids may contain CSS-unsafe chars
236
279
  const element = document.getElementById(id);
237
280
  if (element) {
238
- element.scrollIntoView();
281
+ element.scrollIntoView({ behavior });
239
282
  return;
240
283
  }
241
284
  }
@@ -258,7 +301,7 @@ function createScrollRestoration(router, options) {
258
301
  return;
259
302
  }
260
303
  if (mode === "top" || !nav) {
261
- scrollToHashOrTop();
304
+ scrollToHashOrTop(route);
262
305
  return;
263
306
  }
264
307
  if (nav.navigationType === "replace") {
@@ -270,7 +313,7 @@ function createScrollRestoration(router, options) {
270
313
  writePos(loadStore()[keyOf(route)] ?? 0);
271
314
  return;
272
315
  }
273
- scrollToHashOrTop();
316
+ scrollToHashOrTop(route);
274
317
  });
275
318
  });
276
319
  const onPageHide = () => {
@@ -300,25 +343,6 @@ function createScrollRestoration(router, options) {
300
343
  function keyOf(state) {
301
344
  return `${state.name}:${canonicalJson(state.params)}`;
302
345
  }
303
- function loadStore() {
304
- try {
305
- const raw = sessionStorage.getItem(STORAGE_KEY);
306
- return raw ? JSON.parse(raw) : {};
307
- }
308
- catch {
309
- return {};
310
- }
311
- }
312
- function putPos(key, pos) {
313
- try {
314
- const store = loadStore();
315
- store[key] = pos;
316
- sessionStorage.setItem(STORAGE_KEY, JSON.stringify(store));
317
- }
318
- catch {
319
- // Ignore quota / security errors.
320
- }
321
- }
322
346
  function canonicalJson(value) {
323
347
  return JSON.stringify(value, canonicalReplacer);
324
348
  }
@@ -461,22 +485,69 @@ function shouldNavigate(evt) {
461
485
  !evt.ctrlKey &&
462
486
  !evt.shiftKey);
463
487
  }
464
- function buildHref(router, routeName, routeParams) {
488
+ /**
489
+ * RFC 3986 fragment encoding: preserve sub-delims (`&`, `=`, `?`, `:`),
490
+ * encode space, `%`, control chars, non-ASCII via encodeURI; defensively
491
+ * escape `#` (encodeURI does not). Mirrors `encodeHashFragment` in
492
+ * `shared/browser-env/url-context.ts` — duplicated here because the
493
+ * shared/dom-utils symlink graph does not reach shared/browser-env.
494
+ */
495
+ function encodeFragmentInline(decoded) {
496
+ return encodeURI(decoded).replaceAll("#", "%23");
497
+ }
498
+ /**
499
+ * Builds an href for a `<Link>` element.
500
+ *
501
+ * - Prefers the URL plugin's `buildUrl` (browser-plugin, navigation-plugin,
502
+ * hash-plugin) when present.
503
+ * - Falls back to `router.buildPath` for runtimes without a URL plugin
504
+ * (memory-plugin, console UIs, NativeScript). In that fallback the hash
505
+ * is appended manually so the rendered href is still correct.
506
+ * - The optional 4th argument is an options object so the contract stays
507
+ * extensible. The `hash` option is a decoded fragment without leading "#";
508
+ * `<Link hash="#section">` is accepted defensively (leading "#" stripped).
509
+ * Frozen API: previous 3-arg call sites continue to work unchanged.
510
+ */
511
+ function buildHref(router, routeName, routeParams, options) {
465
512
  try {
513
+ const rawHash = options?.hash;
514
+ let normHash;
515
+ if (rawHash !== undefined) {
516
+ normHash = rawHash.startsWith("#") ? rawHash.slice(1) : rawHash;
517
+ }
466
518
  const buildUrl = router.buildUrl;
467
519
  if (buildUrl) {
468
- const url = buildUrl(routeName, routeParams);
520
+ const url = buildUrl(routeName, routeParams, normHash === undefined ? undefined : { hash: normHash });
469
521
  if (url !== undefined) {
470
522
  return url;
471
523
  }
472
524
  }
473
- return router.buildPath(routeName, routeParams);
525
+ const path = router.buildPath(routeName, routeParams);
526
+ return normHash ? `${path}#${encodeFragmentInline(normHash)}` : path;
474
527
  }
475
528
  catch {
476
529
  console.error(`[real-router] Route "${routeName}" is not defined. The element will render without an href attribute.`);
477
530
  return undefined;
478
531
  }
479
532
  }
533
+ function navigateWithHash(router, routeName, routeParams, hash, extraOptions) {
534
+ const opts = { ...extraOptions };
535
+ if (hash !== undefined) {
536
+ opts.hash = hash;
537
+ }
538
+ const current = router.getState();
539
+ if (current?.name === routeName &&
540
+ shallowEqual(current.params, routeParams)) {
541
+ const currentHash = current.context?.url?.hash ??
542
+ "";
543
+ const newHash = hash ?? currentHash;
544
+ if (currentHash !== newHash) {
545
+ opts.force = true;
546
+ opts.hashChange = true;
547
+ }
548
+ }
549
+ return router.navigate(routeName, routeParams, opts);
550
+ }
480
551
  function parseTokens(value) {
481
552
  return value ? (value.match(/\S+/g) ?? []) : [];
482
553
  }
@@ -650,10 +721,14 @@ function injectRouterTransition() {
650
721
 
651
722
  function injectIsActiveRoute(routeName, params, options) {
652
723
  const router = injectRouter();
653
- const source = createActiveRouteSource(router, routeName, params, {
654
- strict: options?.strict ?? false,
655
- ignoreQueryParams: options?.ignoreQueryParams ?? true,
656
- });
724
+ const strict = options?.strict ?? false;
725
+ const ignoreQueryParams = options?.ignoreQueryParams ?? true;
726
+ const hash = options?.hash;
727
+ // exactOptionalPropertyTypes forbids `{ hash: undefined }` literally — pass
728
+ // the field only when a value was provided. (#532)
729
+ const source = createActiveRouteSource(router, routeName, params, hash === undefined
730
+ ? { strict, ignoreQueryParams }
731
+ : { strict, ignoreQueryParams, hash });
657
732
  return sourceToSignal(source);
658
733
  }
659
734
 
@@ -1021,18 +1096,37 @@ class RealLink {
1021
1096
  activeClassName = input("active", ...(ngDevMode ? [{ debugName: "activeClassName" }] : /* istanbul ignore next */ []));
1022
1097
  activeStrict = input(false, ...(ngDevMode ? [{ debugName: "activeStrict" }] : /* istanbul ignore next */ []));
1023
1098
  ignoreQueryParams = input(true, ...(ngDevMode ? [{ debugName: "ignoreQueryParams" }] : /* istanbul ignore next */ []));
1099
+ /**
1100
+ * URL fragment (decoded form, no leading "#") (#532).
1101
+ * - omitted/`undefined` → preserve current fragment on same-route navigation
1102
+ * - `""` → clear fragment
1103
+ * - non-empty → set fragment
1104
+ */
1105
+ hash = input(undefined, ...(ngDevMode ? [{ debugName: "hash" }] : /* istanbul ignore next */ []));
1024
1106
  router = injectRouter();
1025
1107
  destroyRef = inject(DestroyRef);
1026
1108
  anchor = inject(ElementRef)
1027
1109
  .nativeElement;
1028
1110
  isActive = signal(false, ...(ngDevMode ? [{ debugName: "isActive" }] : /* istanbul ignore next */ []));
1029
- href = computed(() => buildHref(this.router, this.routeName(), this.routeParams()), ...(ngDevMode ? [{ debugName: "href" }] : /* istanbul ignore next */ []));
1111
+ href = computed(() => {
1112
+ const hashValue = this.hash();
1113
+ return buildHref(this.router, this.routeName(), this.routeParams(), hashValue === undefined ? undefined : { hash: hashValue });
1114
+ }, ...(ngDevMode ? [{ debugName: "href" }] : /* istanbul ignore next */ []));
1030
1115
  prevActiveClass = "";
1031
1116
  ngOnInit() {
1032
- const source = createActiveRouteSource(this.router, this.routeName(), this.routeParams(), {
1033
- strict: this.activeStrict(),
1034
- ignoreQueryParams: this.ignoreQueryParams(),
1035
- });
1117
+ // Hash-aware active state (#532): pass `hash` so that tab-style links
1118
+ // (same routeName, different `hash` input) only mark the active variant.
1119
+ const hashValue = this.hash();
1120
+ const source = createActiveRouteSource(this.router, this.routeName(), this.routeParams(), hashValue === undefined
1121
+ ? {
1122
+ strict: this.activeStrict(),
1123
+ ignoreQueryParams: this.ignoreQueryParams(),
1124
+ }
1125
+ : {
1126
+ strict: this.activeStrict(),
1127
+ ignoreQueryParams: this.ignoreQueryParams(),
1128
+ hash: hashValue,
1129
+ });
1036
1130
  this.isActive.set(source.getSnapshot());
1037
1131
  this.updateDom();
1038
1132
  const unsub = source.subscribe(() => {
@@ -1049,9 +1143,7 @@ class RealLink {
1049
1143
  return;
1050
1144
  }
1051
1145
  event.preventDefault();
1052
- this.router
1053
- .navigate(this.routeName(), this.routeParams(), this.routeOptions())
1054
- .catch(() => { });
1146
+ navigateWithHash(this.router, this.routeName(), this.routeParams(), this.hash(), this.routeOptions()).catch(() => { });
1055
1147
  }
1056
1148
  updateDom() {
1057
1149
  const href = this.href();
@@ -1068,7 +1160,7 @@ class RealLink {
1068
1160
  this.prevActiveClass = activeClass;
1069
1161
  }
1070
1162
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: RealLink, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1071
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.8", type: RealLink, isStandalone: true, selector: "a[realLink]", inputs: { routeName: { classPropertyName: "routeName", publicName: "routeName", isSignal: true, isRequired: false, transformFunction: null }, routeParams: { classPropertyName: "routeParams", publicName: "routeParams", isSignal: true, isRequired: false, transformFunction: null }, routeOptions: { classPropertyName: "routeOptions", publicName: "routeOptions", isSignal: true, isRequired: false, transformFunction: null }, activeClassName: { classPropertyName: "activeClassName", publicName: "activeClassName", isSignal: true, isRequired: false, transformFunction: null }, activeStrict: { classPropertyName: "activeStrict", publicName: "activeStrict", isSignal: true, isRequired: false, transformFunction: null }, ignoreQueryParams: { classPropertyName: "ignoreQueryParams", publicName: "ignoreQueryParams", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "click": "onClick($event)" } }, ngImport: i0 });
1163
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.8", type: RealLink, isStandalone: true, selector: "a[realLink]", inputs: { routeName: { classPropertyName: "routeName", publicName: "routeName", isSignal: true, isRequired: false, transformFunction: null }, routeParams: { classPropertyName: "routeParams", publicName: "routeParams", isSignal: true, isRequired: false, transformFunction: null }, routeOptions: { classPropertyName: "routeOptions", publicName: "routeOptions", isSignal: true, isRequired: false, transformFunction: null }, activeClassName: { classPropertyName: "activeClassName", publicName: "activeClassName", isSignal: true, isRequired: false, transformFunction: null }, activeStrict: { classPropertyName: "activeStrict", publicName: "activeStrict", isSignal: true, isRequired: false, transformFunction: null }, ignoreQueryParams: { classPropertyName: "ignoreQueryParams", publicName: "ignoreQueryParams", isSignal: true, isRequired: false, transformFunction: null }, hash: { classPropertyName: "hash", publicName: "hash", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "click": "onClick($event)" } }, ngImport: i0 });
1072
1164
  }
1073
1165
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: RealLink, decorators: [{
1074
1166
  type: Directive,
@@ -1078,7 +1170,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
1078
1170
  "(click)": "onClick($event)",
1079
1171
  },
1080
1172
  }]
1081
- }], propDecorators: { routeName: [{ type: i0.Input, args: [{ isSignal: true, alias: "routeName", required: false }] }], routeParams: [{ type: i0.Input, args: [{ isSignal: true, alias: "routeParams", required: false }] }], routeOptions: [{ type: i0.Input, args: [{ isSignal: true, alias: "routeOptions", required: false }] }], activeClassName: [{ type: i0.Input, args: [{ isSignal: true, alias: "activeClassName", required: false }] }], activeStrict: [{ type: i0.Input, args: [{ isSignal: true, alias: "activeStrict", required: false }] }], ignoreQueryParams: [{ type: i0.Input, args: [{ isSignal: true, alias: "ignoreQueryParams", required: false }] }] } });
1173
+ }], propDecorators: { routeName: [{ type: i0.Input, args: [{ isSignal: true, alias: "routeName", required: false }] }], routeParams: [{ type: i0.Input, args: [{ isSignal: true, alias: "routeParams", required: false }] }], routeOptions: [{ type: i0.Input, args: [{ isSignal: true, alias: "routeOptions", required: false }] }], activeClassName: [{ type: i0.Input, args: [{ isSignal: true, alias: "activeClassName", required: false }] }], activeStrict: [{ type: i0.Input, args: [{ isSignal: true, alias: "activeStrict", required: false }] }], ignoreQueryParams: [{ type: i0.Input, args: [{ isSignal: true, alias: "ignoreQueryParams", required: false }] }], hash: [{ type: i0.Input, args: [{ isSignal: true, alias: "hash", required: false }] }] } });
1082
1174
 
1083
1175
  class RealLinkActive {
1084
1176
  realLinkActive = input("", ...(ngDevMode ? [{ debugName: "realLinkActive" }] : /* istanbul ignore next */ []));