@furystack/shades 11.1.0 → 12.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/CHANGELOG.md +291 -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 +178 -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 +659 -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 +3 -0
  50. package/esm/components/router.js.map +1 -1
  51. package/esm/components/router.spec.js +75 -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 +30 -0
  74. package/esm/services/resource-manager.js.map +1 -1
  75. package/esm/services/resource-manager.spec.js +93 -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 +3 -3
  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 +817 -0
  138. package/src/components/nested-router.tsx +256 -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 +109 -108
  142. package/src/components/router.tsx +15 -2
  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 +116 -0
  151. package/src/services/resource-manager.ts +30 -0
  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,21 +1,74 @@
1
1
  import { Injectable } from '@furystack/inject'
2
2
  import { ObservableValue } from '@furystack/utils'
3
3
 
4
+ /**
5
+ * Available screen size breakpoint identifiers, ordered from smallest to largest.
6
+ */
4
7
  export const ScreenSizes = ['xs', 'sm', 'md', 'lg', 'xl'] as const
5
8
 
9
+ /**
10
+ * A screen size breakpoint identifier.
11
+ */
6
12
  export type ScreenSize = (typeof ScreenSizes)[number]
7
13
 
14
+ /**
15
+ * Breakpoint definition with name and size constraints.
16
+ */
8
17
  export type Breakpoint = { name: ScreenSize; minSize: number; maxSize?: number }
9
18
 
10
19
  /**
11
- * Service for handling screen size changes
20
+ * Service for detecting and subscribing to screen size and orientation changes.
21
+ *
22
+ * This service provides reactive observables for responsive design decisions.
23
+ * Use `screenSize.atLeast[size]` to check if the viewport is at least a certain size.
24
+ *
25
+ * **Breakpoint Thresholds:**
26
+ * - `xs`: 0px+ (all sizes)
27
+ * - `sm`: 600px+ (small tablets and up)
28
+ * - `md`: 960px+ (tablets and up)
29
+ * - `lg`: 1280px+ (desktops and up)
30
+ * - `xl`: 1920px+ (large desktops)
31
+ *
32
+ * @example
33
+ * ```typescript
34
+ * const screenService = injector.getInstance(ScreenService);
35
+ *
36
+ * // Check if screen is at least medium size
37
+ * if (screenService.screenSize.atLeast.md.getValue()) {
38
+ * // Show desktop layout
39
+ * }
40
+ *
41
+ * // Subscribe to size changes for responsive behavior
42
+ * screenService.screenSize.atLeast.md.subscribe((isAtLeastMd) => {
43
+ * if (isAtLeastMd) {
44
+ * console.log('Desktop or tablet view');
45
+ * } else {
46
+ * console.log('Mobile view');
47
+ * }
48
+ * });
49
+ *
50
+ * // Get current breakpoint by checking from largest to smallest
51
+ * const getCurrentBreakpoint = (): ScreenSize => {
52
+ * if (screenService.screenSize.atLeast.xl.getValue()) return 'xl';
53
+ * if (screenService.screenSize.atLeast.lg.getValue()) return 'lg';
54
+ * if (screenService.screenSize.atLeast.md.getValue()) return 'md';
55
+ * if (screenService.screenSize.atLeast.sm.getValue()) return 'sm';
56
+ * return 'xs';
57
+ * };
58
+ *
59
+ * // Subscribe to orientation changes
60
+ * screenService.orientation.subscribe((orientation) => {
61
+ * console.log(`Screen is now in ${orientation} mode`);
62
+ * });
63
+ * ```
12
64
  */
13
65
  @Injectable({ lifetime: 'singleton' })
