@talelk/newui-organisms 1.0.0 → 2.1.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 (50) hide show
  1. package/package.json +8 -4
  2. package/skills/newui-organisms/SKILL.md +91 -0
  3. package/src/exports.test.ts +3 -0
  4. package/src/organisms/AppSidebar/AppSidebar.a11y.test.tsx +75 -0
  5. package/src/organisms/AppSidebar/AppSidebar.test.tsx +36 -0
  6. package/src/organisms/AppSidebar/AppSidebar.tsx +57 -12
  7. package/src/organisms/AuthCard/AuthCard.tsx +41 -32
  8. package/src/organisms/CommandPalette/CommandPalette.a11y.test.tsx +58 -0
  9. package/src/organisms/CommandPalette/CommandPalette.tsx +13 -2
  10. package/src/organisms/DataState/DataState.a11y.test.tsx +59 -0
  11. package/src/organisms/DataState/DataState.browser.test.tsx +52 -0
  12. package/src/organisms/DataState/DataState.test.tsx +45 -0
  13. package/src/organisms/DataState/DataState.tsx +111 -0
  14. package/src/organisms/DataState/index.ts +6 -0
  15. package/src/organisms/DataTable/DataTable.a11y.test.tsx +71 -0
  16. package/src/organisms/DataTable/DataTable.features.test.tsx +191 -0
  17. package/src/organisms/DataTable/DataTable.test.tsx +73 -1
  18. package/src/organisms/DataTable/DataTable.tsx +609 -149
  19. package/src/organisms/DetailHeader/DetailHeader.a11y.test.tsx +53 -0
  20. package/src/organisms/DetailHeader/DetailHeader.browser.test.tsx +50 -0
  21. package/src/organisms/DetailHeader/DetailHeader.tsx +9 -1
  22. package/src/organisms/FilterBar/FilterBar.a11y.test.tsx +58 -0
  23. package/src/organisms/FilterBar/FilterBar.browser.test.tsx +46 -0
  24. package/src/organisms/FilterBar/FilterBar.test.tsx +2 -1
  25. package/src/organisms/FilterBar/FilterBar.tsx +24 -5
  26. package/src/organisms/KanbanBoard/KanbanBoard.a11y.test.tsx +75 -0
  27. package/src/organisms/KanbanBoard/KanbanBoard.browser.test.tsx +84 -0
  28. package/src/organisms/KanbanBoard/KanbanBoard.tsx +220 -165
  29. package/src/organisms/MobileNav/MobileNav.a11y.test.tsx +49 -0
  30. package/src/organisms/MobileNav/MobileNav.tsx +1 -1
  31. package/src/organisms/PageToolbar/PageToolbar.tsx +63 -14
  32. package/src/organisms/Section/Section.a11y.test.tsx +49 -0
  33. package/src/organisms/Section/Section.browser.test.tsx +63 -0
  34. package/src/organisms/Section/Section.test.tsx +41 -0
  35. package/src/organisms/Section/Section.tsx +108 -0
  36. package/src/organisms/Section/index.ts +1 -0
  37. package/src/organisms/SelectionBar/SelectionBar.a11y.test.tsx +48 -0
  38. package/src/organisms/SelectionBar/SelectionBar.browser.test.tsx +57 -0
  39. package/src/organisms/SelectionBar/SelectionBar.test.tsx +35 -0
  40. package/src/organisms/SelectionBar/SelectionBar.tsx +84 -0
  41. package/src/organisms/SelectionBar/index.ts +5 -0
  42. package/src/organisms/SettingsDialog/SettingsDialog.tsx +13 -4
  43. package/src/organisms/Sidebar/Sidebar.a11y.test.tsx +71 -0
  44. package/src/organisms/Sidebar/Sidebar.tsx +17 -10
  45. package/src/organisms/StatGrid/StatGrid.test.tsx +13 -1
  46. package/src/organisms/StatGrid/StatGrid.tsx +98 -71
  47. package/src/organisms/VirtualList/VirtualList.a11y.test.tsx +109 -0
  48. package/src/organisms/VirtualList/VirtualList.tsx +56 -22
  49. package/src/organisms/index.ts +3 -0
  50. package/src/organisms/Sidebar/__screenshots__/Sidebar.browser.test.tsx/Sidebar--browser--real-CSS--reserves-its-declared-width-1.png +0 -0
