@furystack/shades 14.0.0 → 15.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 (125) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/esm/compile-route.d.ts +5 -0
  3. package/esm/compile-route.d.ts.map +1 -1
  4. package/esm/compile-route.js +5 -0
  5. package/esm/compile-route.js.map +1 -1
  6. package/esm/component-factory.spec.d.ts.map +1 -1
  7. package/esm/component-factory.spec.js +2 -2
  8. package/esm/component-factory.spec.js.map +1 -1
  9. package/esm/components/nested-hooks.js +2 -2
  10. package/esm/components/nested-hooks.js.map +1 -1
  11. package/esm/components/nested-hooks.spec.js +14 -14
  12. package/esm/components/nested-hooks.spec.js.map +1 -1
  13. package/esm/components/nested-navigate.js +1 -1
  14. package/esm/components/nested-navigate.js.map +1 -1
  15. package/esm/components/nested-navigate.spec.js +16 -16
  16. package/esm/components/nested-navigate.spec.js.map +1 -1
  17. package/esm/components/nested-replace.js +1 -1
  18. package/esm/components/nested-replace.js.map +1 -1
  19. package/esm/components/nested-replace.spec.js +6 -6
  20. package/esm/components/nested-replace.spec.js.map +1 -1
  21. package/esm/components/nested-route-link.js +1 -1
  22. package/esm/components/nested-route-link.js.map +1 -1
  23. package/esm/components/nested-route-link.spec.js +8 -8
  24. package/esm/components/nested-route-link.spec.js.map +1 -1
  25. package/esm/components/nested-router.js +3 -3
  26. package/esm/components/nested-router.js.map +1 -1
  27. package/esm/components/nested-router.spec.js +24 -24
  28. package/esm/components/nested-router.spec.js.map +1 -1
  29. package/esm/css-generator.d.ts +9 -26
  30. package/esm/css-generator.d.ts.map +1 -1
  31. package/esm/css-generator.js +9 -26
  32. package/esm/css-generator.js.map +1 -1
  33. package/esm/css-generator.spec.js.map +1 -1
  34. package/esm/models/css-object.d.ts +3 -5
  35. package/esm/models/css-object.d.ts.map +1 -1
  36. package/esm/models/render-options.d.ts +16 -20
  37. package/esm/models/render-options.d.ts.map +1 -1
  38. package/esm/models/shade-component.d.ts +6 -4
  39. package/esm/models/shade-component.d.ts.map +1 -1
  40. package/esm/models/shade-component.js +3 -3
  41. package/esm/models/shade-component.js.map +1 -1
  42. package/esm/services/location-service.d.ts +39 -42
  43. package/esm/services/location-service.d.ts.map +1 -1
  44. package/esm/services/location-service.js +115 -134
  45. package/esm/services/location-service.js.map +1 -1
  46. package/esm/services/location-service.spec.js +32 -30
  47. package/esm/services/location-service.spec.js.map +1 -1
  48. package/esm/services/route-match-service.d.ts +3 -2
  49. package/esm/services/route-match-service.d.ts.map +1 -1
  50. package/esm/services/route-match-service.js +13 -22
  51. package/esm/services/route-match-service.js.map +1 -1
  52. package/esm/services/route-match-service.spec.js +40 -51
  53. package/esm/services/route-match-service.spec.js.map +1 -1
  54. package/esm/services/route-meta-utils.js.map +1 -1
  55. package/esm/services/route-meta-utils.spec.js +3 -3
  56. package/esm/services/route-meta-utils.spec.js.map +1 -1
  57. package/esm/services/screen-service.d.ts +13 -72
  58. package/esm/services/screen-service.d.ts.map +1 -1
  59. package/esm/services/screen-service.js +45 -137
  60. package/esm/services/screen-service.js.map +1 -1
  61. package/esm/services/screen-service.spec.js +19 -18
  62. package/esm/services/screen-service.spec.js.map +1 -1
  63. package/esm/services/spatial-navigation-service.d.ts +17 -39
  64. package/esm/services/spatial-navigation-service.d.ts.map +1 -1
  65. package/esm/services/spatial-navigation-service.js +280 -313
  66. package/esm/services/spatial-navigation-service.js.map +1 -1
  67. package/esm/services/spatial-navigation-service.spec.js +163 -158
  68. package/esm/services/spatial-navigation-service.spec.js.map +1 -1
  69. package/esm/shade-component.d.ts +14 -17
  70. package/esm/shade-component.d.ts.map +1 -1
  71. package/esm/shade-component.js +14 -17
  72. package/esm/shade-component.js.map +1 -1
  73. package/esm/shade.d.ts +18 -15
  74. package/esm/shade.d.ts.map +1 -1
  75. package/esm/shade.js +22 -32
  76. package/esm/shade.js.map +1 -1
  77. package/esm/style-manager.d.ts +8 -22
  78. package/esm/style-manager.d.ts.map +1 -1
  79. package/esm/style-manager.js +8 -22
  80. package/esm/style-manager.js.map +1 -1
  81. package/esm/styled-shade.d.ts +4 -7
  82. package/esm/styled-shade.d.ts.map +1 -1
  83. package/esm/styled-shade.js +4 -4
  84. package/esm/styled-shade.js.map +1 -1
  85. package/esm/view-transition.d.ts +10 -22
  86. package/esm/view-transition.d.ts.map +1 -1
  87. package/esm/view-transition.js +10 -22
  88. package/esm/view-transition.js.map +1 -1
  89. package/esm/vnode.js.map +1 -1
  90. package/esm/vnode.spec.js.map +1 -1
  91. package/package.json +9 -8
  92. package/src/compile-route.ts +5 -0
  93. package/src/component-factory.spec.tsx +3 -2
  94. package/src/components/nested-hooks.spec.ts +14 -14
  95. package/src/components/nested-hooks.ts +2 -2
  96. package/src/components/nested-navigate.spec.ts +17 -16
  97. package/src/components/nested-navigate.ts +1 -1
  98. package/src/components/nested-replace.spec.ts +7 -6
  99. package/src/components/nested-replace.ts +1 -1
  100. package/src/components/nested-route-link.spec.tsx +8 -8
  101. package/src/components/nested-route-link.tsx +1 -1
  102. package/src/components/nested-router.spec.tsx +24 -24
  103. package/src/components/nested-router.tsx +5 -5
  104. package/src/css-generator.spec.ts +1 -1
  105. package/src/css-generator.ts +9 -26
  106. package/src/models/css-object.ts +3 -5
  107. package/src/models/render-options.ts +17 -20
  108. package/src/models/shade-component.ts +6 -4
  109. package/src/services/location-service.spec.ts +35 -31
  110. package/src/services/location-service.tsx +156 -122
  111. package/src/services/route-match-service.spec.ts +41 -32
  112. package/src/services/route-match-service.ts +17 -8
  113. package/src/services/route-meta-utils.spec.ts +3 -3
  114. package/src/services/route-meta-utils.ts +1 -1
  115. package/src/services/screen-service.spec.ts +21 -20
  116. package/src/services/screen-service.ts +65 -110
  117. package/src/services/spatial-navigation-service.spec.ts +165 -160
  118. package/src/services/spatial-navigation-service.ts +299 -287
  119. package/src/shade-component.ts +14 -17
  120. package/src/shade.ts +31 -41
  121. package/src/style-manager.ts +9 -22
  122. package/src/styled-shade.ts +5 -8
  123. package/src/view-transition.ts +10 -22
  124. package/src/vnode.spec.ts +3 -3
  125. package/src/vnode.ts +2 -2
