@furystack/shades 14.0.0 → 15.0.1

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 (125) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/esm/compile-route.d.ts +5 -0
  3. package/esm/compile-route.d.ts.map +1 -1
  4. package/esm/compile-route.js +5 -0
  5. package/esm/compile-route.js.map +1 -1
  6. package/esm/component-factory.spec.d.ts.map +1 -1
  7. package/esm/component-factory.spec.js +2 -2
  8. package/esm/component-factory.spec.js.map +1 -1
  9. package/esm/components/nested-hooks.js +2 -2
  10. package/esm/components/nested-hooks.js.map +1 -1
  11. package/esm/components/nested-hooks.spec.js +14 -14
  12. package/esm/components/nested-hooks.spec.js.map +1 -1
  13. package/esm/components/nested-navigate.js +1 -1
  14. package/esm/components/nested-navigate.js.map +1 -1
  15. package/esm/components/nested-navigate.spec.js +16 -16
  16. package/esm/components/nested-navigate.spec.js.map +1 -1
  17. package/esm/components/nested-replace.js +1 -1
  18. package/esm/components/nested-replace.js.map +1 -1
  19. package/esm/components/nested-replace.spec.js +6 -6
  20. package/esm/components/nested-replace.spec.js.map +1 -1
  21. package/esm/components/nested-route-link.js +1 -1
  22. package/esm/components/nested-route-link.js.map +1 -1
  23. package/esm/components/nested-route-link.spec.js +8 -8
  24. package/esm/components/nested-route-link.spec.js.map +1 -1
  25. package/esm/components/nested-router.js +3 -3
  26. package/esm/components/nested-router.js.map +1 -1
  27. package/esm/components/nested-router.spec.js +24 -24
  28. package/esm/components/nested-router.spec.js.map +1 -1
  29. package/esm/css-generator.d.ts +9 -26
  30. package/esm/css-generator.d.ts.map +1 -1
  31. package/esm/css-generator.js +9 -26
  32. package/esm/css-generator.js.map +1 -1
  33. package/esm/css-generator.spec.js.map +1 -1
  34. package/esm/models/css-object.d.ts +3 -5
  35. package/esm/models/css-object.d.ts.map +1 -1
  36. package/esm/models/render-options.d.ts +16 -20
  37. package/esm/models/render-options.d.ts.map +1 -1
  38. package/esm/models/shade-component.d.ts +6 -4
  39. package/esm/models/shade-component.d.ts.map +1 -1
  40. package/esm/models/shade-component.js +3 -3
  41. package/esm/models/shade-component.js.map +1 -1
  42. package/esm/services/location-service.d.ts +39 -42
  43. package/esm/services/location-service.d.ts.map +1 -1
  44. package/esm/services/location-service.js +115 -134
  45. package/esm/services/location-service.js.map +1 -1
  46. package/esm/services/location-service.spec.js +32 -30
  47. package/esm/services/location-service.spec.js.map +1 -1
  48. package/esm/services/route-match-service.d.ts +3 -2
  49. package/esm/services/route-match-service.d.ts.map +1 -1
  50. package/esm/services/route-match-service.js +13 -22
  51. package/esm/services/route-match-service.js.map +1 -1
  52. package/esm/services/route-match-service.spec.js +40 -51
  53. package/esm/services/route-match-service.spec.js.map +1 -1
  54. package/esm/services/route-meta-utils.js.map +1 -1
  55. package/esm/services/route-meta-utils.spec.js +3 -3
  56. package/esm/services/route-meta-utils.spec.js.map +1 -1
  57. package/esm/services/screen-service.d.ts +13 -72
  58. package/esm/services/screen-service.d.ts.map +1 -1
  59. package/esm/services/screen-service.js +45 -137
  60. package/esm/services/screen-service.js.map +1 -1
  61. package/esm/services/screen-service.spec.js +19 -18
  62. package/esm/services/screen-service.spec.js.map +1 -1
  63. package/esm/services/spatial-navigation-service.d.ts +17 -39
  64. package/esm/services/spatial-navigation-service.d.ts.map +1 -1
  65. package/esm/services/spatial-navigation-service.js +280 -313
  66. package/esm/services/spatial-navigation-service.js.map +1 -1
  67. package/esm/services/spatial-navigation-service.spec.js +163 -158
  68. package/esm/services/spatial-navigation-service.spec.js.map +1 -1
  69. package/esm/shade-component.d.ts +14 -17
  70. package/esm/shade-component.d.ts.map +1 -1
  71. package/esm/shade-component.js +14 -17
  72. package/esm/shade-component.js.map +1 -1
  73. package/esm/shade.d.ts +18 -15
  74. package/esm/shade.d.ts.map +1 -1
  75. package/esm/shade.js +22 -32
  76. package/esm/shade.js.map +1 -1
  77. package/esm/style-manager.d.ts +8 -22
  78. package/esm/style-manager.d.ts.map +1 -1
  79. package/esm/style-manager.js +8 -22
  80. package/esm/style-manager.js.map +1 -1
  81. package/esm/styled-shade.d.ts +4 -7
  82. package/esm/styled-shade.d.ts.map +1 -1
  83. package/esm/styled-shade.js +4 -4
  84. package/esm/styled-shade.js.map +1 -1
  85. package/esm/view-transition.d.ts +10 -22
  86. package/esm/view-transition.d.ts.map +1 -1
  87. package/esm/view-transition.js +10 -22
  88. package/esm/view-transition.js.map +1 -1
  89. package/esm/vnode.js.map +1 -1
  90. package/esm/vnode.spec.js.map +1 -1
  91. package/package.json +9 -8
  92. package/src/compile-route.ts +5 -0
  93. package/src/component-factory.spec.tsx +3 -2
  94. package/src/components/nested-hooks.spec.ts +14 -14
  95. package/src/components/nested-hooks.ts +2 -2
  96. package/src/components/nested-navigate.spec.ts +17 -16
  97. package/src/components/nested-navigate.ts +1 -1
  98. package/src/components/nested-replace.spec.ts +7 -6
  99. package/src/components/nested-replace.ts +1 -1
  100. package/src/components/nested-route-link.spec.tsx +8 -8
  101. package/src/components/nested-route-link.tsx +1 -1
  102. package/src/components/nested-router.spec.tsx +24 -24
  103. package/src/components/nested-router.tsx +5 -5
  104. package/src/css-generator.spec.ts +1 -1
  105. package/src/css-generator.ts +9 -26
  106. package/src/models/css-object.ts +3 -5
  107. package/src/models/render-options.ts +17 -20
  108. package/src/models/shade-component.ts +6 -4
  109. package/src/services/location-service.spec.ts +35 -31
  110. package/src/services/location-service.tsx +156 -122
  111. package/src/services/route-match-service.spec.ts +41 -32
  112. package/src/services/route-match-service.ts +17 -8
  113. package/src/services/route-meta-utils.spec.ts +3 -3
  114. package/src/services/route-meta-utils.ts +1 -1
  115. package/src/services/screen-service.spec.ts +21 -20
  116. package/src/services/screen-service.ts +65 -110
  117. package/src/services/spatial-navigation-service.spec.ts +165 -160
  118. package/src/services/spatial-navigation-service.ts +299 -287
  119. package/src/shade-component.ts +14 -17
  120. package/src/shade.ts +31 -41
  121. package/src/style-manager.ts +9 -22
  122. package/src/styled-shade.ts +5 -8
  123. package/src/view-transition.ts +10 -22
  124. package/src/vnode.spec.ts +3 -3
  125. package/src/vnode.ts +2 -2
