@furystack/shades-common-components 12.0.1 → 12.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +26 -0
  3. package/esm/components/avatar.d.ts.map +1 -1
  4. package/esm/components/avatar.js +3 -1
  5. package/esm/components/avatar.js.map +1 -1
  6. package/esm/components/avatar.spec.js +4 -4
  7. package/esm/components/avatar.spec.js.map +1 -1
  8. package/esm/components/cache-view.d.ts +46 -0
  9. package/esm/components/cache-view.d.ts.map +1 -0
  10. package/esm/components/cache-view.js +65 -0
  11. package/esm/components/cache-view.js.map +1 -0
  12. package/esm/components/cache-view.spec.d.ts +2 -0
  13. package/esm/components/cache-view.spec.d.ts.map +1 -0
  14. package/esm/components/cache-view.spec.js +183 -0
  15. package/esm/components/cache-view.spec.js.map +1 -0
  16. package/esm/components/icons/icon-definitions.d.ts +82 -0
  17. package/esm/components/icons/icon-definitions.d.ts.map +1 -1
  18. package/esm/components/icons/icon-definitions.js +717 -0
  19. package/esm/components/icons/icon-definitions.js.map +1 -1
  20. package/esm/components/icons/icon-definitions.spec.js +22 -2
  21. package/esm/components/icons/icon-definitions.spec.js.map +1 -1
  22. package/esm/components/icons/icon-types.d.ts +10 -0
  23. package/esm/components/icons/icon-types.d.ts.map +1 -1
  24. package/esm/components/icons/index.d.ts +1 -1
  25. package/esm/components/icons/index.d.ts.map +1 -1
  26. package/esm/components/index.d.ts +1 -0
  27. package/esm/components/index.d.ts.map +1 -1
  28. package/esm/components/index.js +1 -0
  29. package/esm/components/index.js.map +1 -1
  30. package/esm/components/page-container/index.d.ts +1 -1
  31. package/esm/components/page-container/index.js +1 -1
  32. package/esm/components/page-container/page-header.d.ts +5 -5
  33. package/esm/components/page-container/page-header.d.ts.map +1 -1
  34. package/esm/components/page-container/page-header.js +3 -3
  35. package/esm/components/skeleton.d.ts.map +1 -1
  36. package/esm/components/skeleton.js +2 -11
  37. package/esm/components/skeleton.js.map +1 -1
  38. package/esm/components/skeleton.spec.js +6 -55
  39. package/esm/components/skeleton.spec.js.map +1 -1
  40. package/esm/components/suggest/index.d.ts +1 -1
  41. package/esm/components/suggest/index.d.ts.map +1 -1
  42. package/package.json +4 -3
  43. package/src/components/avatar.spec.tsx +4 -4
  44. package/src/components/avatar.tsx +3 -1
  45. package/src/components/cache-view.spec.tsx +210 -0
  46. package/src/components/cache-view.tsx +103 -0
  47. package/src/components/icons/icon-definitions.spec.ts +28 -2
  48. package/src/components/icons/icon-definitions.ts +759 -0
  49. package/src/components/icons/icon-types.ts +12 -0
  50. package/src/components/icons/index.ts +1 -1
  51. package/src/components/index.ts +1 -0
  52. package/src/components/page-container/index.tsx +1 -1
  53. package/src/components/page-container/page-header.tsx +5 -5
  54. package/src/components/skeleton.spec.tsx +6 -73
  55. package/src/components/skeleton.tsx +2 -11
  56. package/src/components/suggest/index.tsx +1 -1