@@ -1,4 +1,4 @@
1
- import { Injector } from '@furystack/inject'
1
+ import { createInjector } from '@furystack/inject'
2
2
  import { serializeValue } from '@furystack/rest'
3
3
  import { sleepAsync, usingAsync } from '@furystack/utils'
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -438,7 +438,7 @@ describe('NestedRouter lifecycle hooks', () => {
438
438
  callOrder.push('leave-other')
439
439
  })
440
440
 
441
- await usingAsync(new Injector(), async (injector) => {
441
+ await usingAsync(createInjector(), async (injector) => {
442
442
  const rootElement = document.getElementById('root') as HTMLDivElement
443
443
 
444
444
  initializeShadeRoot({
@@ -556,7 +556,7 @@ describe('NestedRouter query / hash re-render', () => {
556
556
  const onLeave = vi.fn(async () => {})
557
557
  const componentFn = vi.fn(({ hash }: { hash: string | undefined }) => <div id="content">hash={hash ?? 'none'}</div>)
558
558
 
559
- await usingAsync(new Injector(), async (injector) => {
559
+ await usingAsync(createInjector(), async (injector) => {
560
560
  const rootElement = document.getElementById('root') as HTMLDivElement
561
561
 
562
562
  initializeShadeRoot({
@@ -581,7 +581,7 @@ describe('NestedRouter query / hash re-render', () => {
581
581
  expect(onVisit).toBeCalledTimes(1)
582
582
  expect(onLeave).not.toBeCalled()
583
583
 
584
- const locationService = injector.getInstance(LocationService)
584
+ const locationService = injector.get(LocationService)
585
585
  locationService.navigate('/tabs#a')
586
586
  await flushUpdates()
587
587
  await flushUpdates()
@@ -609,7 +609,7 @@ describe('NestedRouter query / hash re-render', () => {
609
609
  <div id="content">page={query?.page ?? 'none'}</div>
610
610
  ))
611
611
 
612
- await usingAsync(new Injector(), async (injector) => {
612
+ await usingAsync(createInjector(), async (injector) => {
613
613
  const rootElement = document.getElementById('root') as HTMLDivElement
614
614
 
615
615
  initializeShadeRoot({
@@ -633,7 +633,7 @@ describe('NestedRouter query / hash re-render', () => {
633
633
  expect(document.getElementById('content')?.innerHTML).toBe('page=none')
634
634
  expect(onVisit).toBeCalledTimes(1)
635
635
 
636
- const locationService = injector.getInstance(LocationService)
636
+ const locationService = injector.get(LocationService)
637
637
  locationService.navigate(`/list?page=${serializeValue(2)}`)
638
638
  await flushUpdates()
639
639
  await flushUpdates()
@@ -662,7 +662,7 @@ describe('NestedRouter query / hash re-render', () => {
662
662
  </div>
663
663
  ))
664
664
 
665
- await usingAsync(new Injector(), async (injector) => {
665
+ await usingAsync(createInjector(), async (injector) => {
666
666
  const rootElement = document.getElementById('root') as HTMLDivElement
667
667
 
668
668
  initializeShadeRoot({
@@ -686,7 +686,7 @@ describe('NestedRouter query / hash re-render', () => {
686
686
  expect(document.getElementById('content')?.innerHTML).toBe('a')
687
687
  bComponent.mockClear()
688
688
 
689
- injector.getInstance(LocationService).navigate(`/b?page=${serializeValue(7)}#x`)
689
+ injector.get(LocationService).navigate(`/b?page=${serializeValue(7)}#x`)
690
690
  await flushUpdates()
691
691
  await flushUpdates()
692
692
 
@@ -731,7 +731,7 @@ describe('NestedRouter latest-wins on rapid navigation', () => {
731
731
  callOrder.push('leave-c')
732
732
  })
733
733
 
734
- await usingAsync(new Injector(), async (injector) => {
734
+ await usingAsync(createInjector(), async (injector) => {
735
735
  const rootElement = document.getElementById('root') as HTMLDivElement
736
736
 
737
737
  initializeShadeRoot({
@@ -813,7 +813,7 @@ describe('NestedRouter lifecycle element scope', () => {
813
813
  const visitElements: Array<{ route: string; element: JSX.Element }> = []
814
814
  const leaveElements: Array<{ route: string; element: JSX.Element }> = []
815
815
 
816
- await usingAsync(new Injector(), async (injector) => {
816
+ await usingAsync(createInjector(), async (injector) => {
817
817
  const rootElement = document.getElementById('root') as HTMLDivElement
818
818
 
819
819
  initializeShadeRoot({
@@ -906,7 +906,7 @@ describe('NestedRouter flat routes', () => {
906
906
  it('should render and navigate between flat (non-nested) Record routes', async () => {
907
907
  history.pushState(null, '', '/')
908
908
 
909
- await usingAsync(new Injector(), async (injector) => {
909
+ await usingAsync(createInjector(), async (injector) => {
910
910
  const rootElement = document.getElementById('root') as HTMLDivElement
911
911
 
912
912
  initializeShadeRoot({
@@ -967,7 +967,7 @@ describe('NestedRouter outlet composition', () => {
967
967
  it('should compose parent layout wrapping child content via outlet', async () => {
968
968
  history.pushState(null, '', '/dashboard/settings')
969
969
 
970
- await usingAsync(new Injector(), async (injector) => {
970
+ await usingAsync(createInjector(), async (injector) => {
971
971
  const rootElement = document.getElementById('root') as HTMLDivElement
972
972
 
973
973
  initializeShadeRoot({
@@ -1012,7 +1012,7 @@ describe('NestedRouter outlet composition', () => {
1012
1012
  it('should render parent with fallback when navigating to parent URL without a child match', async () => {
1013
1013
  history.pushState(null, '', '/dashboard')
1014
1014
 
1015
- await usingAsync(new Injector(), async (injector) => {
1015
+ await usingAsync(createInjector(), async (injector) => {
1016
1016
  const rootElement = document.getElementById('root') as HTMLDivElement
1017
1017
 
1018
1018
  initializeShadeRoot({
@@ -1065,7 +1065,7 @@ describe('NestedRouter route param changes', () => {
1065
1065
  callOrder.push('leave-user')
1066
1066
  })
1067
1067
 
1068
- await usingAsync(new Injector(), async (injector) => {
1068
+ await usingAsync(createInjector(), async (injector) => {
1069
1069
  const rootElement = document.getElementById('root') as HTMLDivElement
1070
1070
 
1071
1071
  initializeShadeRoot({
@@ -1141,7 +1141,7 @@ describe('NestedRouter route param changes', () => {
1141
1141
  const onVisitDash = vi.fn()
1142
1142
  const onLeaveDash = vi.fn()
1143
1143
 
1144
- await usingAsync(new Injector(), async (injector) => {
1144
+ await usingAsync(createInjector(), async (injector) => {
1145
1145
  const rootElement = document.getElementById('root') as HTMLDivElement
1146
1146
 
1147
1147
  initializeShadeRoot({
@@ -1214,9 +1214,9 @@ describe('NestedRouter + RouteMatchService integration', () => {
1214
1214
  it('should update RouteMatchService with the current match chain on navigation', async () => {
1215
1215
  history.pushState(null, '', '/parent/child-a')
1216
1216
 
1217
- await usingAsync(new Injector(), async (injector) => {
1217
+ await usingAsync(createInjector(), async (injector) => {
1218
1218
  const rootElement = document.getElementById('root') as HTMLDivElement
1219
- const routeMatchService = injector.getInstance(RouteMatchService)
1219
+ const routeMatchService = injector.get(RouteMatchService)
1220
1220
 
1221
1221
  const parentRoute: NestedRoute = {
1222
1222
  meta: { title: 'Parent' },
@@ -1278,9 +1278,9 @@ describe('NestedRouter + RouteMatchService integration', () => {
1278
1278
  it('should expose match params through RouteMatchService', async () => {
1279
1279
  history.pushState(null, '', '/users/42')
1280
1280
 
1281
- await usingAsync(new Injector(), async (injector) => {
1281
+ await usingAsync(createInjector(), async (injector) => {
1282
1282
  const rootElement = document.getElementById('root') as HTMLDivElement
1283
- const routeMatchService = injector.getInstance(RouteMatchService)
1283
+ const routeMatchService = injector.get(RouteMatchService)
1284
1284
 
1285
1285
  initializeShadeRoot({
1286
1286
  injector,
@@ -1378,7 +1378,7 @@ describe('NestedRouter view transitions', () => {
1378
1378
  it('should call startViewTransition when viewTransition is enabled', async () => {
1379
1379
  history.pushState(null, '', '/')
1380
1380
 
1381
- await usingAsync(new Injector(), async (injector) => {
1381
+ await usingAsync(createInjector(), async (injector) => {
1382
1382
  const rootElement = document.getElementById('root') as HTMLDivElement
1383
1383
 
1384
1384
  initializeShadeRoot({
@@ -1414,7 +1414,7 @@ describe('NestedRouter view transitions', () => {
1414
1414
  it('should not call startViewTransition when viewTransition is not set', async () => {
1415
1415
  history.pushState(null, '', '/')
1416
1416
 
1417
- await usingAsync(new Injector(), async (injector) => {
1417
+ await usingAsync(createInjector(), async (injector) => {
1418
1418
  const rootElement = document.getElementById('root') as HTMLDivElement
1419
1419
 
1420
1420
  initializeShadeRoot({
@@ -1449,7 +1449,7 @@ describe('NestedRouter view transitions', () => {
1449
1449
  it('should pass types to startViewTransition when configured', async () => {
1450
1450
  history.pushState(null, '', '/')
1451
1451
 
1452
- await usingAsync(new Injector(), async (injector) => {
1452
+ await usingAsync(createInjector(), async (injector) => {
1453
1453
  const rootElement = document.getElementById('root') as HTMLDivElement
1454
1454
 
1455
1455
  initializeShadeRoot({
@@ -1484,7 +1484,7 @@ describe('NestedRouter view transitions', () => {
1484
1484
  it('should respect per-route viewTransition: false override', async () => {
1485
1485
  history.pushState(null, '', '/')
1486
1486
 
1487
- await usingAsync(new Injector(), async (injector) => {
1487
+ await usingAsync(createInjector(), async (injector) => {
1488
1488
  const rootElement = document.getElementById('root') as HTMLDivElement
1489
1489
 
1490
1490
  initializeShadeRoot({
@@ -1525,7 +1525,7 @@ describe('NestedRouter view transitions', () => {
1525
1525
 
1526
1526
  history.pushState(null, '', '/')
1527
1527
 
1528
- await usingAsync(new Injector(), async (injector) => {
1528
+ await usingAsync(createInjector(), async (injector) => {
1529
1529
  const rootElement = document.getElementById('root') as HTMLDivElement
1530
1530
 
1531
1531
  initializeShadeRoot({
@@ -271,8 +271,8 @@ const isShallowEqual = (a: unknown, b: unknown): boolean => {
271
271
  }
272
272
  if (Array.isArray(b)) return false
273
273
 
274
- const aKeys = Object.keys(a as Record<string, unknown>)
275
- const bKeys = Object.keys(b as Record<string, unknown>)
274
+ const aKeys = Object.keys(a)
275
+ const bKeys = Object.keys(b)
276
276
  if (aKeys.length !== bKeys.length) return false
277
277
  for (const key of aKeys) {
278
278
  if (!Object.prototype.hasOwnProperty.call(b, key)) return false
@@ -386,7 +386,7 @@ export const NestedRouter = Shade<NestedRouterProps>({
386
386
  chainElements: [],
387
387
  })
388
388
 
389
- const locationService = injector.getInstance(LocationService)
389
+ const locationService = injector.get(LocationService)
390
390
 
391
391
  const updateUrl = async (currentUrl: string) => {
392
392
  const [lastState] = useState<NestedRouterState>('routerState', state)
@@ -426,7 +426,7 @@ export const NestedRouter = Shade<NestedRouterProps>({
426
426
 
427
427
  const applyUpdate = () => {
428
428
  setState({ matchChain: newChain, jsx: newResult.jsx, chainElements: newResult.chainElements })
429
- injector.getInstance(RouteMatchService).currentMatchChain.setValue(newChain)
429
+ injector.get(RouteMatchService).currentMatchChain.setValue(newChain)
430
430
  }
431
431
 
432
432
  const vtConfig = resolveViewTransition(options.props.viewTransition, newChain)
@@ -451,7 +451,7 @@ export const NestedRouter = Shade<NestedRouterProps>({
451
451
  jsx: options.props.notFound || <div />,
452
452
  chainElements: [],
453
453
  })
454
- injector.getInstance(RouteMatchService).currentMatchChain.setValue([])
454
+ injector.get(RouteMatchService).currentMatchChain.setValue([])
455
455
  }
456
456
 
457
457
  await maybeViewTransition(options.props.viewTransition, applyNotFound)
@@ -71,7 +71,7 @@ describe('css-generator', () => {
71
71
  color: 'red',
72
72
  '&:hover': { color: 'blue' },
73
73
  }
74
- const result = propertiesToCSSString(mixedObject as unknown as CSSProperties)
74
+ const result = propertiesToCSSString(mixedObject)
75
75
  expect(result).toBe('color: red')
76
76
  })
77
77
 
@@ -1,30 +1,20 @@
1
1
  import type { CSSObject, CSSProperties } from './models/css-object.js'
2
2
 
3
3
  /**
4
- * Converts a camelCase string to kebab-case
5
- * @param str - The camelCase string to convert
6
- * @returns The kebab-case string
7
4
  * @example
8
5
  * camelToKebab('backgroundColor') // 'background-color'
9
- * camelToKebab('fontSize') // 'font-size'
10
6
  */
11
7
  export const camelToKebab = (str: string): string => {
12
8
  return str.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`)
13
9
  }
14
10
 
15
- /**
16
- * Checks if a key is a selector key (starts with '&')
17
- * @param key - The key to check
18
- * @returns True if the key is a selector key
19
- */
20
11
  export const isSelectorKey = (key: string): boolean => {
21
12
  return key.startsWith('&')
22
13
  }
23
14
 
24
15
  /**
25
- * Converts CSS properties to a CSS declaration string
26
- * @param properties - The CSS properties object
27
- * @returns A CSS declaration string (e.g., "color: red; padding: 10px;")
16
+ * Serializes the non-selector entries of a {@link CSSProperties} object as
17
+ * a CSS declaration body. Selector keys (`&...`) are skipped.
28
18
  */
29
19
  export const propertiesToCSSString = (properties: CSSProperties): string => {
30
20
  const declarations: string[] = []
@@ -41,12 +31,7 @@ export const propertiesToCSSString = (properties: CSSProperties): string => {
41
31
  return declarations.join('; ')
42
32
  }
43
33
 
44
- /**
45
- * Generates a CSS rule string from a selector and properties
46
- * @param selector - The CSS selector
47
- * @param properties - The CSS properties object
48
- * @returns A complete CSS rule string (e.g., "selector { color: red; }")
49
- */
34
+ /** Builds `selector { decls; }` from the non-selector entries. Empty when no decls. */
50
35
  export const generateCSSRule = (selector: string, properties: CSSProperties): string => {
51
36
  const cssString = propertiesToCSSString(properties)
52
37
  if (!cssString) {
@@ -56,10 +41,9 @@ export const generateCSSRule = (selector: string, properties: CSSProperties): st
56
41
  }
57
42
 
58
43
  /**
59
- * Generates complete CSS from a CSSObject for a given component selector
60
- * @param selector - The base selector (typically the customElementName)
61
- * @param cssObject - The CSSObject containing styles and nested selectors
62
- * @returns A complete CSS string with all rules
44
+ * Renders a {@link CSSObject} as CSS rules. Base properties become a single
45
+ * rule against `selector`; each `&...` selector key produces an additional
46
+ * rule with `&` substituted for `selector`.
63
47
  *
64
48
  * @example
65
49
  * ```typescript
@@ -68,10 +52,9 @@ export const generateCSSRule = (selector: string, properties: CSSProperties): st
68
52
  * '&:hover': { color: 'blue' },
69
53
  * '& .inner': { fontWeight: 'bold' }
70
54
  * })
71
- * // Returns:
72
- * // "my-component { color: red; }
73
- * // my-component:hover { color: blue; }
74
- * // my-component .inner { font-weight: bold; }"
55
+ * // my-component { color: red; }
56
+ * // my-component:hover { color: blue; }
57
+ * // my-component .inner { font-weight: bold; }
75
58
  * ```
76
59
  */
77
60
  export const generateCSS = (selector: string, cssObject: CSSObject): string => {
@@ -1,11 +1,9 @@
1
- /**
2
- * Base CSS properties - subset of CSSStyleDeclaration
3
- */
4
1
  export type CSSProperties = Partial<CSSStyleDeclaration>
5
2
 
6
3
  /**
7
- * Selector key pattern for pseudo-classes and nested selectors
8
- * Examples: '&:hover', '&:active', '& .className', '& > div'
4
+ * Selector keys for pseudo-classes and nested selectors. The leading `&`
5
+ * is replaced with the host selector at registration time
6
+ * (e.g. `&:hover` → `my-element:hover`).
9
7
  */
10
8
  export type SelectorKey = `&${string}`
11
9
 
@@ -1,5 +1,6 @@
1
1
  import type { Injector } from '@furystack/inject'
2
2
  import type { ObservableValue, ValueObserverOptions } from '@furystack/utils'
3
+ import type { LocationService } from '../services/location-service.js'
3
4
  import type { ChildrenList } from './children-list.js'
4
5
  import type { PartialElement } from './partial-element.js'
5
6
 
@@ -79,13 +80,11 @@ export type RenderOptions<TProps, TElementBase extends HTMLElement = HTMLElement
79
80
  useRef: <T extends Element = HTMLElement>(key: string) => RefObject<T>
80
81
 
81
82
  /**
82
- * Creates and disposes a resource after the component has been detached from the DOM.
83
- * When `deps` is provided, the resource is re-created (and the old one disposed) whenever
84
- * the serialized deps value changes.
85
- * @param key The key for caching the disposable resource
86
- * @param factory A factory method for creating the disposable resource
87
- * @param deps Optional dependency array — when deps change, the old resource is disposed and a new one is created
88
- * @returns The Disposable instance
83
+ * Creates a disposable resource the first time it is requested per `key`,
84
+ * caches it across renders, and disposes it on `disconnectedCallback`.
85
+ * When `deps` is provided, the cached resource is disposed + re-created
86
+ * whenever the serialized deps change (use for `key`-stable resources
87
+ * that depend on dynamic inputs).
89
88
  */
90
89
  useDisposable: <T extends Disposable | AsyncDisposable>(key: string, factory: () => T, deps?: readonly unknown[]) => T
91
90
 
@@ -121,27 +120,25 @@ export type RenderOptions<TProps, TElementBase extends HTMLElement = HTMLElement
121
120
  ) => [value: T, setValue: (newValue: T) => void]
122
121
 
123
122
  /**
124
- * Creates a state object that will trigger a component re-render on change
125
- * @param key The Key for caching the observable value
126
- * @param initialValue The initial value for the observable
127
- * @returns tuple with the current value and a setter function
123
+ * Local component state. Returns `[value, setValue]`; calling the setter
124
+ * with a new value triggers a re-render. Preferred over manually wiring
125
+ * an `ObservableValue` + `useObservable` (`furystack/prefer-use-state`
126
+ * enforces this).
128
127
  */
129
128
  useState: <T>(key: string, initialValue: T) => [value: T, setValue: (newValue: T) => void]
130
129
 
131
130
  /**
132
- * Creates a state object that will use a value from the search string of the current location. Triggers a component re-render on change
133
- * @param key The Key for caching the observable value
134
- * @param initialValue The initial value - if the value is not found in the search string
135
- * @returns a tuple with the current value and a setter function
131
+ * State backed by a URL search-string parameter via {@link LocationService}.
132
+ * Bidirectional: setting the value pushes a history entry; navigating
133
+ * (back/forward, manual edit) updates the value. `initialValue` applies
134
+ * when the key is absent from the current URL.
136
135
  */
137
136
  useSearchState: <T>(key: string, initialValue: T) => [value: T, setValue: (newValue: T) => void]
138
137
 
139
138
  /**
140
- * Creates a state object that will use a value from the storage area. Triggers a component re-render on change
141
- * @param key The key in the storage area
142
- * @param initialValue The initial value that will be used if the key is not found in the storage area
143
- * @param storageArea The storage area to use
144
- * @returns a tuple with the current value and a setter function
139
+ * State backed by `localStorage` (or a custom `Storage`). Synchronizes
140
+ * across tabs via the `storage` event and a `BroadcastChannel`.
141
+ * `initialValue` applies when the key is absent from `storageArea`.
145
142
  */
146
143
  useStoredState: <T>(
147
144
  key: string,
@@ -1,14 +1,16 @@
1
1
  import type { ChildrenList } from './children-list.js'
2
2
 
3
3
  /**
4
- * Type definition for a Shade component
4
+ * Callable that produces a JSX element from props (and optional children).
5
+ * Both `Shade(...)` factories and plain functional components conform to
6
+ * this shape.
5
7
  */
6
8
  export type ShadeComponent<TProps = object> = (arg: TProps, children?: ChildrenList) => JSX.Element
7
9
 
8
10
  /**
9
- * Type guard that checks if an object is a stateless component
10
- * @param obj The object to check
11
- * @returns a value that indicates if the object is a Shade component
11
+ * Discriminates {@link ShadeComponent} from intrinsic-element tag names.
12
+ * Intentionally permissive — any function is treated as a component since
13
+ * VNodes carry no other discriminator.
12
14
  */
13
15
  export const isShadeComponent = <T = any>(obj: any): obj is ShadeComponent<T> => {
14
16
  return typeof obj === 'function'
@@ -1,4 +1,4 @@
1
- import { Injector } from '@furystack/inject'
1
+ import { createInjector } from '@furystack/inject'
2
2
  import { deserializeQueryString, serializeToQueryString, serializeValue } from '@furystack/rest'
3
3
  import { usingAsync } from '@furystack/utils'
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -13,16 +13,17 @@ describe('LocationService', () => {
13
13
  })
14
14
 
15
15
  it('Shuld be constructed', async () => {
16
- await usingAsync(new Injector(), async (i) => {
17
- const s = i.getInstance(LocationService)
18
- expect(s).toBeInstanceOf(LocationService)
16
+ await usingAsync(createInjector(), async (i) => {
17
+ const s = i.get(LocationService)
18
+ expect(s).toBeDefined()
19
+ expect(typeof s.navigate).toBe('function')
19
20
  })
20
21
  })
21
22
 
22
23
  it('Shuld update state on events', async () => {
23
- await usingAsync(new Injector(), async (i) => {
24
+ await usingAsync(createInjector(), async (i) => {
24
25
  const onLocaionChanged = vi.fn()
25
- const s = i.getInstance(LocationService)
26
+ const s = i.get(LocationService)
26
27
  s.onLocationPathChanged.subscribe(onLocaionChanged)
27
28
  expect(onLocaionChanged).toBeCalledTimes(0)
28
29
  history.pushState(null, '', '/loc1')
@@ -39,9 +40,9 @@ describe('LocationService', () => {
39
40
  })
40
41
 
41
42
  it('Should update location path when navigate is called', async () => {
42
- await usingAsync(new Injector(), async (i) => {
43
+ await usingAsync(createInjector(), async (i) => {
43
44
  const onLocationChanged = vi.fn()
44
- const s = i.getInstance(LocationService)
45
+ const s = i.get(LocationService)
45
46
  s.onLocationPathChanged.subscribe(onLocationChanged)
46
47
  s.navigate('/dashboard')
47
48
  expect(s.onLocationPathChanged.getValue()).toBe('/dashboard')
@@ -51,8 +52,8 @@ describe('LocationService', () => {
51
52
 
52
53
  describe('replace', () => {
53
54
  it('Should update the observable path without pushing a new history entry', async () => {
54
- await usingAsync(new Injector(), async (i) => {
55
- const s = i.getInstance(LocationService)
55
+ await usingAsync(createInjector(), async (i) => {
56
+ const s = i.get(LocationService)
56
57
  const lengthBefore = history.length
57
58
  s.replace('/replaced')
58
59
  expect(s.onLocationPathChanged.getValue()).toBe('/replaced')
@@ -61,8 +62,8 @@ describe('LocationService', () => {
61
62
  })
62
63
 
63
64
  it('Should call history.replaceState rather than pushState', async () => {
64
- await usingAsync(new Injector(), async (i) => {
65
- const s = i.getInstance(LocationService)
65
+ await usingAsync(createInjector(), async (i) => {
66
+ const s = i.get(LocationService)
66
67
  const pushSpy = vi.spyOn(history, 'pushState')
67
68
  const replaceSpy = vi.spyOn(history, 'replaceState')
68
69
 
@@ -78,8 +79,8 @@ describe('LocationService', () => {
78
79
  })
79
80
 
80
81
  it('Should notify path subscribers after replace', async () => {
81
- await usingAsync(new Injector(), async (i) => {
82
- const s = i.getInstance(LocationService)
82
+ await usingAsync(createInjector(), async (i) => {
83
+ const s = i.get(LocationService)
83
84
  const onLocationChanged = vi.fn()
84
85
  s.onLocationPathChanged.subscribe(onLocationChanged)
85
86
  s.replace('/notify')
@@ -90,8 +91,8 @@ describe('LocationService', () => {
90
91
 
91
92
  describe('useSearchParam', () => {
92
93
  it('Should create observables lazily', async () => {
93
- await usingAsync(new Injector(), async (i) => {
94
- const service = i.getInstance(LocationService)
94
+ await usingAsync(createInjector(), async (i) => {
95
+ const service = i.get(LocationService)
95
96
  const observables = service.searchParamObservables
96
97
 
97
98
  const testSearchParam = service.useSearchParam('test', null)
@@ -110,16 +111,16 @@ describe('LocationService', () => {
110
111
  })
111
112
 
112
113
  it('Should return the default value, if not present in the query string', async () => {
113
- await usingAsync(new Injector(), async (i) => {
114
- const service = i.getInstance(LocationService)
114
+ await usingAsync(createInjector(), async (i) => {
115
+ const service = i.get(LocationService)
115
116
  const testSearchParam = service.useSearchParam('test', { value: 'foo' })
116
117
  expect(testSearchParam.getValue()).toEqual({ value: 'foo' })
117
118
  })
118
119
  })
119
120
 
120
121
  it('Should return the value from the query string', async () => {
121
- await usingAsync(new Injector(), async (i) => {
122
- const service = i.getInstance(LocationService)
122
+ await usingAsync(createInjector(), async (i) => {
123
+ const service = i.get(LocationService)
123
124
  history.pushState(null, '', `/loc1?test=${serializeValue(1)}`)
124
125
  const testSearchParam = service.useSearchParam('test', 123)
125
126
  expect(testSearchParam.getValue()).toBe(1)
@@ -127,8 +128,8 @@ describe('LocationService', () => {
127
128
  })
128
129
 
129
130
  it('should update the observable value on push / replace states', async () => {
130
- await usingAsync(new Injector(), async (i) => {
131
- const service = i.getInstance(LocationService)
131
+ await usingAsync(createInjector(), async (i) => {
132
+ const service = i.get(LocationService)
132
133
  history.pushState(null, '', `/loc1?test=${serializeValue(1)}`)
133
134
  const testSearchParam = service.useSearchParam('test', 234)
134
135
  expect(testSearchParam.getValue()).toBe(1)
@@ -138,8 +139,8 @@ describe('LocationService', () => {
138
139
  })
139
140
 
140
141
  it('Should update the URL based on search value change', async () => {
141
- await usingAsync(new Injector(), async (i) => {
142
- const service = i.getInstance(LocationService)
142
+ await usingAsync(createInjector(), async (i) => {
143
+ const service = i.get(LocationService)
143
144
  history.pushState(null, '', `/loc1?test=${serializeValue('2')}`)
144
145
  const testSearchParam = service.useSearchParam('test', '')
145
146
  testSearchParam.setValue('2')
@@ -147,25 +148,28 @@ describe('LocationService', () => {
147
148
  })
148
149
  })
149
150
 
150
- it('Should throw an error when trying to use a custom serializer after LocationService has been instantiated', async () => {
151
- await usingAsync(new Injector(), async (i) => {
151
+ it('Should throw when called after LocationService has been resolved', async () => {
152
+ await usingAsync(createInjector(), async (i) => {
152
153
  const customSerializer = vi.fn((value: any) => serializeToQueryString(value))
153
154
  const customDeserializer = vi.fn((value: string) => deserializeQueryString(value))
154
- i.getInstance(LocationService)
155
- expect(() => useCustomSearchStateSerializer(i, customSerializer, customDeserializer)).toThrowError(
156
- 'useCustomSearchStateSerializer must be called before the LocationService is instantiated',
155
+
156
+ // Eagerly resolve once so the service patches history / adds listeners.
157
+ i.get(LocationService)
158
+
159
+ expect(() => useCustomSearchStateSerializer(i, customSerializer, customDeserializer)).toThrow(
160
+ /before LocationService is resolved/,
157
161
  )
158
162
  })
159
163
  })
160
164
 
161
165
  it('Should use custom serializer and deserializer', async () => {
162
- await usingAsync(new Injector(), async (i) => {
166
+ await usingAsync(createInjector(), async (i) => {
163
167
  const customSerializer = vi.fn((value: any) => serializeToQueryString(value))
164
168
  const customDeserializer = vi.fn((value: string) => deserializeQueryString(value))
165
169
 
166
170
  useCustomSearchStateSerializer(i, customSerializer, customDeserializer)
167
171
 
168
- const locationService = i.getInstance(LocationService)
172
+ const locationService = i.get(LocationService)
169
173
  const testSearchParam = locationService.useSearchParam('test', { value: 'foo' })
170
174
 
171
175
  testSearchParam.setValue({ value: 'bar' })