@real-router/svelte 0.8.0 → 0.9.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
@@ -183,11 +183,21 @@ Navigation link with automatic active state detection. Uses `$derived` for href
183
183
  | `activeClassName` | `string` | `"active"` | Class added when route is active |
184
184
  | `activeStrict` | `boolean` | `false` | Exact match only (no ancestor matching) |
185
185
  | `ignoreQueryParams` | `boolean` | `true` | Query params don't affect active state |
186
+ | `hash` | `string` | `undefined` | URL fragment (decoded). Tri-state: undefined preserves, `""` clears, value sets. (#532) |
186
187
  | `target` | `string` | `undefined` | Link target (`_blank`, etc.) |
187
188
  | `onclick` | `(evt: MouseEvent) => void` | `undefined` | Custom click handler. Runs **before** the navigation logic — call `evt.preventDefault()` to suppress navigation. |
188
189
 
189
190
  All other props are spread onto the `<a>` element.
190
191
 
192
+ #### `hash` — URL fragment / tab-style UIs
193
+
194
+ ```svelte
195
+ <Link routeName="settings" hash="profile">Profile</Link>
196
+ <Link routeName="settings" hash="account">Account</Link>
197
+ ```
198
+
199
+ 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.
200
+
191
201
  ### `<Lazy>`
192
202
 
193
203
  Lazy-load route content with a fallback component while loading. Useful for code-splitting and dynamic imports.
@@ -6,6 +6,7 @@
6
6
  shouldNavigate,
7
7
  buildHref,
8
8
  buildActiveClassName,
9
+ navigateWithHash,
9
10
  } from "../dom-utils";
10
11
 
11
12
  import type { NavigationOptions, Params } from "@real-router/core";
@@ -19,6 +20,7 @@
19
20
  activeClassName = "active",
20
21
  activeStrict = false,
21
22
  ignoreQueryParams = true,
23
+ hash = undefined,
22
24
  target = undefined,
23
25
  children = undefined,
24
26
  onclick: userOnClick = undefined,
@@ -31,6 +33,13 @@
31
33
  activeClassName?: string;
32
34
  activeStrict?: boolean;
33
35
  ignoreQueryParams?: boolean;
36
+ /**
37
+ * URL fragment (decoded form, no leading "#") (#532).
38
+ * - omitted/`undefined` → preserve current fragment on same-route navigation
39
+ * - `""` → clear fragment
40
+ * - non-empty → set fragment
41
+ */
42
+ hash?: string;
34
43
  target?: string;
35
44
  children?: Snippet;
36
45
  onclick?: (evt: MouseEvent) => void;
@@ -38,14 +47,24 @@
38
47
  } = $props();
39
48
 
40
49
  const router = useRouter();
50
+ // Hash-aware active (#532): tab links sharing routeName but differing in
51
+ // hash should only light up the matching variant.
41
52
  const activeState = useIsActiveRoute(
42
53
  routeName,
43
54
  routeParams,
44
55
  activeStrict,
45
56
  ignoreQueryParams,
57
+ hash,
46
58
  );
47
59
 
48
- const href = $derived(buildHref(router, routeName, routeParams));
60
+ const href = $derived(
61
+ buildHref(
62
+ router,
63
+ routeName,
64
+ routeParams,
65
+ hash !== undefined ? { hash } : undefined,
66
+ ),
67
+ );
49
68
 
50
69
  const finalClassName = $derived(
51
70
  buildActiveClassName(activeState.current, activeClassName, className),
@@ -65,7 +84,9 @@
65
84
  }
66
85
 
67
86
  evt.preventDefault();
68
- router.navigate(routeName, routeParams, routeOptions).catch(NOOP);
87
+ navigateWithHash(router, routeName, routeParams, hash, routeOptions).catch(
88
+ NOOP,
89
+ );
69
90
  }
70
91
  </script>
71
92
 
