@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.
- package/CHANGELOG.md +30 -0
- package/esm/compile-route.d.ts +5 -0
- package/esm/compile-route.d.ts.map +1 -1
- package/esm/compile-route.js +5 -0
- package/esm/compile-route.js.map +1 -1
- package/esm/component-factory.spec.d.ts.map +1 -1
- package/esm/component-factory.spec.js +2 -2
- package/esm/component-factory.spec.js.map +1 -1
- package/esm/components/nested-hooks.js +2 -2
- package/esm/components/nested-hooks.js.map +1 -1
- package/esm/components/nested-hooks.spec.js +14 -14
- package/esm/components/nested-hooks.spec.js.map +1 -1
- package/esm/components/nested-navigate.js +1 -1
- package/esm/components/nested-navigate.js.map +1 -1
- package/esm/components/nested-navigate.spec.js +16 -16
- package/esm/components/nested-navigate.spec.js.map +1 -1
- package/esm/components/nested-replace.js +1 -1
- package/esm/components/nested-replace.js.map +1 -1
- package/esm/components/nested-replace.spec.js +6 -6
- package/esm/components/nested-replace.spec.js.map +1 -1
- package/esm/components/nested-route-link.js +1 -1
- package/esm/components/nested-route-link.js.map +1 -1
- package/esm/components/nested-route-link.spec.js +8 -8
- package/esm/components/nested-route-link.spec.js.map +1 -1
- package/esm/components/nested-router.js +3 -3
- package/esm/components/nested-router.js.map +1 -1
- package/esm/components/nested-router.spec.js +24 -24
- package/esm/components/nested-router.spec.js.map +1 -1
- package/esm/css-generator.d.ts +9 -26
- package/esm/css-generator.d.ts.map +1 -1
- package/esm/css-generator.js +9 -26
- package/esm/css-generator.js.map +1 -1
- package/esm/css-generator.spec.js.map +1 -1
- package/esm/models/css-object.d.ts +3 -5
- package/esm/models/css-object.d.ts.map +1 -1
- package/esm/models/render-options.d.ts +16 -20
- package/esm/models/render-options.d.ts.map +1 -1
- package/esm/models/shade-component.d.ts +6 -4
- package/esm/models/shade-component.d.ts.map +1 -1
- package/esm/models/shade-component.js +3 -3
- package/esm/models/shade-component.js.map +1 -1
- package/esm/services/location-service.d.ts +39 -42
- package/esm/services/location-service.d.ts.map +1 -1
- package/esm/services/location-service.js +115 -134
- package/esm/services/location-service.js.map +1 -1
- package/esm/services/location-service.spec.js +32 -30
- package/esm/services/location-service.spec.js.map +1 -1
- package/esm/services/route-match-service.d.ts +3 -2
- package/esm/services/route-match-service.d.ts.map +1 -1
- package/esm/services/route-match-service.js +13 -22
- package/esm/services/route-match-service.js.map +1 -1
- package/esm/services/route-match-service.spec.js +40 -51
- package/esm/services/route-match-service.spec.js.map +1 -1
- package/esm/services/route-meta-utils.js.map +1 -1
- package/esm/services/route-meta-utils.spec.js +3 -3
- package/esm/services/route-meta-utils.spec.js.map +1 -1
- package/esm/services/screen-service.d.ts +13 -72
- package/esm/services/screen-service.d.ts.map +1 -1
- package/esm/services/screen-service.js +45 -137
- package/esm/services/screen-service.js.map +1 -1
- package/esm/services/screen-service.spec.js +19 -18
- package/esm/services/screen-service.spec.js.map +1 -1
- package/esm/services/spatial-navigation-service.d.ts +17 -39
- package/esm/services/spatial-navigation-service.d.ts.map +1 -1
- package/esm/services/spatial-navigation-service.js +280 -313
- package/esm/services/spatial-navigation-service.js.map +1 -1
- package/esm/services/spatial-navigation-service.spec.js +163 -158
- package/esm/services/spatial-navigation-service.spec.js.map +1 -1
- package/esm/shade-component.d.ts +14 -17
- package/esm/shade-component.d.ts.map +1 -1
- package/esm/shade-component.js +14 -17
- package/esm/shade-component.js.map +1 -1
- package/esm/shade.d.ts +18 -15
- package/esm/shade.d.ts.map +1 -1
- package/esm/shade.js +22 -32
- package/esm/shade.js.map +1 -1
- package/esm/style-manager.d.ts +8 -22
- package/esm/style-manager.d.ts.map +1 -1
- package/esm/style-manager.js +8 -22
- package/esm/style-manager.js.map +1 -1
- package/esm/styled-shade.d.ts +4 -7
- package/esm/styled-shade.d.ts.map +1 -1
- package/esm/styled-shade.js +4 -4
- package/esm/styled-shade.js.map +1 -1
- package/esm/view-transition.d.ts +10 -22
- package/esm/view-transition.d.ts.map +1 -1
- package/esm/view-transition.js +10 -22
- package/esm/view-transition.js.map +1 -1
- package/esm/vnode.js.map +1 -1
- package/esm/vnode.spec.js.map +1 -1
- package/package.json +9 -8
- package/src/compile-route.ts +5 -0
- package/src/component-factory.spec.tsx +3 -2
- package/src/components/nested-hooks.spec.ts +14 -14
- package/src/components/nested-hooks.ts +2 -2
- package/src/components/nested-navigate.spec.ts +17 -16
- package/src/components/nested-navigate.ts +1 -1
- package/src/components/nested-replace.spec.ts +7 -6
- package/src/components/nested-replace.ts +1 -1
- package/src/components/nested-route-link.spec.tsx +8 -8
- package/src/components/nested-route-link.tsx +1 -1
- package/src/components/nested-router.spec.tsx +24 -24
- package/src/components/nested-router.tsx +5 -5
- package/src/css-generator.spec.ts +1 -1
- package/src/css-generator.ts +9 -26
- package/src/models/css-object.ts +3 -5
- package/src/models/render-options.ts +17 -20
- package/src/models/shade-component.ts +6 -4
- package/src/services/location-service.spec.ts +35 -31
- package/src/services/location-service.tsx +156 -122
- package/src/services/route-match-service.spec.ts +41 -32
- package/src/services/route-match-service.ts +17 -8
- package/src/services/route-meta-utils.spec.ts +3 -3
- package/src/services/route-meta-utils.ts +1 -1
- package/src/services/screen-service.spec.ts +21 -20
- package/src/services/screen-service.ts +65 -110
- package/src/services/spatial-navigation-service.spec.ts +165 -160
- package/src/services/spatial-navigation-service.ts +299 -287
- package/src/shade-component.ts +14 -17
- package/src/shade.ts +31 -41
- package/src/style-manager.ts +9 -22
- package/src/styled-shade.ts +5 -8
- package/src/view-transition.ts +10 -22
- package/src/vnode.spec.ts +3 -3
- package/src/vnode.ts +2 -2
|
@@ -1,174 +1,208 @@
|
|
|
1
|
-
import {
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
*
|
|
40
|
+
* Synchronizes the observable state with the current browser location.
|
|
41
|
+
* Called internally after navigation events and history state changes.
|
|
54
42
|
*/
|
|
55
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
61
|
-
|
|
53
|
+
replace(path: string): void
|
|
62
54
|
/**
|
|
63
|
-
*
|
|
55
|
+
* Gets or creates an observable for a search parameter.
|
|
64
56
|
*/
|
|
65
|
-
|
|
57
|
+
useSearchParam<T>(key: string, defaultValue: T): ObservableValue<T>
|
|
58
|
+
}
|
|
66
59
|
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
80
|
+
const locationDeserializerObserver = onLocationSearchChanged.subscribe((search) => {
|
|
81
|
+
onDeserializedLocationSearchChanged.setValue(deserialize(search))
|
|
82
|
+
})
|
|
72
83
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
119
|
-
const
|
|
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
|
-
|
|
127
|
+
searchParamObservables.set(key, newObservable)
|
|
127
128
|
|
|
128
129
|
newObservable.subscribe((value) => {
|
|
129
|
-
const currentQueryStringObject =
|
|
130
|
+
const currentQueryStringObject = onDeserializedLocationSearchChanged.getValue()
|
|
130
131
|
if (currentQueryStringObject[key] !== value) {
|
|
131
|
-
const params =
|
|
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
|
-
|
|
139
|
+
onDeserializedLocationSearchChanged.subscribe((search) => {
|
|
139
140
|
const value = (search[key] as T) ?? defaultValue
|
|
140
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
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.
|
|
169
|
-
throw new Error(
|
|
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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
47
|
+
expect(listener).toHaveBeenCalledWith(chain)
|
|
48
|
+
})
|
|
41
49
|
})
|
|
42
50
|
|
|
43
|
-
it('should dispose the observable on
|
|
44
|
-
const
|
|
45
|
-
service
|
|
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 {
|
|
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
|
-
|
|
11
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
}
|
|
111
|
+
}
|
|
112
112
|
})
|
|
113
113
|
return build(routes, parentPath)
|
|
114
114
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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(
|
|
17
|
-
const s = i.
|
|
18
|
-
expect(s).
|
|
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(
|
|
25
|
-
const s = i.
|
|
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(
|
|
39
|
-
const s = i.
|
|
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(
|
|
50
|
-
const s = i.
|
|
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(
|
|
58
|
-
const s = i.
|
|
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(
|
|
90
|
-
const s = i.
|
|
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(
|
|
102
|
+
await usingAsync(createInjector(), async (i) => {
|
|
102
103
|
// Set initial orientation to landscape
|
|
103
|
-
matchMediaMock.mockReturnValue({ matches: true }
|
|
104
|
-
const s = i.
|
|
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 }
|
|
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(
|
|
123
|
-
i.
|
|
123
|
+
await usingAsync(createInjector(), async (i) => {
|
|
124
|
+
i.get(ScreenService)
|
|
124
125
|
})
|
|
125
126
|
|
|
126
127
|
expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function))
|