@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
@@ -1,6 +1,7 @@
1
1
  import { Cache } from '@furystack/cache'
2
2
  import type { CacheResult, CacheWithValue } from '@furystack/cache'
3
3
  import { Shade, createComponent, flushUpdates } from '@furystack/shades'
4
+ import { using, usingAsync } from '@furystack/utils'
4
5
 
5
6
  import { describe, expect, it, vi } from 'vitest'
6
7
  import { CacheView } from './cache-view.js'
@@ -45,232 +46,324 @@ describe('CacheView', () => {
45
46
  })
46
47
 
47
48
  it('should create a cache-view element', () => {
48
- const cache = new Cache<string, [string]>({ load: async (key) => key })
49
- const el = (<CacheView cache={cache} args={['test']} content={TestContent} />) as unknown as HTMLElement
50
- expect(el).toBeDefined()
51
- expect(el.tagName?.toLowerCase()).toBe('shade-cache-view')
52
- cache[Symbol.dispose]()
49
+ using(new Cache<string, [string]>({ load: async (key) => key }), (cache) => {
50
+ const el = (<CacheView cache={cache} args={['test']} content={TestContent} />) as unknown as HTMLElement
51
+ expect(el).toBeDefined()
52
+ expect(el.tagName?.toLowerCase()).toBe('shade-cache-view')
53
+ })
53
54
  })
54
55
 
55
56
  describe('loading state', () => {
56
57
  it('should render null by default when loading', async () => {
57
- const cache = new Cache<string, [string]>({
58
- load: () => new Promise(() => {}),
59
- })
60
- const { cacheView } = await renderCacheView(cache, ['test'])
61
- expect(cacheView.querySelector('test-cache-content')).toBeNull()
62
- expect(cacheView.querySelector('shade-result')).toBeNull()
63
- cache[Symbol.dispose]()
58
+ await usingAsync(
59
+ new Cache<string, [string]>({
60
+ load: () => new Promise(() => {}),
61
+ }),
62
+ async (cache) => {
63
+ const { cacheView } = await renderCacheView(cache, ['test'])
64
+ expect(cacheView.querySelector('test-cache-content')).toBeNull()
65
+ expect(cacheView.querySelector('shade-result')).toBeNull()
66
+ },
67
+ )
64
68
  })
65
69
 
66
70
  it('should render custom loader when provided', async () => {
67
- const cache = new Cache<string, [string]>({
68
- load: () => new Promise(() => {}),
69
- })
70
- const { cacheView } = await renderCacheView(cache, ['test'], {
71
- loader: (<span className="custom-loader">Loading...</span>) as unknown as JSX.Element,
72
- })
73
- const loader = cacheView.querySelector('.custom-loader')
74
- expect(loader).not.toBeNull()
75
- expect(loader?.textContent).toBe('Loading...')
76
- cache[Symbol.dispose]()
71
+ await usingAsync(
72
+ new Cache<string, [string]>({
73
+ load: () => new Promise(() => {}),
74
+ }),
75
+ async (cache) => {
76
+ const { cacheView } = await renderCacheView(cache, ['test'], {
77
+ loader: (<span className="custom-loader">Loading...</span>) as unknown as JSX.Element,
78
+ })
79
+ const loader = cacheView.querySelector('.custom-loader')
80
+ expect(loader).not.toBeNull()
81
+ expect(loader?.textContent).toBe('Loading...')
82
+ },
83
+ )
77
84
  })
78
85
  })
79
86
 
