@real-router/svelte 0.2.13 → 0.3.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 +12 -3
- package/dist/RouterProvider.svelte +3 -17
- package/dist/actions/link.svelte.d.ts +3 -1
- package/dist/actions/link.svelte.js +14 -16
- package/dist/components/Lazy.svelte +23 -15
- package/dist/components/Link.svelte +4 -3
- package/dist/components/RouteView.svelte +24 -19
- package/dist/components/RouteView.svelte.d.ts +1 -0
- package/dist/components/RouterErrorBoundary.svelte +13 -5
- package/dist/composables/useIsActiveRoute.svelte.d.ts +1 -1
- package/dist/composables/useIsActiveRoute.svelte.js +1 -1
- package/dist/composables/useNavigator.svelte.js +2 -9
- package/dist/composables/useRoute.svelte.js +2 -9
- package/dist/composables/useRouteNode.svelte.js +2 -17
- package/dist/composables/useRouter.svelte.js +2 -9
- package/dist/constants.d.ts +4 -0
- package/dist/constants.js +3 -0
- package/dist/context.d.ts +1 -0
- package/dist/context.js +13 -0
- package/dist/createRouteContext.svelte.d.ts +9 -0
- package/dist/createRouteContext.svelte.js +13 -0
- package/dist/dom-utils/index.d.ts +1 -1
- package/dist/dom-utils/index.js +1 -1
- package/dist/dom-utils/link-utils.d.ts +2 -1
- package/dist/dom-utils/link-utils.js +48 -6
- package/dist/dom-utils/route-announcer.js +37 -14
- package/package.json +4 -4
- package/src/RouterProvider.svelte +3 -17
- package/src/actions/link.svelte.ts +27 -29
- package/src/components/Lazy.svelte +23 -15
- package/src/components/Link.svelte +4 -3
- package/src/components/RouteView.svelte +24 -19
- package/src/components/RouterErrorBoundary.svelte +13 -5
- package/src/composables/useIsActiveRoute.svelte.ts +3 -3
- package/src/composables/useNavigator.svelte.ts +3 -12
- package/src/composables/useRoute.svelte.ts +3 -12
- package/src/composables/useRouteNode.svelte.ts +2 -17
- package/src/composables/useRouter.svelte.ts +3 -12
- package/src/constants.ts +9 -0
- package/src/context.ts +17 -0
- package/src/createRouteContext.svelte.ts +27 -0
package/README.md
CHANGED
|
@@ -142,6 +142,7 @@ Navigation link with automatic active state detection. Uses `$derived` for href
|
|
|
142
142
|
| `activeStrict` | `boolean` | `false` | Exact match only (no ancestor matching) |
|
|
143
143
|
| `ignoreQueryParams` | `boolean` | `true` | Query params don't affect active state |
|
|
144
144
|
| `target` | `string` | `undefined` | Link target (`_blank`, etc.) |
|
|
145
|
+
| `onclick` | `(evt: MouseEvent) => void` | `undefined` | Custom click handler. Runs **before** the navigation logic — call `evt.preventDefault()` to suppress navigation. |
|
|
145
146
|
|
|
146
147
|
All other props are spread onto the `<a>` element.
|
|
147
148
|
|
|
@@ -206,7 +207,13 @@ Declarative error handling for navigation errors. Shows a fallback **alongside**
|
|
|
206
207
|
</script>
|
|
207
208
|
|
|
208
209
|
<RouterErrorBoundary
|
|
209
|
-
onError={(error
|
|
210
|
+
onError={(error, toRoute, fromRoute) =>
|
|
211
|
+
analytics.track("nav_error", {
|
|
212
|
+
code: error.code,
|
|
213
|
+
to: toRoute?.name,
|
|
214
|
+
from: fromRoute?.name,
|
|
215
|
+
})
|
|
216
|
+
}
|
|
210
217
|
>
|
|
211
218
|
{#snippet fallback(error, resetError)}
|
|
212
219
|
<div class="toast">
|
|
@@ -220,6 +227,8 @@ Declarative error handling for navigation errors. Shows a fallback **alongside**
|
|
|
220
227
|
|
|
221
228
|
Auto-resets on next successful navigation. Works with both `<Link>` and imperative `router.navigate()`.
|
|
222
229
|
|
|
230
|
+
**`onError` signature:** `(error, toRoute, fromRoute) => void`. Receives the `RouterError`, the attempted destination (`State | null`), and the previously active route (`State | null`). A throwing `onError` is caught by the boundary, logged via `console.error`, and never breaks reactivity.
|
|
231
|
+
|
|
223
232
|
## Actions
|
|
224
233
|
|
|
225
234
|
### `createLinkAction`
|
|
@@ -363,9 +372,9 @@ Full documentation: [Wiki](https://github.com/greydragon888/real-router/wiki)
|
|
|
363
372
|
|
|
364
373
|
## Examples
|
|
365
374
|
|
|
366
|
-
|
|
375
|
+
16 runnable examples — each is a standalone Vite app. Run: `cd examples/svelte/basic && pnpm dev`
|
|
367
376
|
|
|
368
|
-
[basic](../../examples/svelte/basic) · [nested-routes](../../examples/svelte/nested-routes) · [auth-guards](../../examples/svelte/auth-guards) · [data-loading](../../examples/svelte/data-loading) · [lazy-loading](../../examples/svelte/lazy-loading) · [async-guards](../../examples/svelte/async-guards) · [hash-routing](../../examples/svelte/hash-routing) · [persistent-params](../../examples/svelte/persistent-params) · [error-handling](../../examples/svelte/error-handling) · [dynamic-routes](../../examples/svelte/dynamic-routes) · [link-action](../../examples/svelte/link-action) · [lazy-loading-svelte](../../examples/svelte/lazy-loading-svelte) · [snippets-routing](../../examples/svelte/snippets-routing) · [reactive-source](../../examples/svelte/reactive-source) · [combined](../../examples/svelte/combined)
|
|
377
|
+
[basic](../../examples/svelte/basic) · [nested-routes](../../examples/svelte/nested-routes) · [auth-guards](../../examples/svelte/auth-guards) · [data-loading](../../examples/svelte/data-loading) · [lazy-loading](../../examples/svelte/lazy-loading) · [async-guards](../../examples/svelte/async-guards) · [hash-routing](../../examples/svelte/hash-routing) · [persistent-params](../../examples/svelte/persistent-params) · [error-handling](../../examples/svelte/error-handling) · [dynamic-routes](../../examples/svelte/dynamic-routes) · [link-action](../../examples/svelte/link-action) · [lazy-loading-svelte](../../examples/svelte/lazy-loading-svelte) · [snippets-routing](../../examples/svelte/snippets-routing) · [reactive-source](../../examples/svelte/reactive-source) · [search-schema](../../examples/svelte/search-schema) · [combined](../../examples/svelte/combined)
|
|
369
378
|
|
|
370
379
|
## Related Packages
|
|
371
380
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { setContext } from "svelte";
|
|
6
6
|
|
|
7
7
|
import { createReactiveSource } from "./createReactiveSource.svelte";
|
|
8
|
+
import { createRouteContext } from "./createRouteContext.svelte";
|
|
8
9
|
import { NAVIGATOR_KEY, ROUTE_KEY, ROUTER_KEY } from "./context";
|
|
9
10
|
|
|
10
11
|
import type { Router } from "@real-router/core";
|
|
@@ -26,26 +27,11 @@
|
|
|
26
27
|
const navigator = getNavigator(router);
|
|
27
28
|
const source = createRouteSource(router);
|
|
28
29
|
const reactive = createReactiveSource(source);
|
|
30
|
+
const routeContext = createRouteContext(navigator, reactive);
|
|
29
31
|
|
|
30
32
|
setContext(ROUTER_KEY, router);
|
|
31
33
|
setContext(NAVIGATOR_KEY, navigator);
|
|
32
|
-
setContext(ROUTE_KEY,
|
|
33
|
-
navigator,
|
|
34
|
-
get route() {
|
|
35
|
-
return {
|
|
36
|
-
get current() {
|
|
37
|
-
return reactive.current.route;
|
|
38
|
-
},
|
|
39
|
-
};
|
|
40
|
-
},
|
|
41
|
-
get previousRoute() {
|
|
42
|
-
return {
|
|
43
|
-
get current() {
|
|
44
|
-
return reactive.current.previousRoute;
|
|
45
|
-
},
|
|
46
|
-
};
|
|
47
|
-
},
|
|
48
|
-
});
|
|
34
|
+
setContext(ROUTE_KEY, routeContext);
|
|
49
35
|
</script>
|
|
50
36
|
|
|
51
37
|
{@render children()}
|
|
@@ -5,6 +5,7 @@ export interface LinkActionParams {
|
|
|
5
5
|
params?: Params;
|
|
6
6
|
options?: NavigationOptions;
|
|
7
7
|
}
|
|
8
|
+
type LinkAction = (node: HTMLElement, params: LinkActionParams) => ActionReturn<LinkActionParams>;
|
|
8
9
|
/**
|
|
9
10
|
* Factory function that captures router context during component initialization.
|
|
10
11
|
* Must be called during component init (not inside event handlers or effects).
|
|
@@ -23,4 +24,5 @@ export interface LinkActionParams {
|
|
|
23
24
|
* <a use:link={{ name: 'users', params: { id: '123' } }}>User Profile</a>
|
|
24
25
|
* ```
|
|
25
26
|
*/
|
|
26
|
-
export declare function createLinkAction():
|
|
27
|
+
export declare function createLinkAction(): LinkAction;
|
|
28
|
+
export {};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { ROUTER_KEY, getContextOrThrow } from "../context";
|
|
2
|
+
import { EMPTY_OPTIONS, EMPTY_PARAMS, NOOP } from "../constants";
|
|
3
3
|
import { shouldNavigate, applyLinkA11y } from "../dom-utils/index.js";
|
|
4
4
|
/**
|
|
5
5
|
* Factory function that captures router context during component initialization.
|
|
@@ -20,30 +20,28 @@ import { shouldNavigate, applyLinkA11y } from "../dom-utils/index.js";
|
|
|
20
20
|
* ```
|
|
21
21
|
*/
|
|
22
22
|
export function createLinkAction() {
|
|
23
|
-
const router =
|
|
24
|
-
if (!router) {
|
|
25
|
-
throw new Error("createLinkAction must be called inside a RouterProvider");
|
|
26
|
-
}
|
|
23
|
+
const router = getContextOrThrow(ROUTER_KEY, "createLinkAction");
|
|
27
24
|
return function link(node, params) {
|
|
28
25
|
let currentParams = params;
|
|
29
26
|
applyLinkA11y(node);
|
|
27
|
+
function navigate() {
|
|
28
|
+
router
|
|
29
|
+
.navigate(currentParams.name, currentParams.params ?? EMPTY_PARAMS, currentParams.options ?? EMPTY_OPTIONS)
|
|
30
|
+
.catch(NOOP);
|
|
31
|
+
}
|
|
30
32
|
function handleClick(evt) {
|
|
31
33
|
if (!shouldNavigate(evt))
|
|
32
34
|
return;
|
|
35
|
+
if (node instanceof HTMLAnchorElement &&
|
|
36
|
+
node.getAttribute("target") === "_blank") {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
33
39
|
evt.preventDefault();
|
|
34
|
-
|
|
35
|
-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
36
|
-
router
|
|
37
|
-
.navigate(currentParams.name, currentParams.params ?? {}, currentParams.options ?? {})
|
|
38
|
-
.catch(() => { });
|
|
40
|
+
navigate();
|
|
39
41
|
}
|
|
40
42
|
function handleKeyDown(evt) {
|
|
41
43
|
if (evt.key === "Enter" && !(node instanceof HTMLButtonElement)) {
|
|
42
|
-
|
|
43
|
-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
44
|
-
router
|
|
45
|
-
.navigate(currentParams.name, currentParams.params ?? {}, currentParams.options ?? {})
|
|
46
|
-
.catch(() => { });
|
|
44
|
+
navigate();
|
|
47
45
|
}
|
|
48
46
|
}
|
|
49
47
|
node.addEventListener("click", handleClick);
|
|
@@ -9,26 +9,33 @@
|
|
|
9
9
|
fallback?: Component | undefined;
|
|
10
10
|
} = $props();
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
type LazyState =
|
|
13
|
+
| { status: "loading" }
|
|
14
|
+
| { status: "ready"; component: Component }
|
|
15
|
+
| { status: "error"; error: Error };
|
|
16
|
+
|
|
17
|
+
let state = $state<LazyState>({ status: "loading" });
|
|
15
18
|
|
|
16
19
|
$effect(() => {
|
|
17
|
-
|
|
18
|
-
error = null;
|
|
19
|
-
LoadedComponent = null;
|
|
20
|
+
state = { status: "loading" };
|
|
20
21
|
let active = true;
|
|
21
22
|
|
|
22
23
|
loader()
|
|
23
24
|
.then((module) => {
|
|
24
25
|
if (!active) return;
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
if (!module || typeof module.default === "undefined") {
|
|
27
|
+
throw new Error(
|
|
28
|
+
"[real-router] Lazy loader resolved without a `default` export.",
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
state = { status: "ready", component: module.default };
|
|
27
32
|
})
|
|
28
|
-
.catch((err) => {
|
|
33
|
+
.catch((err: unknown) => {
|
|
29
34
|
if (!active) return;
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
state = {
|
|
36
|
+
status: "error",
|
|
37
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
38
|
+
};
|
|
32
39
|
});
|
|
33
40
|
|
|
34
41
|
return () => {
|
|
@@ -37,11 +44,12 @@
|
|
|
37
44
|
});
|
|
38
45
|
</script>
|
|
39
46
|
|
|
40
|
-
{#if loading && fallback}
|
|
47
|
+
{#if state.status === "loading" && fallback}
|
|
41
48
|
{@const Fallback = fallback}
|
|
42
49
|
<Fallback />
|
|
43
|
-
{:else if error}
|
|
44
|
-
<p>Error loading component: {error.message}</p>
|
|
45
|
-
{:else if
|
|
50
|
+
{:else if state.status === "error"}
|
|
51
|
+
<p>Error loading component: {state.error.message}</p>
|
|
52
|
+
{:else if state.status === "ready"}
|
|
53
|
+
{@const LoadedComponent = state.component}
|
|
46
54
|
<LoadedComponent />
|
|
47
55
|
{/if}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { useIsActiveRoute } from "../composables/useIsActiveRoute.svelte";
|
|
3
3
|
import { useRouter } from "../composables/useRouter.svelte";
|
|
4
|
+
import { EMPTY_OPTIONS, EMPTY_PARAMS, NOOP } from "../constants";
|
|
4
5
|
import {
|
|
5
6
|
shouldNavigate,
|
|
6
7
|
buildHref,
|
|
@@ -12,8 +13,8 @@
|
|
|
12
13
|
|
|
13
14
|
let {
|
|
14
15
|
routeName,
|
|
15
|
-
routeParams =
|
|
16
|
-
routeOptions =
|
|
16
|
+
routeParams = EMPTY_PARAMS,
|
|
17
|
+
routeOptions = EMPTY_OPTIONS,
|
|
17
18
|
class: className = undefined,
|
|
18
19
|
activeClassName = "active",
|
|
19
20
|
activeStrict = false,
|
|
@@ -64,7 +65,7 @@
|
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
evt.preventDefault();
|
|
67
|
-
router.navigate(routeName, routeParams, routeOptions).catch(
|
|
68
|
+
router.navigate(routeName, routeParams, routeOptions).catch(NOOP);
|
|
68
69
|
}
|
|
69
70
|
</script>
|
|
70
71
|
|
|
@@ -1,6 +1,26 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import { startsWithSegment } from "@real-router/route-utils";
|
|
3
|
+
|
|
4
|
+
export function getActiveSegment(
|
|
5
|
+
routeName: string,
|
|
6
|
+
node: string,
|
|
7
|
+
snippets: Record<string, unknown>,
|
|
8
|
+
): string {
|
|
9
|
+
const prefix = node ? `${node}.` : "";
|
|
10
|
+
|
|
11
|
+
for (const segment in snippets) {
|
|
12
|
+
if (segment === "notFound") continue;
|
|
13
|
+
if (startsWithSegment(routeName, prefix + segment)) {
|
|
14
|
+
return segment;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return "";
|
|
19
|
+
}
|
|
20
|
+
</script>
|
|
21
|
+
|
|
1
22
|
<script lang="ts">
|
|
2
23
|
import { UNKNOWN_ROUTE } from "@real-router/core";
|
|
3
|
-
import { startsWithSegment } from "@real-router/route-utils";
|
|
4
24
|
|
|
5
25
|
import { useRouteNode } from "../composables/useRouteNode.svelte";
|
|
6
26
|
|
|
@@ -17,29 +37,14 @@
|
|
|
17
37
|
} = $props();
|
|
18
38
|
|
|
19
39
|
const routeContext = useRouteNode(nodeName);
|
|
20
|
-
|
|
21
|
-
function getActiveSegment(
|
|
22
|
-
routeName: string,
|
|
23
|
-
node: string,
|
|
24
|
-
snippets: Record<string, unknown>,
|
|
25
|
-
): string {
|
|
26
|
-
for (const segment of Object.keys(snippets)) {
|
|
27
|
-
const fullSegmentName = node ? `${node}.${segment}` : segment;
|
|
28
|
-
|
|
29
|
-
if (startsWithSegment(routeName, fullSegmentName)) {
|
|
30
|
-
return segment;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
return "";
|
|
35
|
-
}
|
|
36
40
|
</script>
|
|
37
41
|
|
|
38
42
|
{#if routeContext.route.current}
|
|
39
43
|
{@const route = routeContext.route.current}
|
|
40
44
|
{@const segment = getActiveSegment(route.name, nodeName, segmentSnippets)}
|
|
41
|
-
{#if segment
|
|
42
|
-
{@
|
|
45
|
+
{#if segment}
|
|
46
|
+
{@const snippet = segmentSnippets[segment] as Snippet}
|
|
47
|
+
{@render snippet()}
|
|
43
48
|
{:else if route.name === UNKNOWN_ROUTE && notFound}
|
|
44
49
|
{@render notFound()}
|
|
45
50
|
{/if}
|
|
@@ -32,12 +32,20 @@
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
$effect(() => {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
const snap = snapshot.current;
|
|
36
|
+
if (!snap.error) return;
|
|
37
|
+
|
|
38
|
+
const { error, toRoute, fromRoute } = snap;
|
|
39
|
+
untrack(() => {
|
|
40
|
+
try {
|
|
38
41
|
onError?.(error, toRoute, fromRoute);
|
|
39
|
-
})
|
|
40
|
-
|
|
42
|
+
} catch (callbackError) {
|
|
43
|
+
console.error(
|
|
44
|
+
"[real-router] RouterErrorBoundary onError handler threw:",
|
|
45
|
+
callbackError,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
41
49
|
});
|
|
42
50
|
</script>
|
|
43
51
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { Params } from "@real-router/core";
|
|
2
|
-
export declare function useIsActiveRoute(routeName: string, params
|
|
2
|
+
export declare function useIsActiveRoute(routeName: string, params: Params | undefined, strict: boolean, ignoreQueryParams: boolean): {
|
|
3
3
|
readonly current: boolean;
|
|
4
4
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
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
|
|
4
|
+
export function useIsActiveRoute(routeName, params, strict, ignoreQueryParams) {
|
|
5
5
|
const router = useRouter();
|
|
6
6
|
const source = createActiveRouteSource(router, routeName, params, {
|
|
7
7
|
strict,
|
|
@@ -1,9 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
export const useNavigator = () => {
|
|
4
|
-
const navigator = getContext(NAVIGATOR_KEY);
|
|
5
|
-
if (!navigator) {
|
|
6
|
-
throw new Error("useNavigator must be used within a RouterProvider");
|
|
7
|
-
}
|
|
8
|
-
return navigator;
|
|
9
|
-
};
|
|
1
|
+
import { NAVIGATOR_KEY, getContextOrThrow } from "../context";
|
|
2
|
+
export const useNavigator = () => getContextOrThrow(NAVIGATOR_KEY, "useNavigator");
|
|
@@ -1,9 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
export const useRoute = () => {
|
|
4
|
-
const routeContext = getContext(ROUTE_KEY);
|
|
5
|
-
if (!routeContext) {
|
|
6
|
-
throw new Error("useRoute must be used within a RouterProvider");
|
|
7
|
-
}
|
|
8
|
-
return routeContext;
|
|
9
|
-
};
|
|
1
|
+
import { ROUTE_KEY, getContextOrThrow } from "../context";
|
|
2
|
+
export const useRoute = () => getContextOrThrow(ROUTE_KEY, "useRoute");
|
|
@@ -1,27 +1,12 @@
|
|
|
1
1
|
import { getNavigator } from "@real-router/core";
|
|
2
2
|
import { createRouteNodeSource } from "@real-router/sources";
|
|
3
3
|
import { createReactiveSource } from "../createReactiveSource.svelte";
|
|
4
|
+
import { createRouteContext } from "../createRouteContext.svelte";
|
|
4
5
|
import { useRouter } from "./useRouter.svelte";
|
|
5
6
|
export function useRouteNode(nodeName) {
|
|
6
7
|
const router = useRouter();
|
|
7
8
|
const navigator = getNavigator(router);
|
|
8
9
|
const source = createRouteNodeSource(router, nodeName);
|
|
9
10
|
const reactive = createReactiveSource(source);
|
|
10
|
-
return
|
|
11
|
-
navigator,
|
|
12
|
-
get route() {
|
|
13
|
-
return {
|
|
14
|
-
get current() {
|
|
15
|
-
return reactive.current.route;
|
|
16
|
-
},
|
|
17
|
-
};
|
|
18
|
-
},
|
|
19
|
-
get previousRoute() {
|
|
20
|
-
return {
|
|
21
|
-
get current() {
|
|
22
|
-
return reactive.current.previousRoute;
|
|
23
|
-
},
|
|
24
|
-
};
|
|
25
|
-
},
|
|
26
|
-
};
|
|
11
|
+
return createRouteContext(navigator, reactive);
|
|
27
12
|
}
|
|
@@ -1,9 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
export const useRouter = () => {
|
|
4
|
-
const router = getContext(ROUTER_KEY);
|
|
5
|
-
if (!router) {
|
|
6
|
-
throw new Error("useRouter must be used within a RouterProvider");
|
|
7
|
-
}
|
|
8
|
-
return router;
|
|
9
|
-
};
|
|
1
|
+
import { ROUTER_KEY, getContextOrThrow } from "../context";
|
|
2
|
+
export const useRouter = () => getContextOrThrow(ROUTER_KEY, "useRouter");
|
package/dist/context.d.ts
CHANGED
package/dist/context.js
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
import { getContext } from "svelte";
|
|
1
2
|
export const ROUTER_KEY = "real-router:router";
|
|
2
3
|
export const NAVIGATOR_KEY = "real-router:navigator";
|
|
3
4
|
export const ROUTE_KEY = "real-router:route";
|
|
5
|
+
// The type parameter is used by the caller to narrow the return type.
|
|
6
|
+
// ESLint's no-unnecessary-type-parameters sees only a single textual use of T
|
|
7
|
+
// (the return type) — but each call site supplies a different T, so it is not
|
|
8
|
+
// unnecessary. Inline generic helpers are a standard pattern for typed context.
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
|
|
10
|
+
export function getContextOrThrow(key, consumerName) {
|
|
11
|
+
const value = getContext(key);
|
|
12
|
+
if (!value) {
|
|
13
|
+
throw new Error(`${consumerName} must be used within a RouterProvider`);
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Navigator, State } from "@real-router/core";
|
|
2
|
+
import type { RouteContext } from "./types";
|
|
3
|
+
export interface RouteSnapshot {
|
|
4
|
+
readonly route: State | undefined;
|
|
5
|
+
readonly previousRoute: State | undefined;
|
|
6
|
+
}
|
|
7
|
+
export declare function createRouteContext(navigator: Navigator, reactive: {
|
|
8
|
+
readonly current: RouteSnapshot;
|
|
9
|
+
}): RouteContext;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function createRouteContext(navigator, reactive) {
|
|
2
|
+
const route = {
|
|
3
|
+
get current() {
|
|
4
|
+
return reactive.current.route;
|
|
5
|
+
},
|
|
6
|
+
};
|
|
7
|
+
const previousRoute = {
|
|
8
|
+
get current() {
|
|
9
|
+
return reactive.current.previousRoute;
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
return { navigator, route, previousRoute };
|
|
13
|
+
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { createRouteAnnouncer } from "./route-announcer.js";
|
|
2
|
-
export { shouldNavigate, buildHref, buildActiveClassName, applyLinkA11y, } from "./link-utils.js";
|
|
2
|
+
export { shouldNavigate, buildHref, buildActiveClassName, shallowEqual, applyLinkA11y, } from "./link-utils.js";
|
|
3
3
|
export type { RouteAnnouncerOptions } from "./route-announcer.js";
|
package/dist/dom-utils/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { createRouteAnnouncer } from "./route-announcer.js";
|
|
2
|
-
export { shouldNavigate, buildHref, buildActiveClassName, applyLinkA11y, } from "./link-utils.js";
|
|
2
|
+
export { shouldNavigate, buildHref, buildActiveClassName, shallowEqual, applyLinkA11y, } from "./link-utils.js";
|
|
@@ -2,4 +2,5 @@ import type { Router, Params } from "@real-router/core";
|
|
|
2
2
|
export declare function shouldNavigate(evt: MouseEvent): boolean;
|
|
3
3
|
export declare function buildHref(router: Router, routeName: string, routeParams: Params): string | undefined;
|
|
4
4
|
export declare function buildActiveClassName(isActive: boolean, activeClassName: string | undefined, baseClassName: string | undefined): string | undefined;
|
|
5
|
-
export declare function
|
|
5
|
+
export declare function shallowEqual(prev: object | undefined, next: object | undefined): boolean;
|
|
6
|
+
export declare function applyLinkA11y(element: HTMLElement | null | undefined): void;
|
|
@@ -9,7 +9,10 @@ export function buildHref(router, routeName, routeParams) {
|
|
|
9
9
|
try {
|
|
10
10
|
const buildUrl = router.buildUrl;
|
|
11
11
|
if (buildUrl) {
|
|
12
|
-
|
|
12
|
+
const url = buildUrl(routeName, routeParams);
|
|
13
|
+
if (url !== undefined) {
|
|
14
|
+
return url;
|
|
15
|
+
}
|
|
13
16
|
}
|
|
14
17
|
return router.buildPath(routeName, routeParams);
|
|
15
18
|
}
|
|
@@ -18,23 +21,62 @@ export function buildHref(router, routeName, routeParams) {
|
|
|
18
21
|
return undefined;
|
|
19
22
|
}
|
|
20
23
|
}
|
|
24
|
+
function parseTokens(value) {
|
|
25
|
+
return value ? (value.match(/\S+/g) ?? []) : [];
|
|
26
|
+
}
|
|
21
27
|
export function buildActiveClassName(isActive, activeClassName, baseClassName) {
|
|
22
28
|
if (isActive && activeClassName) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
29
|
+
const activeTokens = parseTokens(activeClassName);
|
|
30
|
+
if (activeTokens.length === 0) {
|
|
31
|
+
return baseClassName ?? undefined;
|
|
32
|
+
}
|
|
33
|
+
if (!baseClassName) {
|
|
34
|
+
return activeTokens.join(" ");
|
|
35
|
+
}
|
|
36
|
+
const baseTokens = parseTokens(baseClassName);
|
|
37
|
+
const seen = new Set(baseTokens);
|
|
38
|
+
for (const token of activeTokens) {
|
|
39
|
+
if (!seen.has(token)) {
|
|
40
|
+
seen.add(token);
|
|
41
|
+
baseTokens.push(token);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return baseTokens.join(" ");
|
|
26
45
|
}
|
|
27
46
|
return baseClassName ?? undefined;
|
|
28
47
|
}
|
|
48
|
+
export function shallowEqual(prev, next) {
|
|
49
|
+
if (Object.is(prev, next)) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
if (!prev || !next) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
const prevKeys = Object.keys(prev);
|
|
56
|
+
if (prevKeys.length !== Object.keys(next).length) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
const prevRecord = prev;
|
|
60
|
+
const nextRecord = next;
|
|
61
|
+
for (const key of prevKeys) {
|
|
62
|
+
if (!Object.is(prevRecord[key], nextRecord[key])) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
29
68
|
export function applyLinkA11y(element) {
|
|
69
|
+
if (!element) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
30
72
|
if (element instanceof HTMLAnchorElement ||
|
|
31
73
|
element instanceof HTMLButtonElement) {
|
|
32
74
|
return;
|
|
33
75
|
}
|
|
34
|
-
if (!element.
|
|
76
|
+
if (!element.hasAttribute("role")) {
|
|
35
77
|
element.setAttribute("role", "link");
|
|
36
78
|
}
|
|
37
|
-
if (!element.
|
|
79
|
+
if (!element.hasAttribute("tabindex")) {
|
|
38
80
|
element.setAttribute("tabindex", "0");
|
|
39
81
|
}
|
|
40
82
|
}
|
|
@@ -10,32 +10,57 @@ export function createRouteAnnouncer(router, options) {
|
|
|
10
10
|
let isReady = false;
|
|
11
11
|
let isDestroyed = false;
|
|
12
12
|
let lastAnnouncedText = "";
|
|
13
|
+
let pendingText = null;
|
|
13
14
|
let clearTimeoutId;
|
|
14
15
|
const announcer = getOrCreateAnnouncer();
|
|
16
|
+
const doAnnounce = (text, h1) => {
|
|
17
|
+
lastAnnouncedText = text;
|
|
18
|
+
clearTimeout(clearTimeoutId);
|
|
19
|
+
announcer.textContent = text;
|
|
20
|
+
clearTimeoutId = setTimeout(() => {
|
|
21
|
+
announcer.textContent = "";
|
|
22
|
+
lastAnnouncedText = "";
|
|
23
|
+
}, CLEAR_DELAY);
|
|
24
|
+
manageFocus(h1);
|
|
25
|
+
};
|
|
26
|
+
// Safari-ready delay: announcing before VoiceOver wires up the aria-live region
|
|
27
|
+
// causes the first announcement to be silently dropped. Wait SAFARI_READY_DELAY ms
|
|
28
|
+
// before marking the announcer "ready" — any navigation during that window is
|
|
29
|
+
// buffered in pendingText and flushed once the delay expires.
|
|
15
30
|
const safariTimeoutId = setTimeout(() => {
|
|
16
31
|
isReady = true;
|
|
32
|
+
if (pendingText !== null && !isDestroyed) {
|
|
33
|
+
const text = pendingText;
|
|
34
|
+
pendingText = null;
|
|
35
|
+
doAnnounce(text, document.querySelector("h1"));
|
|
36
|
+
}
|
|
17
37
|
}, SAFARI_READY_DELAY);
|
|
18
38
|
const unsubscribe = router.subscribe(({ route }) => {
|
|
19
39
|
if (isInitialNavigation) {
|
|
20
40
|
isInitialNavigation = false;
|
|
21
41
|
return;
|
|
22
42
|
}
|
|
43
|
+
// Double rAF: waits for two paint frames so the incoming route's DOM
|
|
44
|
+
// (including the new <h1>) is fully rendered before resolveText reads it.
|
|
45
|
+
// Single rAF fires before the new route's template has been attached,
|
|
46
|
+
// which would cause resolveText to pick up the OLD h1 or fall back to
|
|
47
|
+
// document.title / route.name prematurely.
|
|
23
48
|
requestAnimationFrame(() => {
|
|
24
49
|
requestAnimationFrame(() => {
|
|
25
50
|
if (isDestroyed) {
|
|
26
51
|
return;
|
|
27
52
|
}
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
manageFocus();
|
|
53
|
+
const h1 = document.querySelector("h1");
|
|
54
|
+
const text = resolveText(route, prefix, getCustomText, h1);
|
|
55
|
+
if (!text || text === lastAnnouncedText) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (!isReady) {
|
|
59
|
+
// Defer announcement until Safari-ready window elapses (see safariTimeoutId).
|
|
60
|
+
pendingText = text;
|
|
61
|
+
return;
|
|
38
62
|
}
|
|
63
|
+
doAnnounce(text, h1);
|
|
39
64
|
});
|
|
40
65
|
});
|
|
41
66
|
});
|
|
@@ -65,11 +90,10 @@ function getOrCreateAnnouncer() {
|
|
|
65
90
|
function removeAnnouncer() {
|
|
66
91
|
document.querySelector(`[${ANNOUNCER_ATTR}]`)?.remove();
|
|
67
92
|
}
|
|
68
|
-
function resolveText(route, prefix, getCustomText) {
|
|
93
|
+
function resolveText(route, prefix, getCustomText, h1) {
|
|
69
94
|
if (getCustomText) {
|
|
70
95
|
return getCustomText(route);
|
|
71
96
|
}
|
|
72
|
-
const h1 = document.querySelector("h1");
|
|
73
97
|
const h1Text = h1?.textContent.trim() ?? "";
|
|
74
98
|
const routeName = route.name.startsWith(INTERNAL_ROUTE_PREFIX)
|
|
75
99
|
? ""
|
|
@@ -77,8 +101,7 @@ function resolveText(route, prefix, getCustomText) {
|
|
|
77
101
|
const rawText = h1Text || document.title || routeName || globalThis.location.pathname;
|
|
78
102
|
return `${prefix}${rawText}`;
|
|
79
103
|
}
|
|
80
|
-
function manageFocus() {
|
|
81
|
-
const h1 = document.querySelector("h1");
|
|
104
|
+
function manageFocus(h1) {
|
|
82
105
|
if (!h1) {
|
|
83
106
|
return;
|
|
84
107
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@real-router/svelte",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Svelte 5 integration for Real-Router",
|
|
6
6
|
"svelte": "./dist/index.js",
|
|
@@ -58,17 +58,17 @@
|
|
|
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.12.
|
|
61
|
+
"@real-router/browser-plugin": "^0.12.2"
|
|
62
62
|
},
|
|
63
63
|
"peerDependencies": {
|
|
64
64
|
"svelte": ">=5.7.0"
|
|
65
65
|
},
|
|
66
66
|
"scripts": {
|
|
67
|
-
"build": "svelte-package -i src -o dist",
|
|
68
67
|
"test": "vitest",
|
|
69
68
|
"test:properties": "vitest run --config vitest.config.properties.mts",
|
|
70
69
|
"test:stress": "vitest run --config vitest.config.stress.mts",
|
|
71
70
|
"type-check": "svelte-check --tsconfig ./tsconfig.json",
|
|
72
|
-
"lint": "eslint --cache src/ tests/ --fix --max-warnings 0"
|
|
71
|
+
"lint": "eslint --cache src/ tests/ --fix --max-warnings 0",
|
|
72
|
+
"bundle": "svelte-package -i src -o dist"
|
|
73
73
|
}
|
|
74
74
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { setContext } from "svelte";
|
|
6
6
|
|
|
7
7
|
import { createReactiveSource } from "./createReactiveSource.svelte";
|
|
8
|
+
import { createRouteContext } from "./createRouteContext.svelte";
|
|
8
9
|
import { NAVIGATOR_KEY, ROUTE_KEY, ROUTER_KEY } from "./context";
|
|
9
10
|
|
|
10
11
|
import type { Router } from "@real-router/core";
|
|
@@ -26,26 +27,11 @@
|
|
|
26
27
|
const navigator = getNavigator(router);
|
|
27
28
|
const source = createRouteSource(router);
|
|
28
29
|
const reactive = createReactiveSource(source);
|
|
30
|
+
const routeContext = createRouteContext(navigator, reactive);
|
|
29
31
|
|
|
30
32
|
setContext(ROUTER_KEY, router);
|
|
31
33
|
setContext(NAVIGATOR_KEY, navigator);
|
|
32
|
-
setContext(ROUTE_KEY,
|
|
33
|
-
navigator,
|
|
34
|
-
get route() {
|
|
35
|
-
return {
|
|
36
|
-
get current() {
|
|
37
|
-
return reactive.current.route;
|
|
38
|
-
},
|
|
39
|
-
};
|
|
40
|
-
},
|
|
41
|
-
get previousRoute() {
|
|
42
|
-
return {
|
|
43
|
-
get current() {
|
|
44
|
-
return reactive.current.previousRoute;
|
|
45
|
-
},
|
|
46
|
-
};
|
|
47
|
-
},
|
|
48
|
-
});
|
|
34
|
+
setContext(ROUTE_KEY, routeContext);
|
|
49
35
|
</script>
|
|
50
36
|
|
|
51
37
|
{@render children()}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { getContext } from "svelte";
|
|
2
1
|
import type { ActionReturn } from "svelte/action";
|
|
3
2
|
import type { Router, Params, NavigationOptions } from "@real-router/core";
|
|
4
|
-
import { ROUTER_KEY } from "../context";
|
|
3
|
+
import { ROUTER_KEY, getContextOrThrow } from "../context";
|
|
4
|
+
import { EMPTY_OPTIONS, EMPTY_PARAMS, NOOP } from "../constants";
|
|
5
5
|
import { shouldNavigate, applyLinkA11y } from "../dom-utils/index.js";
|
|
6
6
|
|
|
7
7
|
export interface LinkActionParams {
|
|
@@ -10,6 +10,11 @@ export interface LinkActionParams {
|
|
|
10
10
|
options?: NavigationOptions;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
type LinkAction = (
|
|
14
|
+
node: HTMLElement,
|
|
15
|
+
params: LinkActionParams,
|
|
16
|
+
) => ActionReturn<LinkActionParams>;
|
|
17
|
+
|
|
13
18
|
/**
|
|
14
19
|
* Factory function that captures router context during component initialization.
|
|
15
20
|
* Must be called during component init (not inside event handlers or effects).
|
|
@@ -28,15 +33,8 @@ export interface LinkActionParams {
|
|
|
28
33
|
* <a use:link={{ name: 'users', params: { id: '123' } }}>User Profile</a>
|
|
29
34
|
* ```
|
|
30
35
|
*/
|
|
31
|
-
export function createLinkAction():
|
|
32
|
-
|
|
33
|
-
params: LinkActionParams,
|
|
34
|
-
) => ActionReturn<LinkActionParams> {
|
|
35
|
-
const router = getContext<Router | undefined>(ROUTER_KEY);
|
|
36
|
-
|
|
37
|
-
if (!router) {
|
|
38
|
-
throw new Error("createLinkAction must be called inside a RouterProvider");
|
|
39
|
-
}
|
|
36
|
+
export function createLinkAction(): LinkAction {
|
|
37
|
+
const router = getContextOrThrow<Router>(ROUTER_KEY, "createLinkAction");
|
|
40
38
|
|
|
41
39
|
return function link(
|
|
42
40
|
node: HTMLElement,
|
|
@@ -46,31 +44,31 @@ export function createLinkAction(): (
|
|
|
46
44
|
|
|
47
45
|
applyLinkA11y(node);
|
|
48
46
|
|
|
49
|
-
function
|
|
50
|
-
|
|
51
|
-
evt.preventDefault();
|
|
52
|
-
// router is guaranteed to exist due to check in factory
|
|
53
|
-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
54
|
-
router!
|
|
47
|
+
function navigate() {
|
|
48
|
+
router
|
|
55
49
|
.navigate(
|
|
56
50
|
currentParams.name,
|
|
57
|
-
currentParams.params ??
|
|
58
|
-
currentParams.options ??
|
|
51
|
+
currentParams.params ?? EMPTY_PARAMS,
|
|
52
|
+
currentParams.options ?? EMPTY_OPTIONS,
|
|
59
53
|
)
|
|
60
|
-
.catch(
|
|
54
|
+
.catch(NOOP);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function handleClick(evt: MouseEvent) {
|
|
58
|
+
if (!shouldNavigate(evt)) return;
|
|
59
|
+
if (
|
|
60
|
+
node instanceof HTMLAnchorElement &&
|
|
61
|
+
node.getAttribute("target") === "_blank"
|
|
62
|
+
) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
evt.preventDefault();
|
|
66
|
+
navigate();
|
|
61
67
|
}
|
|
62
68
|
|
|
63
69
|
function handleKeyDown(evt: KeyboardEvent) {
|
|
64
70
|
if (evt.key === "Enter" && !(node instanceof HTMLButtonElement)) {
|
|
65
|
-
|
|
66
|
-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
67
|
-
router!
|
|
68
|
-
.navigate(
|
|
69
|
-
currentParams.name,
|
|
70
|
-
currentParams.params ?? {},
|
|
71
|
-
currentParams.options ?? {},
|
|
72
|
-
)
|
|
73
|
-
.catch(() => {});
|
|
71
|
+
navigate();
|
|
74
72
|
}
|
|
75
73
|
}
|
|
76
74
|
|
|
@@ -9,26 +9,33 @@
|
|
|
9
9
|
fallback?: Component | undefined;
|
|
10
10
|
} = $props();
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
type LazyState =
|
|
13
|
+
| { status: "loading" }
|
|
14
|
+
| { status: "ready"; component: Component }
|
|
15
|
+
| { status: "error"; error: Error };
|
|
16
|
+
|
|
17
|
+
let state = $state<LazyState>({ status: "loading" });
|
|
15
18
|
|
|
16
19
|
$effect(() => {
|
|
17
|
-
|
|
18
|
-
error = null;
|
|
19
|
-
LoadedComponent = null;
|
|
20
|
+
state = { status: "loading" };
|
|
20
21
|
let active = true;
|
|
21
22
|
|
|
22
23
|
loader()
|
|
23
24
|
.then((module) => {
|
|
24
25
|
if (!active) return;
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
if (!module || typeof module.default === "undefined") {
|
|
27
|
+
throw new Error(
|
|
28
|
+
"[real-router] Lazy loader resolved without a `default` export.",
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
state = { status: "ready", component: module.default };
|
|
27
32
|
})
|
|
28
|
-
.catch((err) => {
|
|
33
|
+
.catch((err: unknown) => {
|
|
29
34
|
if (!active) return;
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
state = {
|
|
36
|
+
status: "error",
|
|
37
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
38
|
+
};
|
|
32
39
|
});
|
|
33
40
|
|
|
34
41
|
return () => {
|
|
@@ -37,11 +44,12 @@
|
|
|
37
44
|
});
|
|
38
45
|
</script>
|
|
39
46
|
|
|
40
|
-
{#if loading && fallback}
|
|
47
|
+
{#if state.status === "loading" && fallback}
|
|
41
48
|
{@const Fallback = fallback}
|
|
42
49
|
<Fallback />
|
|
43
|
-
{:else if error}
|
|
44
|
-
<p>Error loading component: {error.message}</p>
|
|
45
|
-
{:else if
|
|
50
|
+
{:else if state.status === "error"}
|
|
51
|
+
<p>Error loading component: {state.error.message}</p>
|
|
52
|
+
{:else if state.status === "ready"}
|
|
53
|
+
{@const LoadedComponent = state.component}
|
|
46
54
|
<LoadedComponent />
|
|
47
55
|
{/if}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { useIsActiveRoute } from "../composables/useIsActiveRoute.svelte";
|
|
3
3
|
import { useRouter } from "../composables/useRouter.svelte";
|
|
4
|
+
import { EMPTY_OPTIONS, EMPTY_PARAMS, NOOP } from "../constants";
|
|
4
5
|
import {
|
|
5
6
|
shouldNavigate,
|
|
6
7
|
buildHref,
|
|
@@ -12,8 +13,8 @@
|
|
|
12
13
|
|
|
13
14
|
let {
|
|
14
15
|
routeName,
|
|
15
|
-
routeParams =
|
|
16
|
-
routeOptions =
|
|
16
|
+
routeParams = EMPTY_PARAMS,
|
|
17
|
+
routeOptions = EMPTY_OPTIONS,
|
|
17
18
|
class: className = undefined,
|
|
18
19
|
activeClassName = "active",
|
|
19
20
|
activeStrict = false,
|
|
@@ -64,7 +65,7 @@
|
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
evt.preventDefault();
|
|
67
|
-
router.navigate(routeName, routeParams, routeOptions).catch(
|
|
68
|
+
router.navigate(routeName, routeParams, routeOptions).catch(NOOP);
|
|
68
69
|
}
|
|
69
70
|
</script>
|
|
70
71
|
|
|
@@ -1,6 +1,26 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import { startsWithSegment } from "@real-router/route-utils";
|
|
3
|
+
|
|
4
|
+
export function getActiveSegment(
|
|
5
|
+
routeName: string,
|
|
6
|
+
node: string,
|
|
7
|
+
snippets: Record<string, unknown>,
|
|
8
|
+
): string {
|
|
9
|
+
const prefix = node ? `${node}.` : "";
|
|
10
|
+
|
|
11
|
+
for (const segment in snippets) {
|
|
12
|
+
if (segment === "notFound") continue;
|
|
13
|
+
if (startsWithSegment(routeName, prefix + segment)) {
|
|
14
|
+
return segment;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return "";
|
|
19
|
+
}
|
|
20
|
+
</script>
|
|
21
|
+
|
|
1
22
|
<script lang="ts">
|
|
2
23
|
import { UNKNOWN_ROUTE } from "@real-router/core";
|
|
3
|
-
import { startsWithSegment } from "@real-router/route-utils";
|
|
4
24
|
|
|
5
25
|
import { useRouteNode } from "../composables/useRouteNode.svelte";
|
|
6
26
|
|
|
@@ -17,29 +37,14 @@
|
|
|
17
37
|
} = $props();
|
|
18
38
|
|
|
19
39
|
const routeContext = useRouteNode(nodeName);
|
|
20
|
-
|
|
21
|
-
function getActiveSegment(
|
|
22
|
-
routeName: string,
|
|
23
|
-
node: string,
|
|
24
|
-
snippets: Record<string, unknown>,
|
|
25
|
-
): string {
|
|
26
|
-
for (const segment of Object.keys(snippets)) {
|
|
27
|
-
const fullSegmentName = node ? `${node}.${segment}` : segment;
|
|
28
|
-
|
|
29
|
-
if (startsWithSegment(routeName, fullSegmentName)) {
|
|
30
|
-
return segment;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
return "";
|
|
35
|
-
}
|
|
36
40
|
</script>
|
|
37
41
|
|
|
38
42
|
{#if routeContext.route.current}
|
|
39
43
|
{@const route = routeContext.route.current}
|
|
40
44
|
{@const segment = getActiveSegment(route.name, nodeName, segmentSnippets)}
|
|
41
|
-
{#if segment
|
|
42
|
-
{@
|
|
45
|
+
{#if segment}
|
|
46
|
+
{@const snippet = segmentSnippets[segment] as Snippet}
|
|
47
|
+
{@render snippet()}
|
|
43
48
|
{:else if route.name === UNKNOWN_ROUTE && notFound}
|
|
44
49
|
{@render notFound()}
|
|
45
50
|
{/if}
|
|
@@ -32,12 +32,20 @@
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
$effect(() => {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
const snap = snapshot.current;
|
|
36
|
+
if (!snap.error) return;
|
|
37
|
+
|
|
38
|
+
const { error, toRoute, fromRoute } = snap;
|
|
39
|
+
untrack(() => {
|
|
40
|
+
try {
|
|
38
41
|
onError?.(error, toRoute, fromRoute);
|
|
39
|
-
})
|
|
40
|
-
|
|
42
|
+
} catch (callbackError) {
|
|
43
|
+
console.error(
|
|
44
|
+
"[real-router] RouterErrorBoundary onError handler threw:",
|
|
45
|
+
callbackError,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
41
49
|
});
|
|
42
50
|
</script>
|
|
43
51
|
|
|
@@ -7,9 +7,9 @@ import type { Params } from "@real-router/core";
|
|
|
7
7
|
|
|
8
8
|
export function useIsActiveRoute(
|
|
9
9
|
routeName: string,
|
|
10
|
-
params
|
|
11
|
-
strict
|
|
12
|
-
ignoreQueryParams
|
|
10
|
+
params: Params | undefined,
|
|
11
|
+
strict: boolean,
|
|
12
|
+
ignoreQueryParams: boolean,
|
|
13
13
|
): { readonly current: boolean } {
|
|
14
14
|
const router = useRouter();
|
|
15
15
|
|
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import { NAVIGATOR_KEY } from "../context";
|
|
1
|
+
import { NAVIGATOR_KEY, getContextOrThrow } from "../context";
|
|
4
2
|
|
|
5
3
|
import type { Navigator } from "@real-router/core";
|
|
6
4
|
|
|
7
|
-
export const useNavigator = (): Navigator =>
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
if (!navigator) {
|
|
11
|
-
throw new Error("useNavigator must be used within a RouterProvider");
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
return navigator;
|
|
15
|
-
};
|
|
5
|
+
export const useNavigator = (): Navigator =>
|
|
6
|
+
getContextOrThrow<Navigator>(NAVIGATOR_KEY, "useNavigator");
|
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import { ROUTE_KEY } from "../context";
|
|
1
|
+
import { ROUTE_KEY, getContextOrThrow } from "../context";
|
|
4
2
|
|
|
5
3
|
import type { RouteContext } from "../types";
|
|
6
4
|
|
|
7
|
-
export const useRoute = (): RouteContext =>
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
if (!routeContext) {
|
|
11
|
-
throw new Error("useRoute must be used within a RouterProvider");
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
return routeContext;
|
|
15
|
-
};
|
|
5
|
+
export const useRoute = (): RouteContext =>
|
|
6
|
+
getContextOrThrow<RouteContext>(ROUTE_KEY, "useRoute");
|
|
@@ -2,6 +2,7 @@ import { getNavigator } from "@real-router/core";
|
|
|
2
2
|
import { createRouteNodeSource } from "@real-router/sources";
|
|
3
3
|
|
|
4
4
|
import { createReactiveSource } from "../createReactiveSource.svelte";
|
|
5
|
+
import { createRouteContext } from "../createRouteContext.svelte";
|
|
5
6
|
import { useRouter } from "./useRouter.svelte";
|
|
6
7
|
|
|
7
8
|
import type { RouteContext } from "../types";
|
|
@@ -13,21 +14,5 @@ export function useRouteNode(nodeName: string): RouteContext {
|
|
|
13
14
|
const source = createRouteNodeSource(router, nodeName);
|
|
14
15
|
const reactive = createReactiveSource(source);
|
|
15
16
|
|
|
16
|
-
return
|
|
17
|
-
navigator,
|
|
18
|
-
get route() {
|
|
19
|
-
return {
|
|
20
|
-
get current() {
|
|
21
|
-
return reactive.current.route;
|
|
22
|
-
},
|
|
23
|
-
};
|
|
24
|
-
},
|
|
25
|
-
get previousRoute() {
|
|
26
|
-
return {
|
|
27
|
-
get current() {
|
|
28
|
-
return reactive.current.previousRoute;
|
|
29
|
-
},
|
|
30
|
-
};
|
|
31
|
-
},
|
|
32
|
-
};
|
|
17
|
+
return createRouteContext(navigator, reactive);
|
|
33
18
|
}
|
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import { ROUTER_KEY } from "../context";
|
|
1
|
+
import { ROUTER_KEY, getContextOrThrow } from "../context";
|
|
4
2
|
|
|
5
3
|
import type { Router } from "@real-router/core";
|
|
6
4
|
|
|
7
|
-
export const useRouter = (): Router =>
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
if (!router) {
|
|
11
|
-
throw new Error("useRouter must be used within a RouterProvider");
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
return router;
|
|
15
|
-
};
|
|
5
|
+
export const useRouter = (): Router =>
|
|
6
|
+
getContextOrThrow<Router>(ROUTER_KEY, "useRouter");
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { NavigationOptions, Params } from "@real-router/core";
|
|
2
|
+
|
|
3
|
+
export const EMPTY_PARAMS: Params = Object.freeze({}) as Params;
|
|
4
|
+
|
|
5
|
+
export const EMPTY_OPTIONS: NavigationOptions = Object.freeze(
|
|
6
|
+
{},
|
|
7
|
+
) as NavigationOptions;
|
|
8
|
+
|
|
9
|
+
export const NOOP = (): void => {};
|
package/src/context.ts
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
|
+
import { getContext } from "svelte";
|
|
2
|
+
|
|
1
3
|
export const ROUTER_KEY = "real-router:router";
|
|
2
4
|
|
|
3
5
|
export const NAVIGATOR_KEY = "real-router:navigator";
|
|
4
6
|
|
|
5
7
|
export const ROUTE_KEY = "real-router:route";
|
|
8
|
+
|
|
9
|
+
// The type parameter is used by the caller to narrow the return type.
|
|
10
|
+
// ESLint's no-unnecessary-type-parameters sees only a single textual use of T
|
|
11
|
+
// (the return type) — but each call site supplies a different T, so it is not
|
|
12
|
+
// unnecessary. Inline generic helpers are a standard pattern for typed context.
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
|
|
14
|
+
export function getContextOrThrow<T>(key: string, consumerName: string): T {
|
|
15
|
+
const value = getContext<T | undefined>(key);
|
|
16
|
+
|
|
17
|
+
if (!value) {
|
|
18
|
+
throw new Error(`${consumerName} must be used within a RouterProvider`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Navigator, State } from "@real-router/core";
|
|
2
|
+
|
|
3
|
+
import type { RouteContext } from "./types";
|
|
4
|
+
|
|
5
|
+
export interface RouteSnapshot {
|
|
6
|
+
readonly route: State | undefined;
|
|
7
|
+
readonly previousRoute: State | undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function createRouteContext(
|
|
11
|
+
navigator: Navigator,
|
|
12
|
+
reactive: { readonly current: RouteSnapshot },
|
|
13
|
+
): RouteContext {
|
|
14
|
+
const route = {
|
|
15
|
+
get current(): State | undefined {
|
|
16
|
+
return reactive.current.route;
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const previousRoute = {
|
|
21
|
+
get current(): State | undefined {
|
|
22
|
+
return reactive.current.previousRoute;
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
return { navigator, route, previousRoute };
|
|
27
|
+
}
|