@furystack/shades 11.1.0 → 12.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 (166) hide show
  1. package/CHANGELOG.md +291 -0
  2. package/README.md +13 -13
  3. package/esm/component-factory.spec.js +13 -5
  4. package/esm/component-factory.spec.js.map +1 -1
  5. package/esm/components/index.d.ts +4 -1
  6. package/esm/components/index.d.ts.map +1 -1
  7. package/esm/components/index.js +4 -1
  8. package/esm/components/index.js.map +1 -1
  9. package/esm/components/lazy-load.d.ts +2 -4
  10. package/esm/components/lazy-load.d.ts.map +1 -1
  11. package/esm/components/lazy-load.js +40 -24
  12. package/esm/components/lazy-load.js.map +1 -1
  13. package/esm/components/lazy-load.spec.js +57 -50
  14. package/esm/components/lazy-load.spec.js.map +1 -1
  15. package/esm/components/link-to-route.d.ts +2 -0
  16. package/esm/components/link-to-route.d.ts.map +1 -1
  17. package/esm/components/link-to-route.js +3 -2
  18. package/esm/components/link-to-route.js.map +1 -1
  19. package/esm/components/link-to-route.spec.js +13 -9
  20. package/esm/components/link-to-route.spec.js.map +1 -1
  21. package/esm/components/nested-route-link.d.ts +62 -0
  22. package/esm/components/nested-route-link.d.ts.map +1 -0
  23. package/esm/components/nested-route-link.js +66 -0
  24. package/esm/components/nested-route-link.js.map +1 -0
  25. package/esm/components/nested-route-link.spec.d.ts +2 -0
  26. package/esm/components/nested-route-link.spec.d.ts.map +1 -0
  27. package/esm/components/nested-route-link.spec.js +179 -0
  28. package/esm/components/nested-route-link.spec.js.map +1 -0
  29. package/esm/components/nested-route-types.d.ts +37 -0
  30. package/esm/components/nested-route-types.d.ts.map +1 -0
  31. package/esm/components/nested-route-types.js +2 -0
  32. package/esm/components/nested-route-types.js.map +1 -0
  33. package/esm/components/nested-router.d.ts +103 -0
  34. package/esm/components/nested-router.d.ts.map +1 -0
  35. package/esm/components/nested-router.js +178 -0
  36. package/esm/components/nested-router.js.map +1 -0
  37. package/esm/components/nested-router.spec.d.ts +2 -0
  38. package/esm/components/nested-router.spec.d.ts.map +1 -0
  39. package/esm/components/nested-router.spec.js +659 -0
  40. package/esm/components/nested-router.spec.js.map +1 -0
  41. package/esm/components/route-link.d.ts +4 -0
  42. package/esm/components/route-link.d.ts.map +1 -1
  43. package/esm/components/route-link.js +5 -5
  44. package/esm/components/route-link.js.map +1 -1
  45. package/esm/components/route-link.spec.js +16 -12
  46. package/esm/components/route-link.spec.js.map +1 -1
  47. package/esm/components/router.d.ts +20 -2
  48. package/esm/components/router.d.ts.map +1 -1
  49. package/esm/components/router.js +3 -0
  50. package/esm/components/router.js.map +1 -1
  51. package/esm/components/router.spec.js +75 -74
  52. package/esm/components/router.spec.js.map +1 -1
  53. package/esm/initialize.d.ts +11 -0
  54. package/esm/initialize.d.ts.map +1 -1
  55. package/esm/initialize.js +5 -0
  56. package/esm/initialize.js.map +1 -1
  57. package/esm/jsx.d.ts +83 -2
  58. package/esm/jsx.d.ts.map +1 -1
  59. package/esm/models/children-list.d.ts +5 -1
  60. package/esm/models/children-list.d.ts.map +1 -1
  61. package/esm/models/partial-element.d.ts +12 -2
  62. package/esm/models/partial-element.d.ts.map +1 -1
  63. package/esm/models/render-options.d.ts +89 -3
  64. package/esm/models/render-options.d.ts.map +1 -1
  65. package/esm/models/selection-state.d.ts +4 -0
  66. package/esm/models/selection-state.d.ts.map +1 -1
  67. package/esm/services/location-service.d.ts +11 -0
  68. package/esm/services/location-service.d.ts.map +1 -1
  69. package/esm/services/location-service.js +11 -0
  70. package/esm/services/location-service.js.map +1 -1
  71. package/esm/services/resource-manager.d.ts +24 -0
  72. package/esm/services/resource-manager.d.ts.map +1 -1
  73. package/esm/services/resource-manager.js +30 -0
  74. package/esm/services/resource-manager.js.map +1 -1
  75. package/esm/services/resource-manager.spec.js +93 -0
  76. package/esm/services/resource-manager.spec.js.map +1 -1
  77. package/esm/services/screen-service.d.ts +81 -4
  78. package/esm/services/screen-service.d.ts.map +1 -1
  79. package/esm/services/screen-service.js +75 -4
  80. package/esm/services/screen-service.js.map +1 -1
  81. package/esm/services/screen-service.spec.js +91 -7
  82. package/esm/services/screen-service.spec.js.map +1 -1
  83. package/esm/shade-component.d.ts +17 -4
  84. package/esm/shade-component.d.ts.map +1 -1
  85. package/esm/shade-component.js +67 -5
  86. package/esm/shade-component.js.map +1 -1
  87. package/esm/shade-host-props-ref.integration.spec.d.ts +2 -0
  88. package/esm/shade-host-props-ref.integration.spec.d.ts.map +1 -0
  89. package/esm/shade-host-props-ref.integration.spec.js +381 -0
  90. package/esm/shade-host-props-ref.integration.spec.js.map +1 -0
  91. package/esm/shade-resources.integration.spec.js +208 -39
  92. package/esm/shade-resources.integration.spec.js.map +1 -1
  93. package/esm/shade.d.ts +20 -17
  94. package/esm/shade.d.ts.map +1 -1
  95. package/esm/shade.js +172 -33
  96. package/esm/shade.js.map +1 -1
  97. package/esm/shade.spec.js +31 -30
  98. package/esm/shade.spec.js.map +1 -1
  99. package/esm/shades.integration.spec.js +135 -72
  100. package/esm/shades.integration.spec.js.map +1 -1
  101. package/esm/style-manager.d.ts +2 -2
  102. package/esm/style-manager.js +2 -2
  103. package/esm/svg-types.d.ts +389 -0
  104. package/esm/svg-types.d.ts.map +1 -0
  105. package/esm/svg-types.js +9 -0
  106. package/esm/svg-types.js.map +1 -0
  107. package/esm/svg.d.ts +15 -0
  108. package/esm/svg.d.ts.map +1 -0
  109. package/esm/svg.js +76 -0
  110. package/esm/svg.js.map +1 -0
  111. package/esm/svg.spec.d.ts +2 -0
  112. package/esm/svg.spec.d.ts.map +1 -0
  113. package/esm/svg.spec.js +80 -0
  114. package/esm/svg.spec.js.map +1 -0
  115. package/esm/vnode.d.ts +103 -0
  116. package/esm/vnode.d.ts.map +1 -0
  117. package/esm/vnode.integration.spec.d.ts +2 -0
  118. package/esm/vnode.integration.spec.d.ts.map +1 -0
  119. package/esm/vnode.integration.spec.js +494 -0
  120. package/esm/vnode.integration.spec.js.map +1 -0
  121. package/esm/vnode.js +453 -0
  122. package/esm/vnode.js.map +1 -0
  123. package/esm/vnode.spec.d.ts +2 -0
  124. package/esm/vnode.spec.d.ts.map +1 -0
  125. package/esm/vnode.spec.js +473 -0
  126. package/esm/vnode.spec.js.map +1 -0
  127. package/package.json +3 -3
  128. package/src/component-factory.spec.tsx +18 -5
  129. package/src/components/index.ts +4 -1
  130. package/src/components/lazy-load.spec.tsx +82 -75
  131. package/src/components/lazy-load.tsx +49 -27
  132. package/src/components/link-to-route.spec.tsx +25 -21
  133. package/src/components/link-to-route.tsx +4 -2
  134. package/src/components/nested-route-link.spec.tsx +303 -0
  135. package/src/components/nested-route-link.tsx +100 -0
  136. package/src/components/nested-route-types.ts +42 -0
  137. package/src/components/nested-router.spec.tsx +817 -0
  138. package/src/components/nested-router.tsx +256 -0
  139. package/src/components/route-link.spec.tsx +22 -18
  140. package/src/components/route-link.tsx +6 -5
  141. package/src/components/router.spec.tsx +109 -108
  142. package/src/components/router.tsx +15 -2
  143. package/src/initialize.ts +12 -0
  144. package/src/jsx.ts +129 -2
  145. package/src/models/children-list.ts +7 -1
  146. package/src/models/partial-element.ts +13 -2
  147. package/src/models/render-options.ts +90 -3
  148. package/src/models/selection-state.ts +4 -0
  149. package/src/services/location-service.tsx +11 -0
  150. package/src/services/resource-manager.spec.ts +116 -0
  151. package/src/services/resource-manager.ts +30 -0
  152. package/src/services/screen-service.spec.ts +109 -7
  153. package/src/services/screen-service.ts +81 -4
  154. package/src/shade-component.ts +72 -6
  155. package/src/shade-host-props-ref.integration.spec.tsx +460 -0
  156. package/src/shade-resources.integration.spec.tsx +276 -52
  157. package/src/shade.spec.tsx +40 -39
  158. package/src/shade.ts +186 -58
  159. package/src/shades.integration.spec.tsx +154 -80
  160. package/src/style-manager.ts +2 -2
  161. package/src/svg-types.ts +437 -0
  162. package/src/svg.spec.ts +89 -0
  163. package/src/svg.ts +78 -0
  164. package/src/vnode.integration.spec.tsx +657 -0
  165. package/src/vnode.spec.ts +579 -0
  166. package/src/vnode.ts +508 -0
