@talelk/newui-organisms 0.0.0-stage → 2.0.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 (64) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +12 -2
  3. package/package.json +38 -3
  4. package/src/exports.test.ts +27 -0
  5. package/src/index.ts +1 -0
  6. package/src/organisms/AppSidebar/AppSidebar.test.tsx +136 -0
  7. package/src/organisms/AppSidebar/AppSidebar.tsx +259 -0
  8. package/src/organisms/AppSidebar/index.ts +1 -0
  9. package/src/organisms/AuthCard/AuthCard.a11y.test.tsx +36 -0
  10. package/src/organisms/AuthCard/AuthCard.test.tsx +56 -0
  11. package/src/organisms/AuthCard/AuthCard.tsx +89 -0
  12. package/src/organisms/AuthCard/index.ts +1 -0
  13. package/src/organisms/CommandPalette/CommandPalette.test.tsx +196 -0
  14. package/src/organisms/CommandPalette/CommandPalette.tsx +468 -0
  15. package/src/organisms/CommandPalette/index.ts +1 -0
  16. package/src/organisms/DataState/DataState.test.tsx +45 -0
  17. package/src/organisms/DataState/DataState.tsx +111 -0
  18. package/src/organisms/DataState/index.ts +6 -0
  19. package/src/organisms/DataTable/DataTable.test.tsx +161 -0
  20. package/src/organisms/DataTable/DataTable.tsx +612 -0
  21. package/src/organisms/DataTable/index.ts +1 -0
  22. package/src/organisms/DetailHeader/DetailHeader.test.tsx +55 -0
  23. package/src/organisms/DetailHeader/DetailHeader.tsx +95 -0
  24. package/src/organisms/DetailHeader/index.ts +1 -0
  25. package/src/organisms/FilterBar/FilterBar.test.tsx +152 -0
  26. package/src/organisms/FilterBar/FilterBar.tsx +251 -0
  27. package/src/organisms/FilterBar/index.ts +1 -0
  28. package/src/organisms/KanbanBoard/KanbanBoard.test.tsx +255 -0
  29. package/src/organisms/KanbanBoard/KanbanBoard.tsx +605 -0
  30. package/src/organisms/KanbanBoard/drag.test.ts +448 -0
  31. package/src/organisms/KanbanBoard/drag.ts +407 -0
  32. package/src/organisms/KanbanBoard/index.ts +17 -0
  33. package/src/organisms/MobileNav/MobileNav.browser.test.tsx +39 -0
  34. package/src/organisms/MobileNav/MobileNav.test.tsx +108 -0
  35. package/src/organisms/MobileNav/MobileNav.tsx +120 -0
  36. package/src/organisms/MobileNav/index.ts +6 -0
  37. package/src/organisms/PageToolbar/PageToolbar.a11y.test.tsx +35 -0
  38. package/src/organisms/PageToolbar/PageToolbar.test.tsx +42 -0
  39. package/src/organisms/PageToolbar/PageToolbar.tsx +126 -0
  40. package/src/organisms/PageToolbar/index.ts +1 -0
  41. package/src/organisms/Section/Section.test.tsx +41 -0
  42. package/src/organisms/Section/Section.tsx +108 -0
  43. package/src/organisms/Section/index.ts +1 -0
  44. package/src/organisms/SelectionBar/SelectionBar.test.tsx +35 -0
  45. package/src/organisms/SelectionBar/SelectionBar.tsx +84 -0
  46. package/src/organisms/SelectionBar/index.ts +5 -0
  47. package/src/organisms/SettingsDialog/SettingsDialog.a11y.test.tsx +58 -0
  48. package/src/organisms/SettingsDialog/SettingsDialog.browser.test.tsx +43 -0
  49. package/src/organisms/SettingsDialog/SettingsDialog.ssr.test.tsx +27 -0
  50. package/src/organisms/SettingsDialog/SettingsDialog.test.tsx +202 -0
  51. package/src/organisms/SettingsDialog/SettingsDialog.tsx +548 -0
  52. package/src/organisms/SettingsDialog/index.ts +20 -0
  53. package/src/organisms/Sidebar/Sidebar.browser.test.tsx +52 -0
  54. package/src/organisms/Sidebar/Sidebar.test.tsx +308 -0
  55. package/src/organisms/Sidebar/Sidebar.tsx +732 -0
  56. package/src/organisms/Sidebar/index.ts +22 -0
  57. package/src/organisms/StatGrid/StatGrid.a11y.test.tsx +40 -0
  58. package/src/organisms/StatGrid/StatGrid.test.tsx +95 -0
  59. package/src/organisms/StatGrid/StatGrid.tsx +151 -0
  60. package/src/organisms/StatGrid/index.ts +1 -0
  61. package/src/organisms/VirtualList/VirtualList.test.tsx +80 -0
  62. package/src/organisms/VirtualList/VirtualList.tsx +113 -0
  63. package/src/organisms/VirtualList/index.ts +1 -0
  64. package/src/organisms/index.ts +16 -0