80
87
  describe('loaded state', () => {
81
88
  it('should render content when cache has loaded value', async () => {
82
- const cache = new Cache<string, [string]>({ load: async (key) => `Hello ${key}` })
83
- await cache.get('world')
84
- const { cacheView } = await renderCacheView(cache, ['world'])
85
- const contentEl = cacheView.querySelector('test-cache-content')
86
- expect(contentEl).not.toBeNull()
87
- const contentComponent = contentEl as JSX.Element
88
- contentComponent.updateComponent()
89
- await flushUpdates()
90
- const valueEl = contentComponent.querySelector('.content-value')
91
- expect(valueEl?.textContent).toBe('Hello world')
92
- cache[Symbol.dispose]()
89
+ await usingAsync(new Cache<string, [string]>({ load: async (key) => `Hello ${key}` }), async (cache) => {
90
+ await cache.get('world')
91
+ const { cacheView } = await renderCacheView(cache, ['world'])
92
+ const contentEl = cacheView.querySelector('test-cache-content')
93
+ expect(contentEl).not.toBeNull()
94
+ const contentComponent = contentEl as JSX.Element
95
+ contentComponent.updateComponent()
96
+ await flushUpdates()
97
+ const valueEl = contentComponent.querySelector('.content-value')
98
+ expect(valueEl?.textContent).toBe('Hello world')
99
+ })
93
100
  })
94
101
  })
95
102
 