@@ -1,10 +1,11 @@
1
1
  import { Injector } from '@furystack/inject'
2
- import { sleepAsync } from '@furystack/utils'
2
+ import { sleepAsync, usingAsync } from '@furystack/utils'
3
3
  import { LazyLoad } from './lazy-load.js'
4
4
 
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6
6
  import { initializeShadeRoot } from '../initialize.js'
7
7
  import { createComponent } from '../shade-component.js'
8
+ import { flushUpdates } from '../shade.js'
8
9
 
9
10
  describe('Lazy Load', () => {
10
11
  beforeEach(() => {
@@ -15,93 +16,99 @@ describe('Lazy Load', () => {
15
16
  })
16
17
 
17
18
  it('Shuld display the loader and completed state', async () => {
18
- const injector = new Injector()
19
- const rootElement = document.getElementById('root') as HTMLDivElement
19
+ await usingAsync(new Injector(), async (injector) => {
20
+ const rootElement = document.getElementById('root') as HTMLDivElement
20
21
 
21
- initializeShadeRoot({
22
- injector,
23
- rootElement,
24
- jsxElement: (
25
- <LazyLoad
26
- loader={<div>Loading...</div>}
27
- component={async () => {
28
- await sleepAsync(100)
29
- return <div>Loaded</div>
30
- }}
31
- />
32
- ),
22
+ initializeShadeRoot({
23
+ injector,
24
+ rootElement,
25
+ jsxElement: (
26
+ <LazyLoad
27
+ loader={<div>Loading...</div>}
28
+ component={async () => {
29
+ await sleepAsync(100)
30
+ return <div>Loaded</div>
31
+ }}
32
+ />
33
+ ),
34
+ })
35
+ await flushUpdates()
36
+ expect(document.body.innerHTML).toBe('<div id="root"><lazy-load><div>Loading...</div></lazy-load></div>')
37
+ await sleepAsync(150)
38
+ expect(document.body.innerHTML).toBe('<div id="root"><lazy-load><div>Loaded</div></lazy-load></div>')
33
39
  })
34
- expect(document.body.innerHTML).toBe('<div id="root"><lazy-load><div>Loading...</div></lazy-load></div>')
35
- await sleepAsync(150)
36
- expect(document.body.innerHTML).toBe('<div id="root"><lazy-load><div>Loaded</div></lazy-load></div>')
37
40
  })
38
41
 
39
42
  it('Shuld display the failed state with a retryer', async () => {
40
- const injector = new Injector()
41
- const rootElement = document.getElementById('root') as HTMLDivElement
43
+ await usingAsync(new Injector(), async (injector) => {
44
+ const rootElement = document.getElementById('root') as HTMLDivElement
42
45
 
43
- const load = vi.fn(async () => {
44
- throw Error(':(')
45
- })
46
+ const load = vi.fn(async () => {
47
+ throw Error(':(')
48
+ })
46
49
 
47
- initializeShadeRoot({
48
- injector,
49
- rootElement,
50
- jsxElement: (
51
- <LazyLoad
52
- loader={<div>Loading...</div>}
53
- component={load}
54
- error={(e, retry) => (
55
- <button id="retry" onclick={retry}>
56
- {(e as Error).message}
57
- </button>
58
- )}
59
- />
60
- ),
50
+ initializeShadeRoot({
51
+ injector,
52
+ rootElement,
53
+ jsxElement: (
54
+ <LazyLoad
55
+ loader={<div>Loading...</div>}
56
+ component={load}
57
+ error={(e, retry) => (
58
+ <button id="retry" onclick={retry}>
59
+ {(e as Error).message}
60
+ </button>
61
+ )}
62
+ />
63
+ ),
64
+ })
65
+ await flushUpdates()
66
+ expect(document.body.innerHTML).toBe('<div id="root"><lazy-load><div>Loading...</div></lazy-load></div>')
67
+ await sleepAsync(1)
68
+ expect(load).toBeCalledTimes(1)
69
+ expect(document.body.innerHTML).toBe('<div id="root"><lazy-load><button id="retry">:(</button></lazy-load></div>')
70
+ document.getElementById('retry')?.click()
71
+ expect(load).toBeCalledTimes(2)
61
72
  })
62
- expect(document.body.innerHTML).toBe('<div id="root"><lazy-load><div>Loading...</div></lazy-load></div>')
63
- await sleepAsync(1)
64
- expect(load).toBeCalledTimes(1)
65
- expect(document.body.innerHTML).toBe('<div id="root"><lazy-load><button id="retry">:(</button></lazy-load></div>')
66
- document.getElementById('retry')?.click()
67
- expect(load).toBeCalledTimes(2)
68
73
  })
69
74
 
70
75
  it('Shuld display the failed state with a retryer', async () => {
71
- const injector = new Injector()
72
- const rootElement = document.getElementById('root') as HTMLDivElement
73
- let counter = 0
76
+ await usingAsync(new Injector(), async (injector) => {
77
+ const rootElement = document.getElementById('root') as HTMLDivElement
78
+ let counter = 0
74
79
 
75
- const load = vi.fn(async () => {
76
- if (!counter) {
77
- counter += 1
78
- throw Error(':(')
79
- }
80
- return <div>success</div>
81
- })
80
+ const load = vi.fn(async () => {
81
+ if (!counter) {
82
+ counter += 1
83
+ throw Error(':(')
84
+ }
85
+ return <div>success</div>
86
+ })
82
87
 
83
- initializeShadeRoot({
84
- injector,
85
- rootElement,
86
- jsxElement: (
87
- <LazyLoad
88
- loader={<div>Loading...</div>}
89
- component={load}
90
- error={(e, retry) => (
91
- <button id="retry" onclick={retry}>
92
- {(e as Error).message}
93
- </button>
94
- )}
95
- />
96
- ),
88
+ initializeShadeRoot({
89
+ injector,
90
+ rootElement,
91
+ jsxElement: (
92
+ <LazyLoad
93
+ loader={<div>Loading...</div>}
94
+ component={load}
95
+ error={(e, retry) => (
96
+ <button id="retry" onclick={retry}>
97
+ {(e as Error).message}
98
+ </button>
99
+ )}
100
+ />
101
+ ),
102
+ })
103
+ await flushUpdates()
104
+ expect(document.body.innerHTML).toBe('<div id="root"><lazy-load><div>Loading...</div></lazy-load></div>')
105
+ await sleepAsync(1)
106
+ expect(load).toBeCalledTimes(1)
107
+ expect(document.body.innerHTML).toBe('<div id="root"><lazy-load><button id="retry">:(</button></lazy-load></div>')
108
+ document.getElementById('retry')?.click()
109
+ expect(load).toBeCalledTimes(2)
110
+ await sleepAsync(1)
111
+ expect(document.body.innerHTML).toBe('<div id="root"><lazy-load><div>success</div></lazy-load></div>')
97
112
  })
98
- expect(document.body.innerHTML).toBe('<div id="root"><lazy-load><div>Loading...</div></lazy-load></div>')
99
- await sleepAsync(1)
100
- expect(load).toBeCalledTimes(1)
101
- expect(document.body.innerHTML).toBe('<div id="root"><lazy-load><button id="retry">:(</button></lazy-load></div>')
102
- document.getElementById('retry')?.click()
103
- expect(load).toBeCalledTimes(2)
104
- await sleepAsync(1)
105
- expect(document.body.innerHTML).toBe('<div id="root"><lazy-load><div>success</div></lazy-load></div>')
106
113
  })
107
114
  })
@@ -6,44 +6,66 @@ export interface LazyLoadProps {
6
6
  component: () => Promise<JSX.Element>
7
7
  }
8
8
 
9
- export interface LazyLoadState {
10
- component?: JSX.Element
11
- error?: unknown
12
- }
13
-
14
9
  export const LazyLoad = Shade<LazyLoadProps>({
15
10
  shadowDomName: 'lazy-load',
16
- constructed: async ({ props, useState, element }) => {
17
- const [_component, setComponent] = useState<JSX.Element | undefined>('component', undefined)
18
- const [_errorState, setErrorState] = useState<unknown>('error', undefined)
19
- try {
20
- const loaded = await props.component()
21
- if (element.isConnected) {
22
- setComponent(loaded)
23
- }
24
- } catch (error) {
25
- if (props.error) {
26
- if (element.isConnected) {
27
- setErrorState(error)
28
- }
29
- } else {
30
- throw error
31
- }
32
- }
33
- },
34
- render: ({ props, useState }) => {
11
+ render: ({ props, useState, useDisposable }) => {
35
12
  const [error, setError] = useState<unknown>('error', undefined)
36
13
  const [component, setComponent] = useState<JSX.Element | undefined>('component', undefined)
37
14
 
15
+ const tracker = useDisposable('loadTracker', () => {
16
+ const state: {
17
+ factory: (() => Promise<JSX.Element>) | null
18
+ active: boolean
19
+ [Symbol.dispose](): void
20
+ } = {
21
+ factory: null,
22
+ active: true,
23
+ [Symbol.dispose]() {
24
+ state.active = false
25
+ },
26
+ }
27
+ return state
28
+ })
29
+
30
+ const isNewFactory = tracker.factory !== props.component
31
+
32
+ if (isNewFactory) {
33
+ tracker.factory = props.component
34
+ const factory = props.component
35
+
36
+ factory()
37
+ .then((loaded) => {
38
+ if (tracker.active && tracker.factory === factory) {
39
+ setError(undefined)
40
+ setComponent(loaded)
41
+ }
42
+ })
43
+ .catch((err: unknown) => {
44
+ if (tracker.active && tracker.factory === factory) {
45
+ setComponent(undefined)
46
+ if (props.error) {
47
+ setError(err)
48
+ }
49
+ }
50
+ })
51
+
52
+ return props.loader
53
+ }
54
+
38
55
  if (error && props.error) {
39
56
  return props.error(error, async () => {
57
+ const factory = props.component
40
58
  try {
41
59
  setError(undefined)
42
60
  setComponent(undefined)
43
- const loaded = await props.component()
44
- setComponent(loaded)
61
+ const loaded = await factory()
62
+ if (tracker.active && tracker.factory === factory) {
63
+ setComponent(loaded)
64
+ }
45
65
  } catch (e) {
46
- setError(e)
66
+ if (tracker.active && tracker.factory === factory) {
67
+ setError(e)
68
+ }
47
69
  }
48
70
  })
49
71
  }
@@ -1,7 +1,9 @@
1
1
  import { Injector } from '@furystack/inject'
2
+ import { usingAsync } from '@furystack/utils'
2
3
  import { afterEach, beforeEach, describe, expect, it } from 'vitest'
3
4
  import { initializeShadeRoot } from '../initialize.js'
4
5
  import { createComponent } from '../shade-component.js'
6
+ import { flushUpdates } from '../shade.js'
5
7
  import { LinkToRoute } from './link-to-route.js'
6
8
  import type { Route } from './router.js'
7
9
 
@@ -14,28 +16,30 @@ describe('LinkToRoute', () => {
14
16
  })
15
17
 
16
18
  it('Shuld display the loader and completed state', async () => {
17
- const injector = new Injector()
18
- const rootElement = document.getElementById('root') as HTMLDivElement
19
+ await usingAsync(new Injector(), async (injector) => {
20
+ const rootElement = document.getElementById('root') as HTMLDivElement
19
21
 
20
- initializeShadeRoot({
21
- injector,
22
- rootElement,
23
- jsxElement: (
24
- <LinkToRoute
25
- route={
26
- {
27
- url: '/subroute/:id',
28
- } as Route<{ id: number }>
29
- }
30
- params={{ id: 123 }}
31
- id="route"
32
- >
33
- Link
34
- </LinkToRoute>
35
- ),
22
+ initializeShadeRoot({
23
+ injector,
24
+ rootElement,
25
+ jsxElement: (
26
+ <LinkToRoute
27
+ route={
28
+ {
29
+ url: '/subroute/:id',
30
+ } as Route<{ id: number }>
31
+ }
32
+ params={{ id: 123 }}
33
+ id="route"
34
+ >
35
+ Link
36
+ </LinkToRoute>
37
+ ),
38
+ })
39
+ await flushUpdates()
40
+ expect(document.body.innerHTML).toBe(
41
+ '<div id="root"><a is="link-to-route" id="route" href="/subroute/123">Link</a></div>',
42
+ )
36
43
  })
37
- expect(document.body.innerHTML).toBe(
38
- '<div id="root"><a is="link-to-route" id="route" href="/subroute/123">Link</a></div>',
39
- )
40
44
  })
41
45
  })
@@ -4,21 +4,23 @@ import { createComponent } from '../shade-component.js'
4
4
  import { Shade } from '../shade.js'
5
5
  import type { Route } from './router.js'
6
6
 
7
+ /** @deprecated Use `NestedRouteLinkProps` from `nested-route-link` instead */
7
8
  export type LinkToRouteProps<T extends object> = {
8
9
  route: Route<T>
9
10
  params: T
10
11
  } & Omit<JSX.IntrinsicElements['a'], 'href'>
11
12
 
13
+ /** @deprecated Use `NestedRouteLink` from `nested-route-link` instead */
12
14
  export const LinkToRoute: <T extends object>(props: LinkToRouteProps<T>, children?: ChildrenList) => JSX.Element =
13
15
  Shade({
14
16
  shadowDomName: 'link-to-route',
15
17
  elementBase: HTMLAnchorElement,
16
18
  elementBaseName: 'a',
17
- render: ({ props, element, children }) => {
19
+ render: ({ props, useHostProps, children }) => {
18
20
  const { route, params } = props
19
21
 
20
22
  const url = compileRoute(route.url, params)
21
- element.setAttribute('href', url)
23
+ useHostProps({ href: url })
22
24
  return <>{children}</>
23
25
  },
24
26
  })
@@ -0,0 +1,303 @@
1
+ import { Injector } from '@furystack/inject'
2
+ import { usingAsync } from '@furystack/utils'
3
+ import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
4
+ import { initializeShadeRoot } from '../initialize.js'
5
+ import { LocationService } from '../services/location-service.js'
6
+ import { createComponent } from '../shade-component.js'
7
+ import { flushUpdates } from '../shade.js'
8
+ import type { TypedNestedRouteLinkProps } from './nested-route-link.js'
9
+ import { NestedRouteLink, createNestedRouteLink } from './nested-route-link.js'
10
+ import type { ConcatPaths, ExtractRouteParams, ExtractRoutePaths, UrlTree } from './nested-route-types.js'
11
+ import type { NestedRoute } from './nested-router.js'
12
+
13
+ // Minimal route type for type-level tests. Using Pick avoids the
14
+ // `children?: Record<string, NestedRoute<any>>` from NestedRoute<unknown>
15
+ // which would widen literal keys in intersections.
16
+ type TestRoute = Pick<NestedRoute<unknown>, 'component'>
17
+
18
+ describe('NestedRouteLink', () => {
19
+ beforeEach(() => {
20
+ document.body.innerHTML = '<div id="root"></div>'
21
+ })
22
+ afterEach(() => {
23
+ document.body.innerHTML = ''
24
+ })
25
+
26
+ it('Should render a link with the correct href', async () => {
27
+ await usingAsync(new Injector(), async (injector) => {
28
+ const rootElement = document.getElementById('root') as HTMLDivElement
29
+
30
+ initializeShadeRoot({
31
+ injector,
32
+ rootElement,
33
+ jsxElement: (
34
+ <NestedRouteLink id="link" href="/buttons">
35
+ Buttons
36
+ </NestedRouteLink>
37
+ ),
38
+ })
39
+ await flushUpdates()
40
+ expect(document.body.innerHTML).toBe(
41
+ '<div id="root"><a is="nested-route-link" id="link" href="/buttons">Buttons</a></div>',
42
+ )
43
+ })
44
+ })
45
+
46
+ it('Should trigger SPA navigation on click', async () => {
47
+ await usingAsync(new Injector(), async (injector) => {
48
+ const rootElement = document.getElementById('root') as HTMLDivElement
49
+ const onRouteChange = vi.fn()
50
+
51
+ injector.getInstance(LocationService).onLocationPathChanged.subscribe(onRouteChange)
52
+
53
+ initializeShadeRoot({
54
+ injector,
55
+ rootElement,
56
+ jsxElement: (
57
+ <NestedRouteLink id="link" href="/buttons">
58
+ Buttons
59
+ </NestedRouteLink>
60
+ ),
61
+ })
62
+ await flushUpdates()
63
+
64
+ expect(onRouteChange).not.toBeCalled()
65
+ document.getElementById('link')?.click()
66
+ expect(onRouteChange).toBeCalledTimes(1)
67
+ })
68
+ })
69
+
70
+ it('Should compile route params in the href', async () => {
71
+ await usingAsync(new Injector(), async (injector) => {
72
+ const rootElement = document.getElementById('root') as HTMLDivElement
73
+
74
+ initializeShadeRoot({
75
+ injector,
76
+ rootElement,
77
+ jsxElement: (
78
+ <NestedRouteLink id="link" href="/users/:id" params={{ id: '42' }}>
79
+ User 42
80
+ </NestedRouteLink>
81
+ ),
82
+ })
83
+ await flushUpdates()
84
+ expect(document.body.innerHTML).toBe(
85
+ '<div id="root"><a is="nested-route-link" id="link" href="/users/42">User 42</a></div>',
86
+ )
87
+ })
88
+ })
89
+
90
+ it('Should compile route params with multiple segments', async () => {
91
+ await usingAsync(new Injector(), async (injector) => {
92
+ const rootElement = document.getElementById('root') as HTMLDivElement
93
+
94
+ initializeShadeRoot({
95
+ injector,
96
+ rootElement,
97
+ jsxElement: (
98
+ <NestedRouteLink id="link" href="/users/:userId/posts/:postId" params={{ userId: '1', postId: '99' }}>
99
+ Post
100
+ </NestedRouteLink>
101
+ ),
102
+ })
103
+ await flushUpdates()
104
+ expect(document.body.innerHTML).toBe(
105
+ '<div id="root"><a is="nested-route-link" id="link" href="/users/1/posts/99">Post</a></div>',
106
+ )
107
+ })
108
+ })
109
+ })
110
+
111
+ describe('Type utilities', () => {
112
+ describe('ConcatPaths', () => {
113
+ it('Should strip root "/" when concatenating', () => {
114
+ expectTypeOf<ConcatPaths<'/', '/buttons'>>().toEqualTypeOf<'/buttons'>()
115
+ })
116
+
117
+ it('Should concatenate non-root parent paths', () => {
118
+ expectTypeOf<ConcatPaths<'/layout-tests', '/appbar-only'>>().toEqualTypeOf<'/layout-tests/appbar-only'>()
119
+ })
120
+
121
+ it('Should handle deeply nested paths', () => {
122
+ expectTypeOf<ConcatPaths<'/a/b', '/c'>>().toEqualTypeOf<'/a/b/c'>()
123
+ })
124
+ })
125
+
126
+ describe('ExtractRouteParams', () => {
127
+ it('Should return Record<string, never> for paths without params', () => {
128
+ expectTypeOf<ExtractRouteParams<'/buttons'>>().toEqualTypeOf<Record<string, never>>()
129
+ })
130
+
131
+ it('Should extract a single param', () => {
132
+ expectTypeOf<ExtractRouteParams<'/users/:id'>>().toEqualTypeOf<{ id: string }>()
133
+ })
134
+
135
+ it('Should extract multiple params', () => {
136
+ expectTypeOf<ExtractRouteParams<'/users/:userId/posts/:postId'>>().toEqualTypeOf<{
137
+ userId: string
138
+ postId: string
139
+ }>()
140
+ })
141
+
142
+ it('Should handle params at the beginning of the path', () => {
143
+ expectTypeOf<ExtractRouteParams<'/:id'>>().toEqualTypeOf<{ id: string }>()
144
+ })
145
+ })
146
+
147
+ describe('ExtractRoutePaths', () => {
148
+ it('Should extract top-level paths', () => {
149
+ type Routes = {
150
+ '/a': TestRoute
151
+ '/b': TestRoute
152
+ }
153
+ expectTypeOf<ExtractRoutePaths<Routes>>().toEqualTypeOf<'/a' | '/b'>()
154
+ })
155
+
156
+ it('Should extract nested child paths with root parent', () => {
157
+ type Routes = {
158
+ '/': TestRoute & {
159
+ children: {
160
+ '/buttons': TestRoute
161
+ '/inputs': TestRoute
162
+ }
163
+ }
164
+ }
165
+ expectTypeOf<ExtractRoutePaths<Routes>>().toEqualTypeOf<'/' | '/buttons' | '/inputs'>()
166
+ })
167
+
168
+ it('Should extract nested child paths with non-root parent', () => {
169
+ type Routes = {
170
+ '/layout-tests': TestRoute & {
171
+ children: {
172
+ '/appbar-only': TestRoute
173
+ '/auto-hide': TestRoute
174
+ }
175
+ }
176
+ }
177
+ expectTypeOf<ExtractRoutePaths<Routes>>().toEqualTypeOf<
178
+ '/layout-tests' | '/layout-tests/appbar-only' | '/layout-tests/auto-hide'
179
+ >()
180
+ })
181
+
182
+ it('Should handle mixed flat and nested routes', () => {
183
+ type Routes = {
184
+ '/standalone': TestRoute
185
+ '/parent': TestRoute & {
186
+ children: {
187
+ '/child': TestRoute
188
+ }
189
+ }
190
+ }
191
+ expectTypeOf<ExtractRoutePaths<Routes>>().toEqualTypeOf<'/standalone' | '/parent' | '/parent/child'>()
192
+ })
193
+ })
194
+
195
+ describe('UrlTree', () => {
196
+ it('Should accept a flat object of valid paths', () => {
197
+ type Paths = '/a' | '/b'
198
+ const urls = {
199
+ a: '/a',
200
+ b: '/b',
201
+ } satisfies UrlTree<Paths>
202
+ expectTypeOf(urls).toExtend<UrlTree<Paths>>()
203
+ })
204
+
205
+ it('Should accept nested objects of valid paths', () => {
206
+ type Paths = '/' | '/buttons' | '/layout-tests' | '/layout-tests/appbar-only'
207
+ const urls = {
208
+ home: '/',
209
+ buttons: '/buttons',
210
+ layoutTests: {
211
+ index: '/layout-tests',
212
+ appBarOnly: '/layout-tests/appbar-only',
213
+ },
214
+ } satisfies UrlTree<Paths>
215
+ expectTypeOf(urls).toExtend<UrlTree<Paths>>()
216
+ })
217
+ })
218
+
219
+ describe('TypedNestedRouteLinkProps', () => {
220
+ it('Should make params optional for paths without parameters', () => {
221
+ type Props = TypedNestedRouteLinkProps<'/buttons'>
222
+ expectTypeOf<Props['href']>().toEqualTypeOf<'/buttons'>()
223
+ expectTypeOf<Props>().toExtend<{ params?: Record<string, string> }>()
224
+ })
225
+
226
+ it('Should require params for parameterized paths', () => {
227
+ type Props = TypedNestedRouteLinkProps<'/users/:id'>
228
+ expectTypeOf<Props['href']>().toEqualTypeOf<'/users/:id'>()
229
+ expectTypeOf<Props>().toExtend<{ params: { id: string } }>()
230
+ })
231
+
232
+ it('Should require all params for multi-param paths', () => {
233
+ type Props = TypedNestedRouteLinkProps<'/users/:userId/posts/:postId'>
234
+ expectTypeOf<Props>().toExtend<{ params: { userId: string; postId: string } }>()
235
+ })
236
+ })
237
+
238
+ describe('NestedRouteLink param inference', () => {
239
+ it('Should infer params as optional when href has no parameters', () => {
240
+ expectTypeOf(NestedRouteLink).parameter(0).toHaveProperty('params')
241
+ expectTypeOf(NestedRouteLink<'/buttons'>)
242
+ .parameter(0)
243
+ .toExtend<{ params?: Record<string, string> }>()
244
+ })
245
+
246
+ it('Should infer params as required when href has a parameter', () => {
247
+ expectTypeOf(NestedRouteLink<'/users/:id'>)
248
+ .parameter(0)
249
+ .toExtend<{ params: { id: string } }>()
250
+ })
251
+
252
+ it('Should infer multiple params from href', () => {
253
+ expectTypeOf(NestedRouteLink<'/users/:userId/posts/:postId'>)
254
+ .parameter(0)
255
+ .toExtend<{ params: { userId: string; postId: string } }>()
256
+ })
257
+ })
258
+
259
+ describe('createNestedRouteLink', () => {
260
+ it('Should constrain href to valid route paths', () => {
261
+ type Routes = {
262
+ '/': TestRoute & {
263
+ children: {
264
+ '/buttons': TestRoute
265
+ }
266
+ }
267
+ }
268
+
269
+ const AppLink = createNestedRouteLink<Routes>()
270
+ expectTypeOf(AppLink).parameter(0).toHaveProperty('href')
271
+ })
272
+
273
+ it('Should require params for parameterized routes in the tree', () => {
274
+ type Routes = {
275
+ '/': TestRoute & {
276
+ children: {
277
+ '/users/:userId': TestRoute
278
+ }
279
+ }
280
+ }
281
+
282
+ const AppLink = createNestedRouteLink<Routes>()
283
+ expectTypeOf(AppLink<'/users/:userId'>)
284
+ .parameter(0)
285
+ .toExtend<{ params: { userId: string } }>()
286
+ })
287
+
288
+ it('Should require combined params from parent and child route segments', () => {
289
+ type Routes = {
290
+ '/users/:userId': TestRoute & {
291
+ children: {
292
+ '/posts/:postId': TestRoute
293
+ }
294
+ }
295
+ }
296
+
297
+ const AppLink = createNestedRouteLink<Routes>()
298
+ expectTypeOf(AppLink<'/users/:userId/posts/:postId'>)
299
+ .parameter(0)
300
+ .toExtend<{ params: { userId: string; postId: string } }>()
301
+ })
302
+ })
303
+ })