@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
@@ -0,0 +1,256 @@
1
+ import { ObservableAlreadyDisposedError } from '@furystack/utils'
2
+ import type { MatchOptions, MatchResult } from 'path-to-regexp'
3
+ import { match } from 'path-to-regexp'
4
+ import { Lock } from 'semaphore-async-await'
5
+ import type { RenderOptions } from '../models/render-options.js'
6
+ import { LocationService } from '../services/location-service.js'
7
+ import { createComponent, setRenderMode } from '../shade-component.js'
8
+ import { Shade } from '../shade.js'
9
+
10
+ /**
11
+ * A single route entry in a NestedRouter configuration.
12
+ * Unlike flat `Route`, the URL is the Record key (not a field), and the
13
+ * `component` receives an `outlet` for rendering matched child content.
14
+ * @typeParam TMatchResult - The type of matched URL parameters
15
+ */
16
+ export type NestedRoute<TMatchResult = unknown> = {
17
+ component: (options: {
18
+ currentUrl: string
19
+ match: MatchResult<TMatchResult extends object ? TMatchResult : object>
20
+ outlet?: JSX.Element
21
+ }) => JSX.Element
22
+ routingOptions?: MatchOptions
23
+ onVisit?: (options: RenderOptions<unknown> & { element: JSX.Element }) => Promise<void>
24
+ onLeave?: (options: RenderOptions<unknown> & { element: JSX.Element }) => Promise<void>
25
+ children?: Record<string, NestedRoute<any>>
26
+ }
27
+
28
+ /**
29
+ * Props for the NestedRouter component.
30
+ * Routes are defined as a Record where keys are URL patterns.
31
+ */
32
+ export type NestedRouterProps = {
33
+ routes: Record<string, NestedRoute<any>>
34
+ notFound?: JSX.Element
35
+ }
36
+
37
+ /**
38
+ * A single entry in a match chain, pairing a matched route with its match result.
39
+ */
40
+ export type MatchChainEntry = {
41
+ route: NestedRoute<unknown>
42
+ match: MatchResult<object>
43
+ }
44
+
45
+ /**
46
+ * Internal state for the NestedRouter component.
47
+ * `matchChain` is `null` when a notFound fallback has been rendered,
48
+ * distinguishing it from the initial empty array (not yet processed).
49
+ */
50
+ export type NestedRouterState = {
51
+ matchChain: MatchChainEntry[] | null
52
+ jsx: JSX.Element
53
+ chainElements: JSX.Element[]
54
+ }
55
+
56
+ /**
57
+ * Recursively builds a match chain from outermost to innermost matched route.
58
+ *
59
+ * For routes with children, a prefix match (`end: false`) is attempted first.
60
+ * If a child matches the remaining URL, the parent and child chain are combined.
61
+ * If no child matches, an exact match on the parent alone is attempted.
62
+ *
63
+ * For leaf routes (no children), only exact matching is used.
64
+ *
65
+ * @param routes - The route definitions to match against
66
+ * @param currentUrl - The URL path to match
67
+ * @returns An array of matched chain entries from outermost to innermost, or null if no match
68
+ */
69
+ export const buildMatchChain = (
70
+ routes: Record<string, NestedRoute<any>>,
71
+ currentUrl: string,
72
+ ): MatchChainEntry[] | null => {
73
+ for (const [pattern, route] of Object.entries(routes)) {
74
+ if (route.children) {
75
+ const prefixMatchFn = match(pattern, { ...route.routingOptions, end: false })
76
+ let prefixResult = prefixMatchFn(currentUrl)
77
+
78
+ // In path-to-regexp v8, match('/', { end: false }) only matches exact '/'.
79
+ // For the root pattern, any URL is logically under '/', so force a prefix match.
80
+ if (!prefixResult && pattern === '/') {
81
+ prefixResult = { path: '/', params: {} }
82
+ }
83
+
84
+ if (prefixResult) {
85
+ let remainingUrl = currentUrl.slice(prefixResult.path.length)
86
+ if (!remainingUrl.startsWith('/')) {
87
+ remainingUrl = `/${remainingUrl}`
88
+ }
89
+
90
+ const childChain = buildMatchChain(route.children, remainingUrl)
91
+ if (childChain) {
92
+ return [{ route, match: prefixResult }, ...childChain]
93
+ }
94
+ }
95
+
96
+ const exactMatchFn = match(pattern, route.routingOptions)
97
+ const exactResult = exactMatchFn(currentUrl)
98
+ if (exactResult) {
99
+ return [{ route, match: exactResult }]
100
+ }
101
+ } else {
102
+ const matchFn = match(pattern, route.routingOptions)
103
+ const matchResult = matchFn(currentUrl)
104
+ if (matchResult) {
105
+ return [{ route, match: matchResult }]
106
+ }
107
+ }
108
+ }
109
+
110
+ return null
111
+ }
112
+
113
+ /**
114
+ * Finds the first index where two match chains diverge.
115
+ * Returns the length of the shorter chain if one is a prefix of the other.
116
+ */
117
+ export const findDivergenceIndex = (oldChain: MatchChainEntry[], newChain: MatchChainEntry[]): number => {
118
+ const minLength = Math.min(oldChain.length, newChain.length)
119
+ for (let i = 0; i < minLength; i++) {
120
+ if (
121
+ oldChain[i].route !== newChain[i].route ||
122
+ JSON.stringify(oldChain[i].match.params) !== JSON.stringify(newChain[i].match.params)
123
+ ) {
124
+ return i
125
+ }
126
+ }
127
+ return minLength
128
+ }
129
+
130
+ /**
131
+ * The result of rendering a match chain, containing both the fully composed
132
+ * JSX tree and per-entry elements for scoped lifecycle animations.
133
+ */
134
+ export type RenderMatchChainResult = {
135
+ jsx: JSX.Element
136
+ chainElements: JSX.Element[]
137
+ }
138
+
139
+ /**
140
+ * Renders a match chain inside-out: starts with the innermost (leaf) route
141
+ * rendered with `outlet: undefined`, then passes its JSX as `outlet` to
142
+ * each successive parent up the chain.
143
+ *
144
+ * Returns per-entry elements so that lifecycle hooks (`onLeave`/`onVisit`)
145
+ * receive only the element for their own route level, not the full tree.
146
+ *
147
+ * @param chain - The match chain from outermost to innermost
148
+ * @param currentUrl - The current URL path
149
+ * @returns The fully composed JSX element and per-entry rendered elements
150
+ */
151
+ export const renderMatchChain = (chain: MatchChainEntry[], currentUrl: string): RenderMatchChainResult => {
152
+ let outlet: JSX.Element | undefined
153
+ const chainElements: JSX.Element[] = new Array<JSX.Element>(chain.length)
154
+
155
+ for (let i = chain.length - 1; i >= 0; i--) {
156
+ const entry = chain[i]
157
+ outlet = entry.route.component({
158
+ currentUrl,
159
+ match: entry.match,
160
+ outlet,
161
+ })
162
+ chainElements[i] = outlet
163
+ }
164
+
165
+ return { jsx: outlet as JSX.Element, chainElements }
166
+ }
167
+
168
+ /**
169
+ * A nested router component that supports hierarchical route definitions
170
+ * with parent/child relationships. Parent routes receive an `outlet` prop
171
+ * containing the rendered child route, enabling layout composition.
172
+ *
173
+ * Routes are defined as a Record where keys are URL patterns (following the
174
+ * RestApi pattern). The matching algorithm builds a chain from outermost to
175
+ * innermost route, then renders inside-out so each parent wraps its child.
176
+ */
177
+ export const NestedRouter = Shade<NestedRouterProps>({
178
+ shadowDomName: 'shade-nested-router',
179
+ render: (options) => {
180
+ const { useState, useObservable, injector } = options
181
+ const [lock] = useState('lock', new Lock())
182
+ const [state, setState] = useState<NestedRouterState>('routerState', {
183
+ matchChain: [],
184
+ jsx: <div />,
185
+ chainElements: [],
186
+ })
187
+
188
+ const updateUrl = async (currentUrl: string) => {
189
+ const [lastState] = useState<NestedRouterState>('routerState', state)
190
+ const { matchChain: lastChain, chainElements: lastChainElements } = lastState
191
+ try {
192
+ await lock.acquire()
193
+ const newChain = buildMatchChain(options.props.routes, currentUrl)
194
+
195
+ if (newChain) {
196
+ const lastChainEntries = lastChain ?? []
197
+ const divergeIndex = findDivergenceIndex(lastChainEntries, newChain)
198
+ const hasChanged =
199
+ divergeIndex < lastChainEntries.length ||
200
+ divergeIndex < newChain.length ||
201
+ lastChainEntries.length !== newChain.length
202
+
203
+ if (hasChanged) {
204
+ // Call onLeave for routes that are being left (from divergence point to end of old chain)
205
+ for (let i = lastChainEntries.length - 1; i >= divergeIndex; i--) {
206
+ await lastChainEntries[i].route.onLeave?.({ ...options, element: lastChainElements[i] })
207
+ }
208
+
209
+ let newResult: RenderMatchChainResult
210
+ setRenderMode(true)
211
+ try {
212
+ newResult = renderMatchChain(newChain, currentUrl)
213
+ } finally {
214
+ setRenderMode(false)
215
+ }
216
+ setState({ matchChain: newChain, jsx: newResult.jsx, chainElements: newResult.chainElements })
217
+
218
+ // Call onVisit for routes that are being entered (from divergence point to end of new chain)
219
+ for (let i = divergeIndex; i < newChain.length; i++) {
220
+ await newChain[i].route.onVisit?.({ ...options, element: newResult.chainElements[i] })
221
+ }
222
+ }
223
+ } else if (lastChain !== null) {
224
+ // No match found — call onLeave for all active routes and show notFound.
225
+ // The null sentinel prevents re-entering this block on re-render.
226
+ for (let i = (lastChain?.length ?? 0) - 1; i >= 0; i--) {
227
+ await lastChain[i].route.onLeave?.({ ...options, element: lastChainElements[i] })
228
+ }
229
+ setState({
230
+ matchChain: null,
231
+ jsx: options.props.notFound || <div />,
232
+ chainElements: [],
233
+ })
234
+ }
235
+ } catch (e) {
236
+ if (!(e instanceof ObservableAlreadyDisposedError)) {
237
+ throw e
238
+ }
239
+ } finally {
240
+ lock?.release()
241
+ }
242
+ }
243
+
244
+ const [locationPath] = useObservable(
245
+ 'locationPathChanged',
246
+ injector.getInstance(LocationService).onLocationPathChanged,
247
+ {
248
+ onChange: (newValue) => {
249
+ void updateUrl(newValue)
250
+ },
251
+ },
252
+ )
253
+ void updateUrl(locationPath)
254
+ return state.jsx
255
+ },
256
+ })
@@ -1,8 +1,10 @@
1
1
  import { Injector } from '@furystack/inject'
