@furystack/shades 13.2.2 → 15.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +157 -0
- 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/define-nested-routes.d.ts +27 -0
- package/esm/components/define-nested-routes.d.ts.map +1 -0
- package/esm/components/define-nested-routes.js +26 -0
- package/esm/components/define-nested-routes.js.map +1 -0
- package/esm/components/define-nested-routes.spec.d.ts +2 -0
- package/esm/components/define-nested-routes.spec.d.ts.map +1 -0
- package/esm/components/define-nested-routes.spec.js +81 -0
- package/esm/components/define-nested-routes.spec.js.map +1 -0
- package/esm/components/index.d.ts +3 -3
- package/esm/components/index.d.ts.map +1 -1
- package/esm/components/index.js +3 -3
- package/esm/components/index.js.map +1 -1
- package/esm/components/nested-hooks.d.ts +62 -0
- package/esm/components/nested-hooks.d.ts.map +1 -0
- package/esm/components/nested-hooks.js +73 -0
- package/esm/components/nested-hooks.js.map +1 -0
- package/esm/components/nested-hooks.spec.d.ts +2 -0
- package/esm/components/nested-hooks.spec.d.ts.map +1 -0
- package/esm/components/nested-hooks.spec.js +211 -0
- package/esm/components/nested-hooks.spec.js.map +1 -0
- package/esm/components/nested-navigate.d.ts +52 -21
- package/esm/components/nested-navigate.d.ts.map +1 -1
- package/esm/components/nested-navigate.js +32 -17
- package/esm/components/nested-navigate.js.map +1 -1
- package/esm/components/nested-navigate.spec.js +109 -19
- package/esm/components/nested-navigate.spec.js.map +1 -1
- package/esm/components/nested-replace.d.ts +36 -0
- package/esm/components/nested-replace.d.ts.map +1 -0
- package/esm/components/nested-replace.js +38 -0
- package/esm/components/nested-replace.js.map +1 -0
- package/esm/components/nested-replace.spec.d.ts +2 -0
- package/esm/components/nested-replace.spec.d.ts.map +1 -0
- package/esm/components/nested-replace.spec.js +59 -0
- package/esm/components/nested-replace.spec.js.map +1 -0
- package/esm/components/nested-route-link.d.ts +36 -26
- package/esm/components/nested-route-link.d.ts.map +1 -1
- package/esm/components/nested-route-link.js +18 -20
- package/esm/components/nested-route-link.js.map +1 -1
- package/esm/components/nested-route-link.spec.js +67 -17
- package/esm/components/nested-route-link.spec.js.map +1 -1
- package/esm/components/nested-route-types.d.ts +79 -2
- package/esm/components/nested-route-types.d.ts.map +1 -1
- package/esm/components/nested-router.d.ts +77 -7
- package/esm/components/nested-router.d.ts.map +1 -1
- package/esm/components/nested-router.js +139 -13
- package/esm/components/nested-router.js.map +1 -1
- package/esm/components/nested-router.spec.js +283 -52
- package/esm/components/nested-router.spec.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 +59 -24
- 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 +42 -49
- package/esm/services/route-match-service.spec.js.map +1 -1
- package/esm/services/route-meta-utils.d.ts +28 -6
- package/esm/services/route-meta-utils.d.ts.map +1 -1
- package/esm/services/route-meta-utils.js +18 -4
- package/esm/services/route-meta-utils.js.map +1 -1
- package/esm/services/route-meta-utils.spec.js +60 -5
- 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.d.ts +1 -1
- package/esm/shade.d.ts.map +1 -1
- package/esm/shade.js +10 -7
- package/esm/shade.js.map +1 -1
- package/esm/vnode.d.ts +1 -1
- package/esm/vnode.js +1 -1
- package/package.json +5 -4
- package/src/component-factory.spec.tsx +3 -2
- package/src/components/define-nested-routes.spec.ts +91 -0
- package/src/components/define-nested-routes.ts +27 -0
- package/src/components/index.ts +3 -3
- package/src/components/nested-hooks.spec.ts +254 -0
- package/src/components/nested-hooks.ts +109 -0
- package/src/components/nested-navigate.spec.ts +136 -32
- package/src/components/nested-navigate.ts +75 -26
- package/src/components/nested-replace.spec.ts +89 -0
- package/src/components/nested-replace.ts +46 -0
- package/src/components/nested-route-link.spec.tsx +90 -22
- package/src/components/nested-route-link.tsx +54 -29
- package/src/components/nested-route-types.ts +82 -2
- package/src/components/nested-router.spec.tsx +334 -51
- package/src/components/nested-router.tsx +184 -24
- package/src/services/location-service.spec.ts +68 -25
- package/src/services/location-service.tsx +156 -122
- package/src/services/route-match-service.spec.ts +41 -28
- package/src/services/route-match-service.ts +17 -8
- package/src/services/route-meta-utils.spec.ts +78 -10
- package/src/services/route-meta-utils.ts +44 -15
- package/src/services/screen-service.spec.ts +19 -18
- 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.ts +11 -8
- package/src/vnode.ts +1 -1
- package/esm/components/link-to-route.d.ts +0 -10
- package/esm/components/link-to-route.d.ts.map +0 -1
- package/esm/components/link-to-route.js +0 -16
- package/esm/components/link-to-route.js.map +0 -1
- package/esm/components/link-to-route.spec.d.ts +0 -2
- package/esm/components/link-to-route.spec.d.ts.map +0 -1
- package/esm/components/link-to-route.spec.js +0 -30
- package/esm/components/link-to-route.spec.js.map +0 -1
- package/esm/components/route-link.d.ts +0 -10
- package/esm/components/route-link.d.ts.map +0 -1
- package/esm/components/route-link.js +0 -25
- package/esm/components/route-link.js.map +0 -1
- package/esm/components/route-link.spec.d.ts +0 -2
- package/esm/components/route-link.spec.d.ts.map +0 -1
- package/esm/components/route-link.spec.js +0 -34
- package/esm/components/route-link.spec.js.map +0 -1
- package/esm/components/router.d.ts +0 -44
- package/esm/components/router.d.ts.map +0 -1
- package/esm/components/router.js +0 -66
- package/esm/components/router.js.map +0 -1
- package/esm/components/router.spec.d.ts +0 -2
- package/esm/components/router.spec.d.ts.map +0 -1
- package/esm/components/router.spec.js +0 -161
- package/esm/components/router.spec.js.map +0 -1
- package/src/components/link-to-route.spec.tsx +0 -45
- package/src/components/link-to-route.tsx +0 -26
- package/src/components/route-link.spec.tsx +0 -44
- package/src/components/route-link.tsx +0 -29
- package/src/components/router.spec.tsx +0 -217
- package/src/components/router.tsx +0 -103
|
@@ -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,44 +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
|
-
|
|
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
|
+
})
|
|
21
29
|
})
|
|
22
30
|
|
|
23
|
-
it('should notify subscribers on match chain change', () => {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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)
|
|
27
36
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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)
|
|
35
46
|
|
|
36
|
-
|
|
47
|
+
expect(listener).toHaveBeenCalledWith(chain)
|
|
48
|
+
})
|
|
37
49
|
})
|
|
38
50
|
|
|
39
|
-
it('should dispose the observable on
|
|
40
|
-
const
|
|
41
|
-
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]()
|
|
42
55
|
|
|
43
56
|
expect(() => service.currentMatchChain.setValue([])).toThrow()
|
|
44
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,15 +1,25 @@
|
|
|
1
|
-
import { Injector } from '@furystack/inject'
|
|
2
|
-
import { describe, expect, it } from 'vitest'
|
|
1
|
+
import { createInjector, Injector } from '@furystack/inject'
|
|
2
|
+
import { describe, expect, expectTypeOf, it } from 'vitest'
|
|
3
3
|
import type { MatchChainEntry, NestedRoute } from '../components/nested-router.js'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
buildDocumentTitle,
|
|
6
|
+
extractNavTree,
|
|
7
|
+
type NavTreeNode,
|
|
8
|
+
resolveRouteTitle,
|
|
9
|
+
resolveRouteTitles,
|
|
10
|
+
} from './route-meta-utils.js'
|
|
11
|
+
|
|
12
|
+
type TestRoute = Pick<NestedRoute<unknown, any, any>, 'component'>
|
|
5
13
|
|
|
6
14
|
describe('resolveRouteTitle', () => {
|
|
7
|
-
const injector =
|
|
15
|
+
const injector = createInjector()
|
|
8
16
|
|
|
9
17
|
it('should return undefined when no meta is configured', async () => {
|
|
10
18
|
const entry: MatchChainEntry = {
|
|
11
19
|
route: { component: () => ({}) as JSX.Element },
|
|
12
20
|
match: { path: '/about', params: {} },
|
|
21
|
+
query: null,
|
|
22
|
+
hash: undefined,
|
|
13
23
|
}
|
|
14
24
|
expect(await resolveRouteTitle(entry, injector)).toBeUndefined()
|
|
15
25
|
})
|
|
@@ -18,6 +28,8 @@ describe('resolveRouteTitle', () => {
|
|
|
18
28
|
const entry: MatchChainEntry = {
|
|
19
29
|
route: { meta: {}, component: () => ({}) as JSX.Element },
|
|
20
30
|
match: { path: '/about', params: {} },
|
|
31
|
+
query: null,
|
|
32
|
+
hash: undefined,
|
|
21
33
|
}
|
|
22
34
|
expect(await resolveRouteTitle(entry, injector)).toBeUndefined()
|
|
23
35
|
})
|
|
@@ -26,6 +38,8 @@ describe('resolveRouteTitle', () => {
|
|
|
26
38
|
const entry: MatchChainEntry = {
|
|
27
39
|
route: { meta: { title: 'About' }, component: () => ({}) as JSX.Element },
|
|
28
40
|
match: { path: '/about', params: {} },
|
|
41
|
+
query: null,
|
|
42
|
+
hash: undefined,
|
|
29
43
|
}
|
|
30
44
|
expect(await resolveRouteTitle(entry, injector)).toBe('About')
|
|
31
45
|
})
|
|
@@ -37,6 +51,8 @@ describe('resolveRouteTitle', () => {
|
|
|
37
51
|
component: () => ({}) as JSX.Element,
|
|
38
52
|
},
|
|
39
53
|
match: { path: '/users/42', params: { id: '42' } },
|
|
54
|
+
query: null,
|
|
55
|
+
hash: undefined,
|
|
40
56
|
}
|
|
41
57
|
expect(await resolveRouteTitle(entry, injector)).toBe('User 42')
|
|
42
58
|
})
|
|
@@ -53,6 +69,8 @@ describe('resolveRouteTitle', () => {
|
|
|
53
69
|
component: () => ({}) as JSX.Element,
|
|
54
70
|
},
|
|
55
71
|
match: { path: '/movies/7', params: { id: '7' } },
|
|
72
|
+
query: null,
|
|
73
|
+
hash: undefined,
|
|
56
74
|
}
|
|
57
75
|
expect(await resolveRouteTitle(entry, injector)).toBe('Movie 7')
|
|
58
76
|
})
|
|
@@ -66,23 +84,29 @@ describe('resolveRouteTitle', () => {
|
|
|
66
84
|
component: () => ({}) as JSX.Element,
|
|
67
85
|
},
|
|
68
86
|
match: { path: '/test', params: {} },
|
|
87
|
+
query: null,
|
|
88
|
+
hash: undefined,
|
|
69
89
|
}
|
|
70
90
|
expect(await resolveRouteTitle(entry, injector)).toBe('has-injector')
|
|
71
91
|
})
|
|
72
92
|
})
|
|
73
93
|
|
|
74
94
|
describe('resolveRouteTitles', () => {
|
|
75
|
-
const injector =
|
|
95
|
+
const injector = createInjector()
|
|
76
96
|
|
|
77
97
|
it('should resolve all titles in a mixed chain', async () => {
|
|
78
98
|
const chain: MatchChainEntry[] = [
|
|
79
99
|
{
|
|
80
100
|
route: { meta: { title: 'Media' }, component: () => ({}) as JSX.Element },
|
|
81
101
|
match: { path: '/media', params: {} },
|
|
102
|
+
query: null,
|
|
103
|
+
hash: undefined,
|
|
82
104
|
},
|
|
83
105
|
{
|
|
84
106
|
route: { meta: { title: 'Movies' }, component: () => ({}) as JSX.Element },
|
|
85
107
|
match: { path: '/movies', params: {} },
|
|
108
|
+
query: null,
|
|
109
|
+
hash: undefined,
|
|
86
110
|
},
|
|
87
111
|
{
|
|
88
112
|
route: {
|
|
@@ -90,6 +114,8 @@ describe('resolveRouteTitles', () => {
|
|
|
90
114
|
component: () => ({}) as JSX.Element,
|
|
91
115
|
},
|
|
92
116
|
match: { path: '/7', params: { id: '7' } },
|
|
117
|
+
query: null,
|
|
118
|
+
hash: undefined,
|
|
93
119
|
},
|
|
94
120
|
]
|
|
95
121
|
const titles = await resolveRouteTitles(chain, injector)
|
|
@@ -106,10 +132,14 @@ describe('resolveRouteTitles', () => {
|
|
|
106
132
|
{
|
|
107
133
|
route: { meta: { title: 'Root' }, component: () => ({}) as JSX.Element },
|
|
108
134
|
match: { path: '/', params: {} },
|
|
135
|
+
query: null,
|
|
136
|
+
hash: undefined,
|
|
109
137
|
},
|
|
110
138
|
{
|
|
111
139
|
route: { component: () => ({}) as JSX.Element },
|
|
112
140
|
match: { path: '/child', params: {} },
|
|
141
|
+
query: null,
|
|
142
|
+
hash: undefined,
|
|
113
143
|
},
|
|
114
144
|
]
|
|
115
145
|
const titles = await resolveRouteTitles(chain, injector)
|
|
@@ -155,7 +185,7 @@ describe('buildDocumentTitle', () => {
|
|
|
155
185
|
|
|
156
186
|
describe('extractNavTree', () => {
|
|
157
187
|
it('should extract a flat route tree', () => {
|
|
158
|
-
const routes: Record<string, NestedRoute<
|
|
188
|
+
const routes: Record<string, NestedRoute<any, any, any>> = {
|
|
159
189
|
'/about': { meta: { title: 'About' }, component: () => ({}) as JSX.Element },
|
|
160
190
|
'/contact': { meta: { title: 'Contact' }, component: () => ({}) as JSX.Element },
|
|
161
191
|
}
|
|
@@ -167,7 +197,7 @@ describe('extractNavTree', () => {
|
|
|
167
197
|
})
|
|
168
198
|
|
|
169
199
|
it('should extract nested routes recursively', () => {
|
|
170
|
-
const routes: Record<string, NestedRoute<
|
|
200
|
+
const routes: Record<string, NestedRoute<any, any, any>> = {
|
|
171
201
|
'/media': {
|
|
172
202
|
meta: { title: 'Media' },
|
|
173
203
|
component: () => ({}) as JSX.Element,
|
|
@@ -197,7 +227,7 @@ describe('extractNavTree', () => {
|
|
|
197
227
|
})
|
|
198
228
|
|
|
199
229
|
it('should handle root "/" parent path correctly', () => {
|
|
200
|
-
const routes: Record<string, NestedRoute<
|
|
230
|
+
const routes: Record<string, NestedRoute<any, any, any>> = {
|
|
201
231
|
'/': {
|
|
202
232
|
meta: { title: 'Home' },
|
|
203
233
|
component: () => ({}) as JSX.Element,
|
|
@@ -212,7 +242,7 @@ describe('extractNavTree', () => {
|
|
|
212
242
|
})
|
|
213
243
|
|
|
214
244
|
it('should compute correct fullPath for deeply nested routes (3+ levels)', () => {
|
|
215
|
-
const routes: Record<string, NestedRoute<
|
|
245
|
+
const routes: Record<string, NestedRoute<any, any, any>> = {
|
|
216
246
|
'/a': {
|
|
217
247
|
meta: { title: 'A' },
|
|
218
248
|
component: () => ({}) as JSX.Element,
|
|
@@ -234,10 +264,48 @@ describe('extractNavTree', () => {
|
|
|
234
264
|
})
|
|
235
265
|
|
|
236
266
|
it('should include routes without meta', () => {
|
|
237
|
-
const routes: Record<string, NestedRoute<
|
|
267
|
+
const routes: Record<string, NestedRoute<any, any, any>> = {
|
|
238
268
|
'/hidden': { component: () => ({}) as JSX.Element },
|
|
239
269
|
}
|
|
240
270
|
const tree = extractNavTree(routes)
|
|
241
271
|
expect(tree[0].meta).toBeUndefined()
|
|
242
272
|
})
|
|
273
|
+
|
|
274
|
+
describe('typed output', () => {
|
|
275
|
+
it('should narrow pattern and fullPath for a flat typed tree', () => {
|
|
276
|
+
const routes = {
|
|
277
|
+
'/about': { component: () => ({}) as JSX.Element } satisfies TestRoute,
|
|
278
|
+
'/contact': { component: () => ({}) as JSX.Element } satisfies TestRoute,
|
|
279
|
+
} satisfies Record<string, NestedRoute<any, any, any>>
|
|
280
|
+
|
|
281
|
+
const tree = extractNavTree(routes)
|
|
282
|
+
expectTypeOf(tree[0].pattern).toEqualTypeOf<'/about' | '/contact'>()
|
|
283
|
+
expectTypeOf(tree[0].fullPath).toEqualTypeOf<'/about' | '/contact'>()
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('should include nested composed paths in the fullPath union', () => {
|
|
287
|
+
const routes = {
|
|
288
|
+
'/media': {
|
|
289
|
+
component: () => ({}) as JSX.Element,
|
|
290
|
+
children: {
|
|
291
|
+
'/movies': { component: () => ({}) as JSX.Element } satisfies TestRoute,
|
|
292
|
+
'/music': { component: () => ({}) as JSX.Element } satisfies TestRoute,
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
} satisfies Record<string, NestedRoute<any, any, any>>
|
|
296
|
+
|
|
297
|
+
const tree = extractNavTree(routes)
|
|
298
|
+
expectTypeOf(tree[0].fullPath).toEqualTypeOf<'/media' | '/media/movies' | '/media/music'>()
|
|
299
|
+
expectTypeOf(tree[0].children).toEqualTypeOf<Array<NavTreeNode<typeof routes>> | undefined>()
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('should preserve backward compatibility with the widened default', () => {
|
|
303
|
+
const node: NavTreeNode = {
|
|
304
|
+
pattern: '/anything',
|
|
305
|
+
fullPath: '/anything',
|
|
306
|
+
}
|
|
307
|
+
expectTypeOf(node.pattern).toEqualTypeOf<string>()
|
|
308
|
+
expectTypeOf(node.fullPath).toEqualTypeOf<string>()
|
|
309
|
+
})
|
|
310
|
+
})
|
|
243
311
|
})
|