@furystack/shades 11.1.0 → 12.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/CHANGELOG.md +312 -0
  2. package/README.md +13 -13
  3. package/esm/component-factory.spec.js +13 -5
  4. package/esm/component-factory.spec.js.map +1 -1
  5. package/esm/components/index.d.ts +4 -1
  6. package/esm/components/index.d.ts.map +1 -1
  7. package/esm/components/index.js +4 -1
  8. package/esm/components/index.js.map +1 -1
  9. package/esm/components/lazy-load.d.ts +2 -4
  10. package/esm/components/lazy-load.d.ts.map +1 -1
  11. package/esm/components/lazy-load.js +40 -24
  12. package/esm/components/lazy-load.js.map +1 -1
  13. package/esm/components/lazy-load.spec.js +57 -50
  14. package/esm/components/lazy-load.spec.js.map +1 -1
  15. package/esm/components/link-to-route.d.ts +2 -0
  16. package/esm/components/link-to-route.d.ts.map +1 -1
  17. package/esm/components/link-to-route.js +3 -2
  18. package/esm/components/link-to-route.js.map +1 -1
  19. package/esm/components/link-to-route.spec.js +13 -9
  20. package/esm/components/link-to-route.spec.js.map +1 -1
  21. package/esm/components/nested-route-link.d.ts +62 -0
  22. package/esm/components/nested-route-link.d.ts.map +1 -0
  23. package/esm/components/nested-route-link.js +66 -0
  24. package/esm/components/nested-route-link.js.map +1 -0
  25. package/esm/components/nested-route-link.spec.d.ts +2 -0
  26. package/esm/components/nested-route-link.spec.d.ts.map +1 -0
  27. package/esm/components/nested-route-link.spec.js +179 -0
  28. package/esm/components/nested-route-link.spec.js.map +1 -0
  29. package/esm/components/nested-route-types.d.ts +37 -0
  30. package/esm/components/nested-route-types.d.ts.map +1 -0
  31. package/esm/components/nested-route-types.js +2 -0
  32. package/esm/components/nested-route-types.js.map +1 -0
  33. package/esm/components/nested-router.d.ts +103 -0
  34. package/esm/components/nested-router.d.ts.map +1 -0
  35. package/esm/components/nested-router.js +183 -0
  36. package/esm/components/nested-router.js.map +1 -0
  37. package/esm/components/nested-router.spec.d.ts +2 -0
  38. package/esm/components/nested-router.spec.d.ts.map +1 -0
  39. package/esm/components/nested-router.spec.js +737 -0
  40. package/esm/components/nested-router.spec.js.map +1 -0
  41. package/esm/components/route-link.d.ts +4 -0
  42. package/esm/components/route-link.d.ts.map +1 -1
  43. package/esm/components/route-link.js +5 -5
  44. package/esm/components/route-link.js.map +1 -1
  45. package/esm/components/route-link.spec.js +16 -12
  46. package/esm/components/route-link.spec.js.map +1 -1
  47. package/esm/components/router.d.ts +20 -2
  48. package/esm/components/router.d.ts.map +1 -1
  49. package/esm/components/router.js +12 -7
  50. package/esm/components/router.js.map +1 -1
  51. package/esm/components/router.spec.js +141 -74
  52. package/esm/components/router.spec.js.map +1 -1
  53. package/esm/initialize.d.ts +11 -0
  54. package/esm/initialize.d.ts.map +1 -1
  55. package/esm/initialize.js +5 -0
  56. package/esm/initialize.js.map +1 -1
  57. package/esm/jsx.d.ts +83 -2
  58. package/esm/jsx.d.ts.map +1 -1
  59. package/esm/models/children-list.d.ts +5 -1
  60. package/esm/models/children-list.d.ts.map +1 -1
  61. package/esm/models/partial-element.d.ts +12 -2
  62. package/esm/models/partial-element.d.ts.map +1 -1
  63. package/esm/models/render-options.d.ts +89 -3
  64. package/esm/models/render-options.d.ts.map +1 -1
  65. package/esm/models/selection-state.d.ts +4 -0
  66. package/esm/models/selection-state.d.ts.map +1 -1
  67. package/esm/services/location-service.d.ts +11 -0
  68. package/esm/services/location-service.d.ts.map +1 -1
  69. package/esm/services/location-service.js +11 -0
  70. package/esm/services/location-service.js.map +1 -1
  71. package/esm/services/resource-manager.d.ts +24 -0
  72. package/esm/services/resource-manager.d.ts.map +1 -1
  73. package/esm/services/resource-manager.js +36 -1
  74. package/esm/services/resource-manager.js.map +1 -1
  75. package/esm/services/resource-manager.spec.js +102 -0
  76. package/esm/services/resource-manager.spec.js.map +1 -1
  77. package/esm/services/screen-service.d.ts +81 -4
  78. package/esm/services/screen-service.d.ts.map +1 -1
  79. package/esm/services/screen-service.js +75 -4
  80. package/esm/services/screen-service.js.map +1 -1
  81. package/esm/services/screen-service.spec.js +91 -7
  82. package/esm/services/screen-service.spec.js.map +1 -1
  83. package/esm/shade-component.d.ts +17 -4
  84. package/esm/shade-component.d.ts.map +1 -1
  85. package/esm/shade-component.js +67 -5
  86. package/esm/shade-component.js.map +1 -1
  87. package/esm/shade-host-props-ref.integration.spec.d.ts +2 -0
  88. package/esm/shade-host-props-ref.integration.spec.d.ts.map +1 -0
  89. package/esm/shade-host-props-ref.integration.spec.js +381 -0
  90. package/esm/shade-host-props-ref.integration.spec.js.map +1 -0
  91. package/esm/shade-resources.integration.spec.js +208 -39
  92. package/esm/shade-resources.integration.spec.js.map +1 -1
  93. package/esm/shade.d.ts +20 -17
  94. package/esm/shade.d.ts.map +1 -1
  95. package/esm/shade.js +172 -33
  96. package/esm/shade.js.map +1 -1
  97. package/esm/shade.spec.js +31 -30
  98. package/esm/shade.spec.js.map +1 -1
  99. package/esm/shades.integration.spec.js +135 -72
  100. package/esm/shades.integration.spec.js.map +1 -1
  101. package/esm/style-manager.d.ts +2 -2
  102. package/esm/style-manager.js +2 -2
  103. package/esm/svg-types.d.ts +389 -0
  104. package/esm/svg-types.d.ts.map +1 -0
  105. package/esm/svg-types.js +9 -0
  106. package/esm/svg-types.js.map +1 -0
  107. package/esm/svg.d.ts +15 -0
  108. package/esm/svg.d.ts.map +1 -0
  109. package/esm/svg.js +76 -0
  110. package/esm/svg.js.map +1 -0
  111. package/esm/svg.spec.d.ts +2 -0
  112. package/esm/svg.spec.d.ts.map +1 -0
  113. package/esm/svg.spec.js +80 -0
  114. package/esm/svg.spec.js.map +1 -0
  115. package/esm/vnode.d.ts +103 -0
  116. package/esm/vnode.d.ts.map +1 -0
  117. package/esm/vnode.integration.spec.d.ts +2 -0
  118. package/esm/vnode.integration.spec.d.ts.map +1 -0
  119. package/esm/vnode.integration.spec.js +494 -0
  120. package/esm/vnode.integration.spec.js.map +1 -0
  121. package/esm/vnode.js +453 -0
  122. package/esm/vnode.js.map +1 -0
  123. package/esm/vnode.spec.d.ts +2 -0
  124. package/esm/vnode.spec.d.ts.map +1 -0
  125. package/esm/vnode.spec.js +473 -0
  126. package/esm/vnode.spec.js.map +1 -0
  127. package/package.json +8 -9
  128. package/src/component-factory.spec.tsx +18 -5
  129. package/src/components/index.ts +4 -1
  130. package/src/components/lazy-load.spec.tsx +82 -75
  131. package/src/components/lazy-load.tsx +49 -27
  132. package/src/components/link-to-route.spec.tsx +25 -21
  133. package/src/components/link-to-route.tsx +4 -2
  134. package/src/components/nested-route-link.spec.tsx +303 -0
  135. package/src/components/nested-route-link.tsx +100 -0
  136. package/src/components/nested-route-types.ts +42 -0
  137. package/src/components/nested-router.spec.tsx +918 -0
  138. package/src/components/nested-router.tsx +260 -0
  139. package/src/components/route-link.spec.tsx +22 -18
  140. package/src/components/route-link.tsx +6 -5
  141. package/src/components/router.spec.tsx +196 -108
  142. package/src/components/router.tsx +21 -8
  143. package/src/initialize.ts +12 -0
  144. package/src/jsx.ts +129 -2
  145. package/src/models/children-list.ts +7 -1
  146. package/src/models/partial-element.ts +13 -2
  147. package/src/models/render-options.ts +90 -3
  148. package/src/models/selection-state.ts +4 -0
  149. package/src/services/location-service.tsx +11 -0
  150. package/src/services/resource-manager.spec.ts +128 -0
  151. package/src/services/resource-manager.ts +36 -1
  152. package/src/services/screen-service.spec.ts +109 -7
  153. package/src/services/screen-service.ts +81 -4
  154. package/src/shade-component.ts +72 -6
  155. package/src/shade-host-props-ref.integration.spec.tsx +460 -0
  156. package/src/shade-resources.integration.spec.tsx +276 -52
  157. package/src/shade.spec.tsx +40 -39
  158. package/src/shade.ts +186 -58
  159. package/src/shades.integration.spec.tsx +154 -80
  160. package/src/style-manager.ts +2 -2
  161. package/src/svg-types.ts +437 -0
  162. package/src/svg.spec.ts +89 -0
  163. package/src/svg.ts +78 -0
  164. package/src/vnode.integration.spec.tsx +657 -0
  165. package/src/vnode.spec.ts +579 -0
  166. package/src/vnode.ts +508 -0