2
+ import { usingAsync } from '@furystack/utils'
2
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
4
  import { initializeShadeRoot } from '../initialize.js'
4
5
  import { LocationService } from '../services/location-service.js'
5
6
  import { createComponent } from '../shade-component.js'
7
+ import { flushUpdates } from '../shade.js'
6
8
  import { RouteLink } from './route-link.js'
7
9
 
8
10
  describe('RouteLink', () => {
@@ -14,27 +16,29 @@ describe('RouteLink', () => {
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
- const onRouteChange = vi.fn()
22
+ const onRouteChange = vi.fn()
21
23
 
22
- injector.getInstance(LocationService).onLocationPathChanged.subscribe(onRouteChange)
24
+ injector.getInstance(LocationService).onLocationPathChanged.subscribe(onRouteChange)
23
25
 
24
- initializeShadeRoot({
25
- injector,
26
- rootElement,
27
- jsxElement: (
28
- <RouteLink id="route" href="/subroute">
29
- Link
30
- </RouteLink>
31
- ),
26
+ initializeShadeRoot({
27
+ injector,
28
+ rootElement,
29
+ jsxElement: (
30
+ <RouteLink id="route" href="/subroute">
31
+ Link
32
+ </RouteLink>
33
+ ),
34
+ })
35
+ await flushUpdates()
36
+ expect(document.body.innerHTML).toMatchInlineSnapshot(
37
+ `"<div id="root"><a is="route-link" id="route" href="/subroute">Link</a></div>"`,
38
+ )
39
+ expect(onRouteChange).not.toBeCalled()
40
+ document.getElementById('route')?.click()
41
+ expect(onRouteChange).toBeCalledTimes(1)
32
42
  })
33
- expect(document.body.innerHTML).toMatchInlineSnapshot(
34
- `"<div id="root"><a is="route-link" id="route" href="/subroute">Link</a></div>"`,
35
- )
36
- expect(onRouteChange).not.toBeCalled()
37
- document.getElementById('route')?.click()
38
- expect(onRouteChange).toBeCalledTimes(1)
39
43
  })
40
44
  })
