@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
package/src/shade.ts CHANGED
@@ -2,14 +2,20 @@ import type { Constructable } from '@furystack/inject'
2
2
  import { hasInjectorReference, Injector } from '@furystack/inject'
3
3
  import { ObservableValue } from '@furystack/utils'
4
4
  import type { ChildrenList, CSSObject, PartialElement, RenderOptions } from './models/index.js'
5
+ import type { RefObject } from './models/render-options.js'
5
6
  import { LocationService } from './services/location-service.js'
6
7
  import { ResourceManager } from './services/resource-manager.js'
7
- import { attachProps, attachStyles } from './shade-component.js'
8
+ import { attachProps, attachStyles, setRenderMode } from './shade-component.js'
8
9
  import { StyleManager } from './style-manager.js'
10
+ import type { VChild } from './vnode.js'
11
+ import { patchChildren, toVChildArray } from './vnode.js'
9
12
 
10
13
  export type ShadeOptions<TProps, TElementBase extends HTMLElement> = {
11
14
  /**
12
- * Explicit shadow dom name. Will fall back to 'shade-{guid}' if not provided
15
+ * The custom element tag name used to register the component.
16
+ * Must follow the Custom Elements naming convention (lowercase, must contain a hyphen).
17
+ *
18
+ * @example 'my-button', 'shade-dialog', 'app-header'
13
19
  */
14
20
  shadowDomName: string
15
21
 
@@ -18,23 +24,6 @@ export type ShadeOptions<TProps, TElementBase extends HTMLElement> = {
18
24
  */
19
25
  render: (options: RenderOptions<TProps, TElementBase>) => JSX.Element | string | null
20
26
 
21
- /**
22
- * Construct hook. Will be executed once when the element has been constructed and initialized
23
- */
24
- constructed?: (
25
- options: RenderOptions<TProps, TElementBase>,
26
- ) => void | undefined | (() => void) | Promise<void | undefined | (() => void)>
27
-
28
- /**
29
- * Will be executed when the element is attached to the DOM.
30
- */
31
- onAttach?: (options: RenderOptions<TProps, TElementBase>) => void
32
-
33
- /**
34
- * Will be executed when the element is detached from the DOM.
35
- */
36
- onDetach?: (options: RenderOptions<TProps, TElementBase>) => void
37
-
38
27
  /**
39
28
  * Name of the HTML Element's base class. Needs to be defined if the elementBase is set. E.g.: 'div', 'button', 'input'
40
29
  */
@@ -46,19 +35,22 @@ export type ShadeOptions<TProps, TElementBase extends HTMLElement> = {
46
35
  elementBase?: Constructable<TElementBase>
47
36
 
48
37
  /**
49
- * A default style that will be applied to the element as inline styles.
50
- * Can be overridden by external styles on instances.
38
+ * Inline styles applied to each component instance.
39
+ * Use for per-instance dynamic overrides. Prefer `css` for component-level defaults.
51
40
  */
52
41
  style?: Partial<CSSStyleDeclaration>
53
42
 
54
43
  /**
55
44
  * CSS styles injected as a stylesheet during component registration.
56
- * Supports pseudo-selectors (&:hover, &:active) and nested selectors (& .class).
57
- * Use this for component-level styling that doesn't need per-instance overrides.
45
+ * Supports pseudo-selectors (`&:hover`, `&:active`) and nested selectors (`& .class`).
46
+ *
47
+ * **Best practice:** Prefer `css` over `style` for component defaults -- styles are injected
48
+ * once per component type (better performance), and support pseudo-selectors and nesting.
58
49
  *
59
50
  * @example
60
51
  * ```typescript
61
52
  * css: {
53
+ * display: 'flex',
62
54
  * padding: '16px',
63
55
  * '&:hover': { backgroundColor: '#f0f0f0' },
64
56
  * '& .title': { fontWeight: 'bold' }
@@ -76,7 +68,7 @@ export type ShadeOptions<TProps, TElementBase extends HTMLElement> = {
76
68
  export const Shade = <TProps, TElementBase extends HTMLElement = HTMLElement>(
77
69
  o: ShadeOptions<TProps, TElementBase>,
78
70
  ) => {
79
- // register shadow-dom element
71
+ // Register custom element
80
72
  const customElementName = o.shadowDomName
81
73
 
82
74
  const existing = customElements.get(customElementName)
@@ -102,15 +94,31 @@ export const Shade = <TProps, TElementBase extends HTMLElement = HTMLElement>(
102
94
 
103
95
  public resourceManager = new ResourceManager()
104
96
 
97
+ /**
98
+ * Host props collected during the current render pass via `useHostProps`.
99
+ * Applied to the host element after each render.
100
+ */
101
+ private _pendingHostProps: Array<Record<string, unknown> & { style?: Record<string, string> }> = []
102
+
103
+ /**
104
+ * The host props that were applied in the previous render, used for diffing.
105
+ */
106
+ private _prevHostProps: Record<string, unknown> | null = null
107
+
108
+ /**
109
+ * Cached ref objects keyed by the user-provided key string.
110
+ */
111
+ private _refs = new Map<string, RefObject<Element>>()
112
+
105
113
  public connectedCallback() {
106
- o.onAttach?.(this.getRenderOptions())
107
- this.callConstructed()
114
+ this.updateComponent()
108
115
  }
109
116
 
110
117
  public async disconnectedCallback() {
111
- o.onDetach?.(this.getRenderOptions())
118
+ this._refs.clear()
119
+ this._prevVTree = null
120
+ this._prevHostProps = null
112
121
  await this.resourceManager[Symbol.asyncDispose]()
113
- this.cleanup?.()
114
122
  }
115
123
 
116
124
  /**
@@ -140,8 +148,17 @@ export const Shade = <TProps, TElementBase extends HTMLElement = HTMLElement>(
140
148
  props: this.props,
141
149
  injector: this.injector,
142
150
  children: this.shadeChildren,
143
- element: this,
144
151
  renderCount: this._renderCount,
152
+ useHostProps: (hostProps) => {
153
+ this._pendingHostProps.push(hostProps)
154
+ },
155
+ useRef: <T extends Element = HTMLElement>(key: string): RefObject<T> => {
156
+ const existingRef = this._refs.get(key) as RefObject<T> | undefined
157
+ if (existingRef) return existingRef
158
+ const refObject = { current: null } as { current: T | null }
159
+ this._refs.set(key, refObject as unknown as RefObject<Element>)
160
+ return refObject as RefObject<T>
161
+ },
145
162
  useObservable: (key, obesrvable, options) => {
146
163
  const onChange = options?.onChange || (() => this.updateComponent())
147
164
  return this.resourceManager.useObservable(key, obesrvable, onChange, options)
@@ -216,47 +233,148 @@ export const Shade = <TProps, TElementBase extends HTMLElement = HTMLElement>(
216
233
  return renderOptions
217
234
  }
218
235
 
236
+ private _updateScheduled = false
237
+
219
238
  /**
220
- * Updates the component in the DOM.
239
+ * The VChild array from the previous render, with `_el` references
240
+ * pointing to the real DOM nodes. Used to diff against the next render.
221
241
  */
222
- public updateComponent() {
223
- const renderResult = this.render(this.getRenderOptions())
242
+ private _prevVTree: VChild[] | null = null
224
243
 
225
- if (renderResult === null || renderResult === undefined) {
226
- this.innerHTML = ''
244
+ /**
245
+ * Schedules a component update via microtask. Multiple calls before the microtask
246
+ * runs are coalesced into a single render pass.
247
+ */
248
+ public updateComponent() {
249
+ if (!this._updateScheduled) {
250
+ this._updateScheduled = true
251
+ queueMicrotask(() => {
252
+ this._updateScheduled = false
253
+ this._performUpdate()
254
+ })
227
255
  }
256
+ }
228
257
 
229
- if (typeof renderResult === 'string' || typeof renderResult === 'number') {
230
- this.innerHTML = renderResult
258
+ private _performUpdate() {
259
+ this._pendingHostProps = []
260
+ let renderResult: unknown
261
+ setRenderMode(true)
262
+ try {
263
+ renderResult = this.render(this.getRenderOptions())
264
+ } finally {
265
+ setRenderMode(false)
231
266
  }
232
267
 
233
- if (renderResult instanceof HTMLElement) {
234
- this.replaceChildren(renderResult)
235
- }
236
- if (renderResult instanceof DocumentFragment) {
237
- this.replaceChildren(renderResult)
238
- }
268
+ const newVTree = toVChildArray(renderResult)
269
+ patchChildren(this, this._prevVTree || [], newVTree)
270
+ this._prevVTree = newVTree
271
+
272
+ this._applyHostProps()
239
273
  }
240
274
 
241
275
  /**
242
- * Finalize the component initialization after it gets the Props. Called by the framework internally
276
+ * Merges all pending host props from the render pass and applies them
277
+ * to the host element, diffing against the previously applied host props.
243
278
  */
244
- public callConstructed() {
245
- this.updateComponent()
246
- const cleanupResult = o.constructed && o.constructed(this.getRenderOptions())
247
- if (cleanupResult instanceof Promise) {
248
- cleanupResult
249
- .then((cleanup) => (this.cleanup = cleanup))
250
- .catch(() => {
251
- /** */
252
- })
253
- } else {
254
- // construct is not async
255
- this.cleanup = cleanupResult
279
+ private _applyHostProps() {
280
+ if (this._pendingHostProps.length === 0) {
281
+ if (this._prevHostProps) {
282
+ // All host props were removed — clean up
283
+ for (const key of Object.keys(this._prevHostProps)) {
284
+ if (key === 'style') continue
285
+ this.removeAttribute(key)
286
+ }
287
+ if (this._prevHostProps.style) {
288
+ for (const sk of Object.keys(this._prevHostProps.style as Record<string, string>)) {
289
+ if (sk.startsWith('--')) {
290
+ this.style.removeProperty(sk)
291
+ } else {
292
+ ;(this.style as unknown as Record<string, string>)[sk] = ''
293
+ }
294
+ }
295
+ }
296
+ this._prevHostProps = null
297
+ }
298
+ return
299
+ }
300
+
301
+ // Merge all pending host prop calls into a single object
302
+ const merged: Record<string, unknown> = {}
303
+ let mergedStyle: Record<string, string> | undefined
304
+
305
+ for (const hp of this._pendingHostProps) {
306
+ for (const [key, value] of Object.entries(hp)) {
307
+ if (key === 'style' && typeof value === 'object' && value !== null) {
308
+ mergedStyle = { ...mergedStyle, ...(value as Record<string, string>) }
309
+ } else {
310
+ merged[key] = value
311
+ }
312
+ }
313
+ }
314
+
315
+ if (mergedStyle) {
316
+ merged.style = mergedStyle
317
+ }
318
+
319
+ const oldHP = this._prevHostProps || {}
320
+ const newHP = merged
321
+
322
+ // Remove attributes no longer present
323
+ for (const key of Object.keys(oldHP)) {
324
+ if (key === 'style') continue
325
+ if (!(key in newHP)) {
326
+ if (key.startsWith('on') && typeof oldHP[key] === 'function') {
327
+ ;(this as unknown as Record<string, unknown>)[key] = null
328
+ } else {
329
+ this.removeAttribute(key)
330
+ }
331
+ }
332
+ }
333
+
334
+ // Apply new/changed attributes
335
+ for (const [key, value] of Object.entries(newHP)) {
336
+ if (key === 'style') continue
337
+ if (oldHP[key] !== value) {
338
+ if (typeof value === 'function' || (typeof value === 'object' && value !== null)) {
339
+ ;(this as unknown as Record<string, unknown>)[key] = value
340
+ } else if (value === null || value === undefined || value === false) {
341
+ if (key in this) {
342
+ ;(this as unknown as Record<string, unknown>)[key] = undefined
343
+ }
344
+ this.removeAttribute(key)
345
+ } else {
346
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string
347
+ this.setAttribute(key, String(value))
348
+ }
349
+ }
256
350
  }
257
- }
258
351
 
259
- private cleanup: void | (() => void) = undefined
352
+ // Diff styles
353
+ const oldStyle = (oldHP.style as Record<string, string>) || {}
354
+ const newStyle = (mergedStyle as Record<string, string>) || {}
355
+
356
+ for (const sk of Object.keys(oldStyle)) {
357
+ if (!(sk in newStyle)) {
358
+ if (sk.startsWith('--')) {
359
+ this.style.removeProperty(sk)
360
+ } else {
361
+ ;(this.style as unknown as Record<string, string>)[sk] = ''
362
+ }
363
+ }
364
+ }
365
+
366
+ for (const [sk, sv] of Object.entries(newStyle)) {
367
+ if (oldStyle[sk] !== sv) {
368
+ if (sk.startsWith('--')) {
369
+ this.style.setProperty(sk, sv)
370
+ } else {
371
+ ;(this.style as unknown as Record<string, string>)[sk] = sv
372
+ }
373
+ }
374
+ }
375
+
376
+ this._prevHostProps = merged
377
+ }
260
378
 
261
379
  private _injector?: Injector
262
380
 
@@ -296,7 +414,7 @@ export const Shade = <TProps, TElementBase extends HTMLElement = HTMLElement>(
296
414
  o.elementBaseName ? { extends: o.elementBaseName } : undefined,
297
415
  )
298
416
  } else {
299
- throw Error(`A custom shade with shadow DOM name '${o.shadowDomName}' has already been registered!`)
417
+ throw Error(`A custom shade with name '${o.shadowDomName}' has already been registered!`)
300
418
  }
301
419
 
302
420
  return (props: TProps & PartialElement<TElementBase>, children?: ChildrenList) => {
@@ -318,3 +436,13 @@ export const Shade = <TProps, TElementBase extends HTMLElement = HTMLElement>(
318
436
  return el as JSX.Element
319
437
  }
320
438
  }
439
+
440
+ /**
441
+ * Flushes any pending microtask-based component updates.
442
+ * Useful in tests to wait for batched renders to complete before asserting DOM state.
443
+ *
444
+ * Note: this flushes one level of pending updates. If a render itself triggers new
445
+ * `updateComponent()` calls, an additional `await flushUpdates()` may be needed.
446
+ * @returns a promise that resolves after the current microtask queue has been processed
447
+ */
448
+ export const flushUpdates = (): Promise<void> => new Promise<void>((resolve) => queueMicrotask(resolve))