@furystack/shades-common-components 13.3.1 → 13.4.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 (54) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/esm/components/alert.d.ts.map +1 -1
  3. package/esm/components/alert.js +7 -6
  4. package/esm/components/alert.js.map +1 -1
  5. package/esm/components/cache-view.d.ts +2 -1
  6. package/esm/components/cache-view.d.ts.map +1 -1
  7. package/esm/components/cache-view.js +9 -7
  8. package/esm/components/cache-view.js.map +1 -1
  9. package/esm/components/cache-view.spec.js +195 -145
  10. package/esm/components/cache-view.spec.js.map +1 -1
  11. package/esm/components/command-palette/command-palette-suggestion-list.js +1 -1
  12. package/esm/components/command-palette/command-palette-suggestion-list.js.map +1 -1
  13. package/esm/components/inputs/input.d.ts.map +1 -1
  14. package/esm/components/inputs/input.js +2 -0
  15. package/esm/components/inputs/input.js.map +1 -1
  16. package/esm/components/inputs/select.d.ts.map +1 -1
  17. package/esm/components/inputs/select.js +3 -1
  18. package/esm/components/inputs/select.js.map +1 -1
  19. package/esm/components/result.d.ts +4 -2
  20. package/esm/components/result.d.ts.map +1 -1
  21. package/esm/components/result.js +11 -10
  22. package/esm/components/result.js.map +1 -1
  23. package/esm/components/suggest/index.d.ts.map +1 -1
  24. package/esm/components/suggest/index.js +7 -3
  25. package/esm/components/suggest/index.js.map +1 -1
  26. package/esm/components/tabs.d.ts +2 -0
  27. package/esm/components/tabs.d.ts.map +1 -1
  28. package/esm/components/tabs.js +5 -4
  29. package/esm/components/tabs.js.map +1 -1
  30. package/esm/components/tabs.spec.js +57 -0
  31. package/esm/components/tabs.spec.js.map +1 -1
  32. package/esm/components/tree/tree.d.ts.map +1 -1
  33. package/esm/components/tree/tree.js +1 -0
  34. package/esm/components/tree/tree.js.map +1 -1
  35. package/esm/components/wizard/index.d.ts +2 -1
  36. package/esm/components/wizard/index.d.ts.map +1 -1
  37. package/esm/components/wizard/index.js +3 -3
  38. package/esm/components/wizard/index.js.map +1 -1
  39. package/esm/components/wizard/index.spec.js +46 -1
  40. package/esm/components/wizard/index.spec.js.map +1 -1
  41. package/package.json +6 -6
  42. package/src/components/alert.tsx +9 -6
  43. package/src/components/cache-view.spec.tsx +266 -173
  44. package/src/components/cache-view.tsx +21 -8
  45. package/src/components/command-palette/command-palette-suggestion-list.tsx +1 -1
  46. package/src/components/inputs/input.tsx +2 -0
  47. package/src/components/inputs/select.tsx +3 -1
  48. package/src/components/result.tsx +17 -10
  49. package/src/components/suggest/index.tsx +18 -15
  50. package/src/components/tabs.spec.tsx +72 -0
  51. package/src/components/tabs.tsx +9 -4
  52. package/src/components/tree/tree.tsx +1 -0
  53. package/src/components/wizard/index.spec.tsx +57 -1
  54. package/src/components/wizard/index.tsx +5 -4
@@ -40,16 +40,19 @@ const statusColorMap: Record<ResultStatus, string> = {
40
40
  '500': paletteMainColors.error.main,
41
41
  }
42
42
 