@@ -0,0 +1,468 @@
1
+ import { Icon, type IconProp } from '@talelk/newui-atoms'
2
+ import { cn } from '@talelk/newui-atoms/internal'
3
+ import { Dialog } from '@talelk/newui-molecules'
4
+ import {
5
+ type Accessor,
6
+ createContext,
7
+ createEffect,
8
+ createMemo,
9
+ createSignal,
10
+ type JSX,
11
+ onCleanup,
12
+ onMount,
13
+ Show,
14
+ useContext,
15
+ } from 'solid-js'
16
+
17
+ /**
18
+ * A command palette: ⌘K, a query, and a list of things you can do.
19
+ *
20
+ * The state is Zag's `dialog` machine (focus trap, Escape, backdrop, scroll
21
+ * lock) plus a filtered list. The list is *not* a Zag listbox, and that is a
22
+ * decision rather than a shortcut: a listbox collection is built once from a
23
+ * static set of items, while a palette is rebuilt on every keystroke — items
24
+ * come and go as the query narrows, and the highlight has to survive that. So
25
+ * the dialog is the machine's and the highlight is ours, expressed with
26
+ * `aria-activedescendant`, which is what keeps focus in the input while the
27
+ * arrows move down the list.
28
+ *
29
+ * Items self-register. That is what lets a consumer write groups and items as
30
+ * plain JSX in any nesting, with the keyboard walking one flat, filtered list
31
+ * across all of them.
32
+ */
33
+
34
+ /**
35
+ * Everything about an item is read through a function, not captured: an item is
36
+ * registered once and lives until it unmounts, so a plain field would freeze the
37
+ * first value a prop ever had.
38
+ */
39
+ interface CommandRecord {
40
+ value: Accessor<string>
41
+ /** Extra search text: words that should match without being shown. */
42
+ keywords: Accessor<string>
43
+ label: Accessor<string>
44
+ disabled: Accessor<boolean>
45
+ matches: Accessor<boolean>
46
+ select: () => void
47
+ }
48
+
49
+ interface CommandContextValue {
50
+ query: Accessor<string>
51
+ setQuery: (value: string) => void
52
+ register: (record: CommandRecord) => void
53
+ unregister: (record: CommandRecord) => void
54
+ visible: Accessor<CommandRecord[]>
55
+ highlighted: Accessor<string | undefined>
56
+ highlight: (value: string | undefined) => void
57
+ move: (delta: number) => void
58
+ accept: () => void
59
+ itemId: (value: string) => string
60
+ }
61
+
62
+ const CommandContext = createContext<CommandContextValue>()
63
+
64
+ function useCommand(): CommandContextValue {
65
+ const context = useContext(CommandContext)
66
+ if (!context)
67
+ throw new Error('CommandPalette parts must be used within <CommandPalette>')
68
+ return context
69
+ }
70
+
71
+ /**
72
+ * The palette is a WAI-ARIA combobox: focus stays in the field, and the option
73
+ * the keyboard is on is named by `aria-activedescendant`. Three lint rules assume
74
+ * the opposite — that focus moves onto the option — so they are off for this file
75
+ * rather than worked around:
76
+ *
77
+ * - `useFocusableInteractive` / `useKeyWithClickEvents`: a focusable option would
78
+ * pull focus out of the field the moment you arrow down, which is exactly what
79
+ * the pattern avoids. The field owns the keys; the click handler is for
80
+ * pointers.
81
+ * - `useSemanticElements`: the options are rich rows (icon, description,
82
+ * shortcut) and the collection is rebuilt on every keystroke, which no native
83
+ * select or datalist expresses.
84
+ */
85
+
86
+ /**
87
+ * Both sides are reduced to letters and digits before comparing, so the way
88
+ * people actually type a compound name finds it: `date picker`, `date-picker`
89
+ * and `DatePicker` are one query, and no one has to know where the case
90
+ * boundary is.
91
+ */
92
+ function fold(text: string): string {
93
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, '')
94
+ }
95
+
96
+ /** Rank: a label that starts with the query beats one that merely contains it. */
97
+ function score(label: string, keywords: string, query: string): number | null {
98
+ const needle = fold(query)
99
+ if (needle === '') return 0
100
+ const index = fold(`${label} ${keywords}`).indexOf(needle)
101
+ if (index === -1) return null
102
+ return (fold(label).startsWith(needle) ? 0 : 100) + index
103
+ }
104
+
105
+ let commandId = 0
106
+
107
+ export interface CommandPaletteProps {
108
+ open?: boolean
109
+ defaultOpen?: boolean
110
+ onOpenChange?: (open: boolean) => void
111
+ /**
112
+ * Bind ⌘K / Ctrl+K. Default `true`; the listener only opens, never closes —
113
+ * pressing it inside an open palette should feel like nothing happened rather
114
+ * than like a toggle you cannot see.
115
+ */
116
+ shortcut?: boolean
117
+ placeholder?: string
118
+ /** Accessible name of both the dialog and the list. Default `Commands`. */
119
+ label?: string
120
+ /** Max width class. Default `md`. */
121
+ size?: 'sm' | 'md' | 'lg' | 'xl'
122
+ /** Rendered when nothing matches. */
123
+ empty?: JSX.Element
124
+ /** Replaces the list with a loading row while commands register. */
125
+ loading?: boolean
126
+ class?: string
127
+ children: JSX.Element
128
+ }
129
+
130
+ export function CommandPalette(props: CommandPaletteProps): JSX.Element {
131
+ commandId += 1
132
+ const id = `command-${commandId}`
133
+ const [uncontrolledOpen, setUncontrolledOpen] = createSignal(
134
+ props.defaultOpen ?? false,
135
+ )
136
+ const isControlled = () => props.open !== undefined
137
+ const open = () => props.open ?? uncontrolledOpen()
138
+ const setOpen = (next: boolean) => {
139
+ if (!isControlled()) setUncontrolledOpen(next)
140
+ props.onOpenChange?.(next)
141
+ }
142
+
143
+ const [query, setQuery] = createSignal('')
144
+ const [highlighted, setHighlighted] = createSignal<string>()
145
+ /**
146
+ * The registry is a signal, not a plain array with a counter beside it.
147
+ *
148
+ * That is not tidiness: `isEmpty` reads the registry *first*, and with a plain
149
+ * array the first render short-circuits on `length === 0` before touching
150
+ * anything reactive — so the empty state's condition records no dependency at
151
+ * all and never re-runs. It renders nothing, forever, while every other read
152
+ * of the same state updates.
153
+ */
154
+ const [registry, setRegistry] = createSignal<CommandRecord[]>([])
155
+
156
+ const visible = createMemo(() => {
157
+ const current = query()
158
+ return registry()
159
+ .filter((record) => record.matches())
160
+ .map((record) => ({
161
+ record,
162
+ rank: score(record.label(), record.keywords(), current) ?? 0,
163
+ }))
164
+ .sort((a, b) => a.rank - b.rank)
165
+ .map((entry) => entry.record)
166
+ })
167
+
168
+ /** Registered but none matching: not the same as "nothing has mounted yet". */
169
+ const isEmpty = () => registry().length > 0 && visible().length === 0
170
+
171
+ const register = (record: CommandRecord) =>
172
+ setRegistry((entries) => [...entries, record])
173
+ const unregister = (record: CommandRecord) =>
174
+ setRegistry((entries) => entries.filter((entry) => entry !== record))
175
+
176
+ const move = (delta: number) => {
177
+ const items = visible()
178
+ if (items.length === 0) return
179
+ const current = items.findIndex((entry) => entry.value() === highlighted())
180
+ let index = current === -1 ? (delta > 0 ? -1 : 0) : current
181
+ // A disabled entry is stepped over rather than landed on: it is in the list
182
+ // because it explains why it cannot be used, not because it is a target.
183
+ for (let step = 0; step < items.length; step += 1) {
184
+ index = (index + delta + items.length) % items.length
185
+ if (!items[index]?.disabled()) break
186
+ }
187
+ setHighlighted(items[index]?.value())
188
+ }
189
+
190
+ const accept = () => {
191
+ const items = visible()
192
+ const chosen =
193
+ items.find((entry) => entry.value() === highlighted()) ?? items[0]
194
+ if (!chosen || chosen.disabled()) return
195
+ setOpen(false)
196
+ chosen.select()
197
+ }
198
+
199
+ // The first match is the highlighted one: a palette you can open and confirm
200
+ // without looking at the list is the point of having one.
201
+ createEffect(() => {
202
+ const items = visible()
203
+ if (!items.some((entry) => entry.value() === highlighted()))
204
+ setHighlighted(items.find((entry) => !entry.disabled())?.value())
205
+ })
206
+ // A fresh palette starts empty, not showing the last search.
207
+ createEffect(() => {
208
+ if (!open()) setQuery('')
209
+ })
210
+
211
+ // Zag's trap moves focus into the dialog content; this moves it one step
212
+ // further, into the only control there is. An effect, not the `autofocus`
213
+ // attribute, because the content does not exist until `open` flips.
214
+ createEffect(() => {
215
+ if (open()) field?.focus()
216
+ })
217
+
218
+ onMount(() => {
219
+ if (props.shortcut === false) return
220
+ const onKey = (event: KeyboardEvent) => {
221
+ if (event.key.toLowerCase() !== 'k') return
222
+ if (!event.metaKey && !event.ctrlKey) return
223
+ event.preventDefault()
224
+ if (!open()) setOpen(true)
225
+ }
226
+ window.addEventListener('keydown', onKey)
227
+ onCleanup(() => window.removeEventListener('keydown', onKey))
228
+ })
229
+
230
+ const context: CommandContextValue = {
231
+ query,
232
+ setQuery,
233
+ register,
234
+ unregister,
235
+ visible,
236
+ highlighted,
237
+ highlight: setHighlighted,
238
+ move,
239
+ accept,
240
+ itemId: (value) => `${id}-${value.replace(/\W+/g, '-')}`,
241
+ }
242
+
243
+ let field: HTMLInputElement | undefined
244
+ const activeId = () => {
245
+ const value = highlighted()
246
+ return value === undefined ? undefined : context.itemId(value)
247
+ }
248
+
249
+ return (
250
+ <CommandContext.Provider value={context}>
251
+ <Dialog.Root open={open()} onOpenChange={setOpen}>
252
+ <Dialog.Content
253
+ bare
254
+ position="top"
255
+ size={props.size ?? 'md'}
256
+ class={cn('max-h-[60vh] overflow-hidden', props.class)}
257
+ >
258
+ {/* A modal dialog has to be named: the field carries the label for the
259
+ listbox, but the dialog itself is what a screen reader announces on
260
+ open. */}
261
+ <Dialog.Title class="sr-only">
262
+ {props.label ?? 'Commands'}
263
+ </Dialog.Title>
264
+ <div data-slot="command-palette" role="presentation">
265
+ <div class="flex items-center gap-2 border-b border-outline-gray-1 px-3">
266
+ <Icon
267
+ icon="lucide-search"
268
+ class="size-4 shrink-0 text-ink-gray-5"
269
+ aria-hidden="true"
270
+ />
271
+ <input
272
+ ref={field}
273
+ type="text"
274
+ role="combobox"
275
+ aria-expanded="true"
276
+ aria-controls={`${id}-list`}
277
+ aria-activedescendant={activeId()}
278
+ aria-autocomplete="list"
279
+ aria-label={props.label ?? 'Commands'}
280
+ placeholder={props.placeholder ?? 'Search commands…'}
281
+ value={query()}
282
+ onInput={(event) => setQuery(event.currentTarget.value)}
283
+ onKeyDown={(event) => {
284
+ if (event.key === 'ArrowDown') {
285
+ event.preventDefault()
286
+ move(1)
287
+ } else if (event.key === 'ArrowUp') {
288
+ event.preventDefault()
289
+ move(-1)
290
+ } else if (event.key === 'Enter') {
291
+ event.preventDefault()
292
+ accept()
293
+ }
294
+ }}
295
+ class="h-11 min-w-0 flex-1 bg-transparent text-base text-ink-gray-9 placeholder:text-ink-gray-5 focus:outline-none"
296
+ />
297
+ </div>
298
+
299
+ <div
300
+ id={`${id}-list`}
301
+ role="listbox"
302
+ aria-label={props.label ?? 'Commands'}
303
+ class="max-h-[50vh] overflow-y-auto p-1.5"
304
+ >
305
+ {/*
306
+ The items are *always* rendered, never behind a `Show` that
307
+ reads the filtered list: an item registers itself when it mounts,
308
+ so hiding the list until the registry is non-empty deadlocks —
309
+ no items, so no registry, so the empty state, forever.
310
+ */}
311
+ <Show when={!props.loading}>{props.children}</Show>
312
+ <Show when={props.loading}>
313
+ <div
314
+ role="option"
315
+ aria-disabled="true"
316
+ class="px-2 py-6 text-center text-base text-ink-gray-6"
317
+ >
318
+ Loading commands…
319
+ </div>
320
+ </Show>
321
+ <Show when={!props.loading && isEmpty()}>
322
+ {/* A listbox may only contain options/groups: the empty
323
+ message is a disabled option, the one shape a screen
324
+ reader can address inside the list. */}
325
+ <div
326
+ role="option"
327
+ aria-disabled="true"
328
+ class="px-2 py-6 text-center text-base text-ink-gray-6"
329
+ >
330
+ {props.empty ?? 'Nothing matches.'}
331
+ </div>
332
+ </Show>
333
+ </div>
334
+ </div>
335
+ </Dialog.Content>
336
+ </Dialog.Root>
337
+ </CommandContext.Provider>
338
+ )
339
+ }
340
+
341
+ export interface CommandGroupProps {
342
+ /** Group heading; also the group's accessible name. */
343
+ label: string
344
+ children: JSX.Element
345
+ }
346
+
347
+ /**
348
+ * A titled run of items.
349
+ *
350
+ * The heading is hidden while the query is filtering: the grouping is an aid to
351
+ * browsing, and once you are searching for one thing the order is relevance.
352
+ */
353
+ export function CommandGroup(props: CommandGroupProps): JSX.Element {
354
+ const context = useCommand()
355
+ return (
356
+ <div role="group" aria-label={props.label}>
357
+ <Show when={context.query() === ''}>
358
+ <div
359
+ data-slot="command-group-label"
360
+ class="px-2 pt-2 pb-1 text-sm-medium text-ink-gray-6"
361
+ >
362
+ {props.label}
363
+ </div>
364
+ </Show>
365
+ {props.children}
366
+ </div>
367
+ )
368
+ }
369
+
370
+ export interface CommandItemProps {
371
+ /** Stable identity; also what `onSelect` reports. */
372
+ value: string
373
+ /** Extra search text for words that are not in the visible label. */
374
+ keywords?: string
375
+ icon?: IconProp
376
+ /** Trailing hint, e.g. `⌘B`. Purely informational. */
377
+ shortcut?: string
378
+ description?: string
379
+ disabled?: boolean
380
+ onSelect?: (value: string) => void
381
+ children: JSX.Element
382
+ }
383
+
384
+ export function CommandItem(props: CommandItemProps): JSX.Element {
385
+ const context = useCommand()
386
+ const [element, setElement] = createSignal<HTMLDivElement>()
387
+
388
+ const label = () =>
389
+ typeof props.children === 'string'
390
+ ? props.children
391
+ : (element()?.textContent ?? '')
392
+
393
+ const value = () => props.value
394
+ const record: CommandRecord = {
395
+ value,
396
+ keywords: () => props.keywords ?? '',
397
+ label,
398
+ disabled: () => props.disabled ?? false,
399
+ matches: () =>
400
+ score(label(), props.keywords ?? '', context.query()) !== null,
401
+ select: () => props.onSelect?.(value()),
402
+ }
403
+
404
+ onMount(() => {
405
+ context.register(record)
406
+ onCleanup(() => context.unregister(record))
407
+ })
408
+
409
+ const selected = () => context.highlighted() === props.value
410
+ const matches = () => record.matches()
411
+
412
+ /* The item stays mounted while it does not match — it is hidden, not removed,
413
+ * because unmounting it would unregister it, and the registry is how the
414
+ * palette tells "nothing has mounted yet" from "nothing matches". The wrapper
415
+ * carries the `hidden` *attribute* and no display utility, so nothing can
416
+ * out-rank it: a `flex` class on the same element would win in the browser
417
+ * while looking right in a test. */
418
+ return (
419
+ <div hidden={!matches()}>
420
+ <div
421
+ ref={setElement}
422
+ id={context.itemId(props.value)}
423
+ role="option"
424
+ aria-selected={selected()}
425
+ aria-disabled={props.disabled ? true : undefined}
426
+ data-slot="command-item"
427
+ data-selected={selected() ? '' : undefined}
428
+ data-disabled={props.disabled ? '' : undefined}
429
+ onClick={() => {
430
+ if (props.disabled) return
431
+ props.onSelect?.(props.value)
432
+ }}
433
+ onPointerMove={() => {
434
+ if (!props.disabled) context.highlight(props.value)
435
+ }}
436
+ class={cn(
437
+ 'flex cursor-pointer items-center gap-2 rounded-control px-2 py-2 text-base text-ink-gray-8 transition-colors',
438
+ 'data-[selected]:bg-surface-gray-2',
439
+ 'data-[disabled]:cursor-not-allowed data-[disabled]:text-ink-gray-4',
440
+ )}
441
+ >
442
+ <Show when={props.icon}>
443
+ {(icon) => (
444
+ <Icon
445
+ icon={icon()}
446
+ class="size-4 shrink-0 text-ink-gray-6"
447
+ aria-hidden="true"
448
+ />
449
+ )}
450
+ </Show>
451
+ <span class="min-w-0 flex-1 truncate">{props.children}</span>
452
+ <Show when={props.description}>
453
+ <span class="shrink-0 text-sm text-ink-gray-5">
454
+ {props.description}
455
+ </span>
456
+ </Show>
457
+ <Show when={props.shortcut}>
458
+ <kbd
459
+ data-slot="command-shortcut"
460
+ class="shrink-0 rounded-control border border-outline-gray-2 px-1.5 py-0.5 font-sans text-xs text-ink-gray-6"
461
+ >
462
+ {props.shortcut}
463
+ </kbd>
464
+ </Show>
465
+ </div>
466
+ </div>
467
+ )
468
+ }
@@ -0,0 +1 @@
1
+ export * from './CommandPalette'
@@ -0,0 +1,45 @@
1
+ import { render, screen } from '@solidjs/testing-library'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import { DataState } from './DataState'
5
+
6
+ describe('DataState', () => {
7
+ it('renders ready children unchanged', () => {
8
+ render(() => (
9
+ <DataState status="ready">
10
+ <p>Rows</p>
11
+ </DataState>
12
+ ))
13
+ expect(screen.getByText('Rows')).toBeTruthy()
14
+ })
15
+
16
+ it('announces loading and renders the requested skeleton', () => {
17
+ const { container } = render(() => (
18
+ <DataState status="loading" skeleton="cards" />
19
+ ))
20
+ expect(screen.getByRole('status', { name: 'Loading' })).toBeTruthy()
21
+ expect(container.querySelectorAll('[data-slot="skeleton"]').length).toBe(4)
22
+ })
23
+
24
+ it('renders an empty state action', () => {
25
+ render(() => (
26
+ <DataState
27
+ status="empty"
28
+ title="No expenses"
29
+ action={<button type="button">Clear filters</button>}
30
+ />
31
+ ))
32
+ expect(screen.getByText('No expenses')).toBeTruthy()
33
+ expect(screen.getByRole('button', { name: 'Clear filters' })).toBeTruthy()
34
+ })
35
+
36
+ it('offers retry on error', async () => {
37
+ const onRetry = vi.fn()
38
+ render(() => (
39
+ <DataState status="error" error="The request failed" onRetry={onRetry} />
40
+ ))
41
+ expect(screen.getByRole('alert')).toBeTruthy()
42
+ await userEvent.click(screen.getByRole('button', { name: 'Try again' }))
43
+ expect(onRetry).toHaveBeenCalled()
44
+ })
45
+ })
@@ -0,0 +1,111 @@
1
+ import { Button, type IconProp, Skeleton } from '@talelk/newui-atoms'
2
+ import { EmptyState } from '@talelk/newui-molecules'
3
+ import { For, type JSX, Show } from 'solid-js'
4
+
5
+ export type DataStatus = 'ready' | 'loading' | 'empty' | 'error'
6
+ export type DataSkeleton = 'rows' | 'cards' | 'chart'
7
+
8
+ export interface DataStateProps {
9
+ status: DataStatus
10
+ /** Skeleton shape while loading. @default 'rows' */
11
+ skeleton?: DataSkeleton
12
+ /** Skeleton row count. @default 5 */
13
+ rows?: number
14
+ title?: JSX.Element
15
+ description?: JSX.Element
16
+ action?: JSX.Element
17
+ /** Icon shown in the empty state. @default 'lucide-inbox' */
18
+ icon?: IconProp
19
+ error?: JSX.Element
20
+ onRetry?: () => void
21
+ children?: JSX.Element
22
+ }
23
+
24
+ function Loading(props: { skeleton: DataSkeleton; rows: number }): JSX.Element {
25
+ return (
26
+ <div
27
+ data-slot="data-state-loading"
28
+ role="status"
29
+ aria-label="Loading"
30
+ class="p-card-pad"
31
+ >
32
+ <Show when={props.skeleton === 'rows'}>
33
+ <div class="flex flex-col gap-2">
34
+ <For each={Array.from({ length: props.rows })}>
35
+ {() => <Skeleton lines={1} class="h-9" />}
36
+ </For>
37
+ </div>
38
+ </Show>
39
+ <Show when={props.skeleton === 'cards'}>
40
+ <div class="grid grid-cols-1 gap-3 min-[360px]:grid-cols-2 xl:grid-cols-4">
41
+ <For each={Array.from({ length: 4 })}>
42
+ {() => <Skeleton lines={3} class="h-24" />}
43
+ </For>
44
+ </div>
45
+ </Show>
46
+ <Show when={props.skeleton === 'chart'}>
47
+ <Skeleton lines={4} class="h-64" />
48
+ </Show>
49
+ </div>
50
+ )
51
+ }
52
+
53
+ /**
54
+ * The one loading / empty / error contract for data organisms.
55
+ *
56
+ * It composes the existing `Skeleton` and `EmptyState` molecules, so every
57
+ * region uses the same tone and action placement. `ready` renders its children
58
+ * unchanged.
59
+ */
60
+ export function DataState(props: DataStateProps): JSX.Element {
61
+ return (
62
+ <Show
63
+ when={props.status === 'ready'}
64
+ fallback={<StateFallback {...props} />}
65
+ >
66
+ {props.children}
67
+ </Show>
68
+ )
69
+ }
70
+
71
+ function StateFallback(props: DataStateProps): JSX.Element {
72
+ return (
73
+ <Show
74
+ when={props.status === 'loading'}
75
+ fallback={
76
+ <Show
77
+ when={props.status === 'error'}
78
+ fallback={
79
+ <div data-slot="data-state-empty">
80
+ <EmptyState
81
+ icon={props.icon ?? 'lucide-inbox'}
82
+ title={props.title ?? 'Nothing here yet'}
83
+ description={props.description}
84
+ action={props.action}
85
+ />
86
+ </div>
87
+ }
88
+ >
89
+ <div data-slot="data-state-error" role="alert">
90
+ <EmptyState
91
+ icon="lucide-triangle-alert"
92
+ title={props.title ?? 'Could not load this'}
93
+ description={props.error ?? props.description}
94
+ action={
95
+ props.onRetry ? (
96
+ <Button variant="outline" onClick={props.onRetry}>
97
+ Try again
98
+ </Button>
99
+ ) : (
100
+ props.action
101
+ )
102
+ }
103
+ />
104
+ </div>
105
+ </Show>
106
+ }
107
+ >
108
+ <Loading skeleton={props.skeleton ?? 'rows'} rows={props.rows ?? 5} />
109
+ </Show>
110
+ )
111
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ type DataSkeleton,
3
+ DataState,
4
+ type DataStateProps,
5
+ type DataStatus,
6
+ } from './DataState'