@@ -33,11 +33,14 @@ const COLLAPSE_QUERY = '(max-width: 639px)'
33
33
  interface SidebarContextValue {
34
34
  collapsed: Accessor<boolean>
35
35
  toggle: () => void
36
+ /** Whether `⌘B`/`Ctrl+B` is bound here, so chrome only advertises a real hint. */
37
+ shortcut: Accessor<boolean>
36
38
  }
37
39
 
38
40
  const SidebarContext = createContext<SidebarContextValue>({
39
41
  collapsed: () => false,
40
42
  toggle: () => {},
43
+ shortcut: () => false,
41
44
  })
42
45
 
43
46
  /** Collapsed state + toggle, shared with every part inside a `Sidebar`. */
@@ -83,6 +86,7 @@ export const Sidebar: ParentComponent<SidebarProps> = (props) => {
83
86
  const [internal, setInternal] = createSignal<boolean | null>(null)
84
87
 
85
88
  const collapsible = () => local.collapsible ?? true
89
+ const shortcut = () => local.shortcut !== false && collapsible()
86
90
  const shouldCollapse = () =>
87
91
  collapsible() &&
88
92
  (local.collapsed !== undefined
@@ -96,7 +100,7 @@ export const Sidebar: ParentComponent<SidebarProps> = (props) => {
96
100
  }
97
101
 
98
102
  onMount(() => {
99
- if (local.shortcut === false || !collapsible()) return
103
+ if (!shortcut()) return
100
104
  const onKey = (event: KeyboardEvent) => {
101
105
  if (event.key.toLowerCase() !== 'b') return
102
106
  if (!event.metaKey && !event.ctrlKey) return
@@ -108,7 +112,9 @@ export const Sidebar: ParentComponent<SidebarProps> = (props) => {
108
112
  })
109
113
 
110
114
  return (
111
- <SidebarContext.Provider value={{ collapsed: shouldCollapse, toggle }}>
115
+ <SidebarContext.Provider
116
+ value={{ collapsed: shouldCollapse, toggle, shortcut }}
117
+ >
112
118
  <nav
113
119
  data-slot="sidebar"
114
120
  data-state={shouldCollapse() ? 'collapsed' : 'expanded'}
@@ -162,7 +168,7 @@ export const SidebarHeader: ParentComponent<SidebarHeaderProps> = (props) => {
162
168
  <div
163
169
  data-slot="sidebar-header"
164
170
  class={cn(
165
- 'flex h-12 shrink-0 items-center justify-between gap-2 px-1',
171
+ 'flex h-12 shrink-0 items-center justify-between gap-2 px-2',
166
172
  local.class,
167
173
  )}
168
174
  >
@@ -235,11 +241,11 @@ export const SidebarLabel: ParentComponent<SidebarLabelProps> = (props) => {
235
241
  return (
236
242
  <div
237
243
  data-slot="sidebar-label"
238
- class={cn('relative flex h-7 items-center pl-2', local.class)}
244
+ class={cn('relative flex h-8 items-center px-2 pt-2', local.class)}
239
245
  >
240
246
  <div
241
247
  class={cn(
242
- 'text-base leading-tighter text-ink-gray-7',
248
+ 'text-xs font-medium text-ink-gray-6',
243
249
  collapsed() ? 'w-0 overflow-hidden opacity-0' : 'w-auto opacity-100',
244
250
  )}
245
251
  >
@@ -319,9 +325,9 @@ export const SidebarItem: ParentComponent<SidebarItemProps> = (props) => {
319
325
 
320
326
  const rowClasses = () =>
321
327
  cn(
322
- 'relative flex h-8 w-full items-center rounded-control text-interface text-ink-gray-6',
328
+ 'group relative flex h-8 w-full items-center rounded-control text-interface text-ink-gray-7',
323
329
  'transition-colors hover:bg-surface-gray-2 motion-reduce:transition-none',
324
- 'data-[active]:bg-surface-blue-1 data-[active]:text-ink-blue-8',
330
+ 'data-[active]:bg-surface-blue-1 data-[active]:font-medium data-[active]:text-ink-blue-8',
325
331
  local.class,
326
332
  )
327
333
  const innerClasses = () =>
@@ -439,7 +445,7 @@ export function SidebarSearch(props: SidebarSearchProps): JSX.Element {
439
445
  }
440
446
 
441
447
  export interface SidebarTriggerProps {
442
- /** Tooltip copy. Defaults to Collapse/Expand, per state. */
448
+ /** Tooltip and accessible copy. Defaults to Collapse sidebar/Expand sidebar. */
443
449
  label?: string
444
450
  class?: string
445
451
  }
@@ -453,9 +459,10 @@ export interface SidebarTriggerProps {
453
459
  * `SidebarItem` at the bottom of the list); this is the compact one.
454
460
  */
455
461
  export function SidebarTrigger(props: SidebarTriggerProps): JSX.Element {
456
- const { collapsed, toggle } = useSidebar()
462
+ const { collapsed, toggle, shortcut } = useSidebar()
457
463
  const text = () =>
458
464
  props.label ?? (collapsed() ? 'Expand sidebar' : 'Collapse sidebar')
465
+ const hint = () => (shortcut() ? `${text()} · ⌘B` : text())
459
466
 
460
467
  return (
461
468
  <TooltipRoot placement="bottom" openDelay={300}>
@@ -478,7 +485,7 @@ export function SidebarTrigger(props: SidebarTriggerProps): JSX.Element {
478
485
  )}
479
486
  />
480
487
  </TooltipTrigger>
481
- <TooltipContent>{`${text()} · ⌘B`}</TooltipContent>
488
+ <TooltipContent>{hint()}</TooltipContent>
482
489
  </TooltipRoot>
483
490
  )
484
491
  }
@@ -77,7 +77,19 @@ describe('StatGrid', () => {
77
77
  const className = container.querySelector(
78
78
  '[data-slot="stat-grid"]',
79
79
  )?.className
80
- expect(className).toContain('sm:grid-cols-2')
80
+ expect(className).toContain('min-[360px]:grid-cols-2')
81
81
  expect(className).toContain('xl:grid-cols-3')
82
82
  })
83
+
84
+ it('compact cards use the tight padding and omit the icon tile', () => {
85
+ const { container } = render(() => (
86
+ <StatGrid
87
+ compact
88
+ stats={[{ label: 'One', value: '1', icon: 'lucide-house' }]}
89
+ />
90
+ ))
91
+ const card = container.querySelector('[data-slot="stat"]')
92
+ expect(card?.className).toContain('p-2.5')
93
+ expect(card?.querySelector('.bg-surface-gray-2')).toBeNull()
94
+ })
83
95
  })
@@ -1,4 +1,4 @@
1
- import { cn, Icon, type IconProp } from '@talelk/newui-atoms'
1
+ import { cn, Icon, type IconProp, Skeleton } from '@talelk/newui-atoms'
2
2
  import { For, type JSX, Show } from 'solid-js'
3
3
  import { Dynamic } from 'solid-js/web'
4
4
 
@@ -17,6 +17,10 @@ export interface StatGridProps {
17
17
  stats: Stat[]
18
18
  /** Columns at the widest breakpoint. Defaults to the number of stats, capped. */
19
19
  columns?: 2 | 3 | 4
20
+ /** Tighter cards without the icon tile; for table-led screens. */
21
+ compact?: boolean
22
+ /** Replaces the cards with the shared skeleton geometry. */
23
+ loading?: boolean
20
24
  class?: string
21
25
  }
22
26
 
@@ -34,9 +38,16 @@ export interface StatGridProps {
34
38
  * to fail it.
35
39
  */
36
40
  const COLUMNS: Record<2 | 3 | 4, string> = {
37
- 2: 'sm:grid-cols-2',
38
- 3: 'sm:grid-cols-2 xl:grid-cols-3',
39
- 4: 'sm:grid-cols-2 xl:grid-cols-4',
41
+ 2: 'min-[360px]:grid-cols-2',
42
+ 3: 'min-[360px]:grid-cols-2 xl:grid-cols-3',
43
+ 4: 'min-[360px]:grid-cols-2 xl:grid-cols-4',
44
+ }
45
+
46
+ /** Compact table-led screens earn a four-up row on tablets. */
47
+ const COMPACT_COLUMNS: Record<2 | 3 | 4, string> = {
48
+ 2: 'min-[360px]:grid-cols-2',
49
+ 3: 'min-[360px]:grid-cols-2 md:grid-cols-3',
50
+ 4: 'min-[360px]:grid-cols-2 md:grid-cols-4',
40
51
  }
41
52
 
42
53
  export function StatGrid(props: StatGridProps): JSX.Element {
@@ -44,76 +55,92 @@ export function StatGrid(props: StatGridProps): JSX.Element {
44
55
  props.columns ?? (Math.min(4, Math.max(2, props.stats.length)) as 2 | 3 | 4)
45
56
 
46
57
  return (
47
- <div
48
- data-slot="stat-grid"
49
- class={`grid gap-card-gap ${COLUMNS[columns()]} ${props.class ?? ''}`}
58
+ <Show
59
+ when={!props.loading}
60
+ fallback={
61
+ <div
62
+ data-slot="stat-grid"
63
+ data-loading
64
+ class={`grid gap-card-gap ${props.compact ? COMPACT_COLUMNS[columns()] : COLUMNS[columns()]} ${props.class ?? ''}`}
65
+ >
66
+ <For each={Array.from({ length: columns() })}>
67
+ {() => <Skeleton lines={3} class="h-24" />}
68
+ </For>
69
+ </div>
70
+ }
50
71
  >
51
- <For each={props.stats}>
52
- {(stat) => (
53
- // A stat that navigates is a button, not a div with a click handler:
54
- // it has to be reachable by keyboard and announced as interactive.
55
- <Dynamic
56
- component={stat.onClick ? 'button' : 'div'}
57
- {...(stat.onClick ? { type: 'button' as const } : {})}
58
- data-slot="stat"
59
- onClick={stat.onClick}
60
- class={cn(
61
- 'flex flex-col gap-control-gap rounded-card border border-outline-gray-1 bg-surface-elevation-1 p-card-pad text-start',
62
- stat.onClick &&
63
- 'cursor-pointer transition-[border-color,box-shadow] hover:border-outline-gray-3 hover:shadow-sm focus-visible:focus-ring motion-reduce:transition-none',
64
- )}
65
- >
66
- <div class="flex items-start gap-2">
67
- <Show when={stat.icon}>
68
- {(icon) => (
69
- <span class="grid size-8 shrink-0 place-items-center rounded-4 bg-surface-gray-2">
70
- <Icon icon={icon()} class="size-4 text-ink-gray-7" />
71
- </span>
72
- )}
73
- </Show>
74
- <span class="min-w-0 flex-1 text-sm-medium [overflow-wrap:anywhere] text-ink-gray-7">
75
- {stat.label}
72
+ <div
73
+ data-slot="stat-grid"
74
+ class={`grid gap-card-gap ${props.compact ? COMPACT_COLUMNS[columns()] : COLUMNS[columns()]} ${props.class ?? ''}`}
75
+ >
76
+ <For each={props.stats}>
77
+ {(stat) => (
78
+ // A stat that navigates is a button, not a div with a click handler:
79
+ // it has to be reachable by keyboard and announced as interactive.
80
+ <Dynamic
81
+ component={stat.onClick ? 'button' : 'div'}
82
+ {...(stat.onClick ? { type: 'button' as const } : {})}
83
+ data-slot="stat"
84
+ onClick={stat.onClick}
85
+ class={cn(
86
+ 'flex flex-col rounded-card border border-outline-gray-1 bg-surface-elevation-1 text-start',
87
+ props.compact ? 'gap-1 p-2.5' : 'gap-control-gap p-card-pad',
88
+ stat.onClick &&
89
+ 'cursor-pointer transition-[border-color,box-shadow] hover:border-outline-gray-3 hover:shadow-sm focus-visible:focus-ring motion-reduce:transition-none',
90
+ )}
91
+ >
92
+ <div class="flex items-start gap-2">
93
+ <Show when={props.compact ? undefined : stat.icon}>
94
+ {(icon) => (
95
+ <span class="grid size-8 shrink-0 place-items-center rounded-4 bg-surface-gray-2">
96
+ <Icon icon={icon()} class="size-4 text-ink-gray-7" />
97
+ </span>
98
+ )}
99
+ </Show>
100
+ <span class="min-w-0 flex-1 text-sm-medium [overflow-wrap:anywhere] text-ink-gray-7">
101
+ {stat.label}
102
+ </span>
103
+ <Show when={stat.delta}>
104
+ {(delta) => (
105
+ <span
106
+ class="inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs-medium tabular-nums"
107
+ classList={{
108
+ 'bg-surface-green-2 text-ink-green-8':
109
+ delta().direction !== 'flat' && isGood(delta()),
110
+ 'bg-surface-red-2 text-ink-red-8':
111
+ delta().direction !== 'flat' && !isGood(delta()),
112
+ 'bg-surface-gray-2 text-ink-gray-7':
113
+ delta().direction === 'flat',
114
+ }}
115
+ >
116
+ <Icon
117
+ icon={
118
+ delta().direction === 'up'
119
+ ? 'lucide-arrow-up'
120
+ : delta().direction === 'down'
121
+ ? 'lucide-arrow-down'
122
+ : 'lucide-minus'
123
+ }
124
+ class="size-3"
125
+ />
126
+ {delta().value}
127
+ </span>
128
+ )}
129
+ </Show>
130
+ </div>
131
+ <span class="text-metric tabular-nums [overflow-wrap:anywhere] text-ink-gray-9">
132
+ {stat.value}
76
133
  </span>
77
- <Show when={stat.delta}>
78
- {(delta) => (
79
- <span
80
- class="inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs-medium tabular-nums"
81
- classList={{
82
- 'bg-surface-green-2 text-ink-green-8':
83
- delta().direction !== 'flat' && isGood(delta()),
84
- 'bg-surface-red-2 text-ink-red-8':
85
- delta().direction !== 'flat' && !isGood(delta()),
86
- 'bg-surface-gray-2 text-ink-gray-7':
87
- delta().direction === 'flat',
88
- }}
89
- >
90
- <Icon
91
- icon={
92
- delta().direction === 'up'
93
- ? 'lucide-arrow-up'
94
- : delta().direction === 'down'
95
- ? 'lucide-arrow-down'
96
- : 'lucide-minus'
97
- }
98
- class="size-3"
99
- />
100
- {delta().value}
101
- </span>
102
- )}
134
+ <Show when={stat.hint}>
135
+ <span class="text-xs [overflow-wrap:anywhere] text-ink-gray-7">
136
+ {stat.hint}
137
+ </span>
103
138
  </Show>
104
- </div>
105
- <span class="text-metric tabular-nums [overflow-wrap:anywhere] text-ink-gray-9">
106
- {stat.value}
107
- </span>
108
- <Show when={stat.hint}>
109
- <span class="text-xs [overflow-wrap:anywhere] text-ink-gray-7">
110
- {stat.hint}
111
- </span>
112
- </Show>
113
- </Dynamic>
114
- )}
115
- </For>
116
- </div>
139
+ </Dynamic>
140
+ )}
141
+ </For>
142
+ </div>
143
+ </Show>
117
144
  )
118
145
  }
119
146
 
@@ -0,0 +1,109 @@
1
+ import { render, screen } from '@solidjs/testing-library'
2
+ import axe, { type AxeResults } from 'axe-core'
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+ import { VirtualList } from './VirtualList'
5
+
6
+ const AXE_OPTIONS = {
7
+ rules: {
8
+ 'color-contrast': { enabled: false },
9
+ region: { enabled: false },
10
+ },
11
+ }
12
+
13
+ function summarize(results: AxeResults) {
14
+ return results.violations.map((violation) => ({
15
+ id: violation.id,
16
+ impact: violation.impact,
17
+ targets: violation.nodes.map((node) => node.target),
18
+ }))
19
+ }
20
+
21
+ const ROWS = Array.from({ length: 1000 }, (_, index) => ({
22
+ id: `ROW-${index + 1}`,
23
+ title: `Row ${index + 1}`,
24
+ }))
25
+
26
+ interface Row {
27
+ id: string
28
+ title: string
29
+ }
30
+
31
+ const EMPTY: Row[] = []
32
+
33
+ /**
34
+ * jsdom has no layout, so the viewport reports a zero rect and the virtualiser
35
+ * renders nothing. The tests give every element a 440px box, which is the only
36
+ * measurement the list needs.
37
+ */
38
+ class MeasuredObserver {
39
+ constructor(private readonly callback: ResizeObserverCallback) {}
40
+ observe(target: Element) {
41
+ const rect = target.getBoundingClientRect()
42
+ this.callback(
43
+ [
44
+ {
45
+ target,
46
+ contentRect: rect,
47
+ borderBoxSize: [{ inlineSize: rect.width, blockSize: rect.height }],
48
+ contentBoxSize: [{ inlineSize: rect.width, blockSize: rect.height }],
49
+ } as unknown as ResizeObserverEntry,
50
+ ],
51
+ this as unknown as ResizeObserver,
52
+ )
53
+ }
54
+ unobserve() {}
55
+ disconnect() {}
56
+ }
57
+
58
+ function withLayout() {
59
+ const rect = {
60
+ width: 600,
61
+ height: 440,
62
+ top: 0,
63
+ left: 0,
64
+ right: 600,
65
+ bottom: 440,
66
+ x: 0,
67
+ y: 0,
68
+ toJSON: () => ({}),
69
+ } as DOMRect
70
+ const spy = vi
71
+ .spyOn(HTMLElement.prototype, 'getBoundingClientRect')
72
+ .mockReturnValue(rect)
73
+ // The jsdom shim observes nothing; the virtualiser needs one measurement.
74
+ vi.stubGlobal('ResizeObserver', MeasuredObserver)
75
+ return spy
76
+ }
77
+
78
+ afterEach(() => {
79
+ vi.unstubAllGlobals()
80
+ vi.restoreAllMocks()
81
+ })
82
+
83
+ describe('VirtualList accessibility', () => {
84
+ it('has no axe violations for a virtualised list of rows', async () => {
85
+ withLayout()
86
+ const { container } = render(() => (
87
+ <VirtualList label="Rows" items={ROWS} itemSize={72} height="440px">
88
+ {(row) => <span>{row.title}</span>}
89
+ </VirtualList>
90
+ ))
91
+ expect(screen.getAllByRole('listitem').length).toBeGreaterThan(0)
92
+ expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
93
+ })
94
+
95
+ it('has no axe violations for the empty state', async () => {
96
+ const { container } = render(() => (
97
+ <VirtualList
98
+ label="Rows"
99
+ items={EMPTY}
100
+ itemSize={72}
101
+ emptyTitle="No rows yet"
102
+ emptyDescription="Create the first one."
103
+ >
104
+ {(row) => <span>{row.title}</span>}
105
+ </VirtualList>
106
+ ))
107
+ expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
108
+ })
109
+ })
@@ -1,5 +1,6 @@
1
1
  import { createVirtualizer } from '@tanstack/solid-virtual'
2
- import { For, type JSX, untrack } from 'solid-js'
2
+ import { For, type JSX, Show, untrack } from 'solid-js'
3
+ import { DataState } from '../DataState'
3
4
 
4
5
  /**
5
6
  * A bounded DOM for a long list.
@@ -26,6 +27,14 @@ export interface VirtualListProps<T> {
26
27
  height?: string
27
28
  /** Rows rendered beyond the viewport. Default `6`. */
28
29
  overscan?: number
30
+ /** Replaces the list with the shared loading skeleton. */
31
+ loading?: boolean
32
+ /** Replaces the list with the shared error state. */
33
+ error?: JSX.Element
34
+ onRetry?: () => void
35
+ emptyTitle?: JSX.Element
36
+ emptyDescription?: JSX.Element
37
+ emptyAction?: JSX.Element
29
38
  /** One row's content; the `li` and its position belong to the list. */
30
39
  children: (item: T, index: number) => JSX.Element
31
40
  }
@@ -43,6 +52,15 @@ export function VirtualList<T>(props: VirtualListProps<T>): JSX.Element {
43
52
  overscan: untrack(() => props.overscan) ?? 6,
44
53
  })
45
54
 
55
+ const status = () =>
56
+ props.loading
57
+ ? ('loading' as const)
58
+ : props.error
59
+ ? ('error' as const)
60
+ : props.items.length === 0
61
+ ? ('empty' as const)
62
+ : ('ready' as const)
63
+
46
64
  return (
47
65
  <section
48
66
  ref={viewport}
@@ -52,28 +70,44 @@ export function VirtualList<T>(props: VirtualListProps<T>): JSX.Element {
52
70
  class="relative overflow-auto rounded-3 focus-visible:focus-ring"
53
71
  data-slot="virtual-list"
54
72
  >
55
- <ul
56
- class="relative m-0 list-none p-0"
57
- style={{ height: `${virtualizer.getTotalSize()}px` }}
73
+ <Show
74
+ when={status() === 'ready'}
75
+ fallback={
76
+ <DataState
77
+ status={status()}
78
+ skeleton="rows"
79
+ rows={5}
80
+ title={props.emptyTitle}
81
+ description={props.emptyDescription}
82
+ action={props.emptyAction}
83
+ error={props.error}
84
+ onRetry={props.onRetry}
85
+ />
86
+ }
58
87
  >
59
- <For each={virtualizer.getVirtualItems()}>
60
- {(row) => (
61
- <li
62
- class="absolute inset-x-0 top-0"
63
- style={{
64
- height: `${props.itemSize}px`,
65
- transform: `translateY(${row.start}px)`,
66
- }}
67
- aria-posinset={row.index + 1}
68
- aria-setsize={props.items.length}
69
- >
70
- {/* The virtualiser's count is the array's length, so the index
71
- is a valid key for the mapped row. */}
72
- {props.children(props.items[row.index] as T, row.index)}
73
- </li>
74
- )}
75
- </For>
76
- </ul>
88
+ <ul
89
+ class="relative m-0 list-none p-0"
90
+ style={{ height: `${virtualizer.getTotalSize()}px` }}
91
+ >
92
+ <For each={virtualizer.getVirtualItems()}>
93
+ {(row) => (
94
+ <li
95
+ class="absolute inset-x-0 top-0"
96
+ style={{
97
+ height: `${props.itemSize}px`,
98
+ transform: `translateY(${row.start}px)`,
99
+ }}
100
+ aria-posinset={row.index + 1}
101
+ aria-setsize={props.items.length}
102
+ >
103
+ {/* The virtualiser's count is the array's length, so the index
104
+ is a valid key for the mapped row. */}
105
+ {props.children(props.items[row.index] as T, row.index)}
106
+ </li>
107
+ )}
108
+ </For>
109
+ </ul>
110
+ </Show>
77
111
  </section>
78
112
  )
79
113
  }
@@ -1,12 +1,15 @@
1
1
  export * from './AppSidebar'
2
2
  export * from './AuthCard'
3
3
  export * from './CommandPalette'
4
+ export * from './DataState'
4
5
  export * from './DataTable'
5
6
  export * from './DetailHeader'
6
7
  export * from './FilterBar'
7
8
  export * from './KanbanBoard'
8
9
  export * from './MobileNav'
9
10
  export * from './PageToolbar'
11
+ export * from './Section'
12
+ export * from './SelectionBar'
10
13
  export * from './SettingsDialog'
11
14
  export * from './Sidebar'
12
15
  export * from './StatGrid'