14
66
  export class ScreenService implements Disposable {
15
67
  private getOrientation = () => (window.matchMedia?.('(orientation:landscape').matches ? 'landscape' : 'portrait')
16
68
 
17
69
  /**
18
- * The definitions of the breakpoints
70
+ * The definitions of the breakpoint thresholds in pixels.
71
+ * Each breakpoint represents the minimum width for that size category.
19
72
  */
20
73
  public readonly breakpoints: { [K in ScreenSize]: { minSize: number } } = {
21
74
  xl: { minSize: 1920 },
@@ -25,12 +78,26 @@ export class ScreenService implements Disposable {
25
78
  xs: { minSize: 0 },
26
79
  }
27
80
 
81
+ /**
82
+ * Cleans up event listeners when the service is disposed.
83
+ */
28
84
  public [Symbol.dispose]() {
29
85
  window.removeEventListener('resize', this.onResizeListener)
30
86
  }
31
87
 
32
88
  /**
33
- * Observers for the current screen size. Will refresh on resize
89
+ * Observable values for checking if the screen is at least a certain size.
90
+ *
91
+ * Each observable emits `true` when the viewport width is >= the breakpoint threshold,
92
+ * and `false` otherwise. Values update automatically on window resize.
93
+ *
94
+ * @example
95
+ * ```typescript
96
+ * // Hide sidebar on small screens
97
+ * screenService.screenSize.atLeast.md.subscribe((isAtLeastMd) => {
98
+ * sidebarVisible.setValue(isAtLeastMd);
99
+ * });
100
+ * ```
34
101
  */
35
102
  public readonly screenSize: {
36
103
  atLeast: { [K in ScreenSize]: ObservableValue<boolean> }
@@ -49,7 +116,17 @@ export class ScreenService implements Disposable {
49
116
  }
50
117
 
51
118
  /**
52
- * Observable value for tracking the screen orientation
119
+ * Observable value for tracking the screen orientation.
120
+ * Emits 'landscape' or 'portrait' based on the current viewport dimensions.
121
+ *
122
+ * @example
123
+ * ```typescript
124
+ * screenService.orientation.subscribe((orientation) => {
125
+ * if (orientation === 'landscape') {
126
+ * // Adjust layout for landscape mode
127
+ * }
128
+ * });
129
+ * ```
53
130
  */
54
131
  public orientation = new ObservableValue<'landscape' | 'portrait'>(this.getOrientation())
55
132
 
@@ -1,17 +1,37 @@
1
1
  import type { ChildrenList, ShadeComponent } from './models/index.js'
2
2
  import { isShadeComponent } from './models/shade-component.js'
3
+ import { SVG_NS, isSvgTag } from './svg.js'
4
+ import { createVNode } from './vnode.js'
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Render-mode toggle
8
+ // ---------------------------------------------------------------------------
9
+
10
+ let renderMode = false
3
11
 
4
12
  /**
5
- * Appends a list of items to a HTML element
13
+ * When true, the JSX factory produces VNode descriptors instead of real DOM elements.
14
+ * Set to true by `_performUpdate` before calling `render()`, then back to false after.
15
+ */
16
+ export const setRenderMode = (mode: boolean): void => {
17
+ renderMode = mode
18
+ }
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Real-DOM helpers (used outside render mode)
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * Appends a list of items to an element
6
26
  * @param el the root element
7
27
  * @param children array of items to append
8
28
  */
9
- export const appendChild = (el: HTMLElement | DocumentFragment, children: ChildrenList) => {
29
+ export const appendChild = (el: Element | DocumentFragment, children: ChildrenList) => {
10
30
  for (const child of children) {
11
31
  if (typeof child === 'string' || typeof child === 'number') {
12
32
  el.appendChild(document.createTextNode(child))
13
33
  } else {
14
- if (child instanceof HTMLElement || child instanceof DocumentFragment) {
34
+ if (child instanceof Element || child instanceof DocumentFragment) {
15
35
  el.appendChild(child)
16
36
  } else if (child instanceof Array) {
17
37
  appendChild(el, child)
@@ -44,7 +64,7 @@ export const attachStyles = (el: HTMLElement, props: unknown) => {
44
64
  export const attachDataAttributes = <TProps extends object>(el: HTMLElement, props: TProps) => {
45
65
  if (props) {
46
66
  Object.entries(props)
47
- .filter(([key]) => key.startsWith('data-'))
67
+ .filter(([key]) => key.startsWith('data-') || key.startsWith('aria-'))
48
68
  .forEach(([key, value]) => el.setAttribute(key, (value as string) || ''))
49
69
  }
50
70
  }
@@ -69,6 +89,32 @@ export const attachProps = <TProps extends object>(el: HTMLElement, props: TProp
69
89
  attachDataAttributes(el, props)
70
90
  }
71
91
 
92
+ /**
93
+ * Attaches properties to an SVG element via setAttribute.
94
+ * SVG attributes are XML-based and must be set via setAttribute,
95
+ * not via property assignment like HTML elements.
96
+ * @param el The target SVG element
97
+ * @param props The props to attach
98
+ */
99
+ export const attachSvgProps = <TProps extends object>(el: Element, props: TProps) => {
100
+ if (!props) {
101
+ return
102
+ }
103
+ for (const [key, value] of Object.entries(props)) {
104
+ if (key === 'style' && typeof value === 'object' && value !== null) {
105
+ for (const [sk, sv] of Object.entries(value as Record<string, string>)) {
106
+ ;((el as HTMLElement).style as unknown as Record<string, string>)[sk] = sv
107
+ }
108
+ } else if (key === 'className') {
109
+ el.setAttribute('class', String(value))
110
+ } else if (key.startsWith('on') && typeof value === 'function') {
111
+ ;(el as unknown as Record<string, unknown>)[key] = value
112
+ } else if (value !== null && value !== undefined && value !== false) {
113
+ el.setAttribute(key, String(value))
114
+ }
115
+ }
116
+ }
117
+
72
118
  type CreateComponentArgs<TProps> = [
73
119
  elementType: string | ShadeComponent<TProps>,
74
120
  props: TProps,
@@ -83,9 +129,14 @@ export const createComponentInner = <TProps extends object>(
83
129
  ...[elementType, props, ...children]: CreateComponentArgs<TProps>
84
130
  ) => {
85
131
  if (typeof elementType === 'string') {
86
- const el = document.createElement(elementType)
132
+ const isSvg = isSvgTag(elementType)
133
+ const el = isSvg ? document.createElementNS(SVG_NS, elementType) : document.createElement(elementType)
87
134
 
88
- attachProps(el, props)
135
+ if (isSvg) {
136
+ attachSvgProps(el, props)
137
+ } else {
138
+ attachProps(el as HTMLElement, props)
139
+ }
89
140
 
90
141
  if (children) {
91
142
  appendChild(el, children)
@@ -108,6 +159,21 @@ export const createFragmentInner = (...[_props, ...children]: CreateFragmentArgs
108
159
  }
109
160
 
110
161
  export const createComponent = <TProps extends object>(...args: CreateComponentArgs<TProps> | CreateFragmentArgs) => {
162
+ // In render mode, produce VNode descriptors instead of real DOM elements
163
+ if (renderMode) {
164
+ const [type, props, ...children] = args
165
+ // When jsxFragmentFactory === jsxFactory (both "createComponent"), the compiler
166
+ // passes createComponent itself as the first arg for fragments: createComponent(createComponent, null, ...)
167
+ if (type === null || (type as unknown) === createComponent) {
168
+ return createVNode(null, null, ...children) as unknown as ReturnType<typeof createComponentInner>
169
+ }
170
+ return createVNode(
171
+ type as string | ((...a: unknown[]) => unknown),
172
+ props as Record<string, unknown> | null,
173
+ ...children,
174
+ ) as unknown as ReturnType<typeof createComponentInner>
175
+ }
176
+
111
177
  if (args[0] === null) {
112
178
  return createFragmentInner(...args)
113
179
  }
@@ -0,0 +1,460 @@
1
+ import { Injector } from '@furystack/inject'
2
+ import { ObservableValue, usingAsync } from '@furystack/utils'
3
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
4
+ import { initializeShadeRoot } from './initialize.js'
5
+ import { createComponent } from './shade-component.js'
6
+ import { flushUpdates, Shade } from './shade.js'
7
+
8
+ describe('useHostProps integration tests', () => {
9
+ beforeEach(() => {
10
+ document.body.innerHTML = '<div id="root"></div>'
11
+ })
12
+ afterEach(() => {
13
+ document.body.innerHTML = ''
14
+ })
15
+
16
+ it('should set data attributes on the host element', async () => {
17
+ await usingAsync(new Injector(), async (injector) => {
18
+ const rootElement = document.getElementById('root') as HTMLDivElement
19
+
20
+ const ExampleComponent = Shade<{ variant: string }>({
21
+ shadowDomName: 'host-props-data-attr-test',
22
+ render: ({ props, useHostProps }) => {
23
+ useHostProps({
24
+ 'data-variant': props.variant,
25
+ })
26
+ return <div>content</div>
27
+ },
28
+ })
29
+
30
+ initializeShadeRoot({
31
+ injector,
32
+ rootElement,
33
+ jsxElement: <ExampleComponent variant="primary" />,
34
+ })
35
+ await flushUpdates()
36
+
37
+ const el = document.querySelector('host-props-data-attr-test') as HTMLElement
38
+ expect(el).toBeTruthy()
39
+ expect(el.getAttribute('data-variant')).toBe('primary')
40
+ })
41
+ })
42
+
43
+ it('should set aria attributes on the host element', async () => {
44
+ await usingAsync(new Injector(), async (injector) => {
45
+ const rootElement = document.getElementById('root') as HTMLDivElement
46
+
47
+ const ExampleComponent = Shade({
48
+ shadowDomName: 'host-props-aria-test',
49
+ render: ({ useHostProps }) => {
50
+ useHostProps({
51
+ role: 'progressbar',
52
+ 'aria-valuenow': '50',
53
+ 'aria-valuemin': '0',
54
+ 'aria-valuemax': '100',
55
+ })
56
+ return <div>progress</div>
57
+ },
58
+ })
59
+
60
+ initializeShadeRoot({
61
+ injector,
62
+ rootElement,
63
+ jsxElement: <ExampleComponent />,
64
+ })
65
+ await flushUpdates()
66
+
67
+ const el = document.querySelector('host-props-aria-test') as HTMLElement
68
+ expect(el.getAttribute('role')).toBe('progressbar')
69
+ expect(el.getAttribute('aria-valuenow')).toBe('50')
70
+ expect(el.getAttribute('aria-valuemin')).toBe('0')
71
+ expect(el.getAttribute('aria-valuemax')).toBe('100')
72
+ })
73
+ })
74
+
75
+ it('should apply CSS custom properties via style', async () => {
76
+ await usingAsync(new Injector(), async (injector) => {
77
+ const rootElement = document.getElementById('root') as HTMLDivElement
78
+
79
+ const ExampleComponent = Shade({
80
+ shadowDomName: 'host-props-css-vars-test',
81
+ render: ({ useHostProps }) => {
82
+ useHostProps({
83
+ style: {
84
+ '--my-color': 'red',
85
+ '--my-size': '16px',
86
+ },
87
+ })
88
+ return <div>styled</div>
89
+ },
90
+ })
91
+
92
+ initializeShadeRoot({
93
+ injector,
94
+ rootElement,
95
+ jsxElement: <ExampleComponent />,
96
+ })
97
+ await flushUpdates()
98
+
99
+ const el = document.querySelector('host-props-css-vars-test') as HTMLElement
100
+ expect(el.style.getPropertyValue('--my-color')).toBe('red')
101
+ expect(el.style.getPropertyValue('--my-size')).toBe('16px')
102
+ })
103
+ })
104
+
105
+ it('should apply standard inline styles', async () => {
106
+ await usingAsync(new Injector(), async (injector) => {
107
+ const rootElement = document.getElementById('root') as HTMLDivElement
108
+
109
+ const ExampleComponent = Shade({
110
+ shadowDomName: 'host-props-inline-style-test',
111
+ render: ({ useHostProps }) => {
112
+ useHostProps({
113
+ style: {
114
+ display: 'flex',
115
+ gap: '8px',
116
+ },
117
+ })
118
+ return <div>styled</div>
119
+ },
120
+ })
121
+
122
+ initializeShadeRoot({
123
+ injector,
124
+ rootElement,
125
+ jsxElement: <ExampleComponent />,
126
+ })
127
+ await flushUpdates()
128
+
129
+ const el = document.querySelector('host-props-inline-style-test') as HTMLElement
130
+ expect(el.style.display).toBe('flex')
131
+ expect(el.style.gap).toBe('8px')
132
+ })
133
+ })
134
+
135
+ it('should merge multiple useHostProps calls', async () => {
136
+ await usingAsync(new Injector(), async (injector) => {
137
+ const rootElement = document.getElementById('root') as HTMLDivElement
138
+
139
+ const ExampleComponent = Shade({
140
+ shadowDomName: 'host-props-merge-test',
141
+ render: ({ useHostProps }) => {
142
+ useHostProps({
143
+ 'data-first': 'one',
144
+ style: { '--color-a': 'red' },
145
+ })
146
+ useHostProps({
147
+ 'data-second': 'two',
148
+ style: { '--color-b': 'blue' },
149
+ })
150
+ return <div>merged</div>
151
+ },
152
+ })
153
+
154
+ initializeShadeRoot({
155
+ injector,
156
+ rootElement,
157
+ jsxElement: <ExampleComponent />,
158
+ })
159
+ await flushUpdates()
160
+
161
+ const el = document.querySelector('host-props-merge-test') as HTMLElement
162
+ expect(el.getAttribute('data-first')).toBe('one')
163
+ expect(el.getAttribute('data-second')).toBe('two')
164
+ expect(el.style.getPropertyValue('--color-a')).toBe('red')
165
+ expect(el.style.getPropertyValue('--color-b')).toBe('blue')
166
+ })
167
+ })
168
+
169
+ it('should remove attributes when they are no longer set on re-render', async () => {
170
+ await usingAsync(new Injector(), async (injector) => {
171
+ const rootElement = document.getElementById('root') as HTMLDivElement
172
+ const showExtra = new ObservableValue(true)
173
+
174
+ const ExampleComponent = Shade({
175
+ shadowDomName: 'host-props-remove-attr-test',
176
+ render: ({ useHostProps, useObservable }) => {
177
+ const [show] = useObservable('showExtra', showExtra)
178
+ useHostProps({
179
+ 'data-always': 'yes',
180
+ ...(show ? { 'data-extra': 'present' } : {}),
181
+ })
182
+ return <div>content</div>
183
+ },
184
+ })
185
+
186
+ initializeShadeRoot({
187
+ injector,
188
+ rootElement,
189
+ jsxElement: <ExampleComponent />,
190
+ })
191
+ await flushUpdates()
192
+
193
+ const el = document.querySelector('host-props-remove-attr-test') as HTMLElement
194
+ expect(el.getAttribute('data-always')).toBe('yes')
195
+ expect(el.getAttribute('data-extra')).toBe('present')
196
+
197
+ showExtra.setValue(false)
198
+ await flushUpdates()
199
+
200
+ expect(el.getAttribute('data-always')).toBe('yes')
201
+ expect(el.getAttribute('data-extra')).toBeNull()
202
+ })
203
+ })
204
+
205
+ it('should remove CSS custom properties when they are no longer set', async () => {
206
+ await usingAsync(new Injector(), async (injector) => {
207
+ const rootElement = document.getElementById('root') as HTMLDivElement
208
+ const showColor = new ObservableValue(true)
209
+
210
+ const ExampleComponent = Shade({
211
+ shadowDomName: 'host-props-remove-css-var-test',
212
+ render: ({ useHostProps, useObservable }) => {
213
+ const [show] = useObservable('showColor', showColor)
214
+ useHostProps({
215
+ style: {
216
+ display: 'block',
217
+ ...(show ? { '--my-color': 'red' } : {}),
218
+ },
219
+ })
220
+ return <div>content</div>
221
+ },
222
+ })
223
+
224
+ initializeShadeRoot({
225
+ injector,
226
+ rootElement,
227
+ jsxElement: <ExampleComponent />,
228
+ })
229
+ await flushUpdates()
230
+
231
+ const el = document.querySelector('host-props-remove-css-var-test') as HTMLElement
232
+ expect(el.style.getPropertyValue('--my-color')).toBe('red')
233
+ expect(el.style.display).toBe('block')
234
+
235
+ showColor.setValue(false)
236
+ await flushUpdates()
237
+
238
+ expect(el.style.getPropertyValue('--my-color')).toBe('')
239
+ expect(el.style.display).toBe('block')
240
+ })
241
+ })
242
+
243
+ it('should set event handlers on the host element', async () => {
244
+ await usingAsync(new Injector(), async (injector) => {
245
+ const rootElement = document.getElementById('root') as HTMLDivElement
246
+ let clicked = false
247
+
248
+ const ExampleComponent = Shade({
249
+ shadowDomName: 'host-props-event-test',
250
+ render: ({ useHostProps }) => {
251
+ useHostProps({
252
+ onclick: () => {
253
+ clicked = true
254
+ },
255
+ })
256
+ return <div>clickable</div>
257
+ },
258
+ })
259
+
260
+ initializeShadeRoot({
261
+ injector,
262
+ rootElement,
263
+ jsxElement: <ExampleComponent />,
264
+ })
265
+ await flushUpdates()
266
+
267
+ const el = document.querySelector('host-props-event-test') as HTMLElement
268
+ el.click()
269
+ expect(clicked).toBe(true)
270
+ })
271
+ })
272
+ })
273
+
274
+ describe('useRef integration tests', () => {
275
+ beforeEach(() => {
276
+ document.body.innerHTML = '<div id="root"></div>'
277
+ })
278
+ afterEach(() => {
279
+ document.body.innerHTML = ''
280
+ })
281
+
282
+ it('should set ref.current to the mounted element', async () => {
283
+ await usingAsync(new Injector(), async (injector) => {
284
+ const rootElement = document.getElementById('root') as HTMLDivElement
285
+
286
+ let capturedRef: { readonly current: HTMLDivElement | null } | undefined
287
+
288
+ const ExampleComponent = Shade({
289
+ shadowDomName: 'use-ref-basic-test',
290
+ render: ({ useRef }) => {
291
+ const divRef = useRef<HTMLDivElement>('myDiv')
292
+ capturedRef = divRef
293
+ return (
294
+ <div ref={divRef} id="target">
295
+ hello
296
+ </div>
297
+ )
298
+ },
299
+ })
300
+
301
+ initializeShadeRoot({
302
+ injector,
303
+ rootElement,
304
+ jsxElement: <ExampleComponent />,
305
+ })
306
+ await flushUpdates()
307
+
308
+ expect(capturedRef).toBeTruthy()
309
+ expect(capturedRef!.current).toBeTruthy()
310
+ expect(capturedRef!.current).toBe(document.getElementById('target'))
311
+ expect(capturedRef!.current?.textContent).toBe('hello')
312
+ })
313
+ })
314
+
315
+ it('should return the same ref object across re-renders', async () => {
316
+ await usingAsync(new Injector(), async (injector) => {
317
+ const rootElement = document.getElementById('root') as HTMLDivElement
318
+ const counter = new ObservableValue(0)
319
+
320
+ const capturedRefs: Array<{ readonly current: Element | null }> = []
321
+
322
+ const ExampleComponent = Shade({
323
+ shadowDomName: 'use-ref-stable-test',
324
+ render: ({ useRef, useObservable }) => {
325
+ const [count] = useObservable('counter', counter)
326
+ const divRef = useRef('myDiv')
327
+ capturedRefs.push(divRef)
328
+ return <div ref={divRef}>{count}</div>
329
+ },
330
+ })
331
+
332
+ initializeShadeRoot({
333
+ injector,
334
+ rootElement,
335
+ jsxElement: <ExampleComponent />,
336
+ })
337
+ await flushUpdates()
338
+
339
+ counter.setValue(1)
340
+ await flushUpdates()
341
+
342
+ counter.setValue(2)
343
+ await flushUpdates()
344
+
345
+ expect(capturedRefs.length).toBe(3)
346
+ expect(capturedRefs[0]).toBe(capturedRefs[1])
347
+ expect(capturedRefs[1]).toBe(capturedRefs[2])
348
+ })
349
+ })
350
+
351
+ it('should set ref.current on nested child elements', async () => {
352
+ await usingAsync(new Injector(), async (injector) => {
353
+ const rootElement = document.getElementById('root') as HTMLDivElement
354
+
355
+ let capturedInputRef: { readonly current: HTMLInputElement | null } | undefined
356
+
357
+ const ExampleComponent = Shade({
358
+ shadowDomName: 'use-ref-nested-test',
359
+ render: ({ useRef }) => {
360
+ const inputRef = useRef<HTMLInputElement>('input')
361
+ capturedInputRef = inputRef
362
+ return (
363
+ <div>
364
+ <label>
365
+ <input ref={inputRef} type="text" id="my-input" />
366
+ </label>
367
+ </div>
368
+ )
369
+ },
370
+ })
371
+
372
+ initializeShadeRoot({
373
+ injector,
374
+ rootElement,
375
+ jsxElement: <ExampleComponent />,
376
+ })
377
+ await flushUpdates()
378
+
379
+ expect(capturedInputRef).toBeTruthy()
380
+ expect(capturedInputRef!.current).toBeTruthy()
381
+ expect(capturedInputRef!.current).toBe(document.getElementById('my-input'))
382
+ expect(capturedInputRef!.current?.type).toBe('text')
383
+ })
384
+ })
385
+
386
+ it('should clear ref.current when element is unmounted', async () => {
387
+ await usingAsync(new Injector(), async (injector) => {
388
+ const rootElement = document.getElementById('root') as HTMLDivElement
389
+ const showChild = new ObservableValue(true)
390
+
391
+ let capturedRef: { readonly current: HTMLSpanElement | null } | undefined
392
+
393
+ const ExampleComponent = Shade({
394
+ shadowDomName: 'use-ref-unmount-test',
395
+ render: ({ useRef, useObservable }) => {
396
+ const [show] = useObservable('showChild', showChild)
397
+ const spanRef = useRef<HTMLSpanElement>('span')
398
+ capturedRef = spanRef
399
+ return <div>{show ? <span ref={spanRef}>visible</span> : null}</div>
400
+ },
401
+ })
402
+
403
+ initializeShadeRoot({
404
+ injector,
405
+ rootElement,
406
+ jsxElement: <ExampleComponent />,
407
+ })
408
+ await flushUpdates()
409
+
410
+ expect(capturedRef!.current).toBeTruthy()
411
+ expect(capturedRef!.current?.textContent).toBe('visible')
412
+
413
+ showChild.setValue(false)
414
+ await flushUpdates()
415
+
416
+ expect(capturedRef!.current).toBeNull()
417
+ })
418
+ })
419
+
420
+ it('should work with useRef in onChange callbacks', async () => {
421
+ await usingAsync(new Injector(), async (injector) => {
422
+ const rootElement = document.getElementById('root') as HTMLDivElement
423
+ const counter = new ObservableValue(0)
424
+
425
+ const ExampleComponent = Shade({
426
+ shadowDomName: 'use-ref-onchange-test',
427
+ render: ({ useRef, useObservable }) => {
428
+ const spanRef = useRef<HTMLSpanElement>('counterSpan')
429
+ useObservable('counter', counter, {
430
+ onChange: (value) => {
431
+ if (spanRef.current) {
432
+ spanRef.current.textContent = String(value)
433
+ }
434
+ },
435
+ })
436
+ return (
437
+ <span ref={spanRef} id="counter-span">
438
+ 0
439
+ </span>
440
+ )
441
+ },
442
+ })
443
+
444
+ initializeShadeRoot({
445
+ injector,
446
+ rootElement,
447
+ jsxElement: <ExampleComponent />,
448
+ })
449
+ await flushUpdates()
450
+
451
+ const span = document.getElementById('counter-span')
452
+ expect(span?.textContent).toBe('0')
453
+
454
+ counter.setValue(42)
455
+ await flushUpdates()
456
+
457
+ expect(span?.textContent).toBe('42')
458
+ })
459
+ })
460
+ })