@talelk/newui-organisms 1.0.0 → 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 (29) hide show
  1. package/package.json +3 -3
  2. package/src/organisms/AppSidebar/AppSidebar.test.tsx +36 -0
  3. package/src/organisms/AppSidebar/AppSidebar.tsx +57 -12
  4. package/src/organisms/AuthCard/AuthCard.tsx +41 -32
  5. package/src/organisms/CommandPalette/CommandPalette.tsx +13 -2
  6. package/src/organisms/DataState/DataState.test.tsx +45 -0
  7. package/src/organisms/DataState/DataState.tsx +111 -0
  8. package/src/organisms/DataState/index.ts +6 -0
  9. package/src/organisms/DataTable/DataTable.test.tsx +73 -1
  10. package/src/organisms/DataTable/DataTable.tsx +426 -148
  11. package/src/organisms/DetailHeader/DetailHeader.tsx +9 -1
  12. package/src/organisms/FilterBar/FilterBar.test.tsx +2 -1
  13. package/src/organisms/FilterBar/FilterBar.tsx +24 -5
  14. package/src/organisms/KanbanBoard/KanbanBoard.tsx +220 -165
  15. package/src/organisms/MobileNav/MobileNav.tsx +1 -1
  16. package/src/organisms/PageToolbar/PageToolbar.tsx +63 -14
  17. package/src/organisms/Section/Section.test.tsx +41 -0
  18. package/src/organisms/Section/Section.tsx +108 -0
  19. package/src/organisms/Section/index.ts +1 -0
  20. package/src/organisms/SelectionBar/SelectionBar.test.tsx +35 -0
  21. package/src/organisms/SelectionBar/SelectionBar.tsx +84 -0
  22. package/src/organisms/SelectionBar/index.ts +5 -0
  23. package/src/organisms/SettingsDialog/SettingsDialog.tsx +13 -4
  24. package/src/organisms/Sidebar/Sidebar.tsx +17 -10
  25. package/src/organisms/StatGrid/StatGrid.test.tsx +13 -1
  26. package/src/organisms/StatGrid/StatGrid.tsx +98 -71
  27. package/src/organisms/VirtualList/VirtualList.tsx +56 -22
  28. package/src/organisms/index.ts +3 -0
  29. package/src/organisms/Sidebar/__screenshots__/Sidebar.browser.test.tsx/Sidebar--browser--real-CSS--reserves-its-declared-width-1.png +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@talelk/newui-organisms",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "description": "Application regions, sidebars, tables and widgets built from atoms and molecules.",
6
6
  "exports": {
@@ -14,8 +14,8 @@
14
14
  "src"
15
15
  ],
16
16
  "dependencies": {
17
- "@talelk/newui-atoms": "1.0.0",
18
- "@talelk/newui-molecules": "1.0.0",
17
+ "@talelk/newui-atoms": "1.0.1",
18
+ "@talelk/newui-molecules": "2.0.0",
19
19
  "@tanstack/solid-table": "^9.3.1",
20
20
  "@tanstack/solid-virtual": "^3.13.47"
21
21
  },
@@ -69,6 +69,42 @@ describe('AppSidebar', () => {
69
69
  expect(screen.getByRole('button', { name: /Rhea Kapoor/ })).toBeTruthy()
70
70
  })
71
71
 
