@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
@@ -0,0 +1,52 @@
1
+ import { render } from '@solidjs/testing-library'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { DataState } from './DataState'
4
+
5
+ function nextFrame() {
6
+ return new Promise((resolve) => requestAnimationFrame(() => resolve(null)))
7
+ }
8
+
9
+ describe('DataState (browser, real CSS)', () => {
10
+ it('renders loading skeletons as real painted rows', async () => {
11
+ const { container } = render(() => (
12
+ <DataState status="loading" skeleton="rows" rows={3} />
13
+ ))
14
+ await nextFrame()
15
+ const rows = container.querySelectorAll(
16
+ '[data-slot="skeleton"]',
17
+ ) as unknown as HTMLElement[]
18
+ expect(rows.length).toBe(3)
19
+ const row = rows[0] as HTMLElement
20
+ // `h-9` per row — a dropped height utility collapses the placeholder.
21
+ expect(row.getBoundingClientRect().height).toBeGreaterThanOrEqual(32)
22
+ // The paint lives on the shimmering line inside the row wrapper.
23
+ const line = row.querySelector('[data-slot="skeleton-line"]') as HTMLElement
24
+ expect(line).toBeTruthy()
25
+ expect(getComputedStyle(line).backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
26
+ })
27
+
28
+ it('paints the empty state icon tile', () => {
29
+ const { container } = render(() => (
30
+ <DataState status="empty" title="No expenses" />
31
+ ))
32
+ const tile = container.querySelector(
33
+ '[data-slot="empty-state-icon"]',
34
+ ) as HTMLElement
35
+ expect(tile.getBoundingClientRect().width).toBeGreaterThan(0)
36
+ expect(getComputedStyle(tile).backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
37
+ })
38
+
39
+ it('keeps the retry button a real target', async () => {
40
+ const { container } = render(() => (
41
+ <DataState status="error" error="The request failed" onRetry={() => {}} />
42
+ ))
43
+ await nextFrame()
44
+ const button = Array.from(container.querySelectorAll('button')).find(
45
+ (node) => node.textContent === 'Try again',
46
+ ) as HTMLElement
47
+ expect(button.getBoundingClientRect().height).toBeGreaterThanOrEqual(24)
48
+ expect(getComputedStyle(button).backgroundColor).not.toBe(
49
+ 'rgba(0, 0, 0, 0)',
50
+ )
51
+ })
52
+ })
@@ -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'
@@ -0,0 +1,71 @@
1
+ import { render } from '@solidjs/testing-library'
2
+ import axe, { type AxeResults } from 'axe-core'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { DataTable, type DataTableColumn } from './DataTable'
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
+ interface Row {
22
+ id: string
23
+ name: string
24
+ amount: number
25
+ }
26
+
27
+ const ROWS: Row[] = [
28
+ { id: '1', name: 'Alpha', amount: 10 },
29
+ { id: '2', name: 'Beta', amount: 20 },
30
+ { id: '3', name: 'Gamma', amount: 30 },
31
+ ]
32
+
33
+ const COLUMNS: DataTableColumn<Row>[] = [
34
+ { accessorKey: 'name', header: 'Name' },
35
+ {
36
+ accessorKey: 'amount',
37
+ header: 'Amount',
38
+ cell: (context) => `$${context.getValue()}`,
39
+ meta: { align: 'end' },
40
+ },
41
+ ]
42
+
43
+ describe('DataTable accessibility', () => {
44
+ it('has no axe violations for a selectable table', async () => {
45
+ const { container } = render(() => (
46
+ <DataTable
47
+ title="Expenses"
48
+ rows={ROWS}
49
+ columns={COLUMNS}
50
+ pageSize={10}
51
+ actionLabel="Approve"
52
+ onRowsAction={() => {}}
53
+ recordsLabel="expenses"
54
+ />
55
+ ))
56
+ expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
57
+ })
58
+
59
+ it('has no axe violations for a read-only table', async () => {
60
+ const { container } = render(() => (
61
+ <DataTable
62
+ title="Expenses"
63
+ rows={ROWS}
64
+ columns={COLUMNS}
65
+ pageSize={10}
66
+ selectable={false}
67
+ />
68
+ ))
69
+ expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
70
+ })
71
+ })
@@ -0,0 +1,191 @@
1
+ import { render, screen } from '@solidjs/testing-library'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { createSignal } from 'solid-js'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+ import { DataTable, type DataTableColumn } from './DataTable'
6
+
7
+ interface Row {
8
+ id: string
9
+ name: string
10
+ amount: number
11
+ }
12
+
13
+ const ROWS: Row[] = [
14
+ { id: '1', name: 'Alpha', amount: 10 },
15
+ { id: '2', name: 'Beta', amount: 20 },
16
+ ]
17
+
18
+ const COLUMNS: DataTableColumn<Row>[] = [
19
+ { accessorKey: 'name', header: 'Name' },
20
+ {
21
+ accessorKey: 'amount',
22
+ header: 'Amount',
23
+ meta: { align: 'end', editable: true, editInput: 'number' },
24
+ },
25
+ ]
26
+
27
+ describe('DataTable pinning', () => {
28
+ it('freezes the named columns to each scroll edge', () => {
29
+ const { container } = render(() => (
30
+ <DataTable
31
+ title="Rows"
32
+ rows={ROWS}
33
+ columns={COLUMNS}
34
+ pageSize={10}
35
+ selectable={false}
36
+ pinnedStart="name"
37
+ pinnedEnd="amount"
38
+ />
39
+ ))
40
+ const head = container.querySelector('thead') as HTMLElement
41
+ const startTh = head.querySelector('th[scope="col"]') as HTMLElement
42
+ expect(startTh.className).toContain('sticky')
43
+ expect(startTh.className).toContain('start-0')
44
+ const endTh = head.querySelectorAll('th[scope="col"]')[1] as HTMLElement
45
+ expect(endTh.className).toContain('end-0')
46
+ // Body cells carry the same stickiness as their header.
47
+ const firstTd = container.querySelector('tbody td') as HTMLElement
48
+ expect(firstTd.className).toContain('sticky')
49
+ })
50
+
51
+ it('leaves unpinned tables in plain flow', () => {
52
+ const { container } = render(() => (
53
+ <DataTable
54
+ title="Rows"
55
+ rows={ROWS}
56
+ columns={COLUMNS}
57
+ pageSize={10}
58
+ selectable={false}
59
+ />
60
+ ))
61
+ const th = container.querySelector('th[scope="col"]') as HTMLElement
62
+ expect(th.className).not.toContain('sticky')
63
+ })
64
+
65
+ it('answers onPinnedColumnsChange only from the app (no in-table UI mutates pinning)', () => {
66
+ const onPinnedColumnsChange = vi.fn()
67
+ render(() => (
68
+ <DataTable
69
+ title="Rows"
70
+ rows={ROWS}
71
+ columns={COLUMNS}
72
+ pageSize={10}
73
+ selectable={false}
74
+ pinnedStart="name"
75
+ onPinnedColumnsChange={onPinnedColumnsChange}
76
+ />
77
+ ))
78
+ // Sorting (the only in-table toggle) does not move pinning.
79
+ const sortButton = screen.getByRole('button', { name: /Name/ })
80
+ userEvent.click(sortButton)
81
+ expect(onPinnedColumnsChange).not.toHaveBeenCalled()
82
+ })
83
+ })
84
+
85
+ describe('DataTable inline editing', () => {
86
+ it('renders a click-to-edit cell that reports the raw value', async () => {
87
+ const onCellChange = vi.fn()
88
+ const { container } = render(() => (
89
+ <DataTable
90
+ title="Rows"
91
+ rows={ROWS}
92
+ columns={COLUMNS}
93
+ pageSize={10}
94
+ selectable={false}
95
+ editable
96
+ onCellChange={onCellChange}
97
+ />
98
+ ))
99
+ const trigger = screen.getByRole('button', {
100
+ name: /— edit Amount of 1$/,
101
+ })
102
+ expect(trigger.textContent).toBe('10')
103
+ await userEvent.click(trigger)
104
+ const input = screen.getByRole('spinbutton', {
105
+ name: 'Edit Amount of 1',
106
+ }) as HTMLInputElement
107
+ expect(document.activeElement).toBe(input)
108
+ await userEvent.clear(input)
109
+ await userEvent.type(input, '15{Enter}')
110
+ expect(onCellChange).toHaveBeenCalledWith('1', 'amount', '15')
111
+ // The edit trigger is gone once editing closes.
112
+ expect(
113
+ container.querySelector('[data-slot="data-table-edit-trigger"]'),
114
+ ).toBeTruthy()
115
+ })
116
+
117
+ it('escapes an edit without reporting', async () => {
118
+ const onCellChange = vi.fn()
119
+ render(() => (
120
+ <DataTable
121
+ title="Rows"
122
+ rows={ROWS}
123
+ columns={COLUMNS}
124
+ pageSize={10}
125
+ selectable={false}
126
+ editable
127
+ onCellChange={onCellChange}
128
+ />
129
+ ))
130
+ await userEvent.click(
131
+ screen.getByRole('button', { name: /— edit Amount of 1$/ }),
132
+ )
133
+ await userEvent.keyboard('999{Escape}')
134
+ expect(onCellChange).not.toHaveBeenCalled()
135
+ // The cell shows the untouched value again.
136
+ expect(
137
+ screen.getByRole('button', { name: /— edit Amount of 1$/ }).textContent,
138
+ ).toBe('10')
139
+ })
140
+
141
+ it('renders the updated value when the app owns the mutation', async () => {
142
+ const [rows, setRows] = createSignal<Row[]>(ROWS)
143
+ const { container } = render(() => (
144
+ <DataTable
145
+ title="Rows"
146
+ rows={rows()}
147
+ columns={COLUMNS}
148
+ pageSize={10}
149
+ selectable={false}
150
+ editable
151
+ onCellChange={(id, columnId, value) =>
152
+ setRows((current) =>
153
+ current.map((row) =>
154
+ row.id === id ? { ...row, [columnId]: Number(value) } : row,
155
+ ),
156
+ )
157
+ }
158
+ />
159
+ ))
160
+ await userEvent.click(
161
+ screen.getByRole('button', { name: /— edit Amount of 2$/ }),
162
+ )
163
+ const input = screen.getByRole('spinbutton', {
164
+ name: 'Edit Amount of 2',
165
+ }) as HTMLInputElement
166
+ await userEvent.clear(input)
167
+ await userEvent.type(input, '25{Enter}')
168
+ await new Promise((resolve) => setTimeout(resolve, 0))
169
+ // The editor closed onto the app-owned new value.
170
+ const body = container.querySelector('tbody') as HTMLElement
171
+ expect(body.textContent).toContain('25')
172
+ expect(
173
+ screen.queryByRole('spinbutton', { name: 'Edit Amount of 2' }),
174
+ ).toBeNull()
175
+ })
176
+
177
+ it('renders plain cells when not editable', () => {
178
+ render(() => (
179
+ <DataTable
180
+ title="Rows"
181
+ rows={ROWS}
182
+ columns={COLUMNS}
183
+ pageSize={10}
184
+ selectable={false}
185
+ />
186
+ ))
187
+ expect(
188
+ screen.queryByRole('button', { name: /— edit Amount of 1$/ }),
189
+ ).toBeNull()
190
+ })
191
+ })
@@ -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()