@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
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { createInjector } from '@furystack/inject'
|
|
2
|
+
import { usingAsync } from '@furystack/utils'
|
|
3
|
+
import { describe, expect, expectTypeOf, it } from 'vitest'
|
|
4
|
+
import { LocationService } from '../services/location-service.js'
|
|
5
|
+
import { createNestedHooks, walkRoute } from './nested-hooks.js'
|
|
6
|
+
import type { NestedRoute } from './nested-router.js'
|
|
7
|
+
|
|
8
|
+
const makeComponent = () => null as unknown as JSX.Element
|
|
9
|
+
|
|
10
|
+
describe('walkRoute', () => {
|
|
11
|
+
it('Should return the route when the pattern matches the path exactly', () => {
|
|
12
|
+
const leaf = { component: () => makeComponent() }
|
|
13
|
+
const routes = { '/users': leaf } satisfies Record<string, NestedRoute<any, any, any>>
|
|
14
|
+
expect(walkRoute(routes, '/users')).toBe(leaf)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('Should return undefined when no pattern matches', () => {
|
|
18
|
+
const routes = { '/users': { component: () => makeComponent() } } satisfies Record<
|
|
19
|
+
string,
|
|
20
|
+
NestedRoute<any, any, any>
|
|
21
|
+
>
|
|
22
|
+
expect(walkRoute(routes, '/nonexistent')).toBeUndefined()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('Should descend through a `/` root pattern transparently', () => {
|
|
26
|
+
const leaf = { component: () => makeComponent() }
|
|
27
|
+
const routes = {
|
|
28
|
+
'/': {
|
|
29
|
+
component: () => makeComponent(),
|
|
30
|
+
children: {
|
|
31
|
+
'/home': leaf,
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
} satisfies Record<string, NestedRoute<any, any, any>>
|
|
35
|
+
|
|
36
|
+
expect(walkRoute(routes, '/home')).toBe(leaf)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('Should resolve a deeply nested path by walking children', () => {
|
|
40
|
+
const leaf = { component: () => makeComponent() }
|
|
41
|
+
const routes = {
|
|
42
|
+
'/navigation': {
|
|
43
|
+
component: () => makeComponent(),
|
|
44
|
+
children: {
|
|
45
|
+
'/tabs': leaf,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
} satisfies Record<string, NestedRoute<any, any, any>>
|
|
49
|
+
|
|
50
|
+
expect(walkRoute(routes, '/navigation/tabs')).toBe(leaf)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('Should resolve through two levels of `/` roots', () => {
|
|
54
|
+
const leaf = { component: () => makeComponent() }
|
|
55
|
+
const routes = {
|
|
56
|
+
'/': {
|
|
57
|
+
component: () => makeComponent(),
|
|
58
|
+
children: {
|
|
59
|
+
'/navigation': {
|
|
60
|
+
component: () => makeComponent(),
|
|
61
|
+
children: {
|
|
62
|
+
'/tabs': leaf,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
} satisfies Record<string, NestedRoute<any, any, any>>
|
|
68
|
+
|
|
69
|
+
expect(walkRoute(routes, '/navigation/tabs')).toBe(leaf)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('Should match a parent prefix when the path is exactly the parent pattern', () => {
|
|
73
|
+
const parent = {
|
|
74
|
+
component: () => makeComponent(),
|
|
75
|
+
children: {
|
|
76
|
+
'/child': { component: () => makeComponent() },
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
const routes = { '/parent': parent } satisfies Record<string, NestedRoute<any, any, any>>
|
|
80
|
+
|
|
81
|
+
expect(walkRoute(routes, '/parent')).toBe(parent)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('Should not confuse a prefix pattern that is not followed by a `/`', () => {
|
|
85
|
+
const routes = {
|
|
86
|
+
'/users': {
|
|
87
|
+
component: () => makeComponent(),
|
|
88
|
+
children: {
|
|
89
|
+
'/:id': { component: () => makeComponent() },
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
} satisfies Record<string, NestedRoute<any, any, any>>
|
|
93
|
+
|
|
94
|
+
expect(walkRoute(routes, '/usersextra')).toBeUndefined()
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('createNestedHooks', () => {
|
|
99
|
+
describe('getTypedQuery', () => {
|
|
100
|
+
it('Should return null when the route has no declared query validator', async () => {
|
|
101
|
+
const routes = {
|
|
102
|
+
'/bare': { component: () => makeComponent() },
|
|
103
|
+
} satisfies Record<string, NestedRoute<any, any, any>>
|
|
104
|
+
|
|
105
|
+
const { getTypedQuery } = createNestedHooks(routes)
|
|
106
|
+
|
|
107
|
+
await usingAsync(createInjector(), async (injector) => {
|
|
108
|
+
expect(getTypedQuery(injector, '/bare')).toBeNull()
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('Should return the validated query when the current search matches', async () => {
|
|
113
|
+
const routes = {
|
|
114
|
+
'/list': {
|
|
115
|
+
component: () => makeComponent(),
|
|
116
|
+
query: (raw): { page: number } | null => (typeof raw.page === 'number' ? { page: raw.page } : null),
|
|
117
|
+
},
|
|
118
|
+
} satisfies Record<string, NestedRoute<any, any, any>>
|
|
119
|
+
|
|
120
|
+
const { getTypedQuery } = createNestedHooks(routes)
|
|
121
|
+
|
|
122
|
+
await usingAsync(createInjector(), async (injector) => {
|
|
123
|
+
const locationService = injector.get(LocationService)
|
|
124
|
+
locationService.onDeserializedLocationSearchChanged.setValue({ page: 3 })
|
|
125
|
+
expect(getTypedQuery(injector, '/list')).toEqual({ page: 3 })
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('Should return null when the validator rejects the current search', async () => {
|
|
130
|
+
const routes = {
|
|
131
|
+
'/list': {
|
|
132
|
+
component: () => makeComponent(),
|
|
133
|
+
query: (raw): { page: number } | null => (typeof raw.page === 'number' ? { page: raw.page } : null),
|
|
134
|
+
},
|
|
135
|
+
} satisfies Record<string, NestedRoute<any, any, any>>
|
|
136
|
+
|
|
137
|
+
const { getTypedQuery } = createNestedHooks(routes)
|
|
138
|
+
|
|
139
|
+
await usingAsync(createInjector(), async (injector) => {
|
|
140
|
+
const locationService = injector.get(LocationService)
|
|
141
|
+
locationService.onDeserializedLocationSearchChanged.setValue({ page: 'not-a-number' })
|
|
142
|
+
expect(getTypedQuery(injector, '/list')).toBeNull()
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe('getTypedHash', () => {
|
|
148
|
+
it('Should return undefined when the route has no declared hash tuple', async () => {
|
|
149
|
+
const routes = {
|
|
150
|
+
'/bare': { component: () => makeComponent() },
|
|
151
|
+
} satisfies Record<string, NestedRoute<any, any, any>>
|
|
152
|
+
|
|
153
|
+
const { getTypedHash } = createNestedHooks(routes)
|
|
154
|
+
|
|
155
|
+
await usingAsync(createInjector(), async (injector) => {
|
|
156
|
+
expect(getTypedHash(injector, '/bare')).toBeUndefined()
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('Should return the current hash when it matches a declared literal', async () => {
|
|
161
|
+
const routes = {
|
|
162
|
+
'/tabs': {
|
|
163
|
+
component: () => makeComponent(),
|
|
164
|
+
hash: ['overview', 'details'] as const,
|
|
165
|
+
},
|
|
166
|
+
} satisfies Record<string, NestedRoute<any, any, any>>
|
|
167
|
+
|
|
168
|
+
const { getTypedHash } = createNestedHooks(routes)
|
|
169
|
+
|
|
170
|
+
await usingAsync(createInjector(), async (injector) => {
|
|
171
|
+
const locationService = injector.get(LocationService)
|
|
172
|
+
locationService.onLocationHashChanged.setValue('details')
|
|
173
|
+
expect(getTypedHash(injector, '/tabs')).toBe('details')
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('Should return undefined when the current hash is not in the declared tuple', async () => {
|
|
178
|
+
const routes = {
|
|
179
|
+
'/tabs': {
|
|
180
|
+
component: () => makeComponent(),
|
|
181
|
+
hash: ['overview', 'details'] as const,
|
|
182
|
+
},
|
|
183
|
+
} satisfies Record<string, NestedRoute<any, any, any>>
|
|
184
|
+
|
|
185
|
+
const { getTypedHash } = createNestedHooks(routes)
|
|
186
|
+
|
|
187
|
+
await usingAsync(createInjector(), async (injector) => {
|
|
188
|
+
const locationService = injector.get(LocationService)
|
|
189
|
+
locationService.onLocationHashChanged.setValue('something-else')
|
|
190
|
+
expect(getTypedHash(injector, '/tabs')).toBeUndefined()
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('Should resolve hashes through nested routes', async () => {
|
|
195
|
+
const routes = {
|
|
196
|
+
'/navigation': {
|
|
197
|
+
component: () => makeComponent(),
|
|
198
|
+
children: {
|
|
199
|
+
'/tabs': {
|
|
200
|
+
component: () => makeComponent(),
|
|
201
|
+
hash: ['ctrl-1', 'ctrl-2'] as const,
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
} satisfies Record<string, NestedRoute<any, any, any>>
|
|
206
|
+
|
|
207
|
+
const { getTypedHash } = createNestedHooks(routes)
|
|
208
|
+
|
|
209
|
+
await usingAsync(createInjector(), async (injector) => {
|
|
210
|
+
const locationService = injector.get(LocationService)
|
|
211
|
+
locationService.onLocationHashChanged.setValue('ctrl-1')
|
|
212
|
+
expect(getTypedHash(injector, '/navigation/tabs')).toBe('ctrl-1')
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
describe('Type utilities', () => {
|
|
218
|
+
it('Should narrow getTypedHash against the declared literal tuple', () => {
|
|
219
|
+
const routes = {
|
|
220
|
+
'/tabs': {
|
|
221
|
+
component: () => makeComponent(),
|
|
222
|
+
hash: ['overview', 'details'] as const,
|
|
223
|
+
},
|
|
224
|
+
} satisfies Record<string, NestedRoute<any, any, any>>
|
|
225
|
+
|
|
226
|
+
const { getTypedHash } = createNestedHooks(routes)
|
|
227
|
+
expectTypeOf(getTypedHash<'/tabs'>).returns.toEqualTypeOf<'overview' | 'details' | undefined>()
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('Should narrow getTypedQuery against the declared validator return type', () => {
|
|
231
|
+
const routes = {
|
|
232
|
+
'/list': {
|
|
233
|
+
component: () => makeComponent(),
|
|
234
|
+
query: (raw): { page: number } | null => (typeof raw.page === 'number' ? { page: raw.page } : null),
|
|
235
|
+
},
|
|
236
|
+
} satisfies Record<string, NestedRoute<any, any, any>>
|
|
237
|
+
|
|
238
|
+
const { getTypedQuery } = createNestedHooks(routes)
|
|
239
|
+
expectTypeOf(getTypedQuery<'/list'>).returns.toEqualTypeOf<{ page: number } | null>()
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('Should reject paths not present in the route tree', () => {
|
|
243
|
+
const routes = {
|
|
244
|
+
'/tabs': {
|
|
245
|
+
component: () => makeComponent(),
|
|
246
|
+
},
|
|
247
|
+
} satisfies Record<string, NestedRoute<any, any, any>>
|
|
248
|
+
|
|
249
|
+
const { getTypedHash } = createNestedHooks(routes)
|
|
250
|
+
// @ts-expect-error -- '/nonexistent' is not a valid route path
|
|
251
|
+
getTypedHash(createInjector(), '/nonexistent')
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { Injector } from '@furystack/inject'
|
|
2
|
+
import { LocationService } from '../services/location-service.js'
|
|
3
|
+
import type { ExtractRouteHash, ExtractRouteQuery, ExtractRoutePaths, RouteAt } from './nested-route-types.js'
|
|
4
|
+
import type { NestedRoute } from './nested-router.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A pair of synchronous read helpers bound to a specific route tree. Use the
|
|
8
|
+
* factory {@link createNestedHooks} to produce one.
|
|
9
|
+
*/
|
|
10
|
+
export type NestedHooks<TRoutes extends Record<string, NestedRoute<any, any, any>>> = {
|
|
11
|
+
/**
|
|
12
|
+
* Reads and validates the current URL query string against the declared
|
|
13
|
+
* validator of the route at `path`. Returns the typed query value, or
|
|
14
|
+
* `null` when the route has no validator or the current URL's query does
|
|
15
|
+
* not satisfy it.
|
|
16
|
+
*
|
|
17
|
+
* This is a synchronous snapshot — for reactive use, subscribe to
|
|
18
|
+
* `locationService.onDeserializedLocationSearchChanged` and call this read
|
|
19
|
+
* on each change.
|
|
20
|
+
*/
|
|
21
|
+
getTypedQuery: <TPath extends ExtractRoutePaths<TRoutes>>(
|
|
22
|
+
injector: Injector,
|
|
23
|
+
path: TPath,
|
|
24
|
+
) => ExtractRouteQuery<RouteAt<TRoutes, TPath>> | null
|
|
25
|
+
/**
|
|
26
|
+
* Reads the current URL hash and narrows it against the declared literal
|
|
27
|
+
* tuple of the route at `path`. Returns the hash when it matches one of
|
|
28
|
+
* the route's declared literals, or `undefined` otherwise.
|
|
29
|
+
*
|
|
30
|
+
* This is a synchronous snapshot — for reactive use, subscribe to
|
|
31
|
+
* `locationService.onLocationHashChanged` and call this read on each change.
|
|
32
|
+
*/
|
|
33
|
+
getTypedHash: <TPath extends ExtractRoutePaths<TRoutes>>(
|
|
34
|
+
injector: Injector,
|
|
35
|
+
path: TPath,
|
|
36
|
+
) => ExtractRouteHash<RouteAt<TRoutes, TPath>> | undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Walks a route tree and returns the route value declared at the given path,
|
|
41
|
+
* matching each URL pattern segment-by-segment. The root `/` pattern is
|
|
42
|
+
* transparent: its children are searched with the full path.
|
|
43
|
+
*
|
|
44
|
+
* Exported for unit testing only — consumers should go through
|
|
45
|
+
* {@link createNestedHooks}.
|
|
46
|
+
*
|
|
47
|
+
* @internal
|
|
48
|
+
*/
|
|
49
|
+
export const walkRoute = (
|
|
50
|
+
routes: Record<string, NestedRoute<any, any, any>>,
|
|
51
|
+
path: string,
|
|
52
|
+
): NestedRoute<any, any, any> | undefined => {
|
|
53
|
+
for (const [pattern, route] of Object.entries(routes)) {
|
|
54
|
+
if (pattern === path) return route
|
|
55
|
+
if (pattern === '/' && route.children) {
|
|
56
|
+
const nested = walkRoute(route.children, path)
|
|
57
|
+
if (nested) return nested
|
|
58
|
+
} else if (route.children && path.startsWith(pattern)) {
|
|
59
|
+
const rest = path.slice(pattern.length)
|
|
60
|
+
if (rest.startsWith('/') || rest === '') {
|
|
61
|
+
const nested = walkRoute(route.children, rest || '/')
|
|
62
|
+
if (nested) return nested
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Creates a pair of type-safe synchronous read helpers (`getTypedQuery`,
|
|
71
|
+
* `getTypedHash`) bound to a specific route tree. The returned functions
|
|
72
|
+
* validate the current URL's query and hash against the route declared at
|
|
73
|
+
* the given path, returning typed values or `null`/`undefined` on mismatch.
|
|
74
|
+
*
|
|
75
|
+
* The concrete route tree is captured at factory time, so call sites only
|
|
76
|
+
* pass `(injector, path)`; the type-level narrowing is derived from the
|
|
77
|
+
* inferred tree generic.
|
|
78
|
+
*
|
|
79
|
+
* @typeParam TRoutes - The route tree type (inferred from the `routes` argument)
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* ```typescript
|
|
83
|
+
* const { getTypedQuery, getTypedHash } = createNestedHooks(appRoutes)
|
|
84
|
+
*
|
|
85
|
+
* const query = getTypedQuery(injector, '/users') // typed
|
|
86
|
+
* const hash = getTypedHash(injector, '/users') // typed
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
export const createNestedHooks = <TRoutes extends Record<string, NestedRoute<any, any, any>>>(
|
|
90
|
+
routes: TRoutes,
|
|
91
|
+
): NestedHooks<TRoutes> => {
|
|
92
|
+
return {
|
|
93
|
+
getTypedQuery: (injector, path) => {
|
|
94
|
+
const route = walkRoute(routes, path)
|
|
95
|
+
if (!route?.query) return null
|
|
96
|
+
const locationService = injector.get(LocationService)
|
|
97
|
+
const deserialized = locationService.onDeserializedLocationSearchChanged.getValue()
|
|
98
|
+
return route.query(deserialized) as never
|
|
99
|
+
},
|
|
100
|
+
getTypedHash: (injector, path) => {
|
|
101
|
+
const route = walkRoute(routes, path)
|
|
102
|
+
const declaredHash = route?.hash as readonly string[] | undefined
|
|
103
|
+
if (!declaredHash) return undefined
|
|
104
|
+
const locationService = injector.get(LocationService)
|
|
105
|
+
const currentHash = locationService.onLocationHashChanged.getValue()
|
|
106
|
+
return declaredHash.includes(currentHash) ? (currentHash as never) : undefined
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -1,65 +1,141 @@
|
|
|
1
|
-
import { Injector } from '@furystack/inject'
|
|
1
|
+
import type { Injector } from '@furystack/inject'
|
|
2
|
+
import { createInjector } from '@furystack/inject'
|
|
2
3
|
import { usingAsync } from '@furystack/utils'
|
|
3
4
|
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
|
|
4
5
|
import { LocationService } from '../services/location-service.js'
|
|
5
|
-
import type {
|
|
6
|
-
import { createNestedNavigate, nestedNavigate } from './nested-navigate.js'
|
|
6
|
+
import type { TypedNestedNavigateArgs } from './nested-navigate.js'
|
|
7
|
+
import { buildNestedNavigateUrl, createNestedNavigate, nestedNavigate } from './nested-navigate.js'
|
|
7
8
|
import type { NestedRoute } from './nested-router.js'
|
|
8
9
|
|
|
9
|
-
type TestRoute = Pick<NestedRoute<unknown>, 'component'>
|
|
10
|
+
type TestRoute = Pick<NestedRoute<unknown, any, any>, 'component'>
|
|
10
11
|
|
|
11
12
|
describe('nestedNavigate', () => {
|
|
12
13
|
it('Should navigate to a simple path', async () => {
|
|
13
|
-
await usingAsync(
|
|
14
|
-
const locationService = injector.
|
|
14
|
+
await usingAsync(createInjector(), async (injector) => {
|
|
15
|
+
const locationService = injector.get(LocationService)
|
|
15
16
|
const spy = vi.spyOn(locationService, 'navigate')
|
|
16
17
|
|
|
17
|
-
nestedNavigate(injector, '/buttons')
|
|
18
|
+
nestedNavigate(injector, { path: '/buttons' })
|
|
18
19
|
|
|
19
20
|
expect(spy).toHaveBeenCalledWith('/buttons')
|
|
20
21
|
})
|
|
21
22
|
})
|
|
22
23
|
|
|
23
24
|
it('Should compile params into the path', async () => {
|
|
24
|
-
await usingAsync(
|
|
25
|
-
const locationService = injector.
|
|
25
|
+
await usingAsync(createInjector(), async (injector) => {
|
|
26
|
+
const locationService = injector.get(LocationService)
|
|
26
27
|
const spy = vi.spyOn(locationService, 'navigate')
|
|
27
28
|
|
|
28
|
-
nestedNavigate(injector, '/users/:id', { id: '42' })
|
|
29
|
+
nestedNavigate(injector, { path: '/users/:id', params: { id: '42' } })
|
|
29
30
|
|
|
30
31
|
expect(spy).toHaveBeenCalledWith('/users/42')
|
|
31
32
|
})
|
|
32
33
|
})
|
|
33
34
|
|
|
34
35
|
it('Should compile multiple params into the path', async () => {
|
|
35
|
-
await usingAsync(
|
|
36
|
-
const locationService = injector.
|
|
36
|
+
await usingAsync(createInjector(), async (injector) => {
|
|
37
|
+
const locationService = injector.get(LocationService)
|
|
37
38
|
const spy = vi.spyOn(locationService, 'navigate')
|
|
38
39
|
|
|
39
|
-
nestedNavigate(injector,
|
|
40
|
+
nestedNavigate(injector, {
|
|
41
|
+
path: '/users/:userId/posts/:postId',
|
|
42
|
+
params: { userId: '1', postId: '99' },
|
|
43
|
+
})
|
|
40
44
|
|
|
41
45
|
expect(spy).toHaveBeenCalledWith('/users/1/posts/99')
|
|
42
46
|
})
|
|
43
47
|
})
|
|
48
|
+
|
|
49
|
+
it('Should append serialized query string when provided', async () => {
|
|
50
|
+
await usingAsync(createInjector(), async (injector) => {
|
|
51
|
+
const locationService = injector.get(LocationService)
|
|
52
|
+
const spy = vi.spyOn(locationService, 'navigate')
|
|
53
|
+
|
|
54
|
+
nestedNavigate(injector, { path: '/buttons', query: { page: 2 } })
|
|
55
|
+
|
|
56
|
+
expect(spy).toHaveBeenCalledTimes(1)
|
|
57
|
+
const navigatedUrl = spy.mock.calls[0][0]
|
|
58
|
+
expect(navigatedUrl.startsWith('/buttons?')).toBe(true)
|
|
59
|
+
const { deserializeQueryString } = locationService
|
|
60
|
+
const search = navigatedUrl.slice(navigatedUrl.indexOf('?') + 1)
|
|
61
|
+
expect(deserializeQueryString(search)).toEqual({ page: 2 })
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('Should omit the query string when no keys are provided', async () => {
|
|
66
|
+
await usingAsync(createInjector(), async (injector) => {
|
|
67
|
+
const locationService = injector.get(LocationService)
|
|
68
|
+
const spy = vi.spyOn(locationService, 'navigate')
|
|
69
|
+
|
|
70
|
+
nestedNavigate(injector, { path: '/buttons', query: {} })
|
|
71
|
+
|
|
72
|
+
expect(spy).toHaveBeenCalledWith('/buttons')
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('Should append the hash segment when provided', async () => {
|
|
77
|
+
await usingAsync(createInjector(), async (injector) => {
|
|
78
|
+
const locationService = injector.get(LocationService)
|
|
79
|
+
const spy = vi.spyOn(locationService, 'navigate')
|
|
80
|
+
|
|
81
|
+
nestedNavigate(injector, { path: '/buttons', hash: 'overview' })
|
|
82
|
+
|
|
83
|
+
expect(spy).toHaveBeenCalledWith('/buttons#overview')
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('Should combine params, query and hash in the correct order', async () => {
|
|
88
|
+
await usingAsync(createInjector(), async (injector) => {
|
|
89
|
+
const locationService = injector.get(LocationService)
|
|
90
|
+
const spy = vi.spyOn(locationService, 'navigate')
|
|
91
|
+
|
|
92
|
+
nestedNavigate(injector, {
|
|
93
|
+
path: '/users/:id',
|
|
94
|
+
params: { id: '7' },
|
|
95
|
+
query: { tab: 'profile' },
|
|
96
|
+
hash: 'notes',
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
expect(spy).toHaveBeenCalledTimes(1)
|
|
100
|
+
const navigatedUrl = spy.mock.calls[0][0]
|
|
101
|
+
expect(navigatedUrl.startsWith('/users/7?')).toBe(true)
|
|
102
|
+
expect(navigatedUrl.endsWith('#notes')).toBe(true)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('buildNestedNavigateUrl', () => {
|
|
108
|
+
it('Should build a bare path URL', () => {
|
|
109
|
+
expect(buildNestedNavigateUrl({ path: '/buttons' })).toBe('/buttons')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('Should build a URL with compiled params', () => {
|
|
113
|
+
expect(buildNestedNavigateUrl({ path: '/users/:id', params: { id: '42' } })).toBe('/users/42')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('Should append a hash segment', () => {
|
|
117
|
+
expect(buildNestedNavigateUrl({ path: '/buttons', hash: 'top' })).toBe('/buttons#top')
|
|
118
|
+
})
|
|
44
119
|
})
|
|
45
120
|
|
|
46
121
|
describe('Type utilities', () => {
|
|
47
|
-
describe('
|
|
122
|
+
describe('TypedNestedNavigateArgs', () => {
|
|
48
123
|
it('Should make params optional for paths without parameters', () => {
|
|
49
|
-
type
|
|
50
|
-
expectTypeOf<
|
|
124
|
+
type Routes = { '/buttons': TestRoute }
|
|
125
|
+
expectTypeOf<TypedNestedNavigateArgs<Routes, '/buttons'>>().toExtend<{ path: '/buttons' }>()
|
|
126
|
+
expectTypeOf<TypedNestedNavigateArgs<Routes, '/buttons'>>().toExtend<{ params?: Record<string, string> }>()
|
|
51
127
|
})
|
|
52
128
|
|
|
53
129
|
it('Should require params for parameterized paths', () => {
|
|
54
|
-
type
|
|
55
|
-
expectTypeOf<
|
|
130
|
+
type Routes = { '/users/:id': TestRoute }
|
|
131
|
+
expectTypeOf<TypedNestedNavigateArgs<Routes, '/users/:id'>>().toExtend<{ params: { id: string } }>()
|
|
56
132
|
})
|
|
57
133
|
|
|
58
134
|
it('Should require all params for multi-param paths', () => {
|
|
59
|
-
type
|
|
60
|
-
expectTypeOf<
|
|
61
|
-
|
|
62
|
-
>()
|
|
135
|
+
type Routes = { '/users/:userId/posts/:postId': TestRoute }
|
|
136
|
+
expectTypeOf<TypedNestedNavigateArgs<Routes, '/users/:userId/posts/:postId'>>().toExtend<{
|
|
137
|
+
params: { userId: string; postId: string }
|
|
138
|
+
}>()
|
|
63
139
|
})
|
|
64
140
|
})
|
|
65
141
|
|
|
@@ -90,9 +166,9 @@ describe('Type utilities', () => {
|
|
|
90
166
|
}
|
|
91
167
|
|
|
92
168
|
const appNavigate = createNestedNavigate<Routes>()
|
|
93
|
-
expectTypeOf(appNavigate<'/users/:userId'>)
|
|
94
|
-
|
|
95
|
-
|
|
169
|
+
expectTypeOf(appNavigate<'/users/:userId'>)
|
|
170
|
+
.parameter(1)
|
|
171
|
+
.toExtend<{ params: { userId: string } }>()
|
|
96
172
|
})
|
|
97
173
|
|
|
98
174
|
it('Should make params optional for non-parameterized routes', () => {
|
|
@@ -105,9 +181,9 @@ describe('Type utilities', () => {
|
|
|
105
181
|
}
|
|
106
182
|
|
|
107
183
|
const appNavigate = createNestedNavigate<Routes>()
|
|
108
|
-
expectTypeOf(appNavigate<'/buttons'>)
|
|
109
|
-
|
|
110
|
-
|
|
184
|
+
expectTypeOf(appNavigate<'/buttons'>)
|
|
185
|
+
.parameter(1)
|
|
186
|
+
.toExtend<{ params?: Record<string, string> }>()
|
|
111
187
|
})
|
|
112
188
|
|
|
113
189
|
it('Should reject invalid paths', () => {
|
|
@@ -122,7 +198,7 @@ describe('Type utilities', () => {
|
|
|
122
198
|
|
|
123
199
|
const appNavigate = createNestedNavigate<Routes>()
|
|
124
200
|
// @ts-expect-error -- '/nonexistent' is not a valid route path
|
|
125
|
-
appNavigate(
|
|
201
|
+
appNavigate(createInjector(), { path: '/nonexistent' })
|
|
126
202
|
})
|
|
127
203
|
|
|
128
204
|
it('Should accept routes with typed match parameters (NestedRoute<T>)', () => {
|
|
@@ -135,9 +211,37 @@ describe('Type utilities', () => {
|
|
|
135
211
|
}
|
|
136
212
|
|
|
137
213
|
const appNavigate = createNestedNavigate<typeof routes>()
|
|
138
|
-
expectTypeOf(appNavigate<'/stacks/:stackName'>)
|
|
139
|
-
|
|
140
|
-
|
|
214
|
+
expectTypeOf(appNavigate<'/stacks/:stackName'>)
|
|
215
|
+
.parameter(1)
|
|
216
|
+
.toExtend<{ params: { stackName: string } }>()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('Should enforce a route-declared required query shape', () => {
|
|
220
|
+
const routes = {
|
|
221
|
+
'/list': {
|
|
222
|
+
component: () => null as unknown as JSX.Element,
|
|
223
|
+
query: (raw): { page: number } | null => (typeof raw.page === 'number' ? { page: raw.page } : null),
|
|
224
|
+
},
|
|
225
|
+
} satisfies Record<string, NestedRoute<any, any, any>>
|
|
226
|
+
|
|
227
|
+
const appNavigate = createNestedNavigate<typeof routes>()
|
|
228
|
+
expectTypeOf(appNavigate<'/list'>)
|
|
229
|
+
.parameter(1)
|
|
230
|
+
.toExtend<{ query: { page: number } }>()
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('Should narrow hash to the declared literal tuple', () => {
|
|
234
|
+
const routes = {
|
|
235
|
+
'/tabs': {
|
|
236
|
+
component: () => null as unknown as JSX.Element,
|
|
237
|
+
hash: ['overview', 'details'] as const,
|
|
238
|
+
},
|
|
239
|
+
} satisfies Record<string, NestedRoute<any, any, any>>
|
|
240
|
+
|
|
241
|
+
const appNavigate = createNestedNavigate<typeof routes>()
|
|
242
|
+
expectTypeOf(appNavigate<'/tabs'>)
|
|
243
|
+
.parameter(1)
|
|
244
|
+
.toExtend<{ hash?: 'overview' | 'details' }>()
|
|
141
245
|
})
|
|
142
246
|
})
|
|
143
247
|
})
|