72
+ it('keeps a compact utility slot and an expand control in the rail', () => {
73
+ render(() => (
74
+ <AppSidebar
75
+ brand={<span>Acme</span>}
76
+ collapsed
77
+ utilities={<span>Preferences</span>}
78
+ utilitiesCollapsed={<button type="button">Theme</button>}
79
+ groups={[{ items: [] }]}
80
+ />
81
+ ))
82
+ expect(screen.queryByText('Preferences')).toBeNull()
83
+ expect(screen.getByRole('button', { name: 'Theme' })).toBeTruthy()
84
+ expect(screen.getByRole('button', { name: /Expand sidebar/ })).toBeTruthy()
85
+ expect(
86
+ screen.queryByRole('button', { name: /Collapse sidebar/ }),
87
+ ).toBeNull()
88
+ })
89
+
90
+ it('has no fold or compact utilities when it cannot collapse', () => {
91
+ render(() => (
92
+ <AppSidebar
93
+ brand={<span>Acme</span>}
94
+ collapsible={false}
95
+ utilities={<span>Preferences</span>}
96
+ utilitiesCollapsed={<button type="button">Theme</button>}
97
+ groups={[{ items: [] }]}
98
+ />
99
+ ))
100
+ expect(screen.getByText('Preferences')).toBeTruthy()
101
+ expect(screen.queryByRole('button', { name: 'Theme' })).toBeNull()
102
+ expect(screen.queryByRole('button', { name: /Expand sidebar/ })).toBeNull()
103
+ expect(
104
+ screen.queryByRole('button', { name: /Collapse sidebar/ }),
105
+ ).toBeNull()
106
+ })
107
+
72
108
  it('collapses labels, preserves a compact tenant control and reports the state', async () => {
73
109
  const onCollapsedChange = vi.fn()
74
110
  sidebar(onCollapsedChange)
@@ -2,10 +2,10 @@ import { type IconProp, ScrollArea } from '@talelk/newui-atoms'
2
2
  import { For, type JSX, Show } from 'solid-js'
3
3
  import {
4
4
  Sidebar,
5
- SidebarCollapseToggle,
6
5
  SidebarItem,
7
6
  SidebarLabel,
8
7
  type SidebarProps,
8
+ SidebarTrigger,
9
9
  useSidebar,
10
10
  } from '../Sidebar'
11
11
 
@@ -44,6 +44,10 @@ export interface AppSidebarProps {
44
44
  groups?: AppSidebarGroup[]
45
45
  /** Advanced navigation composition, still inside this single sidebar. */
46
46
  children?: JSX.Element | (() => JSX.Element)
47
+ /** Global utilities rendered above Settings while the sidebar is expanded. */
48
+ utilities?: JSX.Element
49
+ /** Compact icon form of `utilities`, shown while the sidebar is collapsed. */
50
+ utilitiesCollapsed?: JSX.Element
47
51
  settings?: JSX.Element
48
52
  account?: JSX.Element
49
53
  accountCollapsed?: JSX.Element
@@ -78,13 +82,29 @@ function AppSidebarIdentity(props: {
78
82
  brandCollapsed?: JSX.Element
79
83
  tenant?: JSX.Element
80
84
  tenantCollapsed?: JSX.Element
85
+ collapsible: boolean
81
86
  }): JSX.Element {
82
87
  const { collapsed } = useSidebar()
83
88
  return (
84
89
  <>
85
- <div data-slot="app-sidebar-brand" class="shrink-0 px-2 pt-3 pb-2">
86
- <Show when={collapsed()} fallback={props.brand}>
87
- {props.brandCollapsed ?? props.brand}
90
+ <div
91
+ data-slot="app-sidebar-brand"
92
+ class="flex h-12 shrink-0 items-center gap-1 ps-2 pe-1"
93
+ classList={{ 'justify-center px-1': collapsed() }}
94
+ >
95
+ <div
96
+ class="flex min-w-0 flex-1 items-center"
97
+ classList={{ 'justify-center': collapsed() }}
98
+ >
99
+ <Show when={collapsed()} fallback={props.brand}>
100
+ {props.brandCollapsed ?? props.brand}
101
+ </Show>
102
+ </div>
103
+ {/* The fold is chrome, not a destination: keeping it in the identity
104
+ row keeps it out of the menu in the expanded state; the rail
105
+ repeats the same control under the tenant. */}
106
+ <Show when={!collapsed() && props.collapsible}>
107
+ <SidebarTrigger />
88
108
  </Show>
89
109
  </div>
90
110
  <Show when={props.tenant}>
@@ -92,30 +112,50 @@ function AppSidebarIdentity(props: {
92
112
  data-slot="app-sidebar-tenant"
93
113
  data-keep-drawer
94
114
  class="shrink-0 px-2 pb-2"
115
+ classList={{ 'flex justify-center': collapsed() }}
95
116
  >
96
117
  <Show when={collapsed()} fallback={props.tenant}>
97
118
  {props.tenantCollapsed}
98
119
  </Show>
99
120
  </div>
100
121
  </Show>
122
+ <Show when={collapsed() && props.collapsible}>
123
+ <div
124
+ data-slot="app-sidebar-expand"
125
+ class="flex shrink-0 justify-center border-b border-outline-gray-1 px-2 pt-1 pb-2"
126
+ >
127
+ <SidebarTrigger />
128
+ </div>
129
+ </Show>
101
130
  </>
102
131
  )
103
132
  }
104
133
 
105
134
  function AppSidebarFooter(props: {
135
+ utilities?: JSX.Element
136
+ utilitiesCollapsed?: JSX.Element
106
137
  settings?: JSX.Element
107
138
  account?: JSX.Element
108
139
  accountCollapsed?: JSX.Element
109
- collapsible: boolean
110
140
  }): JSX.Element {
111
141
  const { collapsed } = useSidebar()
112
142
  return (
113
143
  <div
114
144
  data-slot="app-sidebar-footer"
115
- class="mt-auto flex shrink-0 flex-col gap-1 px-2 pt-2 pb-2"
145
+ class="mt-auto flex shrink-0 flex-col gap-1 border-t border-outline-gray-1 px-2 pt-2 pb-2"
116
146
  >
117
- <Show when={props.collapsible}>
118
- <SidebarCollapseToggle />
147
+ <Show when={!collapsed() && props.utilities}>
148
+ <div data-slot="app-sidebar-utilities" class="pb-1">
149
+ {props.utilities}
150
+ </div>
151
+ </Show>
152
+ <Show when={collapsed() && props.utilitiesCollapsed}>
153
+ <div
154
+ data-slot="app-sidebar-utilities"
155
+ class="flex justify-center pb-0.5"
156
+ >
157
+ {props.utilitiesCollapsed}
158
+ </div>
119
159
  </Show>
120
160
  <Show when={props.settings}>{props.settings}</Show>
121
161
  <Show
@@ -136,7 +176,6 @@ export function AppSidebar(props: AppSidebarProps): JSX.Element {
136
176
  collapsed={props.collapsed}
137
177
  onCollapsedChange={props.onCollapsedChange}
138
178
  collapsible={props.collapsible}
139
- shortcut={false}
140
179
  ariaLabel={props.ariaLabel ?? 'Application navigation'}
141
180
  class={props.class}
142
181
  >
@@ -145,6 +184,7 @@ export function AppSidebar(props: AppSidebarProps): JSX.Element {
145
184
  brandCollapsed={props.brandCollapsed}
146
185
  tenant={props.tenant}
147
186
  tenantCollapsed={props.tenantCollapsed}
187
+ collapsible={props.collapsible !== false}
148
188
  />
149
189
 
150
190
  <Show when={props.search}>
@@ -174,14 +214,18 @@ export function AppSidebar(props: AppSidebarProps): JSX.Element {
174
214
  {(item) => (
175
215
  <SidebarItem
176
216
  active={item.active}
177
- label={item.label}
217
+ label={
218
+ item.count
219
+ ? `${item.label}, ${item.count}`
220
+ : item.label
221
+ }
178
222
  icon={item.icon}
179
223
  route={item.route}
180
224
  href={item.href}
181
225
  onClick={() => item.onClick?.()}
182
226
  suffix={
183
227
  item.count ? (
184
- <span class="rounded-full bg-surface-gray-2 px-1.5 py-px text-caption tabular-nums text-ink-gray-7">
228
+ <span class="rounded-full bg-surface-gray-2 px-1.5 py-px text-caption tabular-nums text-ink-gray-7 group-data-[active]:bg-surface-blue-2 group-data-[active]:text-ink-blue-8">
185
229
  {item.count}
186
230
  </span>
187
231
  ) : undefined
@@ -204,10 +248,11 @@ export function AppSidebar(props: AppSidebarProps): JSX.Element {
204
248
  </ScrollArea.Root>
205
249
 
206
250
  <AppSidebarFooter
251
+ utilities={props.utilities}
252
+ utilitiesCollapsed={props.utilitiesCollapsed}
207
253
  settings={props.settings}
208
254
  account={props.account}
209
255
  accountCollapsed={props.accountCollapsed}
210
- collapsible={props.collapsible !== false}
211
256
  />
212
257
  </Sidebar>
213
258
  )
@@ -13,6 +13,8 @@ export interface AuthCardProps {
13
13
  description?: JSX.Element
14
14
  /** The form itself — fields and the submit button. */
15
15
  children: JSX.Element
16
+ /** Form-level failure summary, above the fields. */
17
+ error?: JSX.Element
16
18
  /** Social sign-in buttons, in order. */
17
19
  providers?: AuthProvider[]
18
20
  /** The line under the form: "Don't have an account? Sign up". */
@@ -38,43 +40,50 @@ export function AuthCard(props: AuthCardProps): JSX.Element {
38
40
  return (
39
41
  <div
40
42
  data-slot="auth-card"
41
- class={`flex min-h-screen items-center justify-center bg-surface-base px-4 py-12 ${props.class ?? ''}`}
43
+ class={`flex w-full max-w-sm flex-col gap-section-gap rounded-card border border-outline-gray-1 bg-surface-elevation-1 p-card-pad-wide ${props.class ?? ''}`}
42
44
  >
43
- <div class="flex w-full max-w-sm flex-col gap-section-gap rounded-card border border-outline-gray-1 bg-surface-elevation-1 p-card-pad-wide shadow-sm">
44
- <div class="flex flex-col items-center gap-3 text-center">
45
- <Show when={props.logo}>{props.logo}</Show>
46
- <div class="flex flex-col gap-1">
47
- <h1 class="text-metric text-ink-gray-9">{props.title}</h1>
48
- <Show when={props.description}>
49
- <p class="text-base text-ink-gray-7">{props.description}</p>
50
- </Show>
51
- </div>
45
+ <div class="flex flex-col items-center gap-3 text-center">
46
+ <Show when={props.logo}>{props.logo}</Show>
47
+ <div class="flex flex-col gap-1">
48
+ <h1 class="text-heading text-ink-gray-9">{props.title}</h1>
49
+ <Show when={props.description}>
50
+ <p class="text-base text-ink-gray-7">{props.description}</p>
51
+ </Show>
52
52
  </div>
53
+ </div>
53
54
 
54
- <div class="flex flex-col gap-4">{props.children}</div>
55
+ <Show when={props.error}>
56
+ <div
57
+ role="alert"
58
+ class="rounded-control border border-outline-red-3 bg-surface-red-1 px-3 py-2 text-interface text-ink-red-8"
59
+ >
60
+ {props.error}
61
+ </div>
62
+ </Show>
55
63
 
56
- <Show when={hasProviders()}>
57
- <Divider label="or" />
58
- <div class="flex flex-col gap-2">
59
- <For each={props.providers}>
60
- {(provider) => (
61
- <Button
62
- variant="outline"
63
- theme="gray"
64
- onClick={provider.onClick}
65
- iconLeft={provider.icon}
66
- >
67
- {provider.label}
68
- </Button>
69
- )}
70
- </For>
71
- </div>
72
- </Show>
64
+ <div class="flex flex-col gap-4">{props.children}</div>
73
65
 
74
- <Show when={props.footer}>
75
- <p class="text-center text-base text-ink-gray-7">{props.footer}</p>
76
- </Show>
77
- </div>
66
+ <Show when={hasProviders()}>
67
+ <Divider label="or" />
68
+ <div class="flex flex-col gap-2">
69
+ <For each={props.providers}>
70
+ {(provider) => (
71
+ <Button
72
+ variant="outline"
73
+ theme="gray"
74
+ onClick={provider.onClick}
75
+ iconLeft={provider.icon}
76
+ >
77
+ {provider.label}
78
+ </Button>
79
+ )}
80
+ </For>
81
+ </div>
82
+ </Show>
83
+
84
+ <Show when={props.footer}>
85
+ <p class="text-center text-base text-ink-gray-7">{props.footer}</p>
86
+ </Show>
78
87
  </div>
79
88
  )
80
89
  }
@@ -121,6 +121,8 @@ export interface CommandPaletteProps {
121
121
  size?: 'sm' | 'md' | 'lg' | 'xl'
122
122
  /** Rendered when nothing matches. */
123
123
  empty?: JSX.Element
124
+ /** Replaces the list with a loading row while commands register. */
125
+ loading?: boolean
124
126
  class?: string
125
127
  children: JSX.Element
126
128
  }
@@ -306,8 +308,17 @@ export function CommandPalette(props: CommandPaletteProps): JSX.Element {
306
308
  so hiding the list until the registry is non-empty deadlocks —
307
309
  no items, so no registry, so the empty state, forever.
308
310
  */}
309
- {props.children}
310
- <Show when={isEmpty()}>
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()}>
311
322
  {/* A listbox may only contain options/groups: the empty
312
323
  message is a disabled option, the one shape a screen
313
324
  reader can address inside the list. */}
@@ -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'
@@ -1,4 +1,4 @@
1
- import { render, screen, waitFor } from '@solidjs/testing-library'
1
+ import { render, screen, waitFor, within } from '@solidjs/testing-library'
2
2
  import userEvent from '@testing-library/user-event'
3
3
  import { describe, expect, it, vi } from 'vitest'
4
4
  import { DataTable, type DataTableColumn } from './DataTable'
@@ -25,6 +25,16 @@ const COLUMNS: DataTableColumn<Row>[] = [
25
25
  },
26
26
  ]
27
27
 
28
+ const MOBILE_COLUMNS: DataTableColumn<Row>[] = [
29
+ { accessorKey: 'name', header: 'Name', meta: { mobile: 'title' } },
30
+ {
31
+ accessorKey: 'amount',
32
+ header: 'Amount',
33
+ cell: (context) => `$${context.getValue()}`,
34
+ meta: { align: 'end', mobile: 'amount' },
35
+ },
36
+ ]
37
+
28
38
  function table(props: Partial<Parameters<typeof DataTable<Row>>[0]> = {}) {
29
39
  return render(() => (
30
40
  <DataTable
@@ -82,6 +92,68 @@ describe('DataTable', () => {
82
92
  })
83
93
  })
84
94
 
95
+ it('renders mobile cards from column meta', () => {
96
+ table({ columns: MOBILE_COLUMNS })
97
+ const cards = document.querySelector('[data-slot="data-table-cards"]')
98
+ expect(cards).toBeTruthy()
99
+ expect(within(cards as HTMLElement).getByText('Alpha')).toBeTruthy()
100
+ expect(within(cards as HTMLElement).getByText('$30')).toBeTruthy()
101
+ })
102
+
103
+ it('skips ineligible rows in selection and select-all', async () => {
104
+ const onRowsAction = vi.fn()
105
+ table({
106
+ columns: MOBILE_COLUMNS,
107
+ rowSelectable: (row) => row.id !== '2',
108
+ onRowsAction,
109
+ })
110
+ const tableRegion = screen.getByRole('region', { name: 'Rows table' })
111
+ expect(
112
+ within(tableRegion)
113
+ .getAllByRole('checkbox', { name: 'Select 2' })
114
+ .every((box) => box.hasAttribute('disabled')),
115
+ ).toBe(true)
116
+ await userEvent.click(
117
+ within(tableRegion).getByRole('checkbox', {
118
+ name: 'Select all rows on this page',
119
+ }),
120
+ )
121
+ const bulk = await screen.findByRole('button', {
122
+ name: /Update selected \(2\)/,
123
+ })
124
+ await userEvent.click(bulk)
125
+ expect(onRowsAction).toHaveBeenCalledWith(['1', '3'])
126
+ })
127
+
128
+ it('keeps a stable titled toolbar when an external filter bar owns search', () => {
129
+ render(() => (
130
+ <DataTable title="Rows" rows={ROWS} columns={COLUMNS} hideSearch />
131
+ ))
132
+ const toolbar = document.querySelector('[data-slot="data-table-toolbar"]')
133
+ expect(toolbar?.textContent).toContain('Rows')
134
+ expect(
135
+ document.querySelector('[data-slot="data-table-columns"]'),
136
+ ).toBeTruthy()
137
+ })
138
+
139
+ it('renders the shared loading and error states', () => {
140
+ const loading = render(() => (
141
+ <DataTable title="Rows" rows={[]} columns={COLUMNS} loading />
142
+ ))
143
+ expect(screen.getByRole('status', { name: 'Loading' })).toBeTruthy()
144
+ loading.unmount()
145
+ render(() => (
146
+ <DataTable title="Rows" rows={[]} columns={COLUMNS} error="Failed" />
147
+ ))
148
+ expect(screen.getByRole('alert')).toBeTruthy()
149
+ })
150
+
151
+ it('shows the mobile selection bar once a row is selected', async () => {
152
+ table({ onRowsAction: () => {} })
153
+ await userEvent.click(screen.getByRole('checkbox', { name: 'Select 1' }))
154
+ expect(document.querySelector('[data-slot="selection-bar"]')).toBeTruthy()
155
+ })
156
+
85
157
  it('can be a read-only table with no selection column', () => {
86
158
  table({ selectable: false })
87
159
  expect(screen.queryByRole('checkbox', { name: /Select/ })).toBeNull()