@@ -1,12 +1,14 @@
1
1
  import { ObservableAlreadyDisposedError } from '@furystack/utils'
2
2
  import type { MatchOptions, MatchResult } from 'path-to-regexp'
3
3
  import { match } from 'path-to-regexp'
4
- import { Lock } from 'semaphore-async-await'
5
4
  import type { RenderOptions } from '../models/render-options.js'
6
5
  import { LocationService } from '../services/location-service.js'
7
6
  import { createComponent } from '../shade-component.js'
8
7
  import { Shade } from '../shade.js'
9
8
 
9
+ /**
10
+ * @deprecated Use NestedRouter instead
11
+ */
10
12
  export interface Route<TMatchResult = unknown> {
11
13
  url: string
12
14
  component: (options: {
@@ -14,26 +16,36 @@ export interface Route<TMatchResult = unknown> {
14
16
  match: MatchResult<TMatchResult extends object ? TMatchResult : object>
15
17
  }) => JSX.Element
16
18
  routingOptions?: MatchOptions
17
- onVisit?: (options: RenderOptions<unknown>) => Promise<void>
18
- onLeave?: (options: RenderOptions<unknown>) => Promise<void>
19
+ onVisit?: (options: RenderOptions<unknown> & { element: JSX.Element }) => Promise<void>
20
+ onLeave?: (options: RenderOptions<unknown> & { element: JSX.Element }) => Promise<void>
19
21
  }
20
22
 
23
+ /**
24
+ * @deprecated Use NestedRouterProps instead
25
+ */
21
26
  export interface RouterProps {
22
27
  style?: CSSStyleDeclaration
23
28
  routes: Array<Route<any>>
24
29
  notFound?: JSX.Element
25
30
  }
26
31
 
32
+ /**
33
+ * @deprecated Use NestedRouterState instead
34
+ */
27
35
  export interface RouterState {
28
36
  activeRoute?: Route<unknown> | null
29
37
  activeRouteParams?: unknown
30
38
  jsx: JSX.Element
31
39
  }
40
+
41
+ /**
42
+ * @deprecated Use NestedRouter instead
43
+ */
32
44
  export const Router = Shade<RouterProps>({
33
45
  shadowDomName: 'shade-router',
34
46
  render: (options) => {
35
47
  const { useState, useObservable, injector } = options
36
- const [lock] = useState('lock', new Lock())
48
+ const [versionRef] = useState('navVersion', { current: 0 })
37
49
  const [state, setState] = useState<RouterState>('routerState', {
38
50
  jsx: <div />,
39
51
  })
@@ -42,23 +54,27 @@ export const Router = Shade<RouterProps>({
42
54
  const [lastState] = useState<RouterState>('routerState', state)
43
55
  const { activeRoute: lastRoute, activeRouteParams: lastRouteParams, jsx: lastJsx } = lastState
44
56
  try {
45
- await lock.acquire()
46
57
  for (const route of options.props.routes) {
47
58
  const matchFn = match(route.url, route.routingOptions)
48
59
  const matchResult = matchFn(currentUrl)
49
60
  if (matchResult) {
50
61
  if (route !== lastRoute || JSON.stringify(lastRouteParams) !== JSON.stringify(matchResult.params)) {
62
+ const version = ++versionRef.current
51
63
  await lastRoute?.onLeave?.({ ...options, element: lastState.jsx })
64
+ if (version !== versionRef.current) return
52
65
  const newJsx = route.component({ currentUrl, match: matchResult })
53
66
  setState({ jsx: newJsx, activeRoute: route, activeRouteParams: matchResult.params })
54
67
  await route.onVisit?.({ ...options, element: newJsx })
68
+ if (version !== versionRef.current) return
55
69
  }
56
70
  return
57
71
  }
58
72
  }
59
73
 
60
74
  if (lastRoute !== null) {
75
+ const version = ++versionRef.current
61
76
  await lastRoute?.onLeave?.({ ...options, element: lastJsx })
77
+ if (version !== versionRef.current) return
62
78
  setState({
63
79
  jsx: options.props.notFound || <div />,
64
80
  activeRoute: null,
@@ -66,12 +82,9 @@ export const Router = Shade<RouterProps>({
66
82
  })
67
83
  }
68
84
  } catch (e) {
69
- // path updates can be async, this can be ignored
70
85
  if (!(e instanceof ObservableAlreadyDisposedError)) {
71
86
  throw e
72
87
  }
73
- } finally {
74
- lock?.release()
75
88
  }
76
89
  }
77
90
 
package/src/initialize.ts CHANGED
@@ -1,10 +1,22 @@
1
1
  import type { Injector } from '@furystack/inject'
2
2
 
3
+ /**
4
+ * Options for bootstrapping a Shades application.
5
+ */
3
6
  export interface InitializeOptions {
7
+ /** The DOM element that will host the application */
4
8
  rootElement: HTMLElement
9
+ /** The root JSX element to render */
5
10
  jsxElement: JSX.Element
11
+ /** The root injector instance for dependency injection */
6
12
  injector: Injector
7
13
  }
14
+
15
+ /**
16
+ * Bootstraps a Shades application by attaching the root JSX element to a DOM node
17
+ * and wiring up the dependency injection context.
18
+ * @param options The initialization options
19
+ */
8
20
  export const initializeShadeRoot = (options: InitializeOptions) => {
9
21
  options.jsxElement.injector = options.injector
10
22
  options.rootElement.appendChild(options.jsxElement)
package/src/jsx.ts CHANGED
@@ -1,6 +1,48 @@
1
1
  import type { Injector } from '@furystack/inject'
2
2
  import type { ChildrenList, PartialElement } from './models/index.js'
3
3
  import type { ResourceManager } from './services/resource-manager.js'
4
+ import type {
5
+ SvgAnimateAttributes,
6
+ SvgAnimateMotionAttributes,
7
+ SvgAnimateTransformAttributes,
8
+ SvgCircleAttributes,
9
+ SvgClipPathAttributes,
10
+ SvgCoreAttributes,
11
+ SvgDefsAttributes,
12
+ SvgDescAttributes,
13
+ SvgEllipseAttributes,
14
+ SvgFeBlendAttributes,
15
+ SvgFeColorMatrixAttributes,
16
+ SvgFeCompositeAttributes,
17
+ SvgFeFloodAttributes,
18
+ SvgFeGaussianBlurAttributes,
19
+ SvgFeMergeAttributes,
20
+ SvgFeMergeNodeAttributes,
21
+ SvgFeOffsetAttributes,
22
+ SvgFilterAttributes,
23
+ SvgForeignObjectAttributes,
24
+ SvgGAttributes,
25
+ SvgImageAttributes,
26
+ SvgLinearGradientAttributes,
27
+ SvgLineAttributes,
28
+ SvgMarkerAttributes,
29
+ SvgMaskAttributes,
30
+ SvgPathAttributes,
31
+ SvgPatternAttributes,
32
+ SvgPolygonAttributes,
33
+ SvgPolylineAttributes,
34
+ SvgRadialGradientAttributes,
35
+ SvgRectAttributes,
36
+ SvgSetAttributes,
37
+ SvgStopAttributes,
38
+ SvgSvgAttributes,
39
+ SvgSymbolAttributes,
40
+ SvgTextAttributes,
41
+ SvgTextPathAttributes,
42
+ SvgTitleAttributes,
43
+ SvgTspanAttributes,
44
+ SvgUseAttributes,
45
+ } from './svg-types.js'
4
46
 
5
47
  declare global {
6
48
  // eslint-disable-next-line @typescript-eslint/no-namespace
@@ -10,7 +52,6 @@ declare global {
10
52
  props: TProps
11
53
  updateComponent: () => void
12
54
  shadeChildren?: ChildrenList
13
- callConstructed: () => void
14
55
  resourceManager: ResourceManager
15
56
  getRenderCount(): number
16
57
  }
@@ -423,7 +464,7 @@ declare global {
423
464
  * The <svg> tag defines a container for SVG graphics.
424
465
  * SVG has several methods for drawing paths, boxes, circles, text, and graphic images.
425
466
  */
426
- svg: PartialElement<SVGElement>
467
+ svg: SvgSvgAttributes
427
468
  /**
428
469
  * The <table> tag defines an HTML table.
429
470
  * An HTML table consists of the <table> element and one or more <tr>, <th>, and <td> elements.
@@ -519,6 +560,92 @@ declare global {
519
560
  * Tip: When a word is too long, or you are afraid that the browser will break your lines at the wrong place, you can use the <wbr> element to add word break opportunities.
520
561
  */
521
562
  wbr: PartialElement<HTMLElement>
563
+
564
+ // -------------------------------------------------------------------
565
+ // SVG Elements
566
+ // -------------------------------------------------------------------
567
+
568
+ /** The `<g>` element groups SVG elements together. */
569
+ g: SvgGAttributes
570
+ /** The `<defs>` element stores graphical objects for later reuse. */
571
+ defs: SvgDefsAttributes
572
+ /** The `<symbol>` element defines a reusable graphical template. */
573
+ symbol: SvgSymbolAttributes
574
+ /** The `<use>` element references another element for rendering. */
575
+ use: SvgUseAttributes
576
+ /** The `<path>` element defines a shape via SVG path commands. */
577
+ path: SvgPathAttributes
578
+ /** The `<rect>` element draws a rectangle. */
579
+ rect: SvgRectAttributes
580
+ /** The `<circle>` element draws a circle. */
581
+ circle: SvgCircleAttributes
582
+ /** The `<ellipse>` element draws an ellipse. */
583
+ ellipse: SvgEllipseAttributes
584
+ /** The `<line>` element draws a straight line between two points. */
585
+ line: SvgLineAttributes
586
+ /** The `<polyline>` element draws connected straight line segments. */
587
+ polyline: SvgPolylineAttributes
588
+ /** The `<polygon>` element draws a closed shape of connected line segments. */
589
+ polygon: SvgPolygonAttributes
590
+ /** The `<text>` element renders text in SVG. */
591
+ text: SvgTextAttributes
592
+ /** The `<tspan>` element defines a subtext within a `<text>` element. */
593
+ tspan: SvgTspanAttributes
594
+ /** The `<textPath>` element renders text along a path. */
595
+ textPath: SvgTextPathAttributes
596
+ /** The `<clipPath>` element defines a clipping region. */
597
+ clipPath: SvgClipPathAttributes
598
+ /** The `<mask>` element defines an alpha mask for compositing. */
599
+ mask: SvgMaskAttributes
600
+ /** The `<linearGradient>` element defines a linear color gradient. */
601
+ linearGradient: SvgLinearGradientAttributes
602
+ /** The `<radialGradient>` element defines a radial color gradient. */
603
+ radialGradient: SvgRadialGradientAttributes
604
+ /** The `<stop>` element defines a color stop in a gradient. */
605
+ stop: SvgStopAttributes
606
+ /** The `<pattern>` element defines a repeating graphic pattern. */
607
+ pattern: SvgPatternAttributes
608
+ /** The `<marker>` element defines a graphic for drawing on edges of shapes. */
609
+ marker: SvgMarkerAttributes
610
+ /** The `<filter>` element defines a set of filter operations. */
611
+ filter: SvgFilterAttributes
612
+ /** The `<feGaussianBlur>` filter primitive blurs the input image. */
613
+ feGaussianBlur: SvgFeGaussianBlurAttributes
614
+ /** The `<feBlend>` filter primitive composites two inputs. */
615
+ feBlend: SvgFeBlendAttributes
616
+ /** The `<feColorMatrix>` filter primitive applies a matrix color transform. */
617
+ feColorMatrix: SvgFeColorMatrixAttributes
618
+ /** The `<feOffset>` filter primitive offsets the input image. */
619
+ feOffset: SvgFeOffsetAttributes
620
+ /** The `<feFlood>` filter primitive fills with a solid color. */
621
+ feFlood: SvgFeFloodAttributes
622
+ /** The `<feMerge>` filter primitive composites multiple inputs. */
623
+ feMerge: SvgFeMergeAttributes
624
+ /** The `<feMergeNode>` element defines an input for `<feMerge>`. */
625
+ feMergeNode: SvgFeMergeNodeAttributes
626
+ /** The `<feComposite>` filter primitive combines images using Porter-Duff operations. */
627
+ feComposite: SvgFeCompositeAttributes
628
+ /** The `<image>` element (SVG) embeds a raster image. */
629
+ image: SvgImageAttributes
630
+ /** The `<foreignObject>` element embeds external XML (e.g. HTML) in SVG. */
631
+ foreignObject: SvgForeignObjectAttributes
632
+ /** The `<animate>` element animates an attribute over time. */
633
+ animate: SvgAnimateAttributes
634
+ /** The `<animateMotion>` element animates an element along a path. */
635
+ animateMotion: SvgAnimateMotionAttributes
636
+ /** The `<animateTransform>` element animates a transformation attribute. */
637
+ animateTransform: SvgAnimateTransformAttributes
638
+ /** The `<set>` element sets an attribute to a value for a duration. */
639
+ set: SvgSetAttributes
640
+ /** The `<title>` element (SVG) provides an accessible title. */
641
+ title: SvgTitleAttributes
642
+ /** The `<desc>` element provides an accessible description. */
643
+ desc: SvgDescAttributes
644
+ /**
645
+ * Catch-all for SVG filter primitives and other SVG elements
646
+ * not explicitly listed above.
647
+ */
648
+ [key: `fe${string}`]: SvgCoreAttributes
522
649
  }
523
650
  }
524
651
  }
@@ -1 +1,7 @@
1
- export type ChildrenList = Array<string | HTMLElement | JSX.Element | string[] | HTMLElement[] | JSX.Element[]>
1
+ /**
2
+ * The type for children passed to Shade components and intrinsic JSX elements.
3
+ * Supports strings, HTML/SVG elements, JSX elements, and nested arrays of these.
4
+ */
5
+ export type ChildrenList = Array<
6
+ string | HTMLElement | SVGElement | JSX.Element | string[] | HTMLElement[] | SVGElement[] | JSX.Element[]
7
+ >
@@ -1,5 +1,16 @@
1
- export type PartialElement<T> = T extends { style?: CSSStyleDeclaration }
1
+ import type { RefObject } from './render-options.js'
2
+
3
+ /**
4
+ * Makes all properties of an HTML element type optional, with `style` narrowed
5
+ * to `Partial<CSSStyleDeclaration>` and a `ref` prop for capturing DOM references.
6
+ * Used as the props type for intrinsic JSX elements and component host element overrides.
7
+ * @typeParam T - The base HTML element type
8
+ */
9
+ export type PartialElement<T> = (T extends { style?: CSSStyleDeclaration }
2
10
  ? Omit<Partial<T>, 'style'> & {
3
11
  style?: Partial<CSSStyleDeclaration>
4
12
  }
5
- : Partial<T>
13
+ : Partial<T>) & {
14
+ /** Ref object to capture a reference to the underlying DOM element. */
15
+ ref?: RefObject<Element>
16
+ }
@@ -3,12 +3,81 @@ import type { ObservableValue, ValueObserverOptions } from '@furystack/utils'
3
3
  import type { ChildrenList } from './children-list.js'
4
4
  import type { PartialElement } from './partial-element.js'
5
5
 
6
+ /**
7
+ * A reference object returned by `useRef`.
8
+ * `current` is set to the DOM element when it is mounted, and `null` when unmounted.
9
+ * The `readonly` modifier ensures covariance so that `RefObject<HTMLInputElement>`
10
+ * is assignable to `RefObject<Element>`.
11
+ */
12
+ export type RefObject<T extends Element = HTMLElement> = {
13
+ readonly current: T | null
14
+ }
15
+
16
+ /**
17
+ * Options provided to a Shade component's `render` function.
18
+ * Contains the current props, injector, children, and hooks for managing state, side effects, and host element attributes.
19
+ * @typeParam TProps - The component's props type
20
+ * @typeParam TElementBase - The base HTML element type (defaults to HTMLElement)
21
+ */
6
22
  export type RenderOptions<TProps, TElementBase extends HTMLElement = HTMLElement> = {
7
23
  readonly props: TProps & PartialElement<TElementBase>
8
24
  renderCount: number
9
25
  injector: Injector
10
26
  children?: ChildrenList
11
- element: JSX.Element<TProps>
27
+ /**
28
+ * Declaratively sets attributes and styles on the host custom element.
29
+ * Can be called multiple times per render; each call merges into the previous values.
30
+ *
31
+ * CSS custom properties (e.g. `--my-color`) are applied via `setProperty`.
32
+ * The `style` property accepts both standard camelCase properties and CSS custom properties.
33
+ *
34
+ * **Best practice:** Use `useHostProps` for data attributes, ARIA attributes, CSS variables,
35
+ * and event handlers on the host element instead of imperative DOM manipulation.
36
+ *
37
+ * @param hostProps An object of attribute key-value pairs, optionally including a `style` record
38
+ *
39
+ * **Note:** Object and function values are assigned as properties on the host element
40
+ * (not as attributes). This means you can set event handlers (e.g. `onclick`) and
41
+ * even class properties like `injector` via `useHostProps`.
42
+ *
43
+ * @example
44
+ * ```typescript
45
+ * useHostProps({
46
+ * 'data-variant': props.variant,
47
+ * role: 'progressbar',
48
+ * 'aria-valuenow': String(value),
49
+ * style: {
50
+ * '--btn-color-main': colors.main,
51
+ * display: 'flex',
52
+ * },
53
+ * })
54
+ * ```
55
+ */
56
+ useHostProps: (hostProps: Record<string, unknown> & { style?: Record<string, string> }) => void
57
+
58
+ /**
59
+ * Creates a mutable ref object that can be attached to intrinsic JSX elements via the `ref` prop.
60
+ * The ref's `current` property will be set to the DOM element after mount and `null` on unmount.
61
+ *
62
+ * Refs are cached by key, so calling `useRef` with the same key returns the same object across renders.
63
+ *
64
+ * **Best practice:** Prefer declarative JSX and `useHostProps` when possible.
65
+ * Use refs sparingly for imperative needs like focus management or measuring elements.
66
+ *
67
+ * @param key A unique key for caching the ref object
68
+ * @returns A ref object with a `current` property
69
+ *
70
+ * @example
71
+ * ```typescript
72
+ * const inputRef = useRef<HTMLInputElement>('input')
73
+ * // In JSX:
74
+ * <input ref={inputRef} />
75
+ * // Later:
76
+ * inputRef.current?.focus()
77
+ * ```
78
+ */
79
+ useRef: <T extends Element = HTMLElement>(key: string) => RefObject<T>
80
+
12
81
  /**
13
82
  * Creates and disposes a resource after the component has been detached from the DOM
14
83
  * @param key The key for caching the disposable resource
@@ -18,11 +87,29 @@ export type RenderOptions<TProps, TElementBase extends HTMLElement = HTMLElement
18
87
  useDisposable: <T extends Disposable | AsyncDisposable>(key: string, factory: () => T) => T
19
88
 
20
89
  /**
21
- * Creates a state object from an existing observable value
90
+ * Creates a state object from an existing observable value.
91
+ *
92
+ * **Important:** By default, this will trigger a full component re-render when the observable value changes.
93
+ * To prevent re-renders (e.g., for manual DOM updates or animations), provide a custom `onChange` callback.
94
+ *
22
95
  * @param key The key for caching the observable value
23
96
  * @param observable The observable value to observe
24
- * @param options Optional options for the observer including onChange callback
97
+ * @param options Optional options for the observer
98
+ * @param options.onChange Custom callback when value changes. If not provided, the component will re-render on each change.
25
99
  * @returns tuple with the current value and a setter function
100
+ *
101
+ * @example
102
+ * // Default behavior: re-renders component on change
103
+ * const [count] = useObservable('count', countObservable)
104
+ *
105
+ * @example
106
+ * // Custom onChange: no re-render, update host element via useHostProps
107
+ * useHostProps({ 'data-active': count > 0 ? '' : undefined })
108
+ * const [count] = useObservable('count', countObservable, {
109
+ * onChange: () => {
110
+ * // Triggers a re-render so useHostProps above picks up the new value
111
+ * }
112
+ * })
26
113
  */
27
114
  useObservable: <T>(
28
115
  key: string,
@@ -1,3 +1,7 @@
1
+ /**
2
+ * Captures the current focus and selection range within a DOM subtree.
3
+ * Used internally by the reconciler to preserve user selection across re-renders.
4
+ */
1
5
  export interface SelectionState {
2
6
  focusedPath?: number[]
3
7
  selectionRange?: {
@@ -4,6 +4,10 @@ import {
4
4
  serializeToQueryString as defaultSerializeToQueryString,
5
5
  } from '@furystack/rest'
6
6
  import { ObservableValue } from '@furystack/utils'
7
+ /**
8
+ * Singleton service that tracks browser location changes (pathname, search, hash)
9
+ * and exposes them as observable values for reactive routing and URL-driven state.
10
+ */
7
11
  @Injectable({ lifetime: 'singleton' })
8
12
  export class LocationService implements Disposable {
9
13
  constructor(
@@ -119,6 +123,13 @@ export class LocationService implements Disposable {
119
123
  }).bind(this)
120
124
  }
121
125
 
126
+ /**
127
+ * Configures custom serialization for URL search state.
128
+ * Must be called **before** `LocationService` is first instantiated by the injector.
129
+ * @param injector The root injector
130
+ * @param serialize Function to serialize state to a query string
131
+ * @param deserialize Function to deserialize a query string to state
132
+ */
122
133
  export const useCustomSearchStateSerializer = (
123
134
  injector: Injector,
124
135
  serialize: typeof defaultSerializeToQueryString,
@@ -19,6 +19,122 @@ describe('ResourceManager', () => {
19
19
  })
20
20
  })
21
21
 
22
+ it('Should switch to a new observable when a different reference is passed for the same key', async () => {
23
+ await usingAsync(new ResourceManager(), async (rm) => {
24
+ const o1 = new ObservableValue(1)
25
+ const o2 = new ObservableValue(42)
26
+ const onChange = vi.fn()
27
+
28
+ const [value1] = rm.useObservable('test', o1, onChange)
29
+ expect(value1).toBe(1)
30
+ expect(o1.getObservers().length).toBe(1)
31
+
32
+ const [value2] = rm.useObservable('test', o2, onChange)
33
+ expect(value2).toBe(42)
34
+ expect(o1.getObservers().length).toBe(0)
35
+ expect(o2.getObservers().length).toBe(1)
36
+ })
37
+ })
38
+
39
+ it('Should subscribe with the new onChange callback when switching observables', async () => {
40
+ await usingAsync(new ResourceManager(), async (rm) => {
41
+ const o1 = new ObservableValue('a')
42
+ const o2 = new ObservableValue('x')
43
+ const onChange1 = vi.fn()
44
+ const onChange2 = vi.fn()
45
+
46
+ rm.useObservable('test', o1, onChange1)
47
+ rm.useObservable('test', o2, onChange2)
48
+
49
+ o2.setValue('y')
50
+ expect(onChange2).toHaveBeenCalledWith('y')
51
+ expect(onChange1).not.toHaveBeenCalled()
52
+ })
53
+ })
54
+
55
+ it('Should not re-subscribe when the same observable reference is passed', async () => {
56
+ await usingAsync(new ResourceManager(), async (rm) => {
57
+ const o = new ObservableValue(1)
58
+ const onChange = vi.fn()
59
+
60
+ rm.useObservable('test', o, onChange)
61
+ rm.useObservable('test', o, onChange)
62
+ rm.useObservable('test', o, onChange)
63
+
64
+ expect(o.getObservers().length).toBe(1)
65
+ })
66
+ })
67
+
68
+ it('Should return a setValue bound to the new observable after switching', async () => {
69
+ await usingAsync(new ResourceManager(), async (rm) => {
70
+ const o1 = new ObservableValue(1)
71
+ const o2 = new ObservableValue(10)
72
+ const onChange = vi.fn()
73
+
74
+ const [, setValue1] = rm.useObservable('test', o1, onChange)
75
+ setValue1(5)
76
+ expect(o1.getValue()).toBe(5)
77
+
78
+ const [, setValue2] = rm.useObservable('test', o2, onChange)
79
+ setValue2(99)
80
+ expect(o2.getValue()).toBe(99)
81
+ expect(o1.getValue()).toBe(5)
82
+ })
83
+ })
84
+
85
+ it('Should handle multiple sequential observable switches for the same key', async () => {
86
+ await usingAsync(new ResourceManager(), async (rm) => {
87
+ const o1 = new ObservableValue('a')
88
+ const o2 = new ObservableValue('b')
89
+ const o3 = new ObservableValue('c')
90
+ const onChange = vi.fn()
91
+
92
+ const [v1] = rm.useObservable('test', o1, onChange)
93
+ expect(v1).toBe('a')
94
+
95
+ const [v2] = rm.useObservable('test', o2, onChange)
96
+ expect(v2).toBe('b')
97
+ expect(o1.getObservers().length).toBe(0)
98
+
99
+ const [v3] = rm.useObservable('test', o3, onChange)
100
+ expect(v3).toBe('c')
101
+ expect(o2.getObservers().length).toBe(0)
102
+ expect(o3.getObservers().length).toBe(1)
103
+ })
104
+ })
105
+
106
+ it('Should not trigger callbacks on the old observable after switching', async () => {
107
+ await usingAsync(new ResourceManager(), async (rm) => {
108
+ const o1 = new ObservableValue(1)
109
+ const o2 = new ObservableValue(2)
110
+ const onChange = vi.fn()
111
+
112
+ rm.useObservable('test', o1, onChange)
113
+ rm.useObservable('test', o2, onChange)
114
+
115
+ onChange.mockClear()
116
+ o1.setValue(999)
117
+ expect(onChange).not.toHaveBeenCalled()
118
+
119
+ o2.setValue(100)
120
+ expect(onChange).toHaveBeenCalledWith(100)
121
+ })
122
+ })
123
+
124
+ it('Should clean up switched observers on dispose', async () => {
125
+ const o1 = new ObservableValue(1)
126
+ const o2 = new ObservableValue(2)
127
+ const onChange = vi.fn()
128
+
129
+ await usingAsync(new ResourceManager(), async (rm) => {
130
+ rm.useObservable('test', o1, onChange)
131
+ rm.useObservable('test', o2, onChange)
132
+ expect(o2.getObservers().length).toBe(1)
133
+ })
134
+
135
+ expect(o2.getObservers().length).toBe(0)
136
+ })
137
+
22
138
  it('Should return a disposable from cache', async () => {
23
139
  await usingAsync(new ResourceManager(), async (rm) => {
24
140
  const factory = vi.fn(() => ({
@@ -80,6 +196,18 @@ describe('ResourceManager', () => {
80
196
  expect(disposable[Symbol.dispose]).toHaveBeenCalledTimes(1)
81
197
  })
82
198
 
199
+ it('Should silently ignore useState setter calls after disposal', async () => {
200
+ let setValueFn: (value: number) => void
201
+
202
+ await usingAsync(new ResourceManager(), async (rm) => {
203
+ const [, setValue] = rm.useState<number>('count', 0, vi.fn())
204
+ setValueFn = setValue
205
+ })
206
+
207
+ // After disposal, calling the setter should not throw
208
+ expect(() => setValueFn!(42)).not.toThrow()
209
+ })
210
+
83
211
  it('Should throw an aggregated error when failed to async dispose something', async () => {
84
212
  const disposable = {
85
213
  [Symbol.asyncDispose]: vi.fn(async () => {
@@ -8,6 +8,13 @@ import { ObservableValue, isAsyncDisposable, isDisposable } from '@furystack/uti
8
8
  export class ResourceManager implements AsyncDisposable {
9
9
  private readonly disposables = new Map<string, Disposable | AsyncDisposable>()
10
10
 
11
+ /**
12
+ * Returns an existing disposable resource by key, or creates and caches a new one.
13
+ * Resources are automatically disposed when the component is removed from the DOM.
14
+ * @param key Unique key for caching this resource
15
+ * @param factory Factory function called once to create the resource
16
+ * @returns The cached or newly created resource
17
+ */
11
18
  public useDisposable<T extends Disposable | AsyncDisposable>(key: string, factory: () => T): T {
12
19
  const existing = this.disposables.get(key)
13
20
  if (!existing) {
@@ -20,6 +27,15 @@ export class ResourceManager implements AsyncDisposable {
20
27
 
21
28
  public readonly observers = new Map<string, ValueObserver<any>>()
22
29
 
30
+ /**
31
+ * Subscribes to an observable value by key. If the observable changes between renders,
32
+ * the previous subscription is disposed and a new one is created.
33
+ * @param key Unique key for caching this subscription
34
+ * @param observable The observable to subscribe to
35
+ * @param onChange Callback invoked when the value changes
36
+ * @param options Additional observer options
37
+ * @returns Tuple of [currentValue, setValue]
38
+ */
23
39
  public useObservable = <T>(
24
40
  key: string,
25
41
  observable: ObservableValue<T>,
@@ -28,6 +44,12 @@ export class ResourceManager implements AsyncDisposable {
28
44
  ): [value: T, setValue: (newValue: T) => void] => {
29
45
  const alreadyUsed = this.observers.get(key) as ValueObserver<T> | undefined
30
46
  if (alreadyUsed) {
47
+ if (alreadyUsed.observable !== observable) {
48
+ alreadyUsed[Symbol.dispose]()
49
+ const observer = observable.subscribe(onChange, options)
50
+ this.observers.set(key, observer)
51
+ return [observable.getValue(), observable.setValue.bind(observable)]
52
+ }
31
53
  return [alreadyUsed.observable.getValue(), alreadyUsed.observable.setValue.bind(alreadyUsed.observable)]
32
54
  }
33
55
  const observer = observable.subscribe(onChange, options)
@@ -37,6 +59,14 @@ export class ResourceManager implements AsyncDisposable {
37
59
 
38
60
  public readonly stateObservers = new Map<string, ObservableValue<any>>()
39
61
 
62
+ /**
63
+ * Creates or retrieves a local state observable by key.
64
+ * State is persisted across re-renders and disposed with the component.
65
+ * @param key Unique key for caching this state
66
+ * @param initialValue Initial value used on first call
67
+ * @param callback Callback invoked when the state changes
68
+ * @returns Tuple of [currentValue, setValue]
69
+ */
40
70
  public useState = <T>(
41
71
  key: string,
42
72
  initialValue: T,
@@ -48,7 +78,12 @@ export class ResourceManager implements AsyncDisposable {
48
78
  newObservable.subscribe(callback)
49
79
  }
50
80
  const observable = this.stateObservers.get(key) as ObservableValue<T>
51
- return [observable.getValue(), observable.setValue.bind(observable)]
81
+ const setValue = (newValue: T) => {
82
+ if (!observable.isDisposed) {
83
+ observable.setValue(newValue)
84
+ }
85
+ }
86
+ return [observable.getValue(), setValue]
52
87
  }
53
88
 
54
89
  public async [Symbol.asyncDispose]() {