@@ -8,6 +8,13 @@ type $$ComponentProps = {
8
8
  activeClassName?: string;
9
9
  activeStrict?: boolean;
10
10
  ignoreQueryParams?: boolean;
11
+ /**
12
+ * URL fragment (decoded form, no leading "#") (#532).
13
+ * - omitted/`undefined` → preserve current fragment on same-route navigation
14
+ * - `""` → clear fragment
15
+ * - non-empty → set fragment
16
+ */
17
+ hash?: string;
11
18
  target?: string;
12
19
  children?: Snippet;
13
20
  onclick?: (evt: MouseEvent) => void;
@@ -1,4 +1,4 @@
1
1
  import type { Params } from "@real-router/core";
2
- export declare function useIsActiveRoute(routeName: string, params: Params | undefined, strict: boolean, ignoreQueryParams: boolean): {
2
+ export declare function useIsActiveRoute(routeName: string, params: Params | undefined, strict: boolean, ignoreQueryParams: boolean, hash?: string): {
3
3
  readonly current: boolean;
4
4
  };
@@ -1,11 +1,13 @@
1
1
  import { createActiveRouteSource } from "@real-router/sources";
2
2
  import { createReactiveSource } from "../createReactiveSource.svelte";
3
3
  import { useRouter } from "./useRouter.svelte";
4
- export function useIsActiveRoute(routeName, params, strict, ignoreQueryParams) {
4
+ export function useIsActiveRoute(routeName, params, strict, ignoreQueryParams, hash) {
5
5
  const router = useRouter();
6
- const source = createActiveRouteSource(router, routeName, params, {
7
- strict,
8
- ignoreQueryParams,
9
- });
6
+ // The `hash` argument (#532) participates in the cache key when defined.
7
+ // exactOptionalPropertyTypes forbids `{ hash: undefined }` literally — we
8
+ // conditionally include the key only when a value is provided.
9
+ const source = createActiveRouteSource(router, routeName, params, hash === undefined
10
+ ? { strict, ignoreQueryParams }
11
+ : { strict, ignoreQueryParams, hash });
10
12
  return createReactiveSource(source);
11
13
  }
@@ -2,7 +2,7 @@ export { createDirectionTracker } from "./direction-tracker.js";
2
2
  export { createRouteAnnouncer } from "./route-announcer.js";
3
3
  export { createScrollRestoration } from "./scroll-restore.js";
4
4
  export { createViewTransitions } from "./view-transitions.js";
5
- export { shouldNavigate, buildHref, buildActiveClassName, shallowEqual, applyLinkA11y, } from "./link-utils.js";
5
+ export { shouldNavigate, buildHref, buildActiveClassName, navigateWithHash, shallowEqual, applyLinkA11y, } from "./link-utils.js";
6
6
  export type { RouteAnnouncerOptions } from "./route-announcer.js";
7
7
  export type { ScrollRestorationOptions, ScrollRestorationMode, } from "./scroll-restore.js";
8
8
  export type { DirectionTracker } from "./direction-tracker.js";
@@ -2,4 +2,4 @@ export { createDirectionTracker } from "./direction-tracker.js";
2
2
  export { createRouteAnnouncer } from "./route-announcer.js";
3
3
  export { createScrollRestoration } from "./scroll-restore.js";
4
4
  export { createViewTransitions } from "./view-transitions.js";
5
- export { shouldNavigate, buildHref, buildActiveClassName, shallowEqual, applyLinkA11y, } from "./link-utils.js";
5
+ export { shouldNavigate, buildHref, buildActiveClassName, navigateWithHash, shallowEqual, applyLinkA11y, } from "./link-utils.js";
@@ -1,6 +1,22 @@
1
- import type { Router, Params } from "@real-router/core";
1
+ import type { NavigationOptions, Params, Router, State } from "@real-router/core";
2
2
  export declare function shouldNavigate(evt: MouseEvent): boolean;
3
- export declare function buildHref(router: Router, routeName: string, routeParams: Params): string | undefined;
3
+ /**
4
+ * Builds an href for a `<Link>` element.
5
+ *
6
+ * - Prefers the URL plugin's `buildUrl` (browser-plugin, navigation-plugin,
7
+ * hash-plugin) when present.
8
+ * - Falls back to `router.buildPath` for runtimes without a URL plugin
9
+ * (memory-plugin, console UIs, NativeScript). In that fallback the hash
10
+ * is appended manually so the rendered href is still correct.
11
+ * - The optional 4th argument is an options object so the contract stays
12
+ * extensible. The `hash` option is a decoded fragment without leading "#";
13
+ * `<Link hash="#section">` is accepted defensively (leading "#" stripped).
14
+ * Frozen API: previous 3-arg call sites continue to work unchanged.
15
+ */
16
+ export declare function buildHref(router: Router, routeName: string, routeParams: Params, options?: {
17
+ hash?: string;
18
+ }): string | undefined;
19
+ export declare function navigateWithHash(router: Router, routeName: string, routeParams: Params, hash: string | undefined, extraOptions?: NavigationOptions): Promise<State>;
4
20
  export declare function buildActiveClassName(isActive: boolean, activeClassName: string | undefined, baseClassName: string | undefined): string | undefined;
5
21
  export declare function shallowEqual(prev: object | undefined, next: object | undefined): boolean;
6
22
  export declare function applyLinkA11y(element: HTMLElement | null | undefined): void;
@@ -5,22 +5,69 @@ export function shouldNavigate(evt) {
5
5
  !evt.ctrlKey &&
6
6
  !evt.shiftKey);
7
7
  }
8
- export function buildHref(router, routeName, routeParams) {
8
+ /**
9
+ * RFC 3986 fragment encoding: preserve sub-delims (`&`, `=`, `?`, `:`),
10
+ * encode space, `%`, control chars, non-ASCII via encodeURI; defensively
11
+ * escape `#` (encodeURI does not). Mirrors `encodeHashFragment` in
12
+ * `shared/browser-env/url-context.ts` — duplicated here because the
13
+ * shared/dom-utils symlink graph does not reach shared/browser-env.
14
+ */
15
+ function encodeFragmentInline(decoded) {
16
+ return encodeURI(decoded).replaceAll("#", "%23");
17
+ }
18
+ /**
19
+ * Builds an href for a `<Link>` element.
20
+ *
21
+ * - Prefers the URL plugin's `buildUrl` (browser-plugin, navigation-plugin,
22
+ * hash-plugin) when present.
23
+ * - Falls back to `router.buildPath` for runtimes without a URL plugin
24
+ * (memory-plugin, console UIs, NativeScript). In that fallback the hash
25
+ * is appended manually so the rendered href is still correct.
26
+ * - The optional 4th argument is an options object so the contract stays
27
+ * extensible. The `hash` option is a decoded fragment without leading "#";
28
+ * `<Link hash="#section">` is accepted defensively (leading "#" stripped).
29
+ * Frozen API: previous 3-arg call sites continue to work unchanged.
30
+ */
31
+ export function buildHref(router, routeName, routeParams, options) {
9
32
  try {
33
+ const rawHash = options?.hash;
34
+ let normHash;
35
+ if (rawHash !== undefined) {
36
+ normHash = rawHash.startsWith("#") ? rawHash.slice(1) : rawHash;
37
+ }
10
38
  const buildUrl = router.buildUrl;
11
39
  if (buildUrl) {
12
- const url = buildUrl(routeName, routeParams);
40
+ const url = buildUrl(routeName, routeParams, normHash === undefined ? undefined : { hash: normHash });
13
41
  if (url !== undefined) {
14
42
  return url;
15
43
  }
16
44
  }
17
- return router.buildPath(routeName, routeParams);
45
+ const path = router.buildPath(routeName, routeParams);
46
+ return normHash ? `${path}#${encodeFragmentInline(normHash)}` : path;
18
47
  }
19
48
  catch {
20
49
  console.error(`[real-router] Route "${routeName}" is not defined. The element will render without an href attribute.`);
21
50
  return undefined;
22
51
  }
23
52
  }
53
+ export function navigateWithHash(router, routeName, routeParams, hash, extraOptions) {
54
+ const opts = { ...extraOptions };
55
+ if (hash !== undefined) {
56
+ opts.hash = hash;
57
+ }
58
+ const current = router.getState();
59
+ if (current?.name === routeName &&
60
+ shallowEqual(current.params, routeParams)) {
61
+ const currentHash = current.context?.url?.hash ??
62
+ "";
63
+ const newHash = hash ?? currentHash;
64
+ if (currentHash !== newHash) {
65
+ opts.force = true;
66
+ opts.hashChange = true;
67
+ }
68
+ }
69
+ return router.navigate(routeName, routeParams, opts);
70
+ }
24
71
  function parseTokens(value) {
25
72
  return value ? (value.match(/\S+/g) ?? []) : [];
26
73
  }
@@ -40,12 +40,31 @@ export function createScrollRestoration(router, options) {
40
40
  globalThis.scrollTo(0, top);
41
41
  }
42
42
  };
43
- const scrollToHashOrTop = () => {
43
+ const scrollToHashOrTop = (route) => {
44
+ // URL plugin path (#532): `state.context.url.hash` is the source of truth
45
+ // when one of the URL plugins (browser-plugin / navigation-plugin) is
46
+ // installed. The value is already DECODED — feeding it through
47
+ // `decodeURIComponent` again would throw on a bare `%`.
48
+ const ctxHash = route.context
49
+ ?.url?.hash;
50
+ if (ctxHash !== undefined) {
51
+ if (anchorEnabled && ctxHash.length > 0) {
52
+ // eslint-disable-next-line unicorn/prefer-query-selector -- ids may contain CSS-unsafe chars
53
+ const element = document.getElementById(ctxHash);
54
+ if (element) {
55
+ element.scrollIntoView();
56
+ return;
57
+ }
58
+ }
59
+ writePos(0);
60
+ return;
61
+ }
62
+ // Fallback path: no URL plugin, read the DOM. `location.hash` is
63
+ // percent-encoded; ids in the DOM are the raw string, so decode for the
64
+ // match. Fall back to the raw slice if the hash contains a malformed
65
+ // escape sequence (decodeURIComponent throws on those).
44
66
  const hash = globalThis.location.hash;
45
67
  if (anchorEnabled && hash.length > 1) {
46
- // location.hash is percent-encoded; ids in the DOM are the raw string.
47
- // Decode for the match. Fall back to the raw slice if the hash contains
48
- // a malformed escape sequence (decodeURIComponent throws on those).
49
68
  let id;
50
69
  try {
51
70
  id = decodeURIComponent(hash.slice(1));
@@ -79,7 +98,7 @@ export function createScrollRestoration(router, options) {
79
98
  return;
80
99
  }
81
100
  if (mode === "top" || !nav) {
82
- scrollToHashOrTop();
101
+ scrollToHashOrTop(route);
83
102
  return;
84
103
  }
85
104
  if (nav.navigationType === "replace") {
@@ -91,7 +110,7 @@ export function createScrollRestoration(router, options) {
91
110
  writePos(loadStore()[keyOf(route)] ?? 0);
92
111
  return;
93
112
  }
94
- scrollToHashOrTop();
113
+ scrollToHashOrTop(route);
95
114
  });
96
115
  });
97
116
  const onPageHide = () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@real-router/svelte",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "Svelte 5 integration for Real-Router",
6
6
  "svelte": "./dist/index.js",
@@ -44,9 +44,9 @@
44
44
  "license": "MIT",
45
45
  "sideEffects": false,
46
46
  "dependencies": {
47
- "@real-router/core": "^0.50.2",
48
- "@real-router/route-utils": "^0.2.1",
49
- "@real-router/sources": "^0.7.2"
47
+ "@real-router/core": "^0.51.0",
48
+ "@real-router/route-utils": "^0.2.2",
49
+ "@real-router/sources": "^0.8.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@sveltejs/package": "2.5.7",
@@ -58,7 +58,7 @@
58
58
  "svelte": "5.54.0",
59
59
  "svelte-check": "4.4.5",
60
60
  "svelte-eslint-parser": "1.6.0",
61
- "@real-router/browser-plugin": "^0.16.0"
61
+ "@real-router/browser-plugin": "^0.17.0"
62
62
  },
63
63
  "peerDependencies": {
64
64
  "svelte": ">=5.7.0"
@@ -6,6 +6,7 @@
6
6
  shouldNavigate,
7
7
  buildHref,
8
8
  buildActiveClassName,
9
+ navigateWithHash,
9
10
  } from "../dom-utils";
10
11
 
11
12
  import type { NavigationOptions, Params } from "@real-router/core";
@@ -19,6 +20,7 @@
19
20
  activeClassName = "active",
20
21
  activeStrict = false,
21
22
  ignoreQueryParams = true,
23
+ hash = undefined,
22
24
  target = undefined,
23
25
  children = undefined,
24
26
  onclick: userOnClick = undefined,
@@ -31,6 +33,13 @@
31
33
  activeClassName?: string;
32
34
  activeStrict?: boolean;
33
35
  ignoreQueryParams?: boolean;
36
+ /**
37
+ * URL fragment (decoded form, no leading "#") (#532).
38
+ * - omitted/`undefined` → preserve current fragment on same-route navigation
39
+ * - `""` → clear fragment
40
+ * - non-empty → set fragment
41
+ */
42
+ hash?: string;
34
43
  target?: string;
35
44
  children?: Snippet;
36
45
  onclick?: (evt: MouseEvent) => void;
@@ -38,14 +47,24 @@
38
47
  } = $props();
39
48
 
40
49
  const router = useRouter();
50
+ // Hash-aware active (#532): tab links sharing routeName but differing in
51
+ // hash should only light up the matching variant.
41
52
  const activeState = useIsActiveRoute(
42
53
  routeName,
43
54
  routeParams,
44
55
  activeStrict,
45
56
  ignoreQueryParams,
57
+ hash,
46
58
  );
47
59
 
48
- const href = $derived(buildHref(router, routeName, routeParams));
60
+ const href = $derived(
61
+ buildHref(
62
+ router,
63
+ routeName,
64
+ routeParams,
65
+ hash !== undefined ? { hash } : undefined,
66
+ ),
67
+ );
49
68
 
50
69
  const finalClassName = $derived(
51
70
  buildActiveClassName(activeState.current, activeClassName, className),
@@ -65,7 +84,9 @@
65
84
  }
66
85
 
67
86
  evt.preventDefault();
68
- router.navigate(routeName, routeParams, routeOptions).catch(NOOP);
87
+ navigateWithHash(router, routeName, routeParams, hash, routeOptions).catch(
88
+ NOOP,
89
+ );
69
90
  }
70
91
  </script>
71
92
 
@@ -10,13 +10,21 @@ export function useIsActiveRoute(
10
10
  params: Params | undefined,
11
11
  strict: boolean,
12
12
  ignoreQueryParams: boolean,
13
+ hash?: string,
13
14
  ): { readonly current: boolean } {
14
15
  const router = useRouter();
15
16
 
16
- const source = createActiveRouteSource(router, routeName, params, {
17
- strict,
18
- ignoreQueryParams,
19
- });
17
+ // The `hash` argument (#532) participates in the cache key when defined.
18
+ // exactOptionalPropertyTypes forbids `{ hash: undefined }` literally — we
19
+ // conditionally include the key only when a value is provided.
20
+ const source = createActiveRouteSource(
21
+ router,
22
+ routeName,
23
+ params,
24
+ hash === undefined
25
+ ? { strict, ignoreQueryParams }
26
+ : { strict, ignoreQueryParams, hash },
27
+ );
20
28
 
21
29
  return createReactiveSource(source);
22
30
  }