43
- const defaultIcons: Record<ResultStatus, JSX.Element> = {
44
- success: (<Icon icon={checkCircle} size={64} />) as unknown as JSX.Element,
45
- error: (<Icon icon={errorCircle} size={64} />) as unknown as JSX.Element,
46
- warning: (<Icon icon={warningIcon} size={64} />) as unknown as JSX.Element,
47
- info: (<Icon icon={infoIcon} size={64} />) as unknown as JSX.Element,
48
- '403': (<Icon icon={forbidden} size={64} />) as unknown as JSX.Element,
49
- '404': (<Icon icon={searchOff} size={64} />) as unknown as JSX.Element,
50
- '500': (<Icon icon={serverError} size={64} />) as unknown as JSX.Element,
43
+ const defaultIconDefs: Record<ResultStatus, typeof checkCircle> = {
44
+ success: checkCircle,
45
+ error: errorCircle,
46
+ warning: warningIcon,
47
+ info: infoIcon,
48
+ '403': forbidden,
49
+ '404': searchOff,
50
+ '500': serverError,
51
51
  }
52
52
 
53
+ const getDefaultIcon = (status: ResultStatus): JSX.Element =>
54
+ (<Icon icon={defaultIconDefs[status]} size={64} />) as unknown as JSX.Element
55
+
53
56
  const defaultTitles: Record<ResultStatus, string> = {
54
57
  success: 'Success',
55
58
  error: 'Error',
@@ -114,7 +117,7 @@ export const Result = Shade<ResultProps>({
114
117
  render: ({ props, children, useHostProps }) => {
115
118
  const { status, title, subtitle, icon, style } = props
116
119
 
117
- const displayIcon = icon ?? defaultIcons[status]
120
+ const displayIcon = icon ?? getDefaultIcon(status)
118
121
  const statusColor = statusColorMap[status]
119
122
 
120
123
  useHostProps({
@@ -146,4 +149,8 @@ export const Result = Shade<ResultProps>({
146
149
  },
147
150
  })
148
151
 
149
- export { defaultIcons as resultDefaultIcons, defaultTitles as resultDefaultTitles }
152
+ export {
153
+ getDefaultIcon as resultGetDefaultIcon,
154
+ defaultIconDefs as resultDefaultIconDefs,
155
+ defaultTitles as resultDefaultTitles,
156
+ }
@@ -83,21 +83,23 @@ export const Suggest: <T>(props: SuggestProps<T>, children: ChildrenList) => JSX
83
83
  useHostProps({
84
84
  'data-opened': isOpened ? '' : undefined,
85
85
  })
86
- manager.isLoading.subscribe((isLoading) => {
87
- const loader = loaderRef.current
88
- if (!loader) return
89
- if (isLoading) {
90
- void promisifyAnimation(loader, [{ opacity: 0 }, { opacity: 1 }], {
91
- duration: 100,
92
- fill: 'forwards',
93
- })
94
- } else {
95
- void promisifyAnimation(loader, [{ opacity: 1 }, { opacity: 0 }], {
96
- duration: 100,
97
- fill: 'forwards',
98
- })
99
- }
100
- })
86
+ useDisposable('isLoadingSubscription', () =>
87
+ manager.isLoading.subscribe((isLoading) => {
88
+ const loader = loaderRef.current
89
+ if (!loader) return
90
+ if (isLoading) {
91
+ void promisifyAnimation(loader, [{ opacity: 0 }, { opacity: 1 }], {
92
+ duration: 100,
93
+ fill: 'forwards',
94
+ })
95
+ } else {
96
+ void promisifyAnimation(loader, [{ opacity: 1 }, { opacity: 0 }], {
97
+ duration: 100,
98
+ fill: 'forwards',
99
+ })
100
+ }
101
+ }),
102
+ )
101
103
  useDisposable('onSelectSuggestion', () =>
102
104
  manager.subscribe('onSelectSuggestion', props.onSelectSuggestion as (entry: unknown) => void),
103
105
  )
@@ -133,6 +135,7 @@ export const Suggest: <T>(props: SuggestProps<T>, children: ChildrenList) => JSX
133
135
  <div className="post-controls">
134
136
  <span ref={loaderRef} style={{ display: 'inline-flex' }}>
135
137
  <Loader
138
+ // eslint-disable-next-line furystack/no-direct-get-value-in-render -- Initial opacity only; animated transitions handled by isLoadingSubscription via DOM
136
139
  style={{ width: '20px', height: '20px', opacity: manager.isLoading.getValue() ? '1' : '0' }}
137
140
  delay={0}
138
141
  borderWidth={4}
@@ -13,6 +13,7 @@ describe('Tabs', () => {
13
13
  afterEach(() => {
14
14
  document.body.innerHTML = ''
15
15
  window.location.hash = ''
16
+ delete (document as unknown as Record<string, unknown>).startViewTransition
16
17
  })
17
18
 
18
19
  const createTabs = (): Tab[] => [
@@ -595,4 +596,75 @@ describe('Tabs', () => {
595
596
  })
596
597
  })
597
598
  })
599
+
600
+ describe('view transitions', () => {
601
+ const mockStartViewTransition = () => {
602
+ const spy = vi.fn((optionsOrCallback: StartViewTransitionOptions | (() => void)) => {
603
+ const update = typeof optionsOrCallback === 'function' ? optionsOrCallback : optionsOrCallback.update
604
+ update?.()
605
+ return {
606
+ finished: Promise.resolve(),
607
+ ready: Promise.resolve(),
608
+ updateCallbackDone: Promise.resolve(),
609
+ skipTransition: vi.fn(),
610
+ } as unknown as ViewTransition
611
+ })
612
+ document.startViewTransition = spy as typeof document.startViewTransition
613
+ return spy
614
+ }
615
+
616
+ it('should call startViewTransition when viewTransition is enabled and hash changes', async () => {
617
+ const spy = mockStartViewTransition()
618
+
619
+ await usingAsync(new Injector(), async (injector) => {
620
+ window.location.hash = '#tab1'
621
+
622
+ const rootElement = document.getElementById('root') as HTMLDivElement
623
+ const tabs = createTabs()
624
+
625
+ initializeShadeRoot({
626
+ injector,
627
+ rootElement,
628
+ jsxElement: <Tabs tabs={tabs} viewTransition />,
629
+ })
630
+
631
+ await flushUpdates()
632
+ spy.mockClear()
633
+
634
+ window.location.hash = '#tab2'
635
+ injector.getInstance(LocationService).updateState()
636
+ await flushUpdates()
637
+
638
+ expect(spy).toHaveBeenCalled()
639
+ expect(document.getElementById('content-2')).toBeTruthy()
640
+ })
641
+ })
642
+
643
+ it('should not call startViewTransition when viewTransition is not set', async () => {
644
+ const spy = mockStartViewTransition()
645
+
646
+ await usingAsync(new Injector(), async (injector) => {
647
+ window.location.hash = '#tab1'
648
+
649
+ const rootElement = document.getElementById('root') as HTMLDivElement
650
+ const tabs = createTabs()
651
+
652
+ initializeShadeRoot({
653
+ injector,
654
+ rootElement,
655
+ jsxElement: <Tabs tabs={tabs} />,
656
+ })
657
+
658
+ await flushUpdates()
659
+ spy.mockClear()
660
+
661
+ window.location.hash = '#tab2'
662
+ injector.getInstance(LocationService).updateState()
663
+ await flushUpdates()
664
+
665
+ expect(spy).not.toHaveBeenCalled()
666
+ expect(document.getElementById('content-2')).toBeTruthy()
667
+ })
668
+ })
669
+ })
598
670
  })
@@ -1,4 +1,5 @@
1
- import { LocationService, Shade, createComponent } from '@furystack/shades'
1
+ import type { ViewTransitionConfig } from '@furystack/shades'
2
+ import { LocationService, Shade, createComponent, transitionedValue } from '@furystack/shades'
2
3
  import { buildTransition, cssVariableTheme } from '../services/css-variable-theme.js'
3
4
  import { close } from './icons/icon-definitions.js'
4
5
  import { Icon } from './icons/icon.js'
@@ -71,6 +72,7 @@ export const Tabs = Shade<{
71
72
  onClose?: (key: string) => void
72
73
  /** Called when the add button is clicked (only shown when this callback is provided) */
73
74
  onAdd?: () => void
75
+ viewTransition?: boolean | ViewTransitionConfig
74
76
  }>({
75
77
  shadowDomName: 'shade-tabs',
76
78
  css: {
@@ -212,7 +214,7 @@ export const Tabs = Shade<{
212
214
  borderRight: `1px solid ${cssVariableTheme.background.paper}`,
213
215
  },
214
216
  },
215
- render: ({ props, useObservable, injector, useHostProps }) => {
217
+ render: ({ props, useObservable, injector, useHostProps, useState }) => {
216
218
  useHostProps({
217
219
  ...(props.containerStyle ? { style: props.containerStyle as Record<string, string> } : {}),
218
220
  ...(props.orientation === 'vertical' ? { 'data-orientation': 'vertical' } : {}),
@@ -224,7 +226,10 @@ export const Tabs = Shade<{
224
226
  const [hash] = useObservable('updateLocation', injector.getInstance(LocationService).onLocationHashChanged)
225
227
 
226
228
  const activeKey = isControlled ? props.activeKey! : hash.replace('#', '')
227
- const activeTab = props.tabs.find((t) => t.hash === activeKey)
229
+
230
+ const displayedKey = transitionedValue(useState, 'displayedKey', activeKey, props.viewTransition)
231
+
232
+ const displayedTab = props.tabs.find((t) => t.hash === displayedKey)
228
233
 
229
234
  const handleTabClick = (e: MouseEvent, tab: Tab, index: number) => {
230
235
  const target = e.target as HTMLElement
@@ -277,7 +282,7 @@ export const Tabs = Shade<{
277
282
  </button>
278
283
  ) : null}
279
284
  </div>
280
- {activeTab?.component}
285
+ {displayedTab?.component}
281
286
  </>
282
287
  )
283
288
  },
@@ -89,6 +89,7 @@ export const Tree: <T>(props: TreeProps<T>, children: ChildrenList) => JSX.Eleme
89
89
 
90
90
  const [flattenedNodes] = useObservable('flattenedNodes', props.treeService.flattenedNodes)
91
91
 
92
+ // eslint-disable-next-line furystack/require-use-observable-for-render -- Used as persistent ref, not reactive state; read and written synchronously in same render cycle
92
93
  const previousItemsRef = useDisposable('previousTreeItems', () => new ObservableValue<Set<unknown>>(new Set()))
93
94
  const previousItems = previousItemsRef.getValue()
94
95
  const currentItems = new Set<unknown>(flattenedNodes.map((n) => n.item))
@@ -67,12 +67,13 @@ describe('Wizard', () => {
67
67
 
68
68
  afterEach(() => {
69
69
  document.body.innerHTML = ''
70
+ delete (document as unknown as Record<string, unknown>).startViewTransition
70
71
  })
71
72
 
72
73
  const renderWizard = async (
73
74
  steps: Array<(props: WizardStepProps, children: ChildrenList) => JSX.Element>,
74
75
  onFinish?: () => void,
75
- options?: { stepLabels?: string[]; showProgress?: boolean },
76
+ options?: { stepLabels?: string[]; showProgress?: boolean; viewTransition?: boolean },
76
77
  ) => {
77
78
  const injector = new Injector()
78
79
  const root = document.getElementById('root')!
@@ -85,6 +86,7 @@ describe('Wizard', () => {
85
86
  onFinish={onFinish}
86
87
  stepLabels={options?.stepLabels}
87
88
  showProgress={options?.showProgress}
89
+ viewTransition={options?.viewTransition}
88
90
  />
89
91
  ),
90
92
  })
@@ -347,4 +349,58 @@ describe('Wizard', () => {
347
349
  })
348
350
  })
349
351
  })
352
+
353
+ describe('view transitions', () => {
354
+ const mockStartViewTransition = () => {
355
+ const spy = vi.fn((optionsOrCallback: StartViewTransitionOptions | (() => void)) => {
356
+ const update = typeof optionsOrCallback === 'function' ? optionsOrCallback : optionsOrCallback.update
357
+ update?.()
358
+ return {
359
+ finished: Promise.resolve(),
360
+ ready: Promise.resolve(),
361
+ updateCallbackDone: Promise.resolve(),
362
+ skipTransition: vi.fn(),
363
+ } as unknown as ViewTransition
364
+ })
365
+ document.startViewTransition = spy as typeof document.startViewTransition
366
+ return spy
367
+ }
368
+
369
+ it('should call startViewTransition on next when viewTransition is enabled', async () => {
370
+ const spy = mockStartViewTransition()
371
+ await usingAsync(
372
+ await renderWizard([Step1, Step2, Step3], undefined, { viewTransition: true }),
373
+ async ({ clickNext, getStepName }) => {
374
+ spy.mockClear()
375
+ await clickNext()
376
+ expect(spy).toHaveBeenCalledTimes(1)
377
+ expect(getStepName()).toBe('step2')
378
+ },
379
+ )
380
+ })
381
+
382
+ it('should call startViewTransition on prev when viewTransition is enabled', async () => {
383
+ const spy = mockStartViewTransition()
384
+ await usingAsync(
385
+ await renderWizard([Step1, Step2, Step3], undefined, { viewTransition: true }),
386
+ async ({ clickNext, clickPrev, getStepName }) => {
387
+ await clickNext()
388
+ spy.mockClear()
389
+ await clickPrev()
390
+ expect(spy).toHaveBeenCalledTimes(1)
391
+ expect(getStepName()).toBe('step1')
392
+ },
393
+ )
394
+ })
395
+
396
+ it('should not call startViewTransition when viewTransition is not set', async () => {
397
+ const spy = mockStartViewTransition()
398
+ await usingAsync(await renderWizard([Step1, Step2, Step3]), async ({ clickNext, getStepName }) => {
399
+ spy.mockClear()
400
+ await clickNext()
401
+ expect(spy).not.toHaveBeenCalled()
402
+ expect(getStepName()).toBe('step2')
403
+ })
404
+ })
405
+ })
350
406
  })
@@ -1,5 +1,5 @@
1
- import type { ChildrenList } from '@furystack/shades'
2
- import { createComponent, Shade } from '@furystack/shades'
1
+ import type { ChildrenList, ViewTransitionConfig } from '@furystack/shades'
2
+ import { createComponent, maybeViewTransition, Shade } from '@furystack/shades'
3
3
  import { cssVariableTheme } from '../../services/css-variable-theme.js'
4
4
  import { Paper } from '../paper.js'
5
5
 
@@ -39,6 +39,7 @@ export interface WizardProps {
39
39
  * When true, a progress bar is shown above the content.
40
40
  */
41
41
  showProgress?: boolean
42
+ viewTransition?: boolean | ViewTransitionConfig
42
43
  }
43
44
 
44
45
  export const Wizard = Shade<WizardProps>({
@@ -182,14 +183,14 @@ export const Wizard = Shade<WizardProps>({
182
183
  maxPages={props.steps.length}
183
184
  onNext={() => {
184
185
  if (currentPage < props.steps.length - 1) {
185
- setCurrentPage(currentPage + 1)
186
+ void maybeViewTransition(props.viewTransition, () => setCurrentPage(currentPage + 1))
186
187
  } else {
187
188
  props.onFinish?.()
188
189
  }
189
190
  }}
190
191
  onPrev={() => {
191
192
  if (currentPage > 0) {
192
- setCurrentPage(currentPage - 1)
193
+ void maybeViewTransition(props.viewTransition, () => setCurrentPage(currentPage - 1))
193
194
  }
194
195
  }}
195
196
  />