@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.
Files changed (146) hide show
  1. package/CHANGELOG.md +157 -0
  2. package/esm/component-factory.spec.d.ts.map +1 -1
  3. package/esm/component-factory.spec.js +2 -2
  4. package/esm/component-factory.spec.js.map +1 -1
  5. package/esm/components/define-nested-routes.d.ts +27 -0
  6. package/esm/components/define-nested-routes.d.ts.map +1 -0
  7. package/esm/components/define-nested-routes.js +26 -0
  8. package/esm/components/define-nested-routes.js.map +1 -0
  9. package/esm/components/define-nested-routes.spec.d.ts +2 -0
  10. package/esm/components/define-nested-routes.spec.d.ts.map +1 -0
  11. package/esm/components/define-nested-routes.spec.js +81 -0
  12. package/esm/components/define-nested-routes.spec.js.map +1 -0
  13. package/esm/components/index.d.ts +3 -3
  14. package/esm/components/index.d.ts.map +1 -1
  15. package/esm/components/index.js +3 -3
  16. package/esm/components/index.js.map +1 -1
  17. package/esm/components/nested-hooks.d.ts +62 -0
  18. package/esm/components/nested-hooks.d.ts.map +1 -0
  19. package/esm/components/nested-hooks.js +73 -0
  20. package/esm/components/nested-hooks.js.map +1 -0
  21. package/esm/components/nested-hooks.spec.d.ts +2 -0
  22. package/esm/components/nested-hooks.spec.d.ts.map +1 -0
  23. package/esm/components/nested-hooks.spec.js +211 -0
  24. package/esm/components/nested-hooks.spec.js.map +1 -0
  25. package/esm/components/nested-navigate.d.ts +52 -21
  26. package/esm/components/nested-navigate.d.ts.map +1 -1
  27. package/esm/components/nested-navigate.js +32 -17
  28. package/esm/components/nested-navigate.js.map +1 -1
  29. package/esm/components/nested-navigate.spec.js +109 -19
  30. package/esm/components/nested-navigate.spec.js.map +1 -1
  31. package/esm/components/nested-replace.d.ts +36 -0
  32. package/esm/components/nested-replace.d.ts.map +1 -0
  33. package/esm/components/nested-replace.js +38 -0
  34. package/esm/components/nested-replace.js.map +1 -0
  35. package/esm/components/nested-replace.spec.d.ts +2 -0
  36. package/esm/components/nested-replace.spec.d.ts.map +1 -0
  37. package/esm/components/nested-replace.spec.js +59 -0
  38. package/esm/components/nested-replace.spec.js.map +1 -0
  39. package/esm/components/nested-route-link.d.ts +36 -26
  40. package/esm/components/nested-route-link.d.ts.map +1 -1
  41. package/esm/components/nested-route-link.js +18 -20
  42. package/esm/components/nested-route-link.js.map +1 -1
  43. package/esm/components/nested-route-link.spec.js +67 -17
  44. package/esm/components/nested-route-link.spec.js.map +1 -1
  45. package/esm/components/nested-route-types.d.ts +79 -2
  46. package/esm/components/nested-route-types.d.ts.map +1 -1
  47. package/esm/components/nested-router.d.ts +77 -7
  48. package/esm/components/nested-router.d.ts.map +1 -1
  49. package/esm/components/nested-router.js +139 -13
  50. package/esm/components/nested-router.js.map +1 -1
  51. package/esm/components/nested-router.spec.js +283 -52
  52. package/esm/components/nested-router.spec.js.map +1 -1
  53. package/esm/services/location-service.d.ts +39 -42
  54. package/esm/services/location-service.d.ts.map +1 -1
  55. package/esm/services/location-service.js +115 -134
  56. package/esm/services/location-service.js.map +1 -1
  57. package/esm/services/location-service.spec.js +59 -24
  58. package/esm/services/location-service.spec.js.map +1 -1
  59. package/esm/services/route-match-service.d.ts +3 -2
  60. package/esm/services/route-match-service.d.ts.map +1 -1
  61. package/esm/services/route-match-service.js +13 -22
  62. package/esm/services/route-match-service.js.map +1 -1
  63. package/esm/services/route-match-service.spec.js +42 -49
  64. package/esm/services/route-match-service.spec.js.map +1 -1
  65. package/esm/services/route-meta-utils.d.ts +28 -6
  66. package/esm/services/route-meta-utils.d.ts.map +1 -1
  67. package/esm/services/route-meta-utils.js +18 -4
  68. package/esm/services/route-meta-utils.js.map +1 -1
  69. package/esm/services/route-meta-utils.spec.js +60 -5
  70. package/esm/services/route-meta-utils.spec.js.map +1 -1
  71. package/esm/services/screen-service.d.ts +13 -72
  72. package/esm/services/screen-service.d.ts.map +1 -1
  73. package/esm/services/screen-service.js +45 -137
  74. package/esm/services/screen-service.js.map +1 -1
  75. package/esm/services/screen-service.spec.js +19 -18
  76. package/esm/services/screen-service.spec.js.map +1 -1
  77. package/esm/services/spatial-navigation-service.d.ts +17 -39
  78. package/esm/services/spatial-navigation-service.d.ts.map +1 -1
  79. package/esm/services/spatial-navigation-service.js +280 -313
  80. package/esm/services/spatial-navigation-service.js.map +1 -1
  81. package/esm/services/spatial-navigation-service.spec.js +163 -158
  82. package/esm/services/spatial-navigation-service.spec.js.map +1 -1
  83. package/esm/shade.d.ts +1 -1
  84. package/esm/shade.d.ts.map +1 -1
  85. package/esm/shade.js +10 -7
  86. package/esm/shade.js.map +1 -1
  87. package/esm/vnode.d.ts +1 -1
  88. package/esm/vnode.js +1 -1
  89. package/package.json +5 -4
  90. package/src/component-factory.spec.tsx +3 -2
  91. package/src/components/define-nested-routes.spec.ts +91 -0
  92. package/src/components/define-nested-routes.ts +27 -0
  93. package/src/components/index.ts +3 -3
  94. package/src/components/nested-hooks.spec.ts +254 -0
  95. package/src/components/nested-hooks.ts +109 -0
  96. package/src/components/nested-navigate.spec.ts +136 -32
  97. package/src/components/nested-navigate.ts +75 -26
  98. package/src/components/nested-replace.spec.ts +89 -0
  99. package/src/components/nested-replace.ts +46 -0
  100. package/src/components/nested-route-link.spec.tsx +90 -22
  101. package/src/components/nested-route-link.tsx +54 -29
  102. package/src/components/nested-route-types.ts +82 -2
  103. package/src/components/nested-router.spec.tsx +334 -51
  104. package/src/components/nested-router.tsx +184 -24
  105. package/src/services/location-service.spec.ts +68 -25
  106. package/src/services/location-service.tsx +156 -122
  107. package/src/services/route-match-service.spec.ts +41 -28
  108. package/src/services/route-match-service.ts +17 -8
  109. package/src/services/route-meta-utils.spec.ts +78 -10
  110. package/src/services/route-meta-utils.ts +44 -15
  111. package/src/services/screen-service.spec.ts +19 -18
  112. package/src/services/screen-service.ts +65 -110
  113. package/src/services/spatial-navigation-service.spec.ts +165 -160
  114. package/src/services/spatial-navigation-service.ts +299 -287
  115. package/src/shade.ts +11 -8
  116. package/src/vnode.ts +1 -1
  117. package/esm/components/link-to-route.d.ts +0 -10
  118. package/esm/components/link-to-route.d.ts.map +0 -1
  119. package/esm/components/link-to-route.js +0 -16
  120. package/esm/components/link-to-route.js.map +0 -1
  121. package/esm/components/link-to-route.spec.d.ts +0 -2
  122. package/esm/components/link-to-route.spec.d.ts.map +0 -1
  123. package/esm/components/link-to-route.spec.js +0 -30
  124. package/esm/components/link-to-route.spec.js.map +0 -1
  125. package/esm/components/route-link.d.ts +0 -10
  126. package/esm/components/route-link.d.ts.map +0 -1
  127. package/esm/components/route-link.js +0 -25
  128. package/esm/components/route-link.js.map +0 -1
  129. package/esm/components/route-link.spec.d.ts +0 -2
  130. package/esm/components/route-link.spec.d.ts.map +0 -1
  131. package/esm/components/route-link.spec.js +0 -34
  132. package/esm/components/route-link.spec.js.map +0 -1
  133. package/esm/components/router.d.ts +0 -44
  134. package/esm/components/router.d.ts.map +0 -1
  135. package/esm/components/router.js +0 -66
  136. package/esm/components/router.js.map +0 -1
  137. package/esm/components/router.spec.d.ts +0 -2
  138. package/esm/components/router.spec.d.ts.map +0 -1
  139. package/esm/components/router.spec.js +0 -161
  140. package/esm/components/router.spec.js.map +0 -1
  141. package/src/components/link-to-route.spec.tsx +0 -45
  142. package/src/components/link-to-route.tsx +0 -26
  143. package/src/components/route-link.spec.tsx +0 -44
  144. package/src/components/route-link.tsx +0 -29
  145. package/src/components/router.spec.tsx +0 -217
  146. 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 { TypedNavigateArgs } from './nested-navigate.js'
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(new Injector(), async (injector) => {
14
- const locationService = injector.getInstance(LocationService)
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(new Injector(), async (injector) => {
25
- const locationService = injector.getInstance(LocationService)
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(new Injector(), async (injector) => {
36
- const locationService = injector.getInstance(LocationService)
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, '/users/:userId/posts/:postId', { userId: '1', postId: '99' })
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('TypedNavigateArgs', () => {
122
+ describe('TypedNestedNavigateArgs', () => {
48
123
  it('Should make params optional for paths without parameters', () => {
49
- type Args = TypedNavigateArgs<'/buttons'>
50
- expectTypeOf<Args>().toEqualTypeOf<[path: '/buttons', params?: Record<string, string>]>()
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 Args = TypedNavigateArgs<'/users/:id'>
55
- expectTypeOf<Args>().toEqualTypeOf<[path: '/users/:id', params: { id: string }]>()
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 Args = TypedNavigateArgs<'/users/:userId/posts/:postId'>
60
- expectTypeOf<Args>().toEqualTypeOf<
61
- [path: '/users/:userId/posts/:postId', params: { userId: string; postId: string }]
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'>).parameters.toEqualTypeOf<
94
- [injector: Injector, path: '/users/:userId', params: { userId: string }]
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'>).parameters.toEqualTypeOf<
109
- [injector: Injector, path: '/buttons', params?: Record<string, string>]
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(new Injector(), '/nonexistent')
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'>).parameters.toEqualTypeOf<
139
- [injector: Injector, path: '/stacks/:stackName', params: { stackName: string }]
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
  })