@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.
Files changed (65) hide show
  1. package/dist/component-factory.spec.js +6 -6
  2. package/dist/component-factory.spec.js.map +1 -1
  3. package/dist/components/lazy-load.js +17 -14
  4. package/dist/components/lazy-load.js.map +1 -1
  5. package/dist/components/route-link.js +7 -3
  6. package/dist/components/route-link.js.map +1 -1
  7. package/dist/components/route-link.spec.js +2 -2
  8. package/dist/components/route-link.spec.js.map +1 -1
  9. package/dist/components/router.js +25 -26
  10. package/dist/components/router.js.map +1 -1
  11. package/dist/components/router.spec.js +2 -2
  12. package/dist/components/router.spec.js.map +1 -1
  13. package/dist/services/location-service.js +52 -4
  14. package/dist/services/location-service.js.map +1 -1
  15. package/dist/services/location-service.spec.js +63 -5
  16. package/dist/services/location-service.spec.js.map +1 -1
  17. package/dist/services/resource-manager.js +48 -0
  18. package/dist/services/resource-manager.js.map +1 -0
  19. package/dist/services/resource-manager.spec.js +32 -0
  20. package/dist/services/resource-manager.spec.js.map +1 -0
  21. package/dist/shade-component.js +28 -14
  22. package/dist/shade-component.js.map +1 -1
  23. package/dist/shade-resources.integration.spec.js +7 -8
  24. package/dist/shade-resources.integration.spec.js.map +1 -1
  25. package/dist/shade.js +79 -69
  26. package/dist/shade.js.map +1 -1
  27. package/dist/shades.integration.spec.js +197 -187
  28. package/dist/shades.integration.spec.js.map +1 -1
  29. package/package.json +6 -6
  30. package/src/component-factory.spec.tsx +7 -7
  31. package/src/components/lazy-load.tsx +19 -15
  32. package/src/components/route-link.spec.tsx +2 -2
  33. package/src/components/route-link.tsx +11 -10
  34. package/src/components/router.spec.tsx +2 -2
  35. package/src/components/router.tsx +32 -32
  36. package/src/jsx.ts +3 -2
  37. package/src/models/render-options.ts +59 -9
  38. package/src/services/location-service.spec.ts +75 -6
  39. package/src/services/location-service.tsx +58 -4
  40. package/src/services/resource-manager.spec.ts +33 -0
  41. package/src/services/resource-manager.ts +60 -0
  42. package/src/shade-component.ts +35 -15
  43. package/src/shade-resources.integration.spec.tsx +8 -14
  44. package/src/shade.ts +95 -104
  45. package/src/shades.integration.spec.tsx +265 -252
  46. package/types/components/lazy-load.d.ts +1 -1
  47. package/types/components/lazy-load.d.ts.map +1 -1
  48. package/types/components/route-link.d.ts +1 -1
  49. package/types/components/route-link.d.ts.map +1 -1
  50. package/types/components/router.d.ts +6 -8
  51. package/types/components/router.d.ts.map +1 -1
  52. package/types/jsx.d.ts +3 -2
  53. package/types/jsx.d.ts.map +1 -1
  54. package/types/models/render-options.d.ts +46 -7
  55. package/types/models/render-options.d.ts.map +1 -1
  56. package/types/services/location-service.d.ts +21 -1
  57. package/types/services/location-service.d.ts.map +1 -1
  58. package/types/services/resource-manager.d.ts +16 -0
  59. package/types/services/resource-manager.d.ts.map +1 -0
  60. package/types/services/resource-manager.spec.d.ts +2 -0
  61. package/types/services/resource-manager.spec.d.ts.map +1 -0
  62. package/types/shade-component.d.ts +16 -5
  63. package/types/shade-component.d.ts.map +1 -1
  64. package/types/shade.d.ts +8 -27
  65. 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).onLocationChanged.subscribe(onRouteChange)
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={() => <div id="content">not found</div>}
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<RouterProps, RouterState>) => Promise<void>
15
- onLeave?: (options: RenderOptions<RouterProps, RouterState>) => Promise<void>
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?: (currentUrl: string) => JSX.Element
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?: JSX.Element
28
- lock: Semaphore
27
+ jsx: JSX.Element
29
28
  }
30
- export const Router = Shade<RouterProps, RouterState>({
29
+ export const Router = Shade<RouterProps>({
31
30
  shadowDomName: 'shade-router',
32
- getInitialState: () => ({
33
- lock: new Semaphore(1),
34
- }),
35
- resources: ({ children, props, injector, updateState, getState, element }) => [
36
- injector.getInstance(LocationService).onLocationChanged.subscribe(async (currentUrl) => {
37
- const { activeRoute: lastRoute, activeRouteParams: lastParams, lock } = getState()
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(lastParams) !== JSON.stringify(matchResult.params)) {
45
- await lastRoute?.onLeave?.({ children, props, injector, updateState, getState, element })
46
- updateState({
47
- jsx: route.component({ currentUrl, match: matchResult }),
48
- activeRoute: route,
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({ children, props, injector, updateState, getState, element })
57
+ await lastRoute.onLeave({ ...options, element: lastJsx })
58
58
  }
59
- updateState({ jsx: props.notFound?.(currentUrl), activeRoute: undefined })
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.release()
70
+ lock?.release()
67
71
  }
68
- }, true),
69
- ],
70
- render: ({ getState }) => {
71
- const { jsx } = getState()
72
- if (jsx) {
73
- return jsx
74
72
  }
75
- return <div></div>
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, TState = any> extends HTMLElement {
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, TState> = {
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, TState>
11
- } & (unknown extends TState
12
- ? {}
13
- : {
14
- getState: () => TState
15
- updateState: (newState: PartialElement<TState>, skipRender?: boolean) => void
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 { usingAsync } from '@furystack/utils'
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', async () => {
15
- await usingAsync(new Injector(), async (i) => {
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', async () => {
22
- await usingAsync(new Injector(), async (i) => {
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.onLocationChanged.subscribe(onLocaionChanged)
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.onLocationChanged.dispose()
11
+ this.onLocationPathChanged.dispose()
12
12
  }
13
13
 
14
- public onLocationChanged = new ObservableValue(new URL(location.href).pathname)
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
- const newUrl = new URL(location.href)
18
- this.onLocationChanged.setValue(newUrl.pathname)
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
+ }
@@ -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 elementType The type of the element (component or stateless component factory method)
52
- * @param props The props for the component
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 createComponent = <TProps>(
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
- if (props && (props as any).style) {
66
- attachStyles(el, props)
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
- export const createFragment = (_props: any, children: ChildrenList) => {
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
- resources: ({ element }) => [
28
- obs1.subscribe(
29
- (val1) => ((element.querySelector('#val1') as HTMLDivElement).innerHTML = val1.toString()),
30
- true,
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"></div>
42
- <div id="val2"></div>
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(1)
75
+ expect(renderCounter).toBeCalledTimes(3)
82
76
  })
83
77
  })