@furystack/shades 6.1.5 → 7.1.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/dist/component-factory.spec.js +6 -6
- package/dist/component-factory.spec.js.map +1 -1
- package/dist/components/lazy-load.js +17 -14
- package/dist/components/lazy-load.js.map +1 -1
- package/dist/components/route-link.js +7 -3
- package/dist/components/route-link.js.map +1 -1
- package/dist/components/route-link.spec.js +2 -2
- package/dist/components/route-link.spec.js.map +1 -1
- package/dist/components/router.js +25 -26
- package/dist/components/router.js.map +1 -1
- package/dist/components/router.spec.js +2 -2
- package/dist/components/router.spec.js.map +1 -1
- package/dist/services/location-service.js +52 -4
- package/dist/services/location-service.js.map +1 -1
- package/dist/services/location-service.spec.js +63 -5
- package/dist/services/location-service.spec.js.map +1 -1
- package/dist/services/resource-manager.js +48 -0
- package/dist/services/resource-manager.js.map +1 -0
- package/dist/services/resource-manager.spec.js +32 -0
- package/dist/services/resource-manager.spec.js.map +1 -0
- package/dist/shade-component.js +28 -14
- package/dist/shade-component.js.map +1 -1
- package/dist/shade-resources.integration.spec.js +7 -8
- package/dist/shade-resources.integration.spec.js.map +1 -1
- package/dist/shade.js +79 -69
- package/dist/shade.js.map +1 -1
- package/dist/shades.integration.spec.js +197 -187
- package/dist/shades.integration.spec.js.map +1 -1
- package/package.json +6 -6
- package/src/component-factory.spec.tsx +7 -7
- package/src/components/lazy-load.tsx +19 -15
- package/src/components/route-link.spec.tsx +2 -2
- package/src/components/route-link.tsx +11 -10
- package/src/components/router.spec.tsx +2 -2
- package/src/components/router.tsx +32 -32
- package/src/jsx.ts +3 -2
- package/src/models/render-options.ts +59 -9
- package/src/services/location-service.spec.ts +75 -6
- package/src/services/location-service.tsx +58 -4
- package/src/services/resource-manager.spec.ts +33 -0
- package/src/services/resource-manager.ts +60 -0
- package/src/shade-component.ts +35 -15
- package/src/shade-resources.integration.spec.tsx +8 -14
- package/src/shade.ts +95 -104
- package/src/shades.integration.spec.tsx +265 -252
- package/types/components/lazy-load.d.ts +1 -1
- package/types/components/lazy-load.d.ts.map +1 -1
- package/types/components/route-link.d.ts +1 -1
- package/types/components/route-link.d.ts.map +1 -1
- package/types/components/router.d.ts +6 -8
- package/types/components/router.d.ts.map +1 -1
- package/types/jsx.d.ts +3 -2
- package/types/jsx.d.ts.map +1 -1
- package/types/models/render-options.d.ts +46 -7
- package/types/models/render-options.d.ts.map +1 -1
- package/types/services/location-service.d.ts +21 -1
- package/types/services/location-service.d.ts.map +1 -1
- package/types/services/resource-manager.d.ts +16 -0
- package/types/services/resource-manager.d.ts.map +1 -0
- package/types/services/resource-manager.spec.d.ts +2 -0
- package/types/services/resource-manager.spec.d.ts.map +1 -0
- package/types/shade-component.d.ts +16 -5
- package/types/shade-component.d.ts.map +1 -1
- package/types/shade.d.ts +8 -27
- package/types/shade.d.ts.map +1 -1
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
import { Shade } from '../shade'
|
|
2
2
|
import type { PartialElement } from '../models'
|
|
3
3
|
import { LocationService } from '../services'
|
|
4
|
-
import { createComponent } from '..'
|
|
4
|
+
import { attachProps, createComponent } from '..'
|
|
5
5
|
|
|
6
6
|
export type RouteLinkProps = PartialElement<HTMLAnchorElement>
|
|
7
7
|
|
|
8
8
|
export const RouteLink = Shade<RouteLinkProps>({
|
|
9
9
|
shadowDomName: 'route-link',
|
|
10
|
-
render: ({ children, props, injector }) => {
|
|
10
|
+
render: ({ children, props, injector, element }) => {
|
|
11
|
+
attachProps(element, {
|
|
12
|
+
...props,
|
|
13
|
+
onclick: (ev: MouseEvent) => {
|
|
14
|
+
ev.preventDefault()
|
|
15
|
+
history.pushState('', props.title || '', props.href)
|
|
16
|
+
injector.getInstance(LocationService).updateState()
|
|
17
|
+
},
|
|
18
|
+
})
|
|
11
19
|
return (
|
|
12
|
-
<a
|
|
13
|
-
{...props}
|
|
14
|
-
onclick={(ev: MouseEvent) => {
|
|
15
|
-
ev.preventDefault()
|
|
16
|
-
history.pushState('', props.title || '', props.href)
|
|
17
|
-
injector.getInstance(LocationService).updateState()
|
|
18
|
-
}}
|
|
19
|
-
>
|
|
20
|
+
<a href={props.href} style={{ color: 'inherit', textDecoration: 'inherit' }} onclick={(e) => e.preventDefault()}>
|
|
20
21
|
{children}
|
|
21
22
|
</a>
|
|
22
23
|
)
|
|
@@ -25,7 +25,7 @@ describe('Router', () => {
|
|
|
25
25
|
|
|
26
26
|
const onRouteChange = jest.fn()
|
|
27
27
|
|
|
28
|
-
injector.getInstance(LocationService).
|
|
28
|
+
injector.getInstance(LocationService).onLocationPathChanged.subscribe(onRouteChange)
|
|
29
29
|
|
|
30
30
|
initializeShadeRoot({
|
|
31
31
|
injector,
|
|
@@ -61,7 +61,7 @@ describe('Router', () => {
|
|
|
61
61
|
},
|
|
62
62
|
{ url: '/', component: () => <div id="content">home</div> },
|
|
63
63
|
]}
|
|
64
|
-
notFound={
|
|
64
|
+
notFound={<div id="content">not found</div>}
|
|
65
65
|
/>
|
|
66
66
|
</div>
|
|
67
67
|
),
|
|
@@ -11,67 +11,67 @@ export interface Route<TMatchResult extends object> {
|
|
|
11
11
|
url: string
|
|
12
12
|
component: (options: { currentUrl: string; match: MatchResult<TMatchResult> }) => JSX.Element
|
|
13
13
|
routingOptions?: TokensToRegexpOptions
|
|
14
|
-
onVisit?: (options: RenderOptions<
|
|
15
|
-
onLeave?: (options: RenderOptions<
|
|
14
|
+
onVisit?: (options: RenderOptions<unknown>) => Promise<void>
|
|
15
|
+
onLeave?: (options: RenderOptions<unknown>) => Promise<void>
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export interface RouterProps {
|
|
19
19
|
style?: CSSStyleDeclaration
|
|
20
20
|
routes: Array<Route<any>>
|
|
21
|
-
notFound?:
|
|
21
|
+
notFound?: JSX.Element
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export interface RouterState {
|
|
25
|
-
activeRoute?: Route<any>
|
|
25
|
+
activeRoute?: Route<any> | null
|
|
26
26
|
activeRouteParams?: any
|
|
27
|
-
jsx
|
|
28
|
-
lock: Semaphore
|
|
27
|
+
jsx: JSX.Element
|
|
29
28
|
}
|
|
30
|
-
export const Router = Shade<RouterProps
|
|
29
|
+
export const Router = Shade<RouterProps>({
|
|
31
30
|
shadowDomName: 'shade-router',
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
31
|
+
render: (options) => {
|
|
32
|
+
const { useState, useObservable, injector } = options
|
|
33
|
+
const [lock] = useState('lock', new Semaphore(1))
|
|
34
|
+
const [state, setState] = useState<RouterState>('routerState', {
|
|
35
|
+
jsx: <div />,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const updateUrl = async (currentUrl: string) => {
|
|
39
|
+
const [lastState] = useState<RouterState>('routerState', state)
|
|
40
|
+
const { activeRoute: lastRoute, activeRouteParams: lastRouteParams, jsx: lastJsx } = lastState
|
|
38
41
|
try {
|
|
39
42
|
await lock.acquire()
|
|
40
|
-
for (const route of props.routes) {
|
|
43
|
+
for (const route of options.props.routes) {
|
|
41
44
|
const matchFn = match(route.url, route.routingOptions)
|
|
42
45
|
const matchResult = matchFn(currentUrl)
|
|
43
46
|
if (matchResult) {
|
|
44
|
-
if (route !== lastRoute || JSON.stringify(
|
|
45
|
-
await lastRoute?.onLeave?.({
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
activeRouteParams: matchResult.params,
|
|
50
|
-
})
|
|
51
|
-
await route.onVisit?.({ children, props, injector, updateState, getState, element })
|
|
47
|
+
if (route !== lastRoute || JSON.stringify(lastRouteParams) !== JSON.stringify(matchResult.params)) {
|
|
48
|
+
await lastRoute?.onLeave?.({ ...options, element: lastState.jsx })
|
|
49
|
+
const newJsx = route.component({ currentUrl, match: matchResult })
|
|
50
|
+
setState({ jsx: newJsx, activeRoute: route, activeRouteParams: matchResult.params })
|
|
51
|
+
await route.onVisit?.({ ...options, element: newJsx })
|
|
52
52
|
}
|
|
53
53
|
return
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
if (lastRoute?.onLeave) {
|
|
57
|
-
await lastRoute.onLeave({
|
|
57
|
+
await lastRoute.onLeave({ ...options, element: lastJsx })
|
|
58
58
|
}
|
|
59
|
-
|
|
59
|
+
setState({
|
|
60
|
+
jsx: options.props.notFound || <div />,
|
|
61
|
+
activeRoute: null,
|
|
62
|
+
activeRouteParams: null,
|
|
63
|
+
})
|
|
60
64
|
} catch (e) {
|
|
61
65
|
// path updates can be async, this can be ignored
|
|
62
66
|
if (!(e instanceof ObservableAlreadyDisposedError)) {
|
|
63
67
|
throw e
|
|
64
68
|
}
|
|
65
69
|
} finally {
|
|
66
|
-
lock
|
|
70
|
+
lock?.release()
|
|
67
71
|
}
|
|
68
|
-
}, true),
|
|
69
|
-
],
|
|
70
|
-
render: ({ getState }) => {
|
|
71
|
-
const { jsx } = getState()
|
|
72
|
-
if (jsx) {
|
|
73
|
-
return jsx
|
|
74
72
|
}
|
|
75
|
-
|
|
73
|
+
|
|
74
|
+
useObservable('locationPathChanged', injector.getInstance(LocationService).onLocationPathChanged, updateUrl, true)
|
|
75
|
+
return state.jsx
|
|
76
76
|
},
|
|
77
77
|
})
|
package/src/jsx.ts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import type { Injector } from '@furystack/inject'
|
|
2
2
|
import type { ChildrenList, PartialElement } from './models'
|
|
3
|
+
import type { ResourceManager } from './services/resource-manager'
|
|
3
4
|
|
|
4
5
|
declare global {
|
|
5
6
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
6
7
|
export namespace JSX {
|
|
7
|
-
interface Element<TProps = any
|
|
8
|
+
interface Element<TProps = any> extends HTMLElement {
|
|
8
9
|
injector: Injector
|
|
9
|
-
state: TState
|
|
10
10
|
props: TProps
|
|
11
11
|
updateComponent: () => void
|
|
12
12
|
shadeChildren?: ChildrenList
|
|
13
13
|
callConstructed: () => void
|
|
14
|
+
resourceManager: ResourceManager
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
interface IntrinsicElements {
|
|
@@ -1,16 +1,66 @@
|
|
|
1
1
|
import type { Injector } from '@furystack/inject'
|
|
2
|
-
import type { PartialElement } from './partial-element'
|
|
3
2
|
import type { ChildrenList } from './children-list'
|
|
3
|
+
import type { Disposable, ObservableValue } from '@furystack/utils'
|
|
4
4
|
|
|
5
|
-
export type RenderOptions<TProps
|
|
5
|
+
export type RenderOptions<TProps> = {
|
|
6
6
|
readonly props: TProps
|
|
7
7
|
|
|
8
8
|
injector: Injector
|
|
9
9
|
children?: ChildrenList
|
|
10
|
-
element: JSX.Element<TProps
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
element: JSX.Element<TProps>
|
|
11
|
+
/**
|
|
12
|
+
* Creates and disposes a resource after the component has been detached from the DOM
|
|
13
|
+
*
|
|
14
|
+
* @param key The key for caching the disposable resource
|
|
15
|
+
* @param factory A factory method for creating the disposable resource
|
|
16
|
+
* @returns The Disposable instance
|
|
17
|
+
*/
|
|
18
|
+
useDisposable: <T extends Disposable>(key: string, factory: () => T) => T
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
*
|
|
22
|
+
* @param key The key for caching the observable value
|
|
23
|
+
* @param observable The observable value to observe
|
|
24
|
+
* @param callback Optional callback for reacting to changes. If no callback provided, the component will re-render on change
|
|
25
|
+
* @param getLast An option to trigger the callback with the initial value
|
|
26
|
+
* @returns tuple with the current value and a setter function
|
|
27
|
+
*/
|
|
28
|
+
useObservable: <T>(
|
|
29
|
+
key: string,
|
|
30
|
+
observable: ObservableValue<T>,
|
|
31
|
+
onChange?: (newValue: T) => void,
|
|
32
|
+
getLast?: boolean,
|
|
33
|
+
) => [value: T, setValue: (newValue: T) => void]
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Creates a state object that will trigger a component re-render on change
|
|
37
|
+
*
|
|
38
|
+
* @param key The Key for caching the observable value
|
|
39
|
+
* @param initialValue The initial value for the observable
|
|
40
|
+
* @returns tuple with the current value and a setter function
|
|
41
|
+
*/
|
|
42
|
+
useState: <T>(key: string, initialValue: T) => [value: T, setValue: (newValue: T) => void]
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Creates a state object that will use a value from the search string of the current location. Triggers a component re-render on change
|
|
46
|
+
*
|
|
47
|
+
* @param key The Key for caching the observable value
|
|
48
|
+
* @param initialValue The initial value - if the value is not found in the search string
|
|
49
|
+
* @returns a tuple with the current value and a setter function
|
|
50
|
+
*/
|
|
51
|
+
useSearchState: <T>(key: string, initialValue: T) => [value: T, setValue: (newValue: T) => void]
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Creates a state object that will use a value from the storage area. Triggers a component re-render on change
|
|
55
|
+
*
|
|
56
|
+
* @param key The key in the storage area
|
|
57
|
+
* @param initialValue The initial value that will be used if the key is not found in the storage area
|
|
58
|
+
* @param storageArea The storage area to use
|
|
59
|
+
* @returns a tuple with the current value and a setter function
|
|
60
|
+
*/
|
|
61
|
+
useStoredState: <T>(
|
|
62
|
+
key: string,
|
|
63
|
+
initialValue: T,
|
|
64
|
+
storageArea?: Storage,
|
|
65
|
+
) => [value: T, setValue: (newValue: T) => void]
|
|
66
|
+
}
|
|
@@ -4,25 +4,25 @@ global.TextEncoder = TextEncoder
|
|
|
4
4
|
global.TextDecoder = TextDecoder as any
|
|
5
5
|
|
|
6
6
|
import { Injector } from '@furystack/inject'
|
|
7
|
-
import {
|
|
7
|
+
import { using } from '@furystack/utils'
|
|
8
8
|
import { LocationService } from './'
|
|
9
9
|
|
|
10
10
|
describe('LocationService', () => {
|
|
11
11
|
beforeEach(() => (document.body.innerHTML = '<div id="root"></div>'))
|
|
12
12
|
afterEach(() => (document.body.innerHTML = ''))
|
|
13
13
|
|
|
14
|
-
it('Shuld be constructed',
|
|
15
|
-
|
|
14
|
+
it('Shuld be constructed', () => {
|
|
15
|
+
using(new Injector(), (i) => {
|
|
16
16
|
const s = i.getInstance(LocationService)
|
|
17
17
|
expect(s).toBeInstanceOf(LocationService)
|
|
18
18
|
})
|
|
19
19
|
})
|
|
20
20
|
|
|
21
|
-
it('Shuld update state on events',
|
|
22
|
-
|
|
21
|
+
it('Shuld update state on events', () => {
|
|
22
|
+
using(new Injector(), (i) => {
|
|
23
23
|
const onLocaionChanged = jest.fn()
|
|
24
24
|
const s = i.getInstance(LocationService)
|
|
25
|
-
s.
|
|
25
|
+
s.onLocationPathChanged.subscribe(onLocaionChanged)
|
|
26
26
|
expect(onLocaionChanged).toBeCalledTimes(0)
|
|
27
27
|
history.pushState(null, '', '/loc1')
|
|
28
28
|
expect(onLocaionChanged).toBeCalledTimes(1)
|
|
@@ -36,4 +36,73 @@ describe('LocationService', () => {
|
|
|
36
36
|
// expect(onLocaionChanged).toBeCalledTimes(4)
|
|
37
37
|
})
|
|
38
38
|
})
|
|
39
|
+
|
|
40
|
+
describe('useSearchParam', () => {
|
|
41
|
+
it('Should create observables lazily', () => {
|
|
42
|
+
using(new Injector(), (i) => {
|
|
43
|
+
const service = i.getInstance(LocationService)
|
|
44
|
+
const observables = service.searchParamObservables
|
|
45
|
+
|
|
46
|
+
const testSearchParam = service.useSearchParam('test', null)
|
|
47
|
+
expect(observables.size).toBe(1)
|
|
48
|
+
|
|
49
|
+
const testSearchParam2 = service.useSearchParam('test', null)
|
|
50
|
+
expect(observables.size).toBe(1)
|
|
51
|
+
|
|
52
|
+
expect(testSearchParam).toBe(testSearchParam2)
|
|
53
|
+
|
|
54
|
+
const testSearchParam3 = service.useSearchParam('test2', undefined)
|
|
55
|
+
expect(observables.size).toBe(2)
|
|
56
|
+
|
|
57
|
+
expect(testSearchParam3).not.toBe(testSearchParam2)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('Should return the default value, if not present in the query string', () => {
|
|
62
|
+
using(new Injector(), (i) => {
|
|
63
|
+
const service = i.getInstance(LocationService)
|
|
64
|
+
const testSearchParam = service.useSearchParam('test', { value: 'foo' })
|
|
65
|
+
expect(testSearchParam.getValue()).toEqual({ value: 'foo' })
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('Should return a default value, if failed to parse the value from the query string', () => {
|
|
70
|
+
using(new Injector(), (i) => {
|
|
71
|
+
const service = i.getInstance(LocationService)
|
|
72
|
+
history.pushState(null, '', '/loc1?test=invalidValue')
|
|
73
|
+
const testSearchParam = service.useSearchParam('test', { value: 'foo' })
|
|
74
|
+
expect(testSearchParam.getValue()).toEqual({ value: 'foo' })
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('Should return the value from the query string', () => {
|
|
79
|
+
using(new Injector(), (i) => {
|
|
80
|
+
const service = i.getInstance(LocationService)
|
|
81
|
+
history.pushState(null, '', '/loc1?test=%25221%2522')
|
|
82
|
+
const testSearchParam = service.useSearchParam('test', 123)
|
|
83
|
+
expect(testSearchParam.getValue()).toBe('1')
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('should update the observable value on push / replace states', () => {
|
|
88
|
+
using(new Injector(), (i) => {
|
|
89
|
+
const service = i.getInstance(LocationService)
|
|
90
|
+
history.pushState(null, '', '/loc1?test=1')
|
|
91
|
+
const testSearchParam = service.useSearchParam('test', 234)
|
|
92
|
+
expect(testSearchParam.getValue()).toBe(1)
|
|
93
|
+
history.replaceState(null, '', '/loc1?test=2')
|
|
94
|
+
expect(testSearchParam.getValue()).toBe(2)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('Should update the URL based on search value change', () => {
|
|
99
|
+
using(new Injector(), (i) => {
|
|
100
|
+
const service = i.getInstance(LocationService)
|
|
101
|
+
history.pushState(null, '', '/loc1?test=%25221%2522')
|
|
102
|
+
const testSearchParam = service.useSearchParam('test', '')
|
|
103
|
+
testSearchParam.setValue('2')
|
|
104
|
+
expect(location.search).toBe('?test=%25222%2522')
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
})
|
|
39
108
|
})
|
|
@@ -8,14 +8,68 @@ export class LocationService implements Disposable {
|
|
|
8
8
|
window.removeEventListener('hashchange', this.updateState)
|
|
9
9
|
this.pushStateTracer.dispose()
|
|
10
10
|
this.replaceStateTracer.dispose()
|
|
11
|
-
this.
|
|
11
|
+
this.onLocationPathChanged.dispose()
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Observable value that will be updated when the location pathname (e.g. /page/1) changes
|
|
16
|
+
*/
|
|
17
|
+
public onLocationPathChanged = new ObservableValue(new URL(location.href).pathname)
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Observable value that will be updated when the location hash (e.g. #hash) changes
|
|
21
|
+
*/
|
|
22
|
+
public onLocationHashChanged = new ObservableValue(location.hash)
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Observable value that will be updated when the location search (e.g. ?search=1) changes
|
|
26
|
+
*/
|
|
27
|
+
public onLocationSearchChanged = new ObservableValue(location.search)
|
|
15
28
|
|
|
16
29
|
public updateState() {
|
|
17
|
-
|
|
18
|
-
this.
|
|
30
|
+
this.onLocationPathChanged.setValue(location.pathname)
|
|
31
|
+
this.onLocationHashChanged.setValue(location.hash)
|
|
32
|
+
this.onLocationSearchChanged.setValue(location.search)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
public readonly searchParamObservables = new Map<string, ObservableValue<any>>()
|
|
36
|
+
|
|
37
|
+
private tryGetValueFromSearch = (key: string, search: string): any | undefined => {
|
|
38
|
+
try {
|
|
39
|
+
const params = new URLSearchParams(search)
|
|
40
|
+
if (params.has(key)) {
|
|
41
|
+
const value = params.get(key)
|
|
42
|
+
return value && JSON.parse(decodeURIComponent(value))
|
|
43
|
+
}
|
|
44
|
+
} catch (error) {
|
|
45
|
+
/** ignore */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
*
|
|
51
|
+
* @param key The search param key (e.g. ?search=1 -> search)
|
|
52
|
+
* @param defaultValue The default value if not provided
|
|
53
|
+
* @returns An observable with the current value (or default value) of the search param
|
|
54
|
+
*/
|
|
55
|
+
public useSearchParam<T>(key: string, defaultValue: T) {
|
|
56
|
+
const actualValue = (this.tryGetValueFromSearch(key, location.search) as T) ?? defaultValue
|
|
57
|
+
if (!this.searchParamObservables.has(key)) {
|
|
58
|
+
const newObservable = new ObservableValue(actualValue)
|
|
59
|
+
this.searchParamObservables.set(key, newObservable)
|
|
60
|
+
|
|
61
|
+
newObservable.subscribe((value) => {
|
|
62
|
+
const params = new URLSearchParams(location.search)
|
|
63
|
+
params.set(key, encodeURIComponent(JSON.stringify(value)))
|
|
64
|
+
history.pushState({}, '', `${location.pathname}?${params}`)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
this.onLocationSearchChanged.subscribe((search) => {
|
|
68
|
+
const value = this.tryGetValueFromSearch(key, search) || defaultValue
|
|
69
|
+
this.searchParamObservables.get(key)?.setValue(value as T)
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
return this.searchParamObservables.get(key) as ObservableValue<T>
|
|
19
73
|
}
|
|
20
74
|
|
|
21
75
|
private pushStateTracer: Disposable
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ObservableValue, using } from '@furystack/utils'
|
|
2
|
+
import { ResourceManager } from './resource-manager'
|
|
3
|
+
describe('ResourceManager', () => {
|
|
4
|
+
it('Should return an observable from cache', () => {
|
|
5
|
+
using(new ResourceManager(), (rm) => {
|
|
6
|
+
const o = new ObservableValue(1)
|
|
7
|
+
const [value1] = rm.useObservable('test', o, () => {
|
|
8
|
+
/** ignore */
|
|
9
|
+
})
|
|
10
|
+
const [value2] = rm.useObservable('test', o, () => {
|
|
11
|
+
/** ignore */
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
expect(value1).toBe(value2)
|
|
15
|
+
|
|
16
|
+
expect(o.getObservers().length).toBe(1)
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('Should return a disposable from cache', () => {
|
|
21
|
+
using(new ResourceManager(), (rm) => {
|
|
22
|
+
const d = {
|
|
23
|
+
dispose: () => {
|
|
24
|
+
/** ignore */
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
const d1 = rm.useDisposable('test', () => d)
|
|
28
|
+
const d2 = rm.useDisposable('test', () => d)
|
|
29
|
+
|
|
30
|
+
expect(d1).toBe(d2)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ObservableValue } from '@furystack/utils'
|
|
2
|
+
import type { Disposable, ValueChangeCallback } from '@furystack/utils'
|
|
3
|
+
import type { ValueObserver } from '@furystack/utils'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Class for managing observables and disposables for components, based on key-value maps
|
|
7
|
+
*/
|
|
8
|
+
export class ResourceManager {
|
|
9
|
+
private readonly disposables = new Map<string, Disposable>()
|
|
10
|
+
|
|
11
|
+
public useDisposable<T extends Disposable>(key: string, factory: () => T): T {
|
|
12
|
+
if (!this.disposables.has(key)) {
|
|
13
|
+
this.disposables.set(key, factory())
|
|
14
|
+
}
|
|
15
|
+
return this.disposables.get(key) as T
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public readonly observers = new Map<string, ValueObserver<any>>()
|
|
19
|
+
|
|
20
|
+
public useObservable = <T>(
|
|
21
|
+
key: string,
|
|
22
|
+
observable: ObservableValue<T>,
|
|
23
|
+
callback: ValueChangeCallback<T>,
|
|
24
|
+
getLast?: boolean,
|
|
25
|
+
): [value: T, setValue: (newValue: T) => void] => {
|
|
26
|
+
const alreadyUsed = this.observers.get(key) as ValueObserver<T> | undefined
|
|
27
|
+
if (alreadyUsed) {
|
|
28
|
+
return [alreadyUsed.observable.getValue(), alreadyUsed.observable.setValue.bind(alreadyUsed.observable)]
|
|
29
|
+
}
|
|
30
|
+
const observer = observable.subscribe(callback, getLast)
|
|
31
|
+
this.observers.set(key, observer)
|
|
32
|
+
return [observable.getValue(), observable.setValue.bind(observable)]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
public readonly stateObservers = new Map<string, ObservableValue<any>>()
|
|
36
|
+
|
|
37
|
+
public useState = <T>(
|
|
38
|
+
key: string,
|
|
39
|
+
initialValue: T,
|
|
40
|
+
callback: ValueChangeCallback<T>,
|
|
41
|
+
): [value: T, setValue: (newValue: T) => void] => {
|
|
42
|
+
if (!this.stateObservers.has(key)) {
|
|
43
|
+
const newObservable = new ObservableValue<T>(initialValue)
|
|
44
|
+
this.stateObservers.set(key, newObservable)
|
|
45
|
+
newObservable.subscribe(callback)
|
|
46
|
+
}
|
|
47
|
+
const observable = this.stateObservers.get(key) as ObservableValue<T>
|
|
48
|
+
return [observable.getValue(), observable.setValue.bind(observable)]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
public dispose() {
|
|
52
|
+
this.disposables.forEach((r) => r.dispose())
|
|
53
|
+
this.disposables.clear()
|
|
54
|
+
this.observers.forEach((r) => r.dispose())
|
|
55
|
+
this.observers.clear()
|
|
56
|
+
|
|
57
|
+
this.stateObservers.forEach((r) => r.dispose())
|
|
58
|
+
this.stateObservers.clear()
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/shade-component.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { isShadeComponent } from './models'
|
|
|
9
9
|
*/
|
|
10
10
|
export const appendChild = (el: HTMLElement | DocumentFragment, children: ChildrenList) => {
|
|
11
11
|
for (const child of children) {
|
|
12
|
-
if (typeof child === 'string') {
|
|
12
|
+
if (typeof child === 'string' || typeof child === 'number') {
|
|
13
13
|
el.appendChild(document.createTextNode(child))
|
|
14
14
|
} else {
|
|
15
15
|
if (child instanceof HTMLElement || child instanceof DocumentFragment) {
|
|
@@ -46,26 +46,37 @@ export const attachDataAttributes = (el: HTMLElement, props: any) => {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
* Factory method that creates a component. This should be configured as a default JSX Factory in tsconfig.
|
|
50
49
|
*
|
|
51
|
-
* @param
|
|
52
|
-
* @param props The
|
|
53
|
-
* @param children additional rest parameters will be parsed as children objects
|
|
54
|
-
* @returns the created JSX element
|
|
50
|
+
* @param el The Target HTML Element
|
|
51
|
+
* @param props The Props to attach
|
|
55
52
|
*/
|
|
56
|
-
export const
|
|
53
|
+
export const attachProps = (el: HTMLElement, props: any) => {
|
|
54
|
+
Object.assign(el, props)
|
|
55
|
+
|
|
56
|
+
if (props && (props as any).style) {
|
|
57
|
+
attachStyles(el, props)
|
|
58
|
+
}
|
|
59
|
+
attachDataAttributes(el, props)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type CreateComponentArgs<TProps> = [
|
|
57
63
|
elementType: string | ShadeComponent<TProps>,
|
|
58
64
|
props: TProps,
|
|
59
|
-
...children: ChildrenList
|
|
60
|
-
|
|
65
|
+
...children: ChildrenList,
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
// eslint-disable-next-line jsdoc/require-param
|
|
69
|
+
/**
|
|
70
|
+
* Factory method that creates a component. This should be configured as a default JSX Factory in tsconfig.
|
|
71
|
+
*
|
|
72
|
+
* @returns the created JSX element
|
|
73
|
+
*/
|
|
74
|
+
export const createComponentInner = <TProps>(...[elementType, props, ...children]: CreateComponentArgs<TProps>) => {
|
|
61
75
|
if (typeof elementType === 'string') {
|
|
62
76
|
const el = document.createElement(elementType)
|
|
63
|
-
Object.assign(el, props)
|
|
64
77
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
attachDataAttributes(el, props)
|
|
78
|
+
attachProps(el, props)
|
|
79
|
+
|
|
69
80
|
if (children) {
|
|
70
81
|
appendChild(el, children)
|
|
71
82
|
}
|
|
@@ -78,8 +89,17 @@ export const createComponent = <TProps>(
|
|
|
78
89
|
return undefined
|
|
79
90
|
}
|
|
80
91
|
|
|
81
|
-
|
|
92
|
+
type CreateFragmentArgs = [props: null, ...children: ChildrenList]
|
|
93
|
+
|
|
94
|
+
export const createFragmentInner = (...[_props, ...children]: CreateFragmentArgs) => {
|
|
82
95
|
const fragment = document.createDocumentFragment()
|
|
83
96
|
appendChild(fragment, children)
|
|
84
97
|
return fragment
|
|
85
98
|
}
|
|
99
|
+
|
|
100
|
+
export const createComponent = <TProps>(...args: CreateComponentArgs<TProps> | CreateFragmentArgs) => {
|
|
101
|
+
if (args[0] === null) {
|
|
102
|
+
return createFragmentInner(...args)
|
|
103
|
+
}
|
|
104
|
+
return createComponentInner(...args)
|
|
105
|
+
}
|
|
@@ -24,22 +24,15 @@ describe('Shade Resources integration tests', () => {
|
|
|
24
24
|
const obs2 = new ObservableValue('a')
|
|
25
25
|
|
|
26
26
|
const ExampleComponent = Shade({
|
|
27
|
-
|
|
28
|
-
obs1
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
),
|
|
32
|
-
obs2.subscribe(
|
|
33
|
-
(val2) => ((element.querySelector('#val2') as HTMLDivElement).innerHTML = val2.toString()),
|
|
34
|
-
true,
|
|
35
|
-
),
|
|
36
|
-
],
|
|
37
|
-
render: () => {
|
|
27
|
+
render: ({ useObservable }) => {
|
|
28
|
+
const [value1] = useObservable('obs1', obs1)
|
|
29
|
+
const [value2] = useObservable('obs2', obs2)
|
|
30
|
+
|
|
38
31
|
renderCounter()
|
|
39
32
|
return (
|
|
40
33
|
<div>
|
|
41
|
-
<div id="val1"
|
|
42
|
-
<div id="val2"
|
|
34
|
+
<div id="val1">{value1}</div>
|
|
35
|
+
<div id="val2">{value2}</div>
|
|
43
36
|
</div>
|
|
44
37
|
)
|
|
45
38
|
},
|
|
@@ -67,6 +60,7 @@ describe('Shade Resources integration tests', () => {
|
|
|
67
60
|
expect(document.body.innerHTML).toBe(
|
|
68
61
|
'<div id="root"><shades-example-resource><div><div id="val1">1</div><div id="val2">a</div></div></shades-example-resource></div>',
|
|
69
62
|
)
|
|
63
|
+
expect(renderCounter).toBeCalledTimes(2)
|
|
70
64
|
|
|
71
65
|
obs2.setValue('b')
|
|
72
66
|
expect(document.body.innerHTML).toBe(
|
|
@@ -78,6 +72,6 @@ describe('Shade Resources integration tests', () => {
|
|
|
78
72
|
expect(obs1.getObservers().length).toBe(0)
|
|
79
73
|
expect(obs2.getObservers().length).toBe(0)
|
|
80
74
|
|
|
81
|
-
expect(renderCounter).toBeCalledTimes(
|
|
75
|
+
expect(renderCounter).toBeCalledTimes(3)
|
|
82
76
|
})
|
|
83
77
|
})
|