96
103
  describe('failed state', () => {
97
104
  it('should render default error UI when cache has failed', async () => {
98
- const cache = new Cache<string, [string]>({
99
- load: async () => {
100
- throw new Error('Test failure')
105
+ await usingAsync(
106
+ new Cache<string, [string]>({
107
+ load: async () => {
108
+ throw new Error('Test failure')
109
+ },
110
+ }),
111
+ async (cache) => {
112
+ try {
113
+ await cache.get('test')
114
+ } catch {
115
+ // expected
116
+ }
117
+ const { cacheView } = await renderCacheView(cache, ['test'])
118
+ const resultEl = cacheView.querySelector('shade-result')
119
+ expect(resultEl).not.toBeNull()
120
+ const resultComponent = resultEl as JSX.Element
121
+ resultComponent.updateComponent()
122
+ await flushUpdates()
123
+ const titleEl = resultComponent.querySelector('.result-title') as JSX.Element
124
+ titleEl.updateComponent()
125
+ await flushUpdates()
126
+ expect(titleEl?.textContent).toBe('Something went wrong')
101
127
  },
102
- })
103
- try {
104
- await cache.get('test')
105
- } catch {
106
- // expected
107
- }
108
- const { cacheView } = await renderCacheView(cache, ['test'])
109
- const resultEl = cacheView.querySelector('shade-result')
110
- expect(resultEl).not.toBeNull()
111
- const resultComponent = resultEl as JSX.Element
112
- resultComponent.updateComponent()
113
- await flushUpdates()
114
- const titleEl = resultComponent.querySelector('.result-title') as JSX.Element
115
- titleEl.updateComponent()
116
- await flushUpdates()
117
- expect(titleEl?.textContent).toBe('Something went wrong')
118
- cache[Symbol.dispose]()
128
+ )
119
129
  })
120
130
 
121
131
  it('should render custom error UI when error prop is provided', async () => {
122
- const cache = new Cache<string, [string]>({
123
- load: async () => {
124
- throw new Error('Custom failure')
132
+ await usingAsync(
133
+ new Cache<string, [string]>({
134
+ load: async () => {
135
+ throw new Error('Custom failure')
136
+ },
137
+ }),
138
+ async (cache) => {
139
+ try {
140
+ await cache.get('test')
141
+ } catch {
142
+ // expected
143
+ }
144
+ const customError = vi.fn(
145
+ (err: unknown, _retry: () => void) =>
146
+ (<span className="custom-error">{String(err)}</span>) as unknown as JSX.Element,
147
+ )
148
+ const { cacheView } = await renderCacheView(cache, ['test'], { error: customError })
149
+ expect(customError).toHaveBeenCalledOnce()
150
+ expect(customError.mock.calls[0][0]).toBeInstanceOf(Error)
151
+ const customErrorEl = cacheView.querySelector('.custom-error')
152
+ expect(customErrorEl).not.toBeNull()
125
153
  },
126
- })
127
- try {
128
- await cache.get('test')
129
- } catch {
130
- // expected
131
- }
132
- const customError = vi.fn(
133
- (err: unknown, _retry: () => void) =>
134
- (<span className="custom-error">{String(err)}</span>) as unknown as JSX.Element,
135
154
  )
136
- const { cacheView } = await renderCacheView(cache, ['test'], { error: customError })
137
- expect(customError).toHaveBeenCalledOnce()
138
- expect(customError.mock.calls[0][0]).toBeInstanceOf(Error)
139
- const customErrorEl = cacheView.querySelector('.custom-error')
140
- expect(customErrorEl).not.toBeNull()
141
- cache[Symbol.dispose]()
142
155
  })
143
156
 
144
157
  it('should not render content when failed even if stale value exists', async () => {
145
- const cache = new Cache<string, [string]>({ load: async (key) => key })
146
- await cache.get('test')
147
- cache.setExplicitValue({
148
- loadArgs: ['test'],
149
- value: { status: 'failed', error: new Error('fail'), value: 'stale', updatedAt: new Date() },
158
+ await usingAsync(new Cache<string, [string]>({ load: async (key) => key }), async (cache) => {
159
+ await cache.get('test')
160
+ cache.setExplicitValue({
161
+ loadArgs: ['test'],
162
+ value: { status: 'failed', error: new Error('fail'), value: 'stale', updatedAt: new Date() },
163
+ })
164
+ const { cacheView } = await renderCacheView(cache, ['test'])
165
+ expect(cacheView.querySelector('test-cache-content')).toBeNull()
166
+ expect(cacheView.querySelector('shade-result')).not.toBeNull()
150
167
  })
151
- const { cacheView } = await renderCacheView(cache, ['test'])
152
- expect(cacheView.querySelector('test-cache-content')).toBeNull()
153
- expect(cacheView.querySelector('shade-result')).not.toBeNull()
154
- cache[Symbol.dispose]()
155
168
  })
156
169
 
157
170
  it('should call cache.reload when retry is invoked', async () => {
158
171
  const loadFn = vi.fn<(key: string) => Promise<string>>(async () => {
159
172
  throw new Error('fail')
160
173
  })
161
- const cache = new Cache<string, [string]>({ load: loadFn })
162
- try {
163
- await cache.get('test')
164
- } catch {
165
- // expected
166
- }
167
- let capturedRetry: (() => void) | undefined
168
- const customError = (_err: unknown, retry: () => void) => {
169
- capturedRetry = retry
170
- return (<span className="custom-error">Error</span>) as unknown as JSX.Element
171
- }
172
- await renderCacheView(cache, ['test'], { error: customError })
173
- expect(capturedRetry).toBeDefined()
174
-
175
- loadFn.mockResolvedValueOnce('recovered')
176
- capturedRetry!()
177
- await flushUpdates()
178
-
179
- const observable = cache.getObservable('test')
180
- const result = observable.getValue()
181
- expect(result.status).toBe('loaded')
182
- expect(result.value).toBe('recovered')
183
- cache[Symbol.dispose]()
174
+ await usingAsync(new Cache<string, [string]>({ load: loadFn }), async (cache) => {
175
+ try {
176
+ await cache.get('test')
177
+ } catch {
178
+ // expected
179
+ }
180
+ let capturedRetry: (() => void) | undefined
181
+ const customError = (_err: unknown, retry: () => void) => {
182
+ capturedRetry = retry
183
+ return (<span className="custom-error">Error</span>) as unknown as JSX.Element
184
+ }
185
+ await renderCacheView(cache, ['test'], { error: customError })
186
+ expect(capturedRetry).toBeDefined()
187
+
188
+ loadFn.mockResolvedValueOnce('recovered')
189
+ capturedRetry!()
190
+ await flushUpdates()
191
+
192
+ const observable = cache.getObservable('test')
193
+ const result = observable.getValue()
194
+ expect(result.status).toBe('loaded')
195
+ expect(result.value).toBe('recovered')
196
+ })
184
197
  })
185
198
  })
186
199
 
187
200
  describe('obsolete state', () => {
188
201
  it('should render content when obsolete and trigger reload', async () => {
189
202
  const loadFn = vi.fn(async (key: string) => `Hello ${key}`)
190
- const cache = new Cache<string, [string]>({ load: loadFn })
191
- await cache.get('test')
192
- cache.setObsolete('test')
193
-
194
- const { cacheView } = await renderCacheView(cache, ['test'])
195
- const contentEl = cacheView.querySelector('test-cache-content')
196
- expect(contentEl).not.toBeNull()
197
-
198
- await flushUpdates()
199
- // reload should have been called (initial load + obsolete reload)
200
- expect(loadFn).toHaveBeenCalledTimes(2)
201
- cache[Symbol.dispose]()
203
+ await usingAsync(new Cache<string, [string]>({ load: loadFn }), async (cache) => {
204
+ await cache.get('test')
205
+ cache.setObsolete('test')
206
+
207
+ const { cacheView } = await renderCacheView(cache, ['test'])
208
+ const contentEl = cacheView.querySelector('test-cache-content')
209
+ expect(contentEl).not.toBeNull()
210
+
211
+ await flushUpdates()
212
+ // reload should have been called (initial load + obsolete reload)
213
+ expect(loadFn).toHaveBeenCalledTimes(2)
214
+ })
202
215
  })
203
216
  })
204
217
 
205
218
  describe('error takes priority over value', () => {
206
219
  it('should show error when failed with value, not content', async () => {
207
- const cache = new Cache<string, [string]>({ load: async (key) => key })
208
- await cache.get('test')
209
- const failedWithValue: CacheResult<string> = {
210
- status: 'failed',
211
- error: new Error('whoops'),
212
- value: 'stale-data',
213
- updatedAt: new Date(),
214
- }
215
- cache.setExplicitValue({ loadArgs: ['test'], value: failedWithValue })
216
- const { cacheView } = await renderCacheView(cache, ['test'])
217
- expect(cacheView.querySelector('test-cache-content')).toBeNull()
218
- expect(cacheView.querySelector('shade-result')).not.toBeNull()
219
- cache[Symbol.dispose]()
220
+ await usingAsync(new Cache<string, [string]>({ load: async (key) => key }), async (cache) => {
221
+ await cache.get('test')
222
+ const failedWithValue: CacheResult<string> = {
223
+ status: 'failed',
224
+ error: new Error('whoops'),
225
+ value: 'stale-data',
226
+ updatedAt: new Date(),
227
+ }
228
+ cache.setExplicitValue({ loadArgs: ['test'], value: failedWithValue })
229
+ const { cacheView } = await renderCacheView(cache, ['test'])
230
+ expect(cacheView.querySelector('test-cache-content')).toBeNull()
231
+ expect(cacheView.querySelector('shade-result')).not.toBeNull()
232
+ })
220
233
  })
221
234
  })
222
235
 
223
236
  describe('contentProps', () => {
224
237
  it('should forward contentProps to the content component', async () => {
225
- const cache = new Cache<string, [string]>({ load: async (key) => `Hello ${key}` })
226
- await cache.get('world')
227
-
228
- const el = (
229
- <div>
230
- <CacheView
231
- cache={cache}
232
- args={['world']}
233
- content={TestContentWithLabel}
234
- contentProps={{ label: 'Greeting' }}
235
- />
236
- </div>
237
- )
238
- const cacheView = el.firstElementChild as JSX.Element
239
- cacheView.updateComponent()
240
- await flushUpdates()
241
-
242
- const contentEl = cacheView.querySelector('test-cache-content-with-label') as JSX.Element
243
- expect(contentEl).not.toBeNull()
244
- contentEl.updateComponent()
245
- await flushUpdates()
246
- const valueEl = contentEl.querySelector('.content-value')
247
- expect(valueEl?.textContent).toBe('Greeting: Hello world')
248
- cache[Symbol.dispose]()
238
+ await usingAsync(new Cache<string, [string]>({ load: async (key) => `Hello ${key}` }), async (cache) => {
239
+ await cache.get('world')
240
+
241
+ const el = (
242
+ <div>
243
+ <CacheView
244
+ cache={cache}
245
+ args={['world']}
246
+ content={TestContentWithLabel}
247
+ contentProps={{ label: 'Greeting' }}
248
+ />
249
+ </div>
250
+ )
251
+ const cacheView = el.firstElementChild as JSX.Element
252
+ cacheView.updateComponent()
253
+ await flushUpdates()
254
+
255
+ const contentEl = cacheView.querySelector('test-cache-content-with-label') as JSX.Element
256
+ expect(contentEl).not.toBeNull()
257
+ contentEl.updateComponent()
258
+ await flushUpdates()
259
+ const valueEl = contentEl.querySelector('.content-value')
260
+ expect(valueEl?.textContent).toBe('Greeting: Hello world')
261
+ })
249
262
  })
250
263
 
251
264
  it('should forward contentProps when cache entry is obsolete', async () => {
252
265
  const loadFn = vi.fn(async (key: string) => `Hello ${key}`)
253
- const cache = new Cache<string, [string]>({ load: loadFn })
254
- await cache.get('world')
255
- cache.setObsolete('world')
256
-
257
- const el = (
258
- <div>
259
- <CacheView cache={cache} args={['world']} content={TestContentWithLabel} contentProps={{ label: 'Stale' }} />
260
- </div>
261
- )
262
- const cacheView = el.firstElementChild as JSX.Element
263
- cacheView.updateComponent()
264
- await flushUpdates()
265
-
266
- const contentEl = cacheView.querySelector('test-cache-content-with-label') as JSX.Element
267
- expect(contentEl).not.toBeNull()
268
- contentEl.updateComponent()
269
- await flushUpdates()
270
- const valueEl = contentEl.querySelector('.content-value')
271
- expect(valueEl?.textContent).toBe('Stale: Hello world')
272
- expect(loadFn).toHaveBeenCalledTimes(2)
273
- cache[Symbol.dispose]()
266
+ await usingAsync(new Cache<string, [string]>({ load: loadFn }), async (cache) => {
267
+ await cache.get('world')
268
+ cache.setObsolete('world')
269
+
270
+ const el = (
271
+ <div>
272
+ <CacheView
273
+ cache={cache}
274
+ args={['world']}
275
+ content={TestContentWithLabel}
276
+ contentProps={{ label: 'Stale' }}
277
+ />
278
+ </div>
279
+ )
280
+ const cacheView = el.firstElementChild as JSX.Element
281
+ cacheView.updateComponent()
282
+ await flushUpdates()
283
+
284
+ const contentEl = cacheView.querySelector('test-cache-content-with-label') as JSX.Element
285
+ expect(contentEl).not.toBeNull()
286
+ contentEl.updateComponent()
287
+ await flushUpdates()
288
+ const valueEl = contentEl.querySelector('.content-value')
289
+ expect(valueEl?.textContent).toBe('Stale: Hello world')
290
+ expect(loadFn).toHaveBeenCalledTimes(2)
291
+ })
292
+ })
293
+ })
294
+
295
+ describe('view transitions', () => {
296
+ const mockStartViewTransition = () => {
297
+ const spy = vi.fn((optionsOrCallback: StartViewTransitionOptions | (() => void)) => {
298
+ const update = typeof optionsOrCallback === 'function' ? optionsOrCallback : optionsOrCallback.update
299
+ update?.()
300
+ return {
301
+ finished: Promise.resolve(),
302
+ ready: Promise.resolve(),
303
+ updateCallbackDone: Promise.resolve(),
304
+ skipTransition: vi.fn(),
305
+ } as unknown as ViewTransition
306
+ })
307
+ document.startViewTransition = spy as typeof document.startViewTransition
308
+ return spy
309
+ }
310
+
311
+ it('should call startViewTransition when viewTransition is enabled and cache state category changes', async () => {
312
+ const spy = mockStartViewTransition()
313
+ await usingAsync(new Cache<string, [string]>({ load: async (key) => `loaded-${key}` }), async (cache) => {
314
+ const el = (
315
+ <div>
316
+ <CacheView
317
+ cache={cache}
318
+ args={['test']}
319
+ content={TestContent}
320
+ loader={<span className="loader">Loading</span>}
321
+ viewTransition
322
+ />
323
+ </div>
324
+ )
325
+ const cacheView = el.firstElementChild as JSX.Element
326
+ cacheView.updateComponent()
327
+ await flushUpdates()
328
+
329
+ expect(cacheView.querySelector('.loader')).toBeTruthy()
330
+ spy.mockClear()
331
+
332
+ await cache.get('test')
333
+ cacheView.updateComponent()
334
+ await flushUpdates()
335
+
336
+ expect(spy).toHaveBeenCalled()
337
+ })
338
+ delete (document as unknown as Record<string, unknown>).startViewTransition
339
+ })
340
+
341
+ it('should not call startViewTransition when viewTransition is not set', async () => {
342
+ const spy = mockStartViewTransition()
343
+ await usingAsync(new Cache<string, [string]>({ load: async (key) => `loaded-${key}` }), async (cache) => {
344
+ const el = (
345
+ <div>
346
+ <CacheView
347
+ cache={cache}
348
+ args={['test']}
349
+ content={TestContent}
350
+ loader={<span className="loader">Loading</span>}
351
+ />
352
+ </div>
353
+ )
354
+ const cacheView = el.firstElementChild as JSX.Element
355
+ cacheView.updateComponent()
356
+ await flushUpdates()
357
+
358
+ spy.mockClear()
359
+
360
+ await cache.get('test')
361
+ cacheView.updateComponent()
362
+ await flushUpdates()
363
+
364
+ expect(spy).not.toHaveBeenCalled()
365
+ })
366
+ delete (document as unknown as Record<string, unknown>).startViewTransition
274
367
  })
275
368
  })
276
369
  })
@@ -1,7 +1,7 @@
1
1
  import type { Cache, CacheWithValue } from '@furystack/cache'
2
2
  import { hasCacheValue, isFailedCacheResult, isObsoleteCacheResult } from '@furystack/cache'
3
- import type { PartialElement, ShadeComponent } from '@furystack/shades'
4
- import { Shade, createComponent } from '@furystack/shades'
3
+ import type { PartialElement, ShadeComponent, ViewTransitionConfig } from '@furystack/shades'
4
+ import { Shade, createComponent, transitionedValue } from '@furystack/shades'
5
5
 
6
6
  import { cssVariableTheme } from '../services/css-variable-theme.js'
7
7
  import { Button } from './button.js'
@@ -32,6 +32,7 @@ export type CacheViewProps<
32
32
  * If not provided, a default Result + retry Button is shown.
33
33
  */
34
34
  error?: (error: unknown, retry: () => void) => JSX.Element
35
+ viewTransition?: boolean | ViewTransitionConfig
35
36
  } & (keyof Omit<TContentProps, 'data' | keyof PartialElement<HTMLElement>> extends never
36
37
  ? { contentProps?: never }
37
38
  : { contentProps: Omit<TContentProps, 'data' | keyof PartialElement<HTMLElement>> })
@@ -80,6 +81,7 @@ type InternalCacheViewProps = {
80
81
  contentProps?: Record<string, unknown>
81
82
  loader?: JSX.Element
82
83
  error?: (error: unknown, retry: () => void) => JSX.Element
84
+ viewTransition?: boolean | ViewTransitionConfig
83
85
  }
84
86
 
85
87
  export const CacheView = Shade<InternalCacheViewProps>({
@@ -88,13 +90,24 @@ export const CacheView = Shade<InternalCacheViewProps>({
88
90
  fontFamily: cssVariableTheme.typography.fontFamily,
89
91
  },
90
92
  render: ({ props, useObservable, useState }): JSX.Element | null => {
91
- const { cache, args, content, loader, error, contentProps } = props
93
+ const { cache, args, content, loader, error, contentProps, viewTransition } = props
92
94
 
93
95
  const argsKey = JSON.stringify(args)
94
96
  const observable = cache.getObservable(...args)
95
97
 
96
98
  const [result] = useObservable(`cache-${argsKey}`, observable)
97
99
 
100
+ const getCategory = (r: typeof result) =>
101
+ isFailedCacheResult(r) ? 'error' : hasCacheValue(r) ? 'value' : 'loading'
102
+
103
+ const displayedResult = transitionedValue(
104
+ useState,
105
+ 'displayedResult',
106
+ result,
107
+ viewTransition,
108
+ (prev, next) => getCategory(prev) !== getCategory(next),
109
+ )
110
+
98
111
  const [lastReloadedArgsKey, setLastReloadedArgsKey] = useState<string | null>('lastReloadedArgsKey', null)
99
112
 
100
113
  const retry = () => {
@@ -104,14 +117,14 @@ export const CacheView = Shade<InternalCacheViewProps>({
104
117
  }
105
118
 
106
119
  // 1. Error first
107
- if (isFailedCacheResult(result)) {
120
+ if (isFailedCacheResult(displayedResult)) {
108
121
  const errorRenderer = error ?? getDefaultErrorUi
109
- return errorRenderer(result.error, retry)
122
+ return errorRenderer(displayedResult.error, retry)
110
123
  }
111
124
 
112
125
  // 2. Value next
113
- if (hasCacheValue(result)) {
114
- if (isObsoleteCacheResult(result)) {
126
+ if (hasCacheValue(displayedResult)) {
127
+ if (isObsoleteCacheResult(displayedResult)) {
115
128
  if (lastReloadedArgsKey !== argsKey) {
116
129
  setLastReloadedArgsKey(argsKey)
117
130
  cache.reload(...args).catch(() => {
@@ -122,7 +135,7 @@ export const CacheView = Shade<InternalCacheViewProps>({
122
135
  setLastReloadedArgsKey(null)
123
136
  }
124
137
  return createComponent(content, {
125
- data: result,
138
+ data: displayedResult,
126
139
  ...(contentProps ?? {}),
127
140
  }) as unknown as JSX.Element
128
141
  }
@@ -95,7 +95,7 @@ export const CommandPaletteSuggestionList = Shade<{ manager: CommandPaletteManag
95
95
  ref={containerRef}
96
96
  className="suggestion-items-container"
97
97
  style={{
98
- opacity: manager.isOpened.getValue() ? '1' : '0',
98
+ opacity: isOpenedAtRender ? '1' : '0',
99
99
  maxHeight: `${window.innerHeight * 0.8}px`,
100
100
  width: `calc(${Math.round(hostParentWidth)}px - 3em)`,
101
101
  ...(props.fullScreenSuggestions ? { left: '0', width: 'calc(100% - 42px)' } : {}),
@@ -243,6 +243,8 @@ export const Input = Shade<TextInputProps>({
243
243
 
244
244
  const themeProvider = injector.getInstance(ThemeProviderService)
245
245
 
246
+ // We want to use the CSS state hooks for the focused and validity states, so we need to disable the rule
247
+ // eslint-disable-next-line furystack/no-css-state-hooks
246
248
  const [focused, setFocused] = useState('focused', props.autofocus || false)
247
249
  const [validity, setValidity] = useState('validity', inputRef.current?.validity || emptyValidity)
248
250
 
@@ -4,8 +4,8 @@ import { cssVariableTheme } from '../../services/css-variable-theme.js'
4
4
  import type { Palette } from '../../services/theme-provider-service.js'
5
5
  import { ThemeProviderService } from '../../services/theme-provider-service.js'
6
6
  import { FormService } from '../form.js'
7
- import { Icon } from '../icons/icon.js'
8
7
  import { check, close } from '../icons/icon-definitions.js'
8
+ import { Icon } from '../icons/icon.js'
9
9
  import type { InputValidationResult } from './input.js'
10
10
 
11
11
  export type SelectOption = {
@@ -384,6 +384,8 @@ export const Select = Shade<SelectProps>({
384
384
  }
385
385
 
386
386
  const [state, setState] = useState<SelectState>('selectState', initialState)
387
+ // We want to use the CSS state hooks for the focused and dropdown direction states, so we need to disable the rule
388
+ // eslint-disable-next-line furystack/no-css-state-hooks
387
389
  const [isFocused, setIsFocused] = useState('isFocused', false)
388
390
  const [dropdownDirection, setDropdownDirection] = useState<'up' | 'down'>('dropdownDirection', 'down')
389
391