@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
@@ -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 { ExtractRouteParams, ExtractRoutePaths } from './nested-route-types.js'
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
- * Arguments for a type-safe nested navigate call.
9
- * When the path contains parameters (e.g. `:id`), the `params` argument becomes required.
10
- * @typeParam TPath - A specific route path string
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 TypedNavigateArgs<TPath extends string> = string extends keyof ExtractRouteParams<TPath>
13
- ? [path: TPath, params?: Record<string, string>]
14
- : [path: TPath, params: ExtractRouteParams<TPath>]
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
- * Navigates to a route path using the LocationService from the given injector.
18
- * Compiles parameterized routes (e.g. `/users/:id`) when `params` is provided.
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 path - The route path to navigate to
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, path: string, params?: Record<string, string>): void => {
25
- const resolvedUrl = params ? compileRoute(path, params) : path
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, and requires `params`
33
- * when the route has parameters.
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
- * // Type-safe: only valid paths accepted
46
- * appNavigate(injector, '/buttons')
47
- *
48
- * // TypeScript error: invalid path
49
- * appNavigate(injector, '/nonexistent')
50
- *
51
- * // Params required for parameterized routes
52
- * appNavigate(injector, '/users/:id', { id: '123' })
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
- ...args: TypedNavigateArgs<TPath>
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 { Injector } from '@furystack/inject'
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(new Injector(), async (injector) => {
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" href="/buttons">
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(new Injector(), async (injector) => {
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.getInstance(LocationService).onLocationPathChanged.subscribe(onRouteChange)
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" href="/buttons">
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(new Injector(), async (injector) => {
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" href="/users/:id" params={{ id: '42' }}>
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(new Injector(), async (injector) => {
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" href="/users/:userId/posts/:postId" params={{ userId: '1', postId: '99' }}>
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['href']>().toEqualTypeOf<'/buttons'>()
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['href']>().toEqualTypeOf<'/users/:id'>()
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 href has no parameters', () => {
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 href has a parameter', () => {
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 href', () => {
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 href to valid route paths', () => {
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('href')
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({ href: '/nonexistent' })
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('href')
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 type { ExtractRouteParams, ExtractRoutePaths } from './nested-route-types.js'
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 from RouteLink with parameter compilation from LinkToRoute.
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
- href: string
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
- href: TPath
26
- } & (string extends keyof ExtractRouteParams<TPath>
27
- ? { params?: Record<string, string> }
28
- : { params: ExtractRouteParams<TPath> }) &
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 { href, params } = props
41
- const resolvedUrl = params ? compileRoute(href, params) : href
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.getInstance(LocationService).updateState()
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
- * and compiles parameterized routes (e.g. `/users/:id`) when `params` is provided.
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 `href` pattern:
64
- * - `href="/buttons"` — `params` is optional
65
- * - `href="/users/:id"` — `params: { id: string }` is required
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, use {@link createNestedRouteLink}.
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 route tree.
76
- * The returned component has the same runtime behavior but narrows `href` to only accept
77
- * valid route paths, and requires `params` when the route has parameters.
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
- * // Type-safe: only valid paths accepted
87
- * <AppLink href="/buttons">Buttons</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: TypedNestedRouteLinkProps<TPath>,
123
+ props: TreeAwareNestedRouteLinkProps<TRoutes, TPath>,
99
124
  children?: ChildrenList,
100
125
  ) => JSX.Element
101
126
  }