@@ -0,0 +1,210 @@
1
+ import { Cache } from '@furystack/cache'
2
+ import type { CacheResult, CacheWithValue } from '@furystack/cache'
3
+ import { Shade, createComponent, flushUpdates } from '@furystack/shades'
4
+ import { sleepAsync } from '@furystack/utils'
5
+ import { describe, expect, it, vi } from 'vitest'
6
+ import { CacheView } from './cache-view.js'
7
+
8
+ const TestContent = Shade<{ data: CacheWithValue<string> }>({
9
+ shadowDomName: 'test-cache-content',
10
+ render: ({ props }) => <span className="content-value">{props.data.value}</span>,
11
+ })
12
+
13
+ const renderCacheView = async (
14
+ cache: Cache<string, [string]>,
15
+ args: [string],
16
+ options?: {
17
+ loader?: JSX.Element
18
+ error?: (error: unknown, retry: () => void) => JSX.Element
19
+ },
20
+ ) => {
21
+ const el = (
22
+ <div>
23
+ <CacheView cache={cache} args={args} content={TestContent} loader={options?.loader} error={options?.error} />
24
+ </div>
25
+ )
26
+ const cacheView = el.firstElementChild as JSX.Element
27
+ cacheView.updateComponent()
28
+ await flushUpdates()
29
+ return { container: el, cacheView }
30
+ }
31
+
32
+ describe('CacheView', () => {
33
+ it('should be defined', () => {
34
+ expect(CacheView).toBeDefined()
35
+ expect(typeof CacheView).toBe('function')
36
+ })
37
+
38
+ it('should create a cache-view element', () => {
39
+ const cache = new Cache<string, [string]>({ load: async (key) => key })
40
+ const el = (<CacheView cache={cache} args={['test']} content={TestContent} />) as unknown as HTMLElement
41
+ expect(el).toBeDefined()
42
+ expect(el.tagName?.toLowerCase()).toBe('shade-cache-view')
43
+ cache[Symbol.dispose]()
44
+ })
45
+
46
+ describe('loading state', () => {
47
+ it('should render null by default when loading', async () => {
48
+ const cache = new Cache<string, [string]>({
49
+ load: () => new Promise(() => {}),
50
+ })
51
+ const { cacheView } = await renderCacheView(cache, ['test'])
52
+ expect(cacheView.querySelector('test-cache-content')).toBeNull()
53
+ expect(cacheView.querySelector('shade-result')).toBeNull()
54
+ cache[Symbol.dispose]()
55
+ })
56
+
57
+ it('should render custom loader when provided', async () => {
58
+ const cache = new Cache<string, [string]>({
59
+ load: () => new Promise(() => {}),
60
+ })
61
+ const { cacheView } = await renderCacheView(cache, ['test'], {
62
+ loader: (<span className="custom-loader">Loading...</span>) as unknown as JSX.Element,
63
+ })
64
+ const loader = cacheView.querySelector('.custom-loader')
65
+ expect(loader).not.toBeNull()
66
+ expect(loader?.textContent).toBe('Loading...')
67
+ cache[Symbol.dispose]()
68
+ })
69
+ })
70
+
71
+ describe('loaded state', () => {
72
+ it('should render content when cache has loaded value', async () => {
73
+ const cache = new Cache<string, [string]>({ load: async (key) => `Hello ${key}` })
74
+ await cache.get('world')
75
+ const { cacheView } = await renderCacheView(cache, ['world'])
76
+ const contentEl = cacheView.querySelector('test-cache-content')
77
+ expect(contentEl).not.toBeNull()
78
+ const contentComponent = contentEl as JSX.Element
79
+ contentComponent.updateComponent()
80
+ await flushUpdates()
81
+ const valueEl = contentComponent.querySelector('.content-value')
82
+ expect(valueEl?.textContent).toBe('Hello world')
83
+ cache[Symbol.dispose]()
84
+ })
85
+ })
86
+
87
+ describe('failed state', () => {
88
+ it('should render default error UI when cache has failed', async () => {
89
+ const cache = new Cache<string, [string]>({
90
+ load: async () => {
91
+ throw new Error('Test failure')
92
+ },
93
+ })
94
+ try {
95
+ await cache.get('test')
96
+ } catch {
97
+ // expected
98
+ }
99
+ const { cacheView } = await renderCacheView(cache, ['test'])
100
+ const resultEl = cacheView.querySelector('shade-result')
101
+ expect(resultEl).not.toBeNull()
102
+ const resultComponent = resultEl as JSX.Element
103
+ resultComponent.updateComponent()
104
+ await flushUpdates()
105
+ expect(resultComponent.querySelector('.result-title')?.textContent).toBe('Something went wrong')
106
+ cache[Symbol.dispose]()
107
+ })
108
+
109
+ it('should render custom error UI when error prop is provided', async () => {
110
+ const cache = new Cache<string, [string]>({
111
+ load: async () => {
112
+ throw new Error('Custom failure')
113
+ },
114
+ })
115
+ try {
116
+ await cache.get('test')
117
+ } catch {
118
+ // expected
119
+ }
120
+ const customError = vi.fn(
121
+ (err: unknown, _retry: () => void) =>
122
+ (<span className="custom-error">{String(err)}</span>) as unknown as JSX.Element,
123
+ )
124
+ const { cacheView } = await renderCacheView(cache, ['test'], { error: customError })
125
+ expect(customError).toHaveBeenCalledOnce()
126
+ expect(customError.mock.calls[0][0]).toBeInstanceOf(Error)
127
+ const customErrorEl = cacheView.querySelector('.custom-error')
128
+ expect(customErrorEl).not.toBeNull()
129
+ cache[Symbol.dispose]()
130
+ })
131
+
132
+ it('should not render content when failed even if stale value exists', async () => {
133
+ const cache = new Cache<string, [string]>({ load: async (key) => key })
134
+ await cache.get('test')
135
+ cache.setExplicitValue({
136
+ loadArgs: ['test'],
137
+ value: { status: 'failed', error: new Error('fail'), value: 'stale', updatedAt: new Date() },
138
+ })
139
+ const { cacheView } = await renderCacheView(cache, ['test'])
140
+ expect(cacheView.querySelector('test-cache-content')).toBeNull()
141
+ expect(cacheView.querySelector('shade-result')).not.toBeNull()
142
+ cache[Symbol.dispose]()
143
+ })
144
+
145
+ it('should call cache.reload when retry is invoked', async () => {
146
+ const loadFn = vi.fn<(key: string) => Promise<string>>(async () => {
147
+ throw new Error('fail')
148
+ })
149
+ const cache = new Cache<string, [string]>({ load: loadFn })
150
+ try {
151
+ await cache.get('test')
152
+ } catch {
153
+ // expected
154
+ }
155
+ let capturedRetry: (() => void) | undefined
156
+ const customError = (_err: unknown, retry: () => void) => {
157
+ capturedRetry = retry
158
+ return (<span className="custom-error">Error</span>) as unknown as JSX.Element
159
+ }
160
+ await renderCacheView(cache, ['test'], { error: customError })
161
+ expect(capturedRetry).toBeDefined()
162
+
163
+ loadFn.mockResolvedValueOnce('recovered')
164
+ capturedRetry!()
165
+ await sleepAsync(50)
166
+
167
+ const observable = cache.getObservable('test')
168
+ const result = observable.getValue()
169
+ expect(result.status).toBe('loaded')
170
+ expect(result.value).toBe('recovered')
171
+ cache[Symbol.dispose]()
172
+ })
173
+ })
174
+
175
+ describe('obsolete state', () => {
176
+ it('should render content when obsolete and trigger reload', async () => {
177
+ const loadFn = vi.fn(async (key: string) => `Hello ${key}`)
178
+ const cache = new Cache<string, [string]>({ load: loadFn })
179
+ await cache.get('test')
180
+ cache.setObsolete('test')
181
+
182
+ const { cacheView } = await renderCacheView(cache, ['test'])
183
+ const contentEl = cacheView.querySelector('test-cache-content')
184
+ expect(contentEl).not.toBeNull()
185
+
186
+ await sleepAsync(50)
187
+ // reload should have been called (initial load + obsolete reload)
188
+ expect(loadFn).toHaveBeenCalledTimes(2)
189
+ cache[Symbol.dispose]()
190
+ })
191
+ })
192
+
193
+ describe('error takes priority over value', () => {
194
+ it('should show error when failed with value, not content', async () => {
195
+ const cache = new Cache<string, [string]>({ load: async (key) => key })
196
+ await cache.get('test')
197
+ const failedWithValue: CacheResult<string> = {
198
+ status: 'failed',
199
+ error: new Error('whoops'),
200
+ value: 'stale-data',
201
+ updatedAt: new Date(),
202
+ }
203
+ cache.setExplicitValue({ loadArgs: ['test'], value: failedWithValue })
204
+ const { cacheView } = await renderCacheView(cache, ['test'])
205
+ expect(cacheView.querySelector('test-cache-content')).toBeNull()
206
+ expect(cacheView.querySelector('shade-result')).not.toBeNull()
207
+ cache[Symbol.dispose]()
208
+ })
209
+ })
210
+ })
@@ -0,0 +1,103 @@
1
+ import type { Cache, CacheWithValue } from '@furystack/cache'
2
+ import { hasCacheValue, isFailedCacheResult, isObsoleteCacheResult } from '@furystack/cache'
3
+ import type { ShadeComponent } from '@furystack/shades'
4
+ import { Shade, createComponent } from '@furystack/shades'
5
+
6
+ import { Button } from './button.js'
7
+ import { Result } from './result.js'
8
+
9
+ /**
10
+ * Props for the CacheView component.
11
+ * @typeParam TData - The type of data stored in the cache
12
+ * @typeParam TArgs - The tuple type of arguments used to identify the cache entry
13
+ */
14
+ export type CacheViewProps<TData, TArgs extends any[]> = {
15
+ /** The cache instance to observe and control */
16
+ cache: Cache<TData, TArgs>
17
+ /** The arguments identifying which cache entry to display */
18
+ args: TArgs
19
+ /** Shades component rendered when a value is available (loaded or obsolete). Receives CacheWithValue<TData>. */
20
+ content: ShadeComponent<{ data: CacheWithValue<TData> }>
21
+ /** Optional custom loader element. Default: null (nothing shown when loading). */
22
+ loader?: JSX.Element
23
+ /**
24
+ * Optional custom error UI. Receives the error and a retry callback.
25
+ * The retry callback calls cache.reload(...args).
26
+ * If not provided, a default Result + retry Button is shown.
27
+ */
28
+ error?: (error: unknown, retry: () => void) => JSX.Element
29
+ }
30
+
31
+ const getDefaultErrorUi = (error: unknown, retry: () => void): JSX.Element =>
32
+ (
33
+ <Result status="error" title="Something went wrong" subtitle={String(error)}>
34
+ <Button variant="outlined" onclick={retry}>
35
+ Retry
36
+ </Button>
37
+ </Result>
38
+ ) as unknown as JSX.Element
39
+
40
+ /**
41
+ * CacheView renders the state of a cache entry for the given cache + args.
42
+ *
43
+ * It subscribes to the cache observable and handles all states:
44
+ * 1. **Error first** - If the cache entry has failed, shows the error UI with a retry button.
45
+ * 2. **Value next** - If the entry has a value (loaded or obsolete), renders the content component.
46
+ * When obsolete, it also triggers a reload automatically.
47
+ * 3. **Loading last** - If there is no value and no error, shows the loader (or null by default).
48
+ *
49
+ * @example
50
+ * ```tsx
51
+ * const MyContent = Shade<{ data: CacheWithValue<User> }>({
52
+ * shadowDomName: 'my-content',
53
+ * render: ({ props }) => <div>{props.data.value.name}</div>,
54
+ * })
55
+ *
56
+ * <CacheView cache={userCache} args={[userId]} content={MyContent} />
57
+ * ```
58
+ */
59
+ export const CacheView: <TData, TArgs extends any[]>(props: CacheViewProps<TData, TArgs>) => JSX.Element = Shade({
60
+ shadowDomName: 'shade-cache-view',
61
+ render: ({ props, useObservable, useState }): JSX.Element | null => {
62
+ const { cache, args, content, loader, error } = props
63
+
64
+ const argsKey = JSON.stringify(args)
65
+ const observable = cache.getObservable(...args)
66
+
67
+ const [result] = useObservable(`cache-${argsKey}`, observable)
68
+
69
+ const [lastReloadedArgsKey, setLastReloadedArgsKey] = useState<string | null>('lastReloadedArgsKey', null)
70
+
71
+ const retry = () => {
72
+ cache.reload(...args).catch(() => {
73
+ /* error state will be set by cache */
74
+ })
75
+ }
76
+
77
+ // 1. Error first
78
+ if (isFailedCacheResult(result)) {
79
+ const errorRenderer = error ?? getDefaultErrorUi
80
+ return errorRenderer(result.error, retry)
81
+ }
82
+
83
+ // 2. Value next
84
+ if (hasCacheValue(result)) {
85
+ if (isObsoleteCacheResult(result)) {
86
+ if (lastReloadedArgsKey !== argsKey) {
87
+ setLastReloadedArgsKey(argsKey)
88
+ cache.reload(...args).catch(() => {
89
+ /* error state will be set by cache */
90
+ })
91
+ }
92
+ } else if (lastReloadedArgsKey !== null) {
93
+ setLastReloadedArgsKey(null)
94
+ }
95
+ return createComponent(content as ShadeComponent<{ data: CacheWithValue<unknown> }>, {
96
+ data: result,
97
+ }) as unknown as JSX.Element
98
+ }
99
+
100
+ // 3. Loading last
101
+ return loader ?? null
102
+ },
103
+ })
@@ -1,11 +1,14 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import * as allIcons from './icon-definitions.js'
3
+ import type { IconCategory } from './icon-types.js'
3
4
 
