@furystack/shades 13.2.2 → 15.0.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/CHANGELOG.md +157 -0
- package/esm/component-factory.spec.d.ts.map +1 -1
- package/esm/component-factory.spec.js +2 -2
- package/esm/component-factory.spec.js.map +1 -1
- package/esm/components/define-nested-routes.d.ts +27 -0
- package/esm/components/define-nested-routes.d.ts.map +1 -0
- package/esm/components/define-nested-routes.js +26 -0
- package/esm/components/define-nested-routes.js.map +1 -0
- package/esm/components/define-nested-routes.spec.d.ts +2 -0
- package/esm/components/define-nested-routes.spec.d.ts.map +1 -0
- package/esm/components/define-nested-routes.spec.js +81 -0
- package/esm/components/define-nested-routes.spec.js.map +1 -0
- package/esm/components/index.d.ts +3 -3
- package/esm/components/index.d.ts.map +1 -1
- package/esm/components/index.js +3 -3
- package/esm/components/index.js.map +1 -1
- package/esm/components/nested-hooks.d.ts +62 -0
- package/esm/components/nested-hooks.d.ts.map +1 -0
- package/esm/components/nested-hooks.js +73 -0
- package/esm/components/nested-hooks.js.map +1 -0
- package/esm/components/nested-hooks.spec.d.ts +2 -0
- package/esm/components/nested-hooks.spec.d.ts.map +1 -0
- package/esm/components/nested-hooks.spec.js +211 -0
- package/esm/components/nested-hooks.spec.js.map +1 -0
- package/esm/components/nested-navigate.d.ts +52 -21
- package/esm/components/nested-navigate.d.ts.map +1 -1
- package/esm/components/nested-navigate.js +32 -17
- package/esm/components/nested-navigate.js.map +1 -1
- package/esm/components/nested-navigate.spec.js +109 -19
- package/esm/components/nested-navigate.spec.js.map +1 -1
- package/esm/components/nested-replace.d.ts +36 -0
- package/esm/components/nested-replace.d.ts.map +1 -0
- package/esm/components/nested-replace.js +38 -0
- package/esm/components/nested-replace.js.map +1 -0
- package/esm/components/nested-replace.spec.d.ts +2 -0
- package/esm/components/nested-replace.spec.d.ts.map +1 -0
- package/esm/components/nested-replace.spec.js +59 -0
- package/esm/components/nested-replace.spec.js.map +1 -0
- package/esm/components/nested-route-link.d.ts +36 -26
- package/esm/components/nested-route-link.d.ts.map +1 -1
- package/esm/components/nested-route-link.js +18 -20
- package/esm/components/nested-route-link.js.map +1 -1
- package/esm/components/nested-route-link.spec.js +67 -17
- package/esm/components/nested-route-link.spec.js.map +1 -1
- package/esm/components/nested-route-types.d.ts +79 -2
- package/esm/components/nested-route-types.d.ts.map +1 -1
- package/esm/components/nested-router.d.ts +77 -7
- package/esm/components/nested-router.d.ts.map +1 -1
- package/esm/components/nested-router.js +139 -13
- package/esm/components/nested-router.js.map +1 -1
- package/esm/components/nested-router.spec.js +283 -52
- package/esm/components/nested-router.spec.js.map +1 -1
- package/esm/services/location-service.d.ts +39 -42
- package/esm/services/location-service.d.ts.map +1 -1
- package/esm/services/location-service.js +115 -134
- package/esm/services/location-service.js.map +1 -1
- package/esm/services/location-service.spec.js +59 -24
- package/esm/services/location-service.spec.js.map +1 -1
- package/esm/services/route-match-service.d.ts +3 -2
- package/esm/services/route-match-service.d.ts.map +1 -1
- package/esm/services/route-match-service.js +13 -22
- package/esm/services/route-match-service.js.map +1 -1
- package/esm/services/route-match-service.spec.js +42 -49
- package/esm/services/route-match-service.spec.js.map +1 -1
- package/esm/services/route-meta-utils.d.ts +28 -6
- package/esm/services/route-meta-utils.d.ts.map +1 -1
- package/esm/services/route-meta-utils.js +18 -4
- package/esm/services/route-meta-utils.js.map +1 -1
- package/esm/services/route-meta-utils.spec.js +60 -5
- package/esm/services/route-meta-utils.spec.js.map +1 -1
- package/esm/services/screen-service.d.ts +13 -72
- package/esm/services/screen-service.d.ts.map +1 -1
- package/esm/services/screen-service.js +45 -137
- package/esm/services/screen-service.js.map +1 -1
- package/esm/services/screen-service.spec.js +19 -18
- package/esm/services/screen-service.spec.js.map +1 -1
- package/esm/services/spatial-navigation-service.d.ts +17 -39
- package/esm/services/spatial-navigation-service.d.ts.map +1 -1
- package/esm/services/spatial-navigation-service.js +280 -313
- package/esm/services/spatial-navigation-service.js.map +1 -1
- package/esm/services/spatial-navigation-service.spec.js +163 -158
- package/esm/services/spatial-navigation-service.spec.js.map +1 -1
- package/esm/shade.d.ts +1 -1
- package/esm/shade.d.ts.map +1 -1
- package/esm/shade.js +10 -7
- package/esm/shade.js.map +1 -1
- package/esm/vnode.d.ts +1 -1
- package/esm/vnode.js +1 -1
- package/package.json +5 -4
- package/src/component-factory.spec.tsx +3 -2
- package/src/components/define-nested-routes.spec.ts +91 -0
- package/src/components/define-nested-routes.ts +27 -0
- package/src/components/index.ts +3 -3
- package/src/components/nested-hooks.spec.ts +254 -0
- package/src/components/nested-hooks.ts +109 -0
- package/src/components/nested-navigate.spec.ts +136 -32
- package/src/components/nested-navigate.ts +75 -26
- package/src/components/nested-replace.spec.ts +89 -0
- package/src/components/nested-replace.ts +46 -0
- package/src/components/nested-route-link.spec.tsx +90 -22
- package/src/components/nested-route-link.tsx +54 -29
- package/src/components/nested-route-types.ts +82 -2
- package/src/components/nested-router.spec.tsx +334 -51
- package/src/components/nested-router.tsx +184 -24
- package/src/services/location-service.spec.ts +68 -25
- package/src/services/location-service.tsx +156 -122
- package/src/services/route-match-service.spec.ts +41 -28
- package/src/services/route-match-service.ts +17 -8
- package/src/services/route-meta-utils.spec.ts +78 -10
- package/src/services/route-meta-utils.ts +44 -15
- package/src/services/screen-service.spec.ts +19 -18
- package/src/services/screen-service.ts +65 -110
- package/src/services/spatial-navigation-service.spec.ts +165 -160
- package/src/services/spatial-navigation-service.ts +299 -287
- package/src/shade.ts +11 -8
- package/src/vnode.ts +1 -1
- package/esm/components/link-to-route.d.ts +0 -10
- package/esm/components/link-to-route.d.ts.map +0 -1
- package/esm/components/link-to-route.js +0 -16
- package/esm/components/link-to-route.js.map +0 -1
- package/esm/components/link-to-route.spec.d.ts +0 -2
- package/esm/components/link-to-route.spec.d.ts.map +0 -1
- package/esm/components/link-to-route.spec.js +0 -30
- package/esm/components/link-to-route.spec.js.map +0 -1
- package/esm/components/route-link.d.ts +0 -10
- package/esm/components/route-link.d.ts.map +0 -1
- package/esm/components/route-link.js +0 -25
- package/esm/components/route-link.js.map +0 -1
- package/esm/components/route-link.spec.d.ts +0 -2
- package/esm/components/route-link.spec.d.ts.map +0 -1
- package/esm/components/route-link.spec.js +0 -34
- package/esm/components/route-link.spec.js.map +0 -1
- package/esm/components/router.d.ts +0 -44
- package/esm/components/router.d.ts.map +0 -1
- package/esm/components/router.js +0 -66
- package/esm/components/router.js.map +0 -1
- package/esm/components/router.spec.d.ts +0 -2
- package/esm/components/router.spec.d.ts.map +0 -1
- package/esm/components/router.spec.js +0 -161
- package/esm/components/router.spec.js.map +0 -1
- package/src/components/link-to-route.spec.tsx +0 -45
- package/src/components/link-to-route.tsx +0 -26
- package/src/components/route-link.spec.tsx +0 -44
- package/src/components/route-link.tsx +0 -29
- package/src/components/router.spec.tsx +0 -217
- package/src/components/router.tsx +0 -103
|
@@ -1,36 +1,85 @@
|
|
|
1
1
|
import type { Injector } from '@furystack/inject'
|
|
2
|
+
import { serializeToQueryString } from '@furystack/rest'
|
|
2
3
|
import { compileRoute } from '../compile-route.js'
|
|
3
4
|
import { LocationService } from '../services/location-service.js'
|
|
4
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
ExtractRouteHash,
|
|
7
|
+
ExtractRouteQuery,
|
|
8
|
+
ExtractRoutePaths,
|
|
9
|
+
RouteAt,
|
|
10
|
+
TypedHashArg,
|
|
11
|
+
TypedParamsArg,
|
|
12
|
+
TypedQueryArg,
|
|
13
|
+
} from './nested-route-types.js'
|
|
5
14
|
import type { NestedRoute } from './nested-router.js'
|
|
6
15
|
|
|
7
16
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* @
|
|
17
|
+
* Untyped runtime arguments for a nested navigate call.
|
|
18
|
+
* Used by the base {@link nestedNavigate} implementation; the typed variants
|
|
19
|
+
* returned by {@link createNestedNavigate} narrow each field against the route
|
|
20
|
+
* tree at compile time.
|
|
11
21
|
*/
|
|
12
|
-
export type
|
|
13
|
-
|
|
14
|
-
|
|
22
|
+
export type NestedNavigateArgs = {
|
|
23
|
+
path: string
|
|
24
|
+
params?: Record<string, string>
|
|
25
|
+
query?: Record<string, unknown>
|
|
26
|
+
hash?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Typed arguments for a nested navigate call, derived from the route tree and
|
|
31
|
+
* the specific target path.
|
|
32
|
+
*
|
|
33
|
+
* - `params` is required when the path contains `:param` segments.
|
|
34
|
+
* - `query` is required when the route's declared query shape has any required
|
|
35
|
+
* key, optional when all keys are optional, and forbidden when the route
|
|
36
|
+
* declares no `query` validator.
|
|
37
|
+
* - `hash` is always optional and restricted to the route's declared literal
|
|
38
|
+
* tuple; forbidden when the route declares no `hash`.
|
|
39
|
+
*
|
|
40
|
+
* @typeParam TRoutes - The route tree
|
|
41
|
+
* @typeParam TPath - A composed route path within `TRoutes`
|
|
42
|
+
*/
|
|
43
|
+
export type TypedNestedNavigateArgs<
|
|
44
|
+
TRoutes extends Record<string, NestedRoute<any, any, any>>,
|
|
45
|
+
TPath extends string,
|
|
46
|
+
> = { path: TPath } & TypedParamsArg<TPath> &
|
|
47
|
+
TypedQueryArg<ExtractRouteQuery<RouteAt<TRoutes, TPath>>> &
|
|
48
|
+
TypedHashArg<ExtractRouteHash<RouteAt<TRoutes, TPath>>>
|
|
15
49
|
|
|
16
50
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
51
|
+
* Builds the resolved URL for a nested navigate call, including path parameters,
|
|
52
|
+
* serialized query string and hash segment. Exported for callers that need the
|
|
53
|
+
* URL without side effects (e.g. link rendering helpers).
|
|
54
|
+
*/
|
|
55
|
+
export const buildNestedNavigateUrl = (args: NestedNavigateArgs): string => {
|
|
56
|
+
const { path, params, query, hash } = args
|
|
57
|
+
const resolvedPath = params && Object.keys(params).length > 0 ? compileRoute(path, params) : path
|
|
58
|
+
const hasQuery = query && Object.keys(query).length > 0
|
|
59
|
+
const search = hasQuery ? `?${serializeToQueryString(query)}` : ''
|
|
60
|
+
const hashPart = hash ? `#${hash}` : ''
|
|
61
|
+
return `${resolvedPath}${search}${hashPart}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Navigates to a route using the LocationService resolved from the given injector.
|
|
66
|
+
* Compiles parameterized routes (e.g. `/users/:id`), serializes the optional
|
|
67
|
+
* `query` record with the default `@furystack/rest` serializer, and appends the
|
|
68
|
+
* optional `hash` segment.
|
|
19
69
|
*
|
|
20
70
|
* @param injector - The injector instance to resolve LocationService from
|
|
21
|
-
* @param
|
|
22
|
-
* @param params - Optional route parameters to compile into the path
|
|
71
|
+
* @param args - The navigation arguments
|
|
23
72
|
*/
|
|
24
|
-
export const nestedNavigate = (injector: Injector,
|
|
25
|
-
|
|
26
|
-
injector.getInstance(LocationService).navigate(resolvedUrl)
|
|
73
|
+
export const nestedNavigate = (injector: Injector, args: NestedNavigateArgs): void => {
|
|
74
|
+
injector.get(LocationService).navigate(buildNestedNavigateUrl(args))
|
|
27
75
|
}
|
|
28
76
|
|
|
29
77
|
/**
|
|
30
78
|
* Creates a type-safe navigate function constrained to a specific route tree.
|
|
31
79
|
* The returned function has the same runtime behavior as {@link nestedNavigate}
|
|
32
|
-
* but narrows `path` to only accept valid route paths,
|
|
33
|
-
*
|
|
80
|
+
* but narrows `path` to only accept valid route paths, requires `params` when
|
|
81
|
+
* the route has parameters, and enforces the route's declared `query` and
|
|
82
|
+
* `hash` schemas.
|
|
34
83
|
*
|
|
35
84
|
* Unlike {@link createNestedRouteLink}, the injector is passed at call time,
|
|
36
85
|
* not at creation time.
|
|
@@ -42,19 +91,19 @@ export const nestedNavigate = (injector: Injector, path: string, params?: Record
|
|
|
42
91
|
* ```typescript
|
|
43
92
|
* const appNavigate = createNestedNavigate<typeof appRoutes>()
|
|
44
93
|
*
|
|
45
|
-
*
|
|
46
|
-
* appNavigate(injector, '/
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
94
|
+
* appNavigate(injector, { path: '/buttons' })
|
|
95
|
+
* appNavigate(injector, { path: '/users/:id', params: { id: '123' } })
|
|
96
|
+
* appNavigate(injector, {
|
|
97
|
+
* path: '/users/:id',
|
|
98
|
+
* params: { id: '123' },
|
|
99
|
+
* query: { tab: 'profile' },
|
|
100
|
+
* hash: 'settings',
|
|
101
|
+
* })
|
|
53
102
|
* ```
|
|
54
103
|
*/
|
|
55
|
-
export const createNestedNavigate = <TRoutes extends Record<string, NestedRoute<any>>>() => {
|
|
104
|
+
export const createNestedNavigate = <TRoutes extends Record<string, NestedRoute<any, any, any>>>() => {
|
|
56
105
|
return nestedNavigate as <TPath extends ExtractRoutePaths<TRoutes>>(
|
|
57
106
|
injector: Injector,
|
|
58
|
-
|
|
107
|
+
args: TypedNestedNavigateArgs<TRoutes, TPath>,
|
|
59
108
|
) => void
|
|
60
109
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Injector } from '@furystack/inject'
|
|
2
|
+
import { createInjector } from '@furystack/inject'
|
|
3
|
+
import { usingAsync } from '@furystack/utils'
|
|
4
|
+
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
|
|
5
|
+
import { LocationService } from '../services/location-service.js'
|
|
6
|
+
import { createNestedReplace, nestedReplace } from './nested-replace.js'
|
|
7
|
+
import type { NestedRoute } from './nested-router.js'
|
|
8
|
+
|
|
9
|
+
type TestRoute = Pick<NestedRoute<unknown, any, any>, 'component'>
|
|
10
|
+
|
|
11
|
+
describe('nestedReplace', () => {
|
|
12
|
+
it('Should replace the current history entry with a simple path', async () => {
|
|
13
|
+
await usingAsync(createInjector(), async (injector) => {
|
|
14
|
+
const locationService = injector.get(LocationService)
|
|
15
|
+
const navigateSpy = vi.spyOn(locationService, 'navigate')
|
|
16
|
+
const replaceSpy = vi.spyOn(locationService, 'replace')
|
|
17
|
+
|
|
18
|
+
nestedReplace(injector, { path: '/buttons' })
|
|
19
|
+
|
|
20
|
+
expect(replaceSpy).toHaveBeenCalledWith('/buttons')
|
|
21
|
+
expect(navigateSpy).not.toHaveBeenCalled()
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('Should compile params, query and hash for a replace call', async () => {
|
|
26
|
+
await usingAsync(createInjector(), async (injector) => {
|
|
27
|
+
const locationService = injector.get(LocationService)
|
|
28
|
+
const replaceSpy = vi.spyOn(locationService, 'replace')
|
|
29
|
+
|
|
30
|
+
nestedReplace(injector, {
|
|
31
|
+
path: '/users/:id',
|
|
32
|
+
params: { id: '7' },
|
|
33
|
+
query: { tab: 'profile' },
|
|
34
|
+
hash: 'notes',
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
expect(replaceSpy).toHaveBeenCalledTimes(1)
|
|
38
|
+
const replacedUrl = replaceSpy.mock.calls[0][0]
|
|
39
|
+
expect(replacedUrl.startsWith('/users/7?')).toBe(true)
|
|
40
|
+
expect(replacedUrl.endsWith('#notes')).toBe(true)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('createNestedReplace', () => {
|
|
46
|
+
it('Should constrain path to valid route paths', () => {
|
|
47
|
+
type Routes = {
|
|
48
|
+
'/': TestRoute & {
|
|
49
|
+
children: {
|
|
50
|
+
'/buttons': TestRoute
|
|
51
|
+
'/inputs': TestRoute
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const appReplace = createNestedReplace<Routes>()
|
|
57
|
+
expectTypeOf(appReplace<'/buttons'>)
|
|
58
|
+
.parameter(0)
|
|
59
|
+
.toEqualTypeOf<Injector>()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('Should reject invalid paths', () => {
|
|
63
|
+
type Routes = {
|
|
64
|
+
'/': TestRoute & {
|
|
65
|
+
children: {
|
|
66
|
+
'/buttons': TestRoute
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const appReplace = createNestedReplace<Routes>()
|
|
72
|
+
// @ts-expect-error -- '/nonexistent' is not a valid route path
|
|
73
|
+
appReplace(createInjector(), { path: '/nonexistent' })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('Should enforce a declared required query shape', () => {
|
|
77
|
+
const routes = {
|
|
78
|
+
'/list': {
|
|
79
|
+
component: () => null as unknown as JSX.Element,
|
|
80
|
+
query: (raw): { page: number } | null => (typeof raw.page === 'number' ? { page: raw.page } : null),
|
|
81
|
+
},
|
|
82
|
+
} satisfies Record<string, NestedRoute<any, any, any>>
|
|
83
|
+
|
|
84
|
+
const appReplace = createNestedReplace<typeof routes>()
|
|
85
|
+
expectTypeOf(appReplace<'/list'>)
|
|
86
|
+
.parameter(1)
|
|
87
|
+
.toExtend<{ query: { page: number } }>()
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Injector } from '@furystack/inject'
|
|
2
|
+
import { LocationService } from '../services/location-service.js'
|
|
3
|
+
import type { NestedNavigateArgs, TypedNestedNavigateArgs } from './nested-navigate.js'
|
|
4
|
+
import { buildNestedNavigateUrl } from './nested-navigate.js'
|
|
5
|
+
import type { ExtractRoutePaths } from './nested-route-types.js'
|
|
6
|
+
import type { NestedRoute } from './nested-router.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Replaces the current history entry with a route using the LocationService
|
|
10
|
+
* resolved from the given injector. Has the same URL-composition semantics as
|
|
11
|
+
* `nestedNavigate`, but calls `history.replaceState` under the hood so the
|
|
12
|
+
* intermediate URL does not appear in the browser's back / forward stack.
|
|
13
|
+
*
|
|
14
|
+
* Use this for SPA redirects (e.g. canonicalization, auth guards) where the
|
|
15
|
+
* caller's URL should not be recoverable with the Back button.
|
|
16
|
+
*
|
|
17
|
+
* @param injector - The injector instance to resolve LocationService from
|
|
18
|
+
* @param args - The navigation arguments
|
|
19
|
+
*/
|
|
20
|
+
export const nestedReplace = (injector: Injector, args: NestedNavigateArgs): void => {
|
|
21
|
+
injector.get(LocationService).replace(buildNestedNavigateUrl(args))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Creates a type-safe replace function constrained to a specific route tree.
|
|
26
|
+
* The returned function has the same runtime behavior as {@link nestedReplace}
|
|
27
|
+
* but narrows `path` to valid route paths, requires `params` when the route has
|
|
28
|
+
* parameters, and enforces the route's declared `query` and `hash` schemas.
|
|
29
|
+
*
|
|
30
|
+
* @typeParam TRoutes - The route tree type (use `typeof yourRoutes`)
|
|
31
|
+
* @returns A type-safe replace function
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```typescript
|
|
35
|
+
* const appReplace = createNestedReplace<typeof appRoutes>()
|
|
36
|
+
*
|
|
37
|
+
* // Redirect an unauthenticated visitor without polluting history
|
|
38
|
+
* appReplace(injector, { path: '/login' })
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export const createNestedReplace = <TRoutes extends Record<string, NestedRoute<any, any, any>>>() => {
|
|
42
|
+
return nestedReplace as <TPath extends ExtractRoutePaths<TRoutes>>(
|
|
43
|
+
injector: Injector,
|
|
44
|
+
args: TypedNestedNavigateArgs<TRoutes, TPath>,
|
|
45
|
+
) => void
|
|
46
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createInjector } from '@furystack/inject'
|
|
2
2
|
import { usingAsync } from '@furystack/utils'
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
|
|
4
4
|
import { initializeShadeRoot } from '../initialize.js'
|
|
@@ -11,9 +11,9 @@ import type { ConcatPaths, ExtractRouteParams, ExtractRoutePaths, UrlTree } from
|
|
|
11
11
|
import type { NestedRoute } from './nested-router.js'
|
|
12
12
|
|
|
13
13
|
// Minimal route type for type-level tests. Using Pick avoids the
|
|
14
|
-
// `children?: Record<string, NestedRoute<any>>` from NestedRoute<unknown>
|
|
14
|
+
// `children?: Record<string, NestedRoute<any, any, any>>` from NestedRoute<unknown>
|
|
15
15
|
// which would widen literal keys in intersections.
|
|
16
|
-
type TestRoute = Pick<NestedRoute<unknown>, 'component'>
|
|
16
|
+
type TestRoute = Pick<NestedRoute<unknown, any, any>, 'component'>
|
|
17
17
|
|
|
18
18
|
describe('NestedRouteLink', () => {
|
|
19
19
|
beforeEach(() => {
|
|
@@ -24,14 +24,14 @@ describe('NestedRouteLink', () => {
|
|
|
24
24
|
})
|
|
25
25
|
|
|
26
26
|
it('Should render a link with the correct href', async () => {
|
|
27
|
-
await usingAsync(
|
|
27
|
+
await usingAsync(createInjector(), async (injector) => {
|
|
28
28
|
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
29
29
|
|
|
30
30
|
initializeShadeRoot({
|
|
31
31
|
injector,
|
|
32
32
|
rootElement,
|
|
33
33
|
jsxElement: (
|
|
34
|
-
<NestedRouteLink id="link"
|
|
34
|
+
<NestedRouteLink id="link" path="/buttons">
|
|
35
35
|
Buttons
|
|
36
36
|
</NestedRouteLink>
|
|
37
37
|
),
|
|
@@ -44,17 +44,17 @@ describe('NestedRouteLink', () => {
|
|
|
44
44
|
})
|
|
45
45
|
|
|
46
46
|
it('Should trigger SPA navigation on click', async () => {
|
|
47
|
-
await usingAsync(
|
|
47
|
+
await usingAsync(createInjector(), async (injector) => {
|
|
48
48
|
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
49
49
|
const onRouteChange = vi.fn()
|
|
50
50
|
|
|
51
|
-
injector.
|
|
51
|
+
injector.get(LocationService).onLocationPathChanged.subscribe(onRouteChange)
|
|
52
52
|
|
|
53
53
|
initializeShadeRoot({
|
|
54
54
|
injector,
|
|
55
55
|
rootElement,
|
|
56
56
|
jsxElement: (
|
|
57
|
-
<NestedRouteLink id="link"
|
|
57
|
+
<NestedRouteLink id="link" path="/buttons">
|
|
58
58
|
Buttons
|
|
59
59
|
</NestedRouteLink>
|
|
60
60
|
),
|
|
@@ -68,14 +68,14 @@ describe('NestedRouteLink', () => {
|
|
|
68
68
|
})
|
|
69
69
|
|
|
70
70
|
it('Should compile route params in the href', async () => {
|
|
71
|
-
await usingAsync(
|
|
71
|
+
await usingAsync(createInjector(), async (injector) => {
|
|
72
72
|
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
73
73
|
|
|
74
74
|
initializeShadeRoot({
|
|
75
75
|
injector,
|
|
76
76
|
rootElement,
|
|
77
77
|
jsxElement: (
|
|
78
|
-
<NestedRouteLink id="link"
|
|
78
|
+
<NestedRouteLink id="link" path="/users/:id" params={{ id: '42' }}>
|
|
79
79
|
User 42
|
|
80
80
|
</NestedRouteLink>
|
|
81
81
|
),
|
|
@@ -88,14 +88,14 @@ describe('NestedRouteLink', () => {
|
|
|
88
88
|
})
|
|
89
89
|
|
|
90
90
|
it('Should compile route params with multiple segments', async () => {
|
|
91
|
-
await usingAsync(
|
|
91
|
+
await usingAsync(createInjector(), async (injector) => {
|
|
92
92
|
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
93
93
|
|
|
94
94
|
initializeShadeRoot({
|
|
95
95
|
injector,
|
|
96
96
|
rootElement,
|
|
97
97
|
jsxElement: (
|
|
98
|
-
<NestedRouteLink id="link"
|
|
98
|
+
<NestedRouteLink id="link" path="/users/:userId/posts/:postId" params={{ userId: '1', postId: '99' }}>
|
|
99
99
|
Post
|
|
100
100
|
</NestedRouteLink>
|
|
101
101
|
),
|
|
@@ -106,6 +106,46 @@ describe('NestedRouteLink', () => {
|
|
|
106
106
|
)
|
|
107
107
|
})
|
|
108
108
|
})
|
|
109
|
+
|
|
110
|
+
it('Should append a serialized query string to the rendered href', async () => {
|
|
111
|
+
await usingAsync(createInjector(), async (injector) => {
|
|
112
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
113
|
+
|
|
114
|
+
initializeShadeRoot({
|
|
115
|
+
injector,
|
|
116
|
+
rootElement,
|
|
117
|
+
jsxElement: (
|
|
118
|
+
<NestedRouteLink id="link" path="/buttons" query={{ page: 2 }}>
|
|
119
|
+
Buttons
|
|
120
|
+
</NestedRouteLink>
|
|
121
|
+
),
|
|
122
|
+
})
|
|
123
|
+
await flushUpdates()
|
|
124
|
+
|
|
125
|
+
const link = document.getElementById('link') as HTMLAnchorElement
|
|
126
|
+
expect(link.getAttribute('href')?.startsWith('/buttons?')).toBe(true)
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('Should append the hash segment to the rendered href', async () => {
|
|
131
|
+
await usingAsync(createInjector(), async (injector) => {
|
|
132
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
133
|
+
|
|
134
|
+
initializeShadeRoot({
|
|
135
|
+
injector,
|
|
136
|
+
rootElement,
|
|
137
|
+
jsxElement: (
|
|
138
|
+
<NestedRouteLink id="link" path="/buttons" hash="overview">
|
|
139
|
+
Buttons
|
|
140
|
+
</NestedRouteLink>
|
|
141
|
+
),
|
|
142
|
+
})
|
|
143
|
+
await flushUpdates()
|
|
144
|
+
|
|
145
|
+
const link = document.getElementById('link') as HTMLAnchorElement
|
|
146
|
+
expect(link.getAttribute('href')).toBe('/buttons#overview')
|
|
147
|
+
})
|
|
148
|
+
})
|
|
109
149
|
})
|
|
110
150
|
|
|
111
151
|
describe('Type utilities', () => {
|
|
@@ -219,13 +259,13 @@ describe('Type utilities', () => {
|
|
|
219
259
|
describe('TypedNestedRouteLinkProps', () => {
|
|
220
260
|
it('Should make params optional for paths without parameters', () => {
|
|
221
261
|
type Props = TypedNestedRouteLinkProps<'/buttons'>
|
|
222
|
-
expectTypeOf<Props['
|
|
262
|
+
expectTypeOf<Props['path']>().toEqualTypeOf<'/buttons'>()
|
|
223
263
|
expectTypeOf<Props>().toExtend<{ params?: Record<string, string> }>()
|
|
224
264
|
})
|
|
225
265
|
|
|
226
266
|
it('Should require params for parameterized paths', () => {
|
|
227
267
|
type Props = TypedNestedRouteLinkProps<'/users/:id'>
|
|
228
|
-
expectTypeOf<Props['
|
|
268
|
+
expectTypeOf<Props['path']>().toEqualTypeOf<'/users/:id'>()
|
|
229
269
|
expectTypeOf<Props>().toExtend<{ params: { id: string } }>()
|
|
230
270
|
})
|
|
231
271
|
|
|
@@ -236,20 +276,20 @@ describe('Type utilities', () => {
|
|
|
236
276
|
})
|
|
237
277
|
|
|
238
278
|
describe('NestedRouteLink param inference', () => {
|
|
239
|
-
it('Should infer params as optional when
|
|
279
|
+
it('Should infer params as optional when path has no parameters', () => {
|
|
240
280
|
expectTypeOf(NestedRouteLink).parameter(0).toHaveProperty('params')
|
|
241
281
|
expectTypeOf(NestedRouteLink<'/buttons'>)
|
|
242
282
|
.parameter(0)
|
|
243
283
|
.toExtend<{ params?: Record<string, string> }>()
|
|
244
284
|
})
|
|
245
285
|
|
|
246
|
-
it('Should infer params as required when
|
|
286
|
+
it('Should infer params as required when path has a parameter', () => {
|
|
247
287
|
expectTypeOf(NestedRouteLink<'/users/:id'>)
|
|
248
288
|
.parameter(0)
|
|
249
289
|
.toExtend<{ params: { id: string } }>()
|
|
250
290
|
})
|
|
251
291
|
|
|
252
|
-
it('Should infer multiple params from
|
|
292
|
+
it('Should infer multiple params from path', () => {
|
|
253
293
|
expectTypeOf(NestedRouteLink<'/users/:userId/posts/:postId'>)
|
|
254
294
|
.parameter(0)
|
|
255
295
|
.toExtend<{ params: { userId: string; postId: string } }>()
|
|
@@ -257,7 +297,7 @@ describe('Type utilities', () => {
|
|
|
257
297
|
})
|
|
258
298
|
|
|
259
299
|
describe('createNestedRouteLink', () => {
|
|
260
|
-
it('Should constrain
|
|
300
|
+
it('Should constrain path to valid route paths', () => {
|
|
261
301
|
type Routes = {
|
|
262
302
|
'/': TestRoute & {
|
|
263
303
|
children: {
|
|
@@ -267,7 +307,7 @@ describe('Type utilities', () => {
|
|
|
267
307
|
}
|
|
268
308
|
|
|
269
309
|
const AppLink = createNestedRouteLink<Routes>()
|
|
270
|
-
expectTypeOf(AppLink).parameter(0).toHaveProperty('
|
|
310
|
+
expectTypeOf(AppLink).parameter(0).toHaveProperty('path')
|
|
271
311
|
})
|
|
272
312
|
|
|
273
313
|
it('Should reject invalid paths', () => {
|
|
@@ -281,7 +321,7 @@ describe('Type utilities', () => {
|
|
|
281
321
|
|
|
282
322
|
const AppLink = createNestedRouteLink<Routes>()
|
|
283
323
|
// @ts-expect-error -- '/nonexistent' is not a valid route path
|
|
284
|
-
AppLink({
|
|
324
|
+
AppLink({ path: '/nonexistent' })
|
|
285
325
|
})
|
|
286
326
|
|
|
287
327
|
it('Should require params for parameterized routes in the tree', () => {
|
|
@@ -343,13 +383,41 @@ describe('Type utilities', () => {
|
|
|
343
383
|
'/users/:userId': usersRoute,
|
|
344
384
|
},
|
|
345
385
|
},
|
|
346
|
-
} satisfies Record<string, NestedRoute<any>>
|
|
386
|
+
} satisfies Record<string, NestedRoute<any, any, any>>
|
|
347
387
|
|
|
348
388
|
const AppLink = createNestedRouteLink<typeof routes>()
|
|
349
|
-
expectTypeOf(AppLink).parameter(0).toHaveProperty('
|
|
389
|
+
expectTypeOf(AppLink).parameter(0).toHaveProperty('path')
|
|
350
390
|
expectTypeOf(AppLink<'/users/:userId'>)
|
|
351
391
|
.parameter(0)
|
|
352
392
|
.toExtend<{ params: { userId: string } }>()
|
|
353
393
|
})
|
|
394
|
+
|
|
395
|
+
it('Should enforce a declared required query shape on the link', () => {
|
|
396
|
+
const routes = {
|
|
397
|
+
'/list': {
|
|
398
|
+
component: () => null as unknown as JSX.Element,
|
|
399
|
+
query: (raw): { page: number } | null => (typeof raw.page === 'number' ? { page: raw.page } : null),
|
|
400
|
+
},
|
|
401
|
+
} satisfies Record<string, NestedRoute<any, any, any>>
|
|
402
|
+
|
|
403
|
+
const AppLink = createNestedRouteLink<typeof routes>()
|
|
404
|
+
expectTypeOf(AppLink<'/list'>)
|
|
405
|
+
.parameter(0)
|
|
406
|
+
.toExtend<{ query: { page: number } }>()
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
it('Should narrow hash on the link to the declared literal tuple', () => {
|
|
410
|
+
const routes = {
|
|
411
|
+
'/tabs': {
|
|
412
|
+
component: () => null as unknown as JSX.Element,
|
|
413
|
+
hash: ['overview', 'details'] as const,
|
|
414
|
+
},
|
|
415
|
+
} satisfies Record<string, NestedRoute<any, any, any>>
|
|
416
|
+
|
|
417
|
+
const AppLink = createNestedRouteLink<typeof routes>()
|
|
418
|
+
expectTypeOf(AppLink<'/tabs'>)
|
|
419
|
+
.parameter(0)
|
|
420
|
+
.toExtend<{ hash?: 'overview' | 'details' }>()
|
|
421
|
+
})
|
|
354
422
|
})
|
|
355
423
|
})
|
|
@@ -1,31 +1,58 @@
|
|
|
1
|
-
import { compileRoute } from '../compile-route.js'
|
|
2
1
|
import type { ChildrenList } from '../models/children-list.js'
|
|
3
2
|
import type { PartialElement } from '../models/partial-element.js'
|
|
4
3
|
import { LocationService } from '../services/location-service.js'
|
|
5
4
|
import { createComponent } from '../shade-component.js'
|
|
6
5
|
import { Shade } from '../shade.js'
|
|
7
|
-
import
|
|
6
|
+
import { buildNestedNavigateUrl } from './nested-navigate.js'
|
|
7
|
+
import type {
|
|
8
|
+
ExtractRouteHash,
|
|
9
|
+
ExtractRouteQuery,
|
|
10
|
+
ExtractRoutePaths,
|
|
11
|
+
RouteAt,
|
|
12
|
+
TypedHashArg,
|
|
13
|
+
TypedParamsArg,
|
|
14
|
+
TypedQueryArg,
|
|
15
|
+
} from './nested-route-types.js'
|
|
8
16
|
import type { NestedRoute } from './nested-router.js'
|
|
9
17
|
|
|
10
18
|
/**
|
|
11
19
|
* Props for the NestedRouteLink component.
|
|
12
|
-
* Combines SPA navigation
|
|
20
|
+
* Combines SPA navigation with parameter compilation for nested routes,
|
|
21
|
+
* plus optional query string and hash fragment composition.
|
|
13
22
|
*/
|
|
14
23
|
export type NestedRouteLinkProps = {
|
|
15
|
-
|
|
24
|
+
path: string
|
|
16
25
|
params?: Record<string, string>
|
|
26
|
+
query?: Record<string, unknown>
|
|
27
|
+
hash?: string
|
|
17
28
|
} & PartialElement<Omit<HTMLAnchorElement, 'onclick' | 'href'>>
|
|
18
29
|
|
|
19
30
|
/**
|
|
20
31
|
* Props for a type-safe nested route link.
|
|
21
32
|
* When the path contains parameters (e.g. `:id`), the `params` prop becomes required.
|
|
33
|
+
* `query` and `hash` remain loose (untyped) on this variant; use
|
|
34
|
+
* {@link createNestedRouteLink} to narrow them against a specific route tree.
|
|
22
35
|
* @typeParam TPath - A specific route path string
|
|
23
36
|
*/
|
|
24
37
|
export type TypedNestedRouteLinkProps<TPath extends string> = {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
38
|
+
path: TPath
|
|
39
|
+
query?: Record<string, unknown>
|
|
40
|
+
hash?: string
|
|
41
|
+
} & TypedParamsArg<TPath> &
|
|
42
|
+
PartialElement<Omit<HTMLAnchorElement, 'onclick' | 'href'>>
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Props for a route-tree-aware nested route link. Narrows `query` and `hash`
|
|
46
|
+
* against the specific route at `TPath` in `TRoutes`.
|
|
47
|
+
* @typeParam TRoutes - The route tree
|
|
48
|
+
* @typeParam TPath - A composed route path within `TRoutes`
|
|
49
|
+
*/
|
|
50
|
+
export type TreeAwareNestedRouteLinkProps<
|
|
51
|
+
TRoutes extends Record<string, NestedRoute<any, any, any>>,
|
|
52
|
+
TPath extends string,
|
|
53
|
+
> = { path: TPath } & TypedParamsArg<TPath> &
|
|
54
|
+
TypedQueryArg<ExtractRouteQuery<RouteAt<TRoutes, TPath>>> &
|
|
55
|
+
TypedHashArg<ExtractRouteHash<RouteAt<TRoutes, TPath>>> &
|
|
29
56
|
PartialElement<Omit<HTMLAnchorElement, 'onclick' | 'href'>>
|
|
30
57
|
|
|
31
58
|
const _NestedRouteLink = Shade<NestedRouteLinkProps>({
|
|
@@ -37,8 +64,8 @@ const _NestedRouteLink = Shade<NestedRouteLinkProps>({
|
|
|
37
64
|
textDecoration: 'inherit',
|
|
38
65
|
},
|
|
39
66
|
render: ({ children, props, injector, useHostProps }) => {
|
|
40
|
-
const {
|
|
41
|
-
const resolvedUrl =
|
|
67
|
+
const { path, params, query, hash } = props
|
|
68
|
+
const resolvedUrl = buildNestedNavigateUrl({ path, params, query, hash })
|
|
42
69
|
|
|
43
70
|
useHostProps({
|
|
44
71
|
href: resolvedUrl,
|
|
@@ -46,7 +73,7 @@ const _NestedRouteLink = Shade<NestedRouteLinkProps>({
|
|
|
46
73
|
ev.preventDefault()
|
|
47
74
|
// eslint-disable-next-line furystack/prefer-location-service -- This IS the SPA link component; it must call pushState directly.
|
|
48
75
|
history.pushState('', props.title || '', resolvedUrl)
|
|
49
|
-
injector.
|
|
76
|
+
injector.get(LocationService).updateState()
|
|
50
77
|
},
|
|
51
78
|
})
|
|
52
79
|
return <>{children}</>
|
|
@@ -58,13 +85,15 @@ const _NestedRouteLink = Shade<NestedRouteLinkProps>({
|
|
|
58
85
|
* type-safe route parameter compilation.
|
|
59
86
|
*
|
|
60
87
|
* Intercepts click events to use `history.pushState` for client-side navigation,
|
|
61
|
-
*
|
|
88
|
+
* compiles parameterized routes (e.g. `/users/:id`) when `params` is provided,
|
|
89
|
+
* serializes `query` to the URL search string and appends `hash` when set.
|
|
62
90
|
*
|
|
63
|
-
* Route parameters are automatically inferred from the `
|
|
64
|
-
* - `
|
|
65
|
-
* - `
|
|
91
|
+
* Route parameters are automatically inferred from the `path` pattern:
|
|
92
|
+
* - `path="/buttons"` — `params` is optional
|
|
93
|
+
* - `path="/users/:id"` — `params: { id: string }` is required
|
|
66
94
|
*
|
|
67
|
-
* For additional URL validation against a route tree
|
|
95
|
+
* For additional URL validation against a route tree (including `query` and
|
|
96
|
+
* `hash` narrowing), use {@link createNestedRouteLink}.
|
|
68
97
|
*/
|
|
69
98
|
export const NestedRouteLink = _NestedRouteLink as unknown as <TPath extends string = string>(
|
|
70
99
|
props: TypedNestedRouteLinkProps<TPath>,
|
|
@@ -72,9 +101,10 @@ export const NestedRouteLink = _NestedRouteLink as unknown as <TPath extends str
|
|
|
72
101
|
) => JSX.Element
|
|
73
102
|
|
|
74
103
|
/**
|
|
75
|
-
* Creates a type-safe wrapper around NestedRouteLink constrained to a specific
|
|
76
|
-
* The returned component has the same runtime behavior but narrows
|
|
77
|
-
* valid route paths,
|
|
104
|
+
* Creates a type-safe wrapper around NestedRouteLink constrained to a specific
|
|
105
|
+
* route tree. The returned component has the same runtime behavior but narrows
|
|
106
|
+
* `path` to only accept valid route paths, requires `params` when the route
|
|
107
|
+
* has parameters, and enforces the route's declared `query` and `hash` schemas.
|
|
78
108
|
*
|
|
79
109
|
* @typeParam TRoutes - The route tree type (use `typeof yourRoutes`)
|
|
80
110
|
* @returns A narrowed NestedRouteLink component
|
|
@@ -83,19 +113,14 @@ export const NestedRouteLink = _NestedRouteLink as unknown as <TPath extends str
|
|
|
83
113
|
* ```typescript
|
|
84
114
|
* const AppLink = createNestedRouteLink<typeof appRoutes>()
|
|
85
115
|
*
|
|
86
|
-
*
|
|
87
|
-
* <AppLink
|
|
88
|
-
*
|
|
89
|
-
* // TypeScript error: invalid path
|
|
90
|
-
* <AppLink href="/nonexistent">Error!</AppLink>
|
|
91
|
-
*
|
|
92
|
-
* // Params required for parameterized routes
|
|
93
|
-
* <AppLink href="/users/:id" params={{ id: '123' }}>User</AppLink>
|
|
116
|
+
* <AppLink path="/buttons">Buttons</AppLink>
|
|
117
|
+
* <AppLink path="/users/:id" params={{ id: '123' }}>User</AppLink>
|
|
118
|
+
* <AppLink path="/users/:id" params={{ id: '1' }} query={{ tab: 'profile' }} hash="notes">User</AppLink>
|
|
94
119
|
* ```
|
|
95
120
|
*/
|
|
96
|
-
export const createNestedRouteLink = <TRoutes extends Record<string, NestedRoute<any>>>() => {
|
|
121
|
+
export const createNestedRouteLink = <TRoutes extends Record<string, NestedRoute<any, any, any>>>() => {
|
|
97
122
|
return _NestedRouteLink as unknown as <TPath extends ExtractRoutePaths<TRoutes>>(
|
|
98
|
-
props:
|
|
123
|
+
props: TreeAwareNestedRouteLinkProps<TRoutes, TPath>,
|
|
99
124
|
children?: ChildrenList,
|
|
100
125
|
) => JSX.Element
|
|
101
126
|
}
|