@real-router/svelte 0.4.1 → 0.5.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 +13 -1
- package/dist/RouterProvider.svelte +34 -4
- package/dist/RouterProvider.svelte.d.ts +2 -0
- package/dist/actions/link.svelte.js +1 -1
- package/dist/components/Link.svelte +1 -1
- package/dist/dom-utils/index.d.ts +2 -0
- package/dist/dom-utils/index.js +1 -0
- package/dist/dom-utils/scroll-restore.d.ts +10 -0
- package/dist/dom-utils/scroll-restore.js +157 -0
- package/package.json +4 -4
- package/src/RouterProvider.svelte +34 -4
- package/src/actions/link.svelte.ts +1 -1
- package/src/components/Link.svelte +1 -1
package/README.md
CHANGED
|
@@ -363,11 +363,23 @@ Enable screen reader announcements for route changes:
|
|
|
363
363
|
|
|
364
364
|
When enabled, a visually hidden `aria-live` region announces each navigation. Focus moves to the first `<h1>` on the new page. See [Accessibility guide](https://github.com/greydragon888/real-router/wiki/Accessibility) for details.
|
|
365
365
|
|
|
366
|
+
## Scroll Restoration
|
|
367
|
+
|
|
368
|
+
Opt-in preservation of scroll position across navigations:
|
|
369
|
+
|
|
370
|
+
```svelte
|
|
371
|
+
<RouterProvider {router} scrollRestoration={{ mode: "restore" }}>
|
|
372
|
+
<!-- Your app -->
|
|
373
|
+
</RouterProvider>
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
Restores scroll on back/forward, scrolls to top (or `#hash`) on push. Three modes: `"restore"` (default), `"top"`, `"manual"`. Custom containers via `scrollContainer: () => HTMLElement | null`. Lifecycle tied to the provider — created on mount, destroyed on unmount. See [Scroll Restoration guide](https://github.com/greydragon888/real-router/wiki/Scroll-Restoration) for details.
|
|
377
|
+
|
|
366
378
|
## Documentation
|
|
367
379
|
|
|
368
380
|
Full documentation: [Wiki](https://github.com/greydragon888/real-router/wiki)
|
|
369
381
|
|
|
370
|
-
- [RouterProvider](https://github.com/greydragon888/real-router/wiki/RouterProvider) · [RouteView](https://github.com/greydragon888/real-router/wiki/RouteView) · [RouterErrorBoundary](https://github.com/greydragon888/real-router/wiki/RouterErrorBoundary) · [Link](https://github.com/greydragon888/real-router/wiki/Link)
|
|
382
|
+
- [RouterProvider](https://github.com/greydragon888/real-router/wiki/RouterProvider) · [RouteView](https://github.com/greydragon888/real-router/wiki/RouteView) · [RouterErrorBoundary](https://github.com/greydragon888/real-router/wiki/RouterErrorBoundary) · [Link](https://github.com/greydragon888/real-router/wiki/Link) · [Scroll Restoration](https://github.com/greydragon888/real-router/wiki/Scroll-Restoration)
|
|
371
383
|
- [useRouter](https://github.com/greydragon888/real-router/wiki/useRouter) · [useRoute](https://github.com/greydragon888/real-router/wiki/useRoute) · [useRouteNode](https://github.com/greydragon888/real-router/wiki/useRouteNode) · [useNavigator](https://github.com/greydragon888/real-router/wiki/useNavigator) · [useRouteUtils](https://github.com/greydragon888/real-router/wiki/useRouteUtils) · [useRouterTransition](https://github.com/greydragon888/real-router/wiki/useRouterTransition)
|
|
372
384
|
|
|
373
385
|
## Examples
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { getNavigator } from "@real-router/core";
|
|
3
3
|
import { createRouteSource } from "@real-router/sources";
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
createRouteAnnouncer,
|
|
6
|
+
createScrollRestoration,
|
|
7
|
+
} from "./dom-utils";
|
|
8
|
+
import { setContext, untrack } from "svelte";
|
|
6
9
|
|
|
7
10
|
import { createReactiveSource } from "./createReactiveSource.svelte";
|
|
8
11
|
import { createRouteContext } from "./createRouteContext.svelte";
|
|
9
12
|
import { NAVIGATOR_KEY, ROUTE_KEY, ROUTER_KEY } from "./context";
|
|
10
13
|
|
|
14
|
+
import type { ScrollRestorationOptions } from "./dom-utils";
|
|
11
15
|
import type { Router } from "@real-router/core";
|
|
12
16
|
import type { Snippet } from "svelte";
|
|
13
17
|
|
|
@@ -15,8 +19,13 @@
|
|
|
15
19
|
router,
|
|
16
20
|
children,
|
|
17
21
|
announceNavigation,
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
scrollRestoration,
|
|
23
|
+
}: {
|
|
24
|
+
router: Router;
|
|
25
|
+
children: Snippet;
|
|
26
|
+
announceNavigation?: boolean;
|
|
27
|
+
scrollRestoration?: ScrollRestorationOptions;
|
|
28
|
+
} = $props();
|
|
20
29
|
|
|
21
30
|
$effect(() => {
|
|
22
31
|
if (!announceNavigation) return;
|
|
@@ -24,6 +33,27 @@
|
|
|
24
33
|
return () => announcer.destroy();
|
|
25
34
|
});
|
|
26
35
|
|
|
36
|
+
// $derived memoizes by === so inline `{ mode: "restore" }` doesn't thrash:
|
|
37
|
+
// each parent re-render produces a new object ref, but .mode stays "restore"
|
|
38
|
+
// → $derived returns the same primitive → $effect doesn't re-run.
|
|
39
|
+
const srEnabled = $derived(scrollRestoration !== undefined);
|
|
40
|
+
const srMode = $derived(scrollRestoration?.mode);
|
|
41
|
+
const srAnchor = $derived(scrollRestoration?.anchorScrolling);
|
|
42
|
+
|
|
43
|
+
$effect(() => {
|
|
44
|
+
if (!srEnabled) return;
|
|
45
|
+
// scrollContainer is a function ref that naturally changes each render.
|
|
46
|
+
// Read it via `untrack` so this $effect does NOT depend on the parent
|
|
47
|
+
// `scrollRestoration` signal. Without this, a new inline options object
|
|
48
|
+
// would re-run the effect regardless of the primitive $derived memos.
|
|
49
|
+
const sr = createScrollRestoration(router, {
|
|
50
|
+
mode: srMode,
|
|
51
|
+
anchorScrolling: srAnchor,
|
|
52
|
+
scrollContainer: untrack(() => scrollRestoration?.scrollContainer),
|
|
53
|
+
});
|
|
54
|
+
return () => sr.destroy();
|
|
55
|
+
});
|
|
56
|
+
|
|
27
57
|
const navigator = getNavigator(router);
|
|
28
58
|
const source = createRouteSource(router);
|
|
29
59
|
const reactive = createReactiveSource(source);
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import type { ScrollRestorationOptions } from "./dom-utils";
|
|
1
2
|
import type { Router } from "@real-router/core";
|
|
2
3
|
import type { Snippet } from "svelte";
|
|
3
4
|
type $$ComponentProps = {
|
|
4
5
|
router: Router;
|
|
5
6
|
children: Snippet;
|
|
6
7
|
announceNavigation?: boolean;
|
|
8
|
+
scrollRestoration?: ScrollRestorationOptions;
|
|
7
9
|
};
|
|
8
10
|
declare const RouterProvider: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
9
11
|
type RouterProvider = ReturnType<typeof RouterProvider>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ROUTER_KEY, getContextOrThrow } from "../context";
|
|
2
2
|
import { EMPTY_OPTIONS, EMPTY_PARAMS, NOOP } from "../constants";
|
|
3
|
-
import { shouldNavigate, applyLinkA11y } from "../dom-utils
|
|
3
|
+
import { shouldNavigate, applyLinkA11y } from "../dom-utils";
|
|
4
4
|
/**
|
|
5
5
|
* Factory function that captures router context during component initialization.
|
|
6
6
|
* Must be called during component init (not inside event handlers or effects).
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { createRouteAnnouncer } from "./route-announcer.js";
|
|
2
|
+
export { createScrollRestoration } from "./scroll-restore.js";
|
|
2
3
|
export { shouldNavigate, buildHref, buildActiveClassName, shallowEqual, applyLinkA11y, } from "./link-utils.js";
|
|
3
4
|
export type { RouteAnnouncerOptions } from "./route-announcer.js";
|
|
5
|
+
export type { ScrollRestorationOptions, ScrollRestorationMode, } from "./scroll-restore.js";
|
package/dist/dom-utils/index.js
CHANGED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Router } from "@real-router/core";
|
|
2
|
+
export type ScrollRestorationMode = "restore" | "top" | "manual";
|
|
3
|
+
export interface ScrollRestorationOptions {
|
|
4
|
+
mode?: ScrollRestorationMode | undefined;
|
|
5
|
+
anchorScrolling?: boolean | undefined;
|
|
6
|
+
scrollContainer?: (() => HTMLElement | null) | undefined;
|
|
7
|
+
}
|
|
8
|
+
export declare function createScrollRestoration(router: Router, options?: ScrollRestorationOptions): {
|
|
9
|
+
destroy: () => void;
|
|
10
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
const STORAGE_KEY = "real-router:scroll";
|
|
2
|
+
const NOOP_INSTANCE = Object.freeze({
|
|
3
|
+
destroy: () => {
|
|
4
|
+
/* no-op */
|
|
5
|
+
},
|
|
6
|
+
});
|
|
7
|
+
export function createScrollRestoration(router, options) {
|
|
8
|
+
if (typeof globalThis.window === "undefined") {
|
|
9
|
+
return NOOP_INSTANCE;
|
|
10
|
+
}
|
|
11
|
+
const mode = options?.mode ?? "restore";
|
|
12
|
+
// mode "manual" = utility does nothing. Don't flip history.scrollRestoration,
|
|
13
|
+
// don't subscribe, don't register pagehide — leave the browser's native
|
|
14
|
+
// auto-restore intact for the app to override if it wants to.
|
|
15
|
+
if (mode === "manual") {
|
|
16
|
+
return NOOP_INSTANCE;
|
|
17
|
+
}
|
|
18
|
+
const anchorEnabled = options?.anchorScrolling ?? true;
|
|
19
|
+
const getContainer = options?.scrollContainer;
|
|
20
|
+
const prevScrollRestoration = history.scrollRestoration;
|
|
21
|
+
try {
|
|
22
|
+
history.scrollRestoration = "manual";
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Ignore — some embedded contexts may reject the assignment.
|
|
26
|
+
}
|
|
27
|
+
// Resolve the container lazily on every event so containers mounted AFTER
|
|
28
|
+
// the provider still get correct scroll handling. Falls back to window when
|
|
29
|
+
// the getter is absent or returns null (pre-mount).
|
|
30
|
+
const readPos = () => {
|
|
31
|
+
const element = getContainer?.();
|
|
32
|
+
return element ? element.scrollTop : globalThis.scrollY;
|
|
33
|
+
};
|
|
34
|
+
const writePos = (top) => {
|
|
35
|
+
const element = getContainer?.();
|
|
36
|
+
if (element) {
|
|
37
|
+
element.scrollTop = top;
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
globalThis.scrollTo(0, top);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const scrollToHashOrTop = () => {
|
|
44
|
+
const hash = globalThis.location.hash;
|
|
45
|
+
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
|
+
let id;
|
|
50
|
+
try {
|
|
51
|
+
id = decodeURIComponent(hash.slice(1));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
id = hash.slice(1);
|
|
55
|
+
}
|
|
56
|
+
// eslint-disable-next-line unicorn/prefer-query-selector -- ids may contain CSS-unsafe chars
|
|
57
|
+
const element = document.getElementById(id);
|
|
58
|
+
if (element) {
|
|
59
|
+
element.scrollIntoView();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
writePos(0);
|
|
64
|
+
};
|
|
65
|
+
let destroyed = false;
|
|
66
|
+
const unsubscribe = router.subscribe(({ route, previousRoute }) => {
|
|
67
|
+
const nav = route.context
|
|
68
|
+
.navigation;
|
|
69
|
+
// Browsers dispatch reload as the initial navigation after refresh, so
|
|
70
|
+
// previousRoute is undefined and capture is naturally skipped. The
|
|
71
|
+
// pre-refresh position was already persisted via pagehide.
|
|
72
|
+
if (previousRoute) {
|
|
73
|
+
putPos(keyOf(previousRoute), readPos());
|
|
74
|
+
}
|
|
75
|
+
// Single rAF so DOM is committed before we read anchors / write scroll.
|
|
76
|
+
// Guard against destroy() racing with the callback.
|
|
77
|
+
requestAnimationFrame(() => {
|
|
78
|
+
if (destroyed) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (mode === "top" || !nav) {
|
|
82
|
+
scrollToHashOrTop();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (nav.navigationType === "replace") {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (nav.direction === "back" ||
|
|
89
|
+
nav.navigationType === "traverse" ||
|
|
90
|
+
nav.navigationType === "reload") {
|
|
91
|
+
writePos(loadStore()[keyOf(route)] ?? 0);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
scrollToHashOrTop();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
const onPageHide = () => {
|
|
98
|
+
const current = router.getState();
|
|
99
|
+
if (current) {
|
|
100
|
+
putPos(keyOf(current), readPos());
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
globalThis.addEventListener("pagehide", onPageHide);
|
|
104
|
+
return {
|
|
105
|
+
destroy: () => {
|
|
106
|
+
if (destroyed) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
destroyed = true;
|
|
110
|
+
unsubscribe();
|
|
111
|
+
globalThis.removeEventListener("pagehide", onPageHide);
|
|
112
|
+
try {
|
|
113
|
+
history.scrollRestoration = prevScrollRestoration;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Ignore.
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function keyOf(state) {
|
|
122
|
+
return `${state.name}:${canonicalJson(state.params)}`;
|
|
123
|
+
}
|
|
124
|
+
function loadStore() {
|
|
125
|
+
try {
|
|
126
|
+
const raw = sessionStorage.getItem(STORAGE_KEY);
|
|
127
|
+
return raw ? JSON.parse(raw) : {};
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return {};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function putPos(key, pos) {
|
|
134
|
+
try {
|
|
135
|
+
const store = loadStore();
|
|
136
|
+
store[key] = pos;
|
|
137
|
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Ignore quota / security errors.
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function canonicalJson(value) {
|
|
144
|
+
return JSON.stringify(value, canonicalReplacer);
|
|
145
|
+
}
|
|
146
|
+
function canonicalReplacer(_key, val) {
|
|
147
|
+
if (val !== null && typeof val === "object" && !Array.isArray(val)) {
|
|
148
|
+
const sorted = {};
|
|
149
|
+
// eslint-disable-next-line unicorn/no-array-sort -- ng-packagr uses pre-ES2023 lib; toSorted unavailable
|
|
150
|
+
const keys = Object.keys(val).sort((left, right) => left.localeCompare(right));
|
|
151
|
+
for (const key of keys) {
|
|
152
|
+
sorted[key] = val[key];
|
|
153
|
+
}
|
|
154
|
+
return sorted;
|
|
155
|
+
}
|
|
156
|
+
return val;
|
|
157
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@real-router/svelte",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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.
|
|
47
|
+
"@real-router/core": "^0.50.0",
|
|
48
48
|
"@real-router/route-utils": "^0.2.1",
|
|
49
|
-
"@real-router/sources": "^0.7.
|
|
49
|
+
"@real-router/sources": "^0.7.2"
|
|
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.
|
|
61
|
+
"@real-router/browser-plugin": "^0.14.0"
|
|
62
62
|
},
|
|
63
63
|
"peerDependencies": {
|
|
64
64
|
"svelte": ">=5.7.0"
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { getNavigator } from "@real-router/core";
|
|
3
3
|
import { createRouteSource } from "@real-router/sources";
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
createRouteAnnouncer,
|
|
6
|
+
createScrollRestoration,
|
|
7
|
+
} from "./dom-utils";
|
|
8
|
+
import { setContext, untrack } from "svelte";
|
|
6
9
|
|
|
7
10
|
import { createReactiveSource } from "./createReactiveSource.svelte";
|
|
8
11
|
import { createRouteContext } from "./createRouteContext.svelte";
|
|
9
12
|
import { NAVIGATOR_KEY, ROUTE_KEY, ROUTER_KEY } from "./context";
|
|
10
13
|
|
|
14
|
+
import type { ScrollRestorationOptions } from "./dom-utils";
|
|
11
15
|
import type { Router } from "@real-router/core";
|
|
12
16
|
import type { Snippet } from "svelte";
|
|
13
17
|
|
|
@@ -15,8 +19,13 @@
|
|
|
15
19
|
router,
|
|
16
20
|
children,
|
|
17
21
|
announceNavigation,
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
scrollRestoration,
|
|
23
|
+
}: {
|
|
24
|
+
router: Router;
|
|
25
|
+
children: Snippet;
|
|
26
|
+
announceNavigation?: boolean;
|
|
27
|
+
scrollRestoration?: ScrollRestorationOptions;
|
|
28
|
+
} = $props();
|
|
20
29
|
|
|
21
30
|
$effect(() => {
|
|
22
31
|
if (!announceNavigation) return;
|
|
@@ -24,6 +33,27 @@
|
|
|
24
33
|
return () => announcer.destroy();
|
|
25
34
|
});
|
|
26
35
|
|
|
36
|
+
// $derived memoizes by === so inline `{ mode: "restore" }` doesn't thrash:
|
|
37
|
+
// each parent re-render produces a new object ref, but .mode stays "restore"
|
|
38
|
+
// → $derived returns the same primitive → $effect doesn't re-run.
|
|
39
|
+
const srEnabled = $derived(scrollRestoration !== undefined);
|
|
40
|
+
const srMode = $derived(scrollRestoration?.mode);
|
|
41
|
+
const srAnchor = $derived(scrollRestoration?.anchorScrolling);
|
|
42
|
+
|
|
43
|
+
$effect(() => {
|
|
44
|
+
if (!srEnabled) return;
|
|
45
|
+
// scrollContainer is a function ref that naturally changes each render.
|
|
46
|
+
// Read it via `untrack` so this $effect does NOT depend on the parent
|
|
47
|
+
// `scrollRestoration` signal. Without this, a new inline options object
|
|
48
|
+
// would re-run the effect regardless of the primitive $derived memos.
|
|
49
|
+
const sr = createScrollRestoration(router, {
|
|
50
|
+
mode: srMode,
|
|
51
|
+
anchorScrolling: srAnchor,
|
|
52
|
+
scrollContainer: untrack(() => scrollRestoration?.scrollContainer),
|
|
53
|
+
});
|
|
54
|
+
return () => sr.destroy();
|
|
55
|
+
});
|
|
56
|
+
|
|
27
57
|
const navigator = getNavigator(router);
|
|
28
58
|
const source = createRouteSource(router);
|
|
29
59
|
const reactive = createReactiveSource(source);
|
|
@@ -2,7 +2,7 @@ import type { ActionReturn } from "svelte/action";
|
|
|
2
2
|
import type { Router, Params, NavigationOptions } from "@real-router/core";
|
|
3
3
|
import { ROUTER_KEY, getContextOrThrow } from "../context";
|
|
4
4
|
import { EMPTY_OPTIONS, EMPTY_PARAMS, NOOP } from "../constants";
|
|
5
|
-
import { shouldNavigate, applyLinkA11y } from "../dom-utils
|
|
5
|
+
import { shouldNavigate, applyLinkA11y } from "../dom-utils";
|
|
6
6
|
|
|
7
7
|
export interface LinkActionParams {
|
|
8
8
|
name: string;
|