@@ -1,10 +1,12 @@
1
- import { Shade } from '../shade.js'
2
1
  import type { PartialElement } from '../models/partial-element.js'
3
2
  import { LocationService } from '../services/location-service.js'
4
- import { attachProps, createComponent } from '../shade-component.js'
3
+ import { createComponent } from '../shade-component.js'
4
+ import { Shade } from '../shade.js'
5
5
 
6
+ /** @deprecated Use `NestedRouteLinkProps` from `nested-route-link` instead */
6
7
  export type RouteLinkProps = PartialElement<Omit<HTMLAnchorElement, 'onclick'>>
7
8
 
9
+ /** @deprecated Use `NestedRouteLink` from `nested-route-link` instead */
8
10
  export const RouteLink = Shade<RouteLinkProps>({
9
11
  shadowDomName: 'route-link',
10
12
  elementBase: HTMLAnchorElement,
@@ -13,9 +15,8 @@ export const RouteLink = Shade<RouteLinkProps>({
13
15
  color: 'inherit',
14
16
  textDecoration: 'inherit',
15
17
  },
16
- render: ({ children, props, injector, element }) => {
17
- attachProps(element, {
18
- ...props,
18
+ render: ({ children, props, injector, useHostProps }) => {
19
+ useHostProps({
19
20
  onclick: (ev: MouseEvent) => {
20
21
  ev.preventDefault()
21
22
  history.pushState('', props.title || '', props.href)
@@ -1,5 +1,5 @@
1
1
  import { Injector } from '@furystack/inject'
2
- import { sleepAsync } from '@furystack/utils'
2
+ import { sleepAsync, usingAsync } from '@furystack/utils'
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
4
  import { initializeShadeRoot } from '../initialize.js'
5
5
  import { LocationService } from '../services/location-service.js'
@@ -16,113 +16,114 @@ describe('Router', () => {
16
16
  })
17
17
 
18
18
  it('Shuld display the loader and completed state', async () => {
19
- history.pushState(null, '', '/')
20
-
21
- const onVisit = vi.fn()
22
- const onLeave = vi.fn()
23
- const onLastLeave = vi.fn()
24
-
25
- const injector = new Injector()
26
- const rootElement = document.getElementById('root') as HTMLDivElement
27
-
28
- const onRouteChange = vi.fn()
29
-
30
- injector.getInstance(LocationService).onLocationPathChanged.subscribe(onRouteChange)
31
-
32
- const route1: Route = {
33
- url: '/route-a',
34
- component: () => <div id="content">route-a</div>,
35
- onVisit,
36
- onLeave,
37
- }
38
-
39
- const route2: Route<{ id: string }> = {
40
- url: '/route-b{/:id}',
41
- component: ({ match }) => <div id="content">route-b{match.params.id}</div>,
42
- }
43
-
44
- const route3: Route = {
45
- url: '/route-c',
46
- component: () => <div id="content">route-c</div>,
47
- onLeave: onLastLeave,
48
- }
49
-
50
- const route4 = { url: '/', component: () => <div id="content">home</div> }
51
-
52
- initializeShadeRoot({
53
- injector,
54
- rootElement,
55
- jsxElement: (
56
- <div>
57
- <RouteLink id="home" href="/">
58
- home
59
- </RouteLink>
60
- <RouteLink id="a" href="/route-a">
61
- a
62
- </RouteLink>
63
- <RouteLink id="b" href="/route-b">
64
- b
65
- </RouteLink>
66
- <RouteLink id="b-with-id" href="/route-b/123">
67
- b-with-id
68
- </RouteLink>
69
- <RouteLink id="c" href="/route-c">
70
- c
71
- </RouteLink>
72
- <RouteLink id="x" href="/route-x">
73
- x
74
- </RouteLink>
75
- <Router routes={[route1, route2, route3, route4]} notFound={<div id="content">not found</div>} />
76
- </div>
77
- ),
19
+ await usingAsync(new Injector(), async (injector) => {
20
+ history.pushState(null, '', '/')
21
+
22
+ const onVisit = vi.fn()
23
+ const onLeave = vi.fn()
24
+ const onLastLeave = vi.fn()
25
+
26
+ const rootElement = document.getElementById('root') as HTMLDivElement
27
+
28
+ const onRouteChange = vi.fn()
29
+
30
+ injector.getInstance(LocationService).onLocationPathChanged.subscribe(onRouteChange)
31
+
32
+ const route1: Route = {
33
+ url: '/route-a',
34
+ component: () => <div id="content">route-a</div>,
35
+ onVisit,
36
+ onLeave,
37
+ }
38
+
39
+ const route2: Route<{ id: string }> = {
40
+ url: '/route-b{/:id}',
41
+ component: ({ match }) => <div id="content">route-b{match.params.id}</div>,
42
+ }
43
+
44
+ const route3: Route = {
45
+ url: '/route-c',
46
+ component: () => <div id="content">route-c</div>,
47
+ onLeave: onLastLeave,
48
+ }
49
+
50
+ const route4 = { url: '/', component: () => <div id="content">home</div> }
51
+
52
+ initializeShadeRoot({
53
+ injector,
54
+ rootElement,
55
+ jsxElement: (
56
+ <div>
57
+ <RouteLink id="home" href="/">
58
+ home
59
+ </RouteLink>
60
+ <RouteLink id="a" href="/route-a">
61
+ a
62
+ </RouteLink>
63
+ <RouteLink id="b" href="/route-b">
64
+ b
65
+ </RouteLink>
66
+ <RouteLink id="b-with-id" href="/route-b/123">
67
+ b-with-id
68
+ </RouteLink>
69
+ <RouteLink id="c" href="/route-c">
70
+ c
71
+ </RouteLink>
72
+ <RouteLink id="x" href="/route-x">
73
+ x
74
+ </RouteLink>
75
+ <Router routes={[route1, route2, route3, route4]} notFound={<div id="content">not found</div>} />
76
+ </div>
77
+ ),
78
+ })
79
+
80
+ const getContent = () => document.getElementById('content')?.innerHTML
81
+ const getLocation = () => location.pathname
82
+
83
+ const clickOn = (name: string) => document.getElementById(name)?.click()
84
+
85
+ await sleepAsync(100)
86
+
87
+ expect(getLocation()).toBe('/')
88
+ expect(getContent()).toBe('home')
89
+
90
+ expect(onVisit).not.toBeCalled()
91
+
92
+ clickOn('a')
93
+ await sleepAsync(100)
94
+ expect(getContent()).toBe('route-a')
95
+ expect(getLocation()).toBe('/route-a')
96
+ expect(onRouteChange).toBeCalledTimes(1)
97
+ expect(onVisit).toBeCalledTimes(1)
98
+
99
+ clickOn('a')
100
+ await sleepAsync(100)
101
+ expect(onVisit).toBeCalledTimes(1)
102
+ expect(onLeave).not.toBeCalled()
103
+
104
+ clickOn('b')
105
+ await sleepAsync(100)
106
+ expect(onLeave).toBeCalledTimes(1)
107
+
108
+ expect(getContent()).toBe('route-b')
109
+ expect(getLocation()).toBe('/route-b')
110
+
111
+ clickOn('b-with-id')
112
+ await sleepAsync(100)
113
+ expect(getContent()).toBe('route-b123')
114
+ expect(getLocation()).toBe('/route-b/123')
115
+
116
+ clickOn('c')
117
+ await sleepAsync(100)
118
+ expect(getContent()).toBe('route-c')
119
+ expect(getLocation()).toBe('/route-c')
120
+
121
+ expect(onLastLeave).not.toBeCalled()
122
+ clickOn('x')
123
+ await sleepAsync(100)
124
+ expect(getContent()).toBe('not found')
125
+ expect(getLocation()).toBe('/route-x')
126
+ expect(onLastLeave).toBeCalledTimes(1)
78
127
  })
79
-
80
- const getContent = () => document.getElementById('content')?.innerHTML
81
- const getLocation = () => location.pathname
82
-
83
- const clickOn = (name: string) => document.getElementById(name)?.click()
84
-
85
- await sleepAsync(100)
86
-
87
- expect(getLocation()).toBe('/')
88
- expect(getContent()).toBe('home')
89
-
90
- expect(onVisit).not.toBeCalled()
91
-
92
- clickOn('a')
93
- await sleepAsync(100)
94
- expect(getContent()).toBe('route-a')
95
- expect(getLocation()).toBe('/route-a')
96
- expect(onRouteChange).toBeCalledTimes(1)
97
- expect(onVisit).toBeCalledTimes(1)
98
-
99
- clickOn('a')
100
- await sleepAsync(100)
101
- expect(onVisit).toBeCalledTimes(1)
102
- expect(onLeave).not.toBeCalled()
103
-
104
- clickOn('b')
105
- await sleepAsync(100)
106
- expect(onLeave).toBeCalledTimes(1)
107
-
108
- expect(getContent()).toBe('route-b')
109
- expect(getLocation()).toBe('/route-b')
110
-
111
- clickOn('b-with-id')
112
- await sleepAsync(100)
113
- expect(getContent()).toBe('route-b123')
114
- expect(getLocation()).toBe('/route-b/123')
115
-
116
- clickOn('c')
117
- await sleepAsync(100)
118
- expect(getContent()).toBe('route-c')
119
- expect(getLocation()).toBe('/route-c')
120
-
121
- expect(onLastLeave).not.toBeCalled()
122
- clickOn('x')
123
- await sleepAsync(100)
124
- expect(getContent()).toBe('not found')
125
- expect(getLocation()).toBe('/route-x')
126
- expect(onLastLeave).toBeCalledTimes(1)
127
128
  })
128
129
  })
@@ -7,6 +7,9 @@ import { LocationService } from '../services/location-service.js'
7
7
  import { createComponent } from '../shade-component.js'
8
8
  import { Shade } from '../shade.js'
9
9
 
10
+ /**
11
+ * @deprecated Use NestedRouter instead
12
+ */
10
13
  export interface Route<TMatchResult = unknown> {
11
14
  url: string
12
15
  component: (options: {
@@ -14,21 +17,31 @@ export interface Route<TMatchResult = unknown> {
14
17
  match: MatchResult<TMatchResult extends object ? TMatchResult : object>
15
18
  }) => JSX.Element
16
19
  routingOptions?: MatchOptions
17
- onVisit?: (options: RenderOptions<unknown>) => Promise<void>
18
- onLeave?: (options: RenderOptions<unknown>) => Promise<void>
20
+ onVisit?: (options: RenderOptions<unknown> & { element: JSX.Element }) => Promise<void>
21
+ onLeave?: (options: RenderOptions<unknown> & { element: JSX.Element }) => Promise<void>
19
22
  }
20
23
 
24
+ /**
25
+ * @deprecated Use NestedRouterProps instead
26
+ */
21
27
  export interface RouterProps {
22
28
  style?: CSSStyleDeclaration
23
29
  routes: Array<Route<any>>
24
30
  notFound?: JSX.Element
25
31
  }
26
32
 
33
+ /**
34
+ * @deprecated Use NestedRouterState instead
35
+ */
27
36
  export interface RouterState {
28
37
  activeRoute?: Route<unknown> | null
29
38
  activeRouteParams?: unknown
30
39
  jsx: JSX.Element
31
40
  }
41
+
42
+ /**
43
+ * @deprecated Use NestedRouter instead
44
+ */
32
45
  export const Router = Shade<RouterProps>({
33
46
  shadowDomName: 'shade-router',
34
47
  render: (options) => {
package/src/initialize.ts CHANGED
@@ -1,10 +1,22 @@
1
1
  import type { Injector } from '@furystack/inject'
2
2
 
3
+ /**
4
+ * Options for bootstrapping a Shades application.
5
+ */
3
6
  export interface InitializeOptions {
7
+ /** The DOM element that will host the application */
4
8
  rootElement: HTMLElement
9
+ /** The root JSX element to render */
5
10
  jsxElement: JSX.Element
11
+ /** The root injector instance for dependency injection */
6
12
  injector: Injector
7
13
  }
14
+
15
+ /**
16
+ * Bootstraps a Shades application by attaching the root JSX element to a DOM node
17
+ * and wiring up the dependency injection context.
18
+ * @param options The initialization options
19
+ */
8
20
  export const initializeShadeRoot = (options: InitializeOptions) => {
9
21
  options.jsxElement.injector = options.injector
10
22
  options.rootElement.appendChild(options.jsxElement)