@@ -1,174 +1,208 @@
1
- import { Injectable, type Injector } from '@furystack/inject'
1
+ import type { Injector, Token } from '@furystack/inject'
2
+ import { defineService } from '@furystack/inject'
2
3
  import {
3
4
  deserializeQueryString as defaultDeserializeQueryString,
4
5
  serializeToQueryString as defaultSerializeToQueryString,
5
6
  } from '@furystack/rest'
6
7
  import { ObservableValue } from '@furystack/utils'
8
+
7
9
  /**
8
- * Singleton service that tracks browser location changes (pathname, search, hash)
9
- * and exposes them as observable values for reactive routing and URL-driven state.
10
+ * Overridable settings for {@link LocationService}. Bind a replacement factory
11
+ * via {@link useCustomSearchStateSerializer} to swap the URL query-string
12
+ * (de)serializers used by the service.
10
13
  */
11
- @Injectable({ lifetime: 'singleton' })
12
- export class LocationService implements Disposable {
13
- constructor(
14
- private readonly serializeToQueryString = defaultSerializeToQueryString,
15
-
16
- public readonly deserializeQueryString = defaultDeserializeQueryString,
17
- ) {
18
- window.addEventListener('popstate', this.popStateListener)
19
- window.addEventListener('hashchange', this.hashChangeListener)
20
-
21
- this.onDeserializedLocationSearchChanged = new ObservableValue(this.deserializeQueryString(location.search))
22
-
23
- this.originalPushState = window.history.pushState.bind(window.history)
24
- window.history.pushState = ((...args: Parameters<typeof window.history.pushState>) => {
25
- this.originalPushState(...args)
26
- this.updateState()
27
- }).bind(this)
28
-
29
- this.originalReplaceState = window.history.replaceState.bind(window.history)
30
- window.history.replaceState = ((...args: Parameters<typeof window.history.replaceState>) => {
31
- this.originalReplaceState(...args)
32
- this.updateState()
33
- }).bind(this)
34
- }
35
-
36
- private originalPushState: typeof window.history.pushState
37
- private originalReplaceState: typeof window.history.replaceState
14
+ export interface LocationServiceSettings {
15
+ serialize: typeof defaultSerializeToQueryString
16
+ deserialize: typeof defaultDeserializeQueryString
17
+ }
38
18
 
39
- public [Symbol.dispose]() {
40
- window.removeEventListener('popstate', this.popStateListener)
41
- window.removeEventListener('hashchange', this.hashChangeListener)
42
- this.onLocationSearchChanged[Symbol.dispose]()
43
- this.onDeserializedLocationSearchChanged[Symbol.dispose]()
44
- this.locationDeserializerObserver[Symbol.dispose]()
45
- this.onLocationPathChanged[Symbol.dispose]()
46
- this.onLocationHashChanged[Symbol.dispose]()
47
-
48
- window.history.pushState = this.originalPushState
49
- window.history.replaceState = this.originalReplaceState
50
- }
19
+ export const LocationServiceSettings: Token<LocationServiceSettings, 'singleton'> = defineService({
20
+ name: '@furystack/shades/LocationServiceSettings',
21
+ lifetime: 'singleton',
22
+ factory: () => ({
23
+ serialize: defaultSerializeToQueryString,
24
+ deserialize: defaultDeserializeQueryString,
25
+ }),
26
+ })
51
27
 
28
+ /**
29
+ * Singleton service that tracks browser location changes (pathname, search, hash)
30
+ * and exposes them as observable values for reactive routing and URL-driven state.
31
+ */
32
+ export interface LocationService {
33
+ readonly deserializeQueryString: typeof defaultDeserializeQueryString
34
+ readonly onLocationPathChanged: ObservableValue<string>
35
+ readonly onLocationHashChanged: ObservableValue<string>
36
+ readonly onLocationSearchChanged: ObservableValue<string>
37
+ readonly onDeserializedLocationSearchChanged: ObservableValue<Record<string, unknown>>
38
+ readonly searchParamObservables: Map<string, ObservableValue<any>>
52
39
  /**
53
- * Observable value that will be updated when the location pathname (e.g. /page/1) changes
40
+ * Synchronizes the observable state with the current browser location.
41
+ * Called internally after navigation events and history state changes.
54
42
  */
55
- public onLocationPathChanged = new ObservableValue(new URL(location.href).pathname)
56
-
43
+ updateState(): void
44
+ /**
45
+ * Navigate to a path. Use this instead of raw history.pushState for SPA routing.
46
+ */
47
+ navigate(path: string): void
57
48
  /**
58
- * Observable value that will be updated when the location hash (e.g. #hash) changes
49
+ * Replace the current history entry with a new path. Use this instead of raw
50
+ * history.replaceState for SPA redirects where the intermediate URL should
51
+ * not appear in the browser's back/forward stack.
59
52
  */
60
- public onLocationHashChanged = new ObservableValue(location.hash.replace('#', ''))
61
-
53
+ replace(path: string): void
62
54
  /**
63
- * Observable value that will be updated when the location search (e.g. ?search=1) changes
55
+ * Gets or creates an observable for a search parameter.
64
56
  */
65
- public onLocationSearchChanged = new ObservableValue<string>(location.search)
57
+ useSearchParam<T>(key: string, defaultValue: T): ObservableValue<T>
58
+ }
66
59
 
67
- public onDeserializedLocationSearchChanged: ObservableValue<Record<string, unknown>>
60
+ export const LocationService: Token<LocationService, 'singleton'> = defineService({
61
+ name: '@furystack/shades/LocationService',
62
+ lifetime: 'singleton',
63
+ factory: ({ inject, onDispose }) => {
64
+ const { serialize, deserialize } = inject(LocationServiceSettings)
65
+
66
+ const onLocationPathChanged = new ObservableValue(new URL(location.href).pathname)
67
+ const onLocationHashChanged = new ObservableValue(location.hash.replace('#', ''))
68
+ const onLocationSearchChanged = new ObservableValue<string>(location.search)
69
+ const onDeserializedLocationSearchChanged = new ObservableValue<Record<string, unknown>>(
70
+ deserialize(location.search),
71
+ )
72
+ const searchParamObservables = new Map<string, ObservableValue<any>>()
73
+
74
+ const updateState = (): void => {
75
+ onLocationPathChanged.setValue(location.pathname)
76
+ onLocationHashChanged.setValue(location.hash.replace('#', ''))
77
+ onLocationSearchChanged.setValue(location.search)
78
+ }
68
79
 
69
- public locationDeserializerObserver = this.onLocationSearchChanged.subscribe((search) => {
70
- this.onDeserializedLocationSearchChanged.setValue(this.deserializeQueryString(search))
71
- })
80
+ const locationDeserializerObserver = onLocationSearchChanged.subscribe((search) => {
81
+ onDeserializedLocationSearchChanged.setValue(deserialize(search))
82
+ })
72
83
 
73
- /**
74
- * Synchronizes the observable state with the current browser location.
75
- * Called internally after navigation events and history state changes.
76
- */
77
- public updateState = (() => {
78
- this.onLocationPathChanged.setValue(location.pathname)
79
- this.onLocationHashChanged.setValue(location.hash.replace('#', ''))
80
- this.onLocationSearchChanged.setValue(location.search)
81
- }).bind(this)
84
+ const popStateListener = (_ev: PopStateEvent): void => {
85
+ updateState()
86
+ }
87
+ const hashChangeListener = (_ev: HashChangeEvent): void => {
88
+ updateState()
89
+ }
90
+ window.addEventListener('popstate', popStateListener)
91
+ window.addEventListener('hashchange', hashChangeListener)
82
92
 
83
- /**
84
- * Navigate to a path. Use this instead of raw history.pushState for SPA routing.
85
- * The LocationService interceptor ensures routing state is updated correctly.
86
- */
87
- public navigate(path: string): void {
88
- // eslint-disable-next-line furystack/prefer-location-service -- This IS the LocationService.navigate() implementation.
89
- history.pushState(null, '', path)
90
- this.updateState()
91
- }
93
+ const originalPushState = window.history.pushState.bind(window.history)
94
+ window.history.pushState = (...args: Parameters<typeof window.history.pushState>) => {
95
+ originalPushState(...args)
96
+ updateState()
97
+ }
92
98
 
93
- /**
94
- * Replace the current history entry with a new path. Use this instead of
95
- * raw history.replaceState for SPA redirects (e.g. the intermediate URL
96
- * should not appear in the browser's back/forward stack).
97
- */
98
- public replace(path: string): void {
99
- // eslint-disable-next-line furystack/prefer-location-service -- This IS the LocationService.replace() implementation.
100
- history.replaceState(null, '', path)
101
- this.updateState()
102
- }
99
+ const originalReplaceState = window.history.replaceState.bind(window.history)
100
+ window.history.replaceState = (...args: Parameters<typeof window.history.replaceState>) => {
101
+ originalReplaceState(...args)
102
+ updateState()
103
+ }
103
104
 
104
- /**
105
- * Internal cache of per-key search parameter observables created by {@link useSearchParam}.
106
- */
107
- public readonly searchParamObservables = new Map<string, ObservableValue<any>>()
105
+ const navigate = (path: string): void => {
106
+ // eslint-disable-next-line furystack/prefer-location-service -- This IS the LocationService.navigate() implementation.
107
+ history.pushState(null, '', path)
108
+ updateState()
109
+ }
108
110
 
109
- /**
110
- * Gets or creates an observable for a search parameter
111
- * @param key The search param key (e.g. ?search=1 -> search)
112
- * @param defaultValue The default value if not provided
113
- * @returns An observable with the current value (or default value) of the search param
114
- */
115
- public useSearchParam<T>(key: string, defaultValue: T) {
116
- const existing = this.searchParamObservables.get(key)
111
+ const replace = (path: string): void => {
112
+ // eslint-disable-next-line furystack/prefer-location-service -- This IS the LocationService.replace() implementation.
113
+ history.replaceState(null, '', path)
114
+ updateState()
115
+ }
117
116
 
118
- if (!existing) {
119
- const currentDeserialized = this.onDeserializedLocationSearchChanged.getValue()
117
+ const useSearchParam = <T,>(key: string, defaultValue: T): ObservableValue<T> => {
118
+ const existing = searchParamObservables.get(key)
119
+ if (existing) return existing as ObservableValue<T>
120
120
 
121
+ const currentDeserialized = onDeserializedLocationSearchChanged.getValue()
121
122
  const actualValue = Object.prototype.hasOwnProperty.call(currentDeserialized, key)
122
123
  ? (currentDeserialized[key] as T)
123
124
  : defaultValue
124
125
 
125
126
  const newObservable = new ObservableValue(actualValue)
126
- this.searchParamObservables.set(key, newObservable)
127
+ searchParamObservables.set(key, newObservable)
127
128
 
128
129
  newObservable.subscribe((value) => {
129
- const currentQueryStringObject = this.onDeserializedLocationSearchChanged.getValue()
130
+ const currentQueryStringObject = onDeserializedLocationSearchChanged.getValue()
130
131
  if (currentQueryStringObject[key] !== value) {
131
- const params = this.serializeToQueryString({ ...currentQueryStringObject, [key]: value })
132
+ const params = serialize({ ...currentQueryStringObject, [key]: value })
132
133
  const newUrl = `${location.pathname}?${params}`
133
134
  // eslint-disable-next-line furystack/prefer-location-service -- Internal LocationService plumbing for search param sync.
134
135
  history.pushState({}, '', newUrl)
135
136
  }
136
137
  })
137
138
 
138
- this.onDeserializedLocationSearchChanged.subscribe((search) => {
139
+ onDeserializedLocationSearchChanged.subscribe((search) => {
139
140
  const value = (search[key] as T) ?? defaultValue
140
- this.searchParamObservables.get(key)?.setValue(value)
141
+ searchParamObservables.get(key)?.setValue(value)
141
142
  })
143
+
142
144
  return newObservable
143
145
  }
144
- return existing as ObservableValue<T>
145
- }
146
-
147
- private popStateListener = (_ev: PopStateEvent) => {
148
- this.updateState()
149
- }
150
146
 
151
- private hashChangeListener = ((_ev: HashChangeEvent) => {
152
- this.updateState()
153
- }).bind(this)
154
- }
147
+ onDispose(() => {
148
+ window.removeEventListener('popstate', popStateListener)
149
+ window.removeEventListener('hashchange', hashChangeListener)
150
+ window.history.pushState = originalPushState
151
+ window.history.replaceState = originalReplaceState
152
+ locationDeserializerObserver[Symbol.dispose]()
153
+ // eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is deferred to the injector's onDispose hook.
154
+ onLocationSearchChanged[Symbol.dispose]()
155
+ // eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is deferred to the injector's onDispose hook.
156
+ onDeserializedLocationSearchChanged[Symbol.dispose]()
157
+ // eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is deferred to the injector's onDispose hook.
158
+ onLocationPathChanged[Symbol.dispose]()
159
+ // eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is deferred to the injector's onDispose hook.
160
+ onLocationHashChanged[Symbol.dispose]()
161
+ })
162
+
163
+ return {
164
+ deserializeQueryString: deserialize,
165
+ onLocationPathChanged,
166
+ onLocationHashChanged,
167
+ onLocationSearchChanged,
168
+ onDeserializedLocationSearchChanged,
169
+ searchParamObservables,
170
+ updateState,
171
+ navigate,
172
+ replace,
173
+ useSearchParam,
174
+ }
175
+ },
176
+ })
155
177
 
156
178
  /**
157
- * Configures custom serialization for URL search state.
158
- * Must be called **before** `LocationService` is first instantiated by the injector.
159
- * @param injector The root injector
160
- * @param serialize Function to serialize state to a query string
161
- * @param deserialize Function to deserialize a query string to state
179
+ * Configures custom (de)serialization for URL search state by binding
180
+ * {@link LocationServiceSettings} on the given injector.
181
+ *
182
+ * Must be called **before** any consumer resolves {@link LocationService}:
183
+ * the service patches `history.pushState` / `history.replaceState` and
184
+ * subscribes to `popstate` / `hashchange` on construction, and those
185
+ * listeners are only torn down when the owning injector is disposed.
186
+ * Rebinding after the first resolution would leak the previous instance,
187
+ * so this helper throws loudly if that happens.
188
+ *
189
+ * @param injector The root injector.
190
+ * @param serialize Function to serialize state to a query string.
191
+ * @param deserialize Function to deserialize a query string to state.
192
+ * @throws If {@link LocationService} has already been resolved on
193
+ * `injector` (or any reachable ancestor).
162
194
  */
163
195
  export const useCustomSearchStateSerializer = (
164
196
  injector: Injector,
165
197
  serialize: typeof defaultSerializeToQueryString,
166
198
  deserialize: typeof defaultDeserializeQueryString,
167
- ) => {
168
- if (injector.cachedSingletons.has(LocationService)) {
169
- throw new Error('useCustomSearchStateSerializer must be called before the LocationService is instantiated')
199
+ ): void => {
200
+ if (injector.isResolved(LocationService)) {
201
+ throw new Error(
202
+ 'useCustomSearchStateSerializer must be called before LocationService is resolved for the first time. ' +
203
+ 'Configure serializers during injector bootstrap (e.g. before the first render).',
204
+ )
170
205
  }
171
-
172
- const locationService = new LocationService(serialize, deserialize)
173
- injector.setExplicitInstance(locationService, LocationService)
206
+ injector.bind(LocationServiceSettings, () => ({ serialize, deserialize }))
207
+ injector.invalidate(LocationServiceSettings)
174
208
  }
@@ -1,48 +1,57 @@
1
+ import { createInjector } from '@furystack/inject'
2
+ import { usingAsync } from '@furystack/utils'
1
3
  import { describe, expect, it, vi } from 'vitest'
2
4
  import type { MatchChainEntry } from '../components/nested-router.js'
3
5
  import { RouteMatchService } from './route-match-service.js'
4
6
 
5
7
  describe('RouteMatchService', () => {
6
- it('should start with an empty match chain', () => {
7
- using service = new RouteMatchService()
8
- expect(service.currentMatchChain.getValue()).toEqual([])
8
+ it('should start with an empty match chain', async () => {
9
+ await usingAsync(createInjector(), async (i) => {
10
+ const service = i.get(RouteMatchService)
11
+ expect(service.currentMatchChain.getValue()).toEqual([])
12
+ })
9
13
  })
10
14
 
11
- it('should update the match chain', () => {
12
- using service = new RouteMatchService()
13
- const chain: MatchChainEntry[] = [
14
- {
15
- route: { component: () => ({}) as JSX.Element },
16
- match: { path: '/about', params: {} },
17
- query: null,
18
- hash: undefined,
19
- },
20
- ]
21
- service.currentMatchChain.setValue(chain)
22
- expect(service.currentMatchChain.getValue()).toBe(chain)
15
+ it('should update the match chain', async () => {
16
+ await usingAsync(createInjector(), async (i) => {
17
+ const service = i.get(RouteMatchService)
18
+ const chain: MatchChainEntry[] = [
19
+ {
20
+ route: { component: () => ({}) as JSX.Element },
21
+ match: { path: '/about', params: {} },
22
+ query: null,
23
+ hash: undefined,
24
+ },
25
+ ]
26
+ service.currentMatchChain.setValue(chain)
27
+ expect(service.currentMatchChain.getValue()).toBe(chain)
28
+ })
23
29
  })
24
30
 
25
- it('should notify subscribers on match chain change', () => {
26
- using service = new RouteMatchService()
27
- const listener = vi.fn()
28
- using _subscription = service.currentMatchChain.subscribe(listener)
31
+ it('should notify subscribers on match chain change', async () => {
32
+ await usingAsync(createInjector(), async (i) => {
33
+ const service = i.get(RouteMatchService)
34
+ const listener = vi.fn()
35
+ using _subscription = service.currentMatchChain.subscribe(listener)
29
36
 
30
- const chain: MatchChainEntry[] = [
31
- {
32
- route: { meta: { title: 'Home' }, component: () => ({}) as JSX.Element },
33
- match: { path: '/', params: {} },
34
- query: null,
35
- hash: undefined,
36
- },
37
- ]
38
- service.currentMatchChain.setValue(chain)
37
+ const chain: MatchChainEntry[] = [
38
+ {
39
+ route: { meta: { title: 'Home' }, component: () => ({}) as JSX.Element },
40
+ match: { path: '/', params: {} },
41
+ query: null,
42
+ hash: undefined,
43
+ },
44
+ ]
45
+ service.currentMatchChain.setValue(chain)
39
46
 
40
- expect(listener).toHaveBeenCalledWith(chain)
47
+ expect(listener).toHaveBeenCalledWith(chain)
48
+ })
41
49
  })
42
50
 
43
- it('should dispose the observable on service disposal', () => {
44
- const service = new RouteMatchService()
45
- service[Symbol.dispose]()
51
+ it('should dispose the observable on injector disposal', async () => {
52
+ const injector = createInjector()
53
+ const service = injector.get(RouteMatchService)
54
+ await injector[Symbol.asyncDispose]()
46
55
 
47
56
  expect(() => service.currentMatchChain.setValue([])).toThrow()
48
57
  })
@@ -1,4 +1,5 @@
1
- import { Injectable } from '@furystack/inject'
1
+ import type { Token } from '@furystack/inject'
2
+ import { defineService } from '@furystack/inject'
2
3
  import { ObservableValue } from '@furystack/utils'
3
4
  import type { MatchChainEntry } from '../components/nested-router.js'
4
5
 
@@ -7,11 +8,19 @@ import type { MatchChainEntry } from '../components/nested-router.js'
7
8
  * NestedRouter as an observable. Consumers (breadcrumbs, document title,
8
9
  * navigation trees) subscribe to this instead of re-running route matching.
9
10
  */
10
- @Injectable({ lifetime: 'singleton' })
11
- export class RouteMatchService implements Disposable {
12
- public readonly currentMatchChain = new ObservableValue<MatchChainEntry[]>([])
13
-
14
- public [Symbol.dispose]() {
15
- this.currentMatchChain[Symbol.dispose]()
16
- }
11
+ export interface RouteMatchService {
12
+ readonly currentMatchChain: ObservableValue<MatchChainEntry[]>
17
13
  }
14
+
15
+ export const RouteMatchService: Token<RouteMatchService, 'singleton'> = defineService({
16
+ name: '@furystack/shades/RouteMatchService',
17
+ lifetime: 'singleton',
18
+ factory: ({ onDispose }) => {
19
+ const currentMatchChain = new ObservableValue<MatchChainEntry[]>([])
20
+ onDispose(() => {
21
+ // eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is deferred to the injector's onDispose hook.
22
+ currentMatchChain[Symbol.dispose]()
23
+ })
24
+ return { currentMatchChain }
25
+ },
26
+ })
@@ -1,4 +1,4 @@
1
- import { Injector } from '@furystack/inject'
1
+ import { createInjector, Injector } from '@furystack/inject'
2
2
  import { describe, expect, expectTypeOf, it } from 'vitest'
3
3
  import type { MatchChainEntry, NestedRoute } from '../components/nested-router.js'
4
4
  import {
@@ -12,7 +12,7 @@ import {
12
12
  type TestRoute = Pick<NestedRoute<unknown, any, any>, 'component'>
13
13
 
14
14
  describe('resolveRouteTitle', () => {
15
- const injector = new Injector()
15
+ const injector = createInjector()
16
16
 
17
17
  it('should return undefined when no meta is configured', async () => {
18
18
  const entry: MatchChainEntry = {
@@ -92,7 +92,7 @@ describe('resolveRouteTitle', () => {
92
92
  })
93
93
 
94
94
  describe('resolveRouteTitles', () => {
95
- const injector = new Injector()
95
+ const injector = createInjector()
96
96
 
97
97
  it('should resolve all titles in a mixed chain', async () => {
98
98
  const chain: MatchChainEntry[] = [
@@ -108,7 +108,7 @@ export const extractNavTree = <TRoutes extends Record<string, NestedRoute<any, a
108
108
  fullPath,
109
109
  meta: route.meta,
110
110
  children: route.children ? build(route.children, fullPath) : undefined,
111
- } as NavTreeNode<TRoutes>
111
+ }
112
112
  })
113
113
  return build(routes, parentPath)
114
114
  }
@@ -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, it, vi } from 'vitest'
4
4
  import { ScreenService, ScreenSizes } from './screen-service.js'
@@ -13,16 +13,17 @@ describe('ScreenService', () => {
13
13
  })
14
14
 
15
15
  it('Should be constructed', async () => {
16
- await usingAsync(new Injector(), async (i) => {
17
- const s = i.getInstance(ScreenService)
18
- expect(s).toBeInstanceOf(ScreenService)
16
+ await usingAsync(createInjector(), async (i) => {
17
+ const s = i.get(ScreenService)
18
+ expect(s).toBeDefined()
19
+ expect(s.breakpoints).toBeDefined()
19
20
  })
20
21
  })
21
22
 
22
23
  describe('breakpoints', () => {
23
24
  it('Should have correct breakpoint definitions', async () => {
24
- await usingAsync(new Injector(), async (i) => {
25
- const s = i.getInstance(ScreenService)
25
+ await usingAsync(createInjector(), async (i) => {
26
+ const s = i.get(ScreenService)
26
27
 
27
28
  expect(s.breakpoints.xs.minSize).toBe(0)
28
29
  expect(s.breakpoints.sm.minSize).toBe(600)
@@ -35,8 +36,8 @@ describe('ScreenService', () => {
35
36
 
36
37
  describe('screenSize.atLeast', () => {
37
38
  it('Should have observable for each screen size', async () => {
38
- await usingAsync(new Injector(), async (i) => {
39
- const s = i.getInstance(ScreenService)
39
+ await usingAsync(createInjector(), async (i) => {
40
+ const s = i.get(ScreenService)
40
41
 
41
42
  for (const size of ScreenSizes) {
42
43
  expect(s.screenSize.atLeast[size]).toBeDefined()
@@ -46,16 +47,16 @@ describe('ScreenService', () => {
46
47
  })
47
48
 
48
49
  it('Should return true for xs on any screen size', async () => {
49
- await usingAsync(new Injector(), async (i) => {
50
- const s = i.getInstance(ScreenService)
50
+ await usingAsync(createInjector(), async (i) => {
51
+ const s = i.get(ScreenService)
51
52
  // xs has minSize 0, so it should always be true
52
53
  expect(s.screenSize.atLeast.xs.getValue()).toBe(true)
53
54
  })
54
55
  })
55
56
 
56
57
  it('Should update screenSize observables on window resize', async () => {
57
- await usingAsync(new Injector(), async (i) => {
58
- const s = i.getInstance(ScreenService)
58
+ await usingAsync(createInjector(), async (i) => {
59
+ const s = i.get(ScreenService)
59
60
 
60
61
  // Mock window.innerWidth to simulate a large screen
61
62
  vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1920)
@@ -86,8 +87,8 @@ describe('ScreenService', () => {
86
87
 
87
88
  describe('orientation', () => {
88
89
  it('Should have an orientation observable', async () => {
89
- await usingAsync(new Injector(), async (i) => {
90
- const s = i.getInstance(ScreenService)
90
+ await usingAsync(createInjector(), async (i) => {
91
+ const s = i.get(ScreenService)
91
92
  const orientation = s.orientation.getValue()
92
93
  expect(['landscape', 'portrait']).toContain(orientation)
93
94
  })
@@ -98,17 +99,17 @@ describe('ScreenService', () => {
98
99
  const matchMediaMock = vi.fn()
99
100
  window.matchMedia = matchMediaMock
100
101
 
101
- await usingAsync(new Injector(), async (i) => {
102
+ await usingAsync(createInjector(), async (i) => {
102
103
  // Set initial orientation to landscape
103
- matchMediaMock.mockReturnValue({ matches: true } as MediaQueryList)
104
- const s = i.getInstance(ScreenService)
104
+ matchMediaMock.mockReturnValue({ matches: true })
105
+ const s = i.get(ScreenService)
105
106
 
106
107
  // Verify initial landscape
107
108
  window.dispatchEvent(new Event('resize'))
108
109
  expect(s.orientation.getValue()).toBe('landscape')
109
110
 
110
111
  // Change to portrait
111
- matchMediaMock.mockReturnValue({ matches: false } as MediaQueryList)
112
+ matchMediaMock.mockReturnValue({ matches: false })
112
113
  window.dispatchEvent(new Event('resize'))
113
114
  expect(s.orientation.getValue()).toBe('portrait')
114
115
  })
@@ -119,8 +120,8 @@ describe('ScreenService', () => {
119
120
  it('Should remove resize event listener on dispose', async () => {
120
121
  const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener')
121
122
 
122
- await usingAsync(new Injector(), async (i) => {
123
- i.getInstance(ScreenService)
123
+ await usingAsync(createInjector(), async (i) => {
124
+ i.get(ScreenService)
124
125
  })
125
126
 
126
127
  expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function))