4
5
  const iconEntries = Object.entries(allIcons)
5
6
 
7
+ const validCategories: IconCategory[] = ['Actions', 'Navigation', 'Status', 'Content', 'UI', 'Common']
8
+
6
9
  describe('Icon definitions', () => {
7
- it('should export at least 50 icons', () => {
8
- expect(iconEntries.length).toBeGreaterThanOrEqual(50)
10
+ it('should export at least 100 icons', () => {
11
+ expect(iconEntries.length).toBeGreaterThanOrEqual(100)
9
12
  })
10
13
 
11
14
  describe.each(iconEntries)('%s', (_name, icon) => {
@@ -46,6 +49,29 @@ describe('Icon definitions', () => {
46
49
  }
47
50
  }
48
51
  })
52
+
53
+ it('should have a non-empty name', () => {
54
+ expect(typeof icon.name).toBe('string')
55
+ expect(icon.name!.length).toBeGreaterThan(0)
56
+ })
57
+
58
+ it('should have a non-empty description', () => {
59
+ expect(typeof icon.description).toBe('string')
60
+ expect(icon.description!.length).toBeGreaterThan(0)
61
+ })
62
+
63
+ it('should have at least one keyword', () => {
64
+ expect(Array.isArray(icon.keywords)).toBe(true)
65
+ expect(icon.keywords!.length).toBeGreaterThanOrEqual(1)
66
+ for (const kw of icon.keywords!) {
67
+ expect(typeof kw).toBe('string')
68
+ expect(kw.length).toBeGreaterThan(0)
69
+ }
70
+ })
71
+
72
+ it('should have a valid category', () => {
73
+ expect(validCategories).toContain(icon.category)
74
+ })
49
75
  })
50
76
 
51
77
  it('should not have duplicate path data across different icons', () => {