@fragments-sdk/ui 0.9.4 → 0.9.6

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 (126) hide show
  1. package/dist/assets/ui.css +443 -247
  2. package/dist/blocks/components/index.d.ts +0 -2
  3. package/dist/blocks/components/index.d.ts.map +1 -1
  4. package/dist/codeblock.cjs +187 -184
  5. package/dist/codeblock.cjs.map +1 -1
  6. package/dist/codeblock.js +183 -180
  7. package/dist/codeblock.js.map +1 -1
  8. package/dist/components/Box/Box.module.scss.cjs +73 -0
  9. package/dist/components/Box/Box.module.scss.cjs.map +1 -1
  10. package/dist/components/Box/Box.module.scss.js +73 -0
  11. package/dist/components/Box/Box.module.scss.js.map +1 -1
  12. package/dist/components/ButtonGroup/ButtonGroup.module.scss.cjs +6 -0
  13. package/dist/components/ButtonGroup/ButtonGroup.module.scss.cjs.map +1 -1
  14. package/dist/components/ButtonGroup/ButtonGroup.module.scss.js +6 -0
  15. package/dist/components/ButtonGroup/ButtonGroup.module.scss.js.map +1 -1
  16. package/dist/components/CodeBlock/CodeBlock.module.scss.cjs +20 -23
  17. package/dist/components/CodeBlock/CodeBlock.module.scss.cjs.map +1 -1
  18. package/dist/components/CodeBlock/CodeBlock.module.scss.js +20 -23
  19. package/dist/components/CodeBlock/CodeBlock.module.scss.js.map +1 -1
  20. package/dist/components/CodeBlock/index.d.ts +11 -7
  21. package/dist/components/CodeBlock/index.d.ts.map +1 -1
  22. package/dist/components/Combobox/Combobox.module.scss.cjs +15 -15
  23. package/dist/components/Combobox/Combobox.module.scss.js +15 -15
  24. package/dist/components/DataTable/DataTable.module.scss.cjs +84 -0
  25. package/dist/components/DataTable/DataTable.module.scss.cjs.map +1 -0
  26. package/dist/components/DataTable/DataTable.module.scss.js +84 -0
  27. package/dist/components/DataTable/DataTable.module.scss.js.map +1 -0
  28. package/dist/components/DataTable/index.cjs +383 -0
  29. package/dist/components/DataTable/index.cjs.map +1 -0
  30. package/dist/components/DataTable/index.d.ts +78 -0
  31. package/dist/components/DataTable/index.d.ts.map +1 -0
  32. package/dist/components/DataTable/index.js +366 -0
  33. package/dist/components/DataTable/index.js.map +1 -0
  34. package/dist/components/Drawer/Drawer.module.scss.cjs +9 -0
  35. package/dist/components/Drawer/Drawer.module.scss.cjs.map +1 -1
  36. package/dist/components/Drawer/Drawer.module.scss.js +9 -0
  37. package/dist/components/Drawer/Drawer.module.scss.js.map +1 -1
  38. package/dist/components/Image/Image.module.scss.cjs +12 -0
  39. package/dist/components/Image/Image.module.scss.cjs.map +1 -1
  40. package/dist/components/Image/Image.module.scss.js +12 -0
  41. package/dist/components/Image/Image.module.scss.js.map +1 -1
  42. package/dist/components/Link/Link.module.scss.cjs +3 -0
  43. package/dist/components/Link/Link.module.scss.cjs.map +1 -1
  44. package/dist/components/Link/Link.module.scss.js +3 -0
  45. package/dist/components/Link/Link.module.scss.js.map +1 -1
  46. package/dist/components/List/List.module.scss.cjs +5 -0
  47. package/dist/components/List/List.module.scss.cjs.map +1 -1
  48. package/dist/components/List/List.module.scss.js +5 -0
  49. package/dist/components/List/List.module.scss.js.map +1 -1
  50. package/dist/components/Loading/Loading.module.scss.cjs +5 -0
  51. package/dist/components/Loading/Loading.module.scss.cjs.map +1 -1
  52. package/dist/components/Loading/Loading.module.scss.js +5 -0
  53. package/dist/components/Loading/Loading.module.scss.js.map +1 -1
  54. package/dist/components/Markdown/Markdown.module.scss.cjs +1 -1
  55. package/dist/components/Markdown/Markdown.module.scss.js +1 -1
  56. package/dist/components/Message/Message.module.scss.cjs +22 -16
  57. package/dist/components/Message/Message.module.scss.cjs.map +1 -1
  58. package/dist/components/Message/Message.module.scss.js +22 -16
  59. package/dist/components/Message/Message.module.scss.js.map +1 -1
  60. package/dist/components/Message/index.cjs +5 -3
  61. package/dist/components/Message/index.cjs.map +1 -1
  62. package/dist/components/Message/index.d.ts +5 -1
  63. package/dist/components/Message/index.d.ts.map +1 -1
  64. package/dist/components/Message/index.js +5 -3
  65. package/dist/components/Message/index.js.map +1 -1
  66. package/dist/components/Skeleton/Skeleton.module.scss.cjs +14 -0
  67. package/dist/components/Skeleton/Skeleton.module.scss.cjs.map +1 -1
  68. package/dist/components/Skeleton/Skeleton.module.scss.js +14 -0
  69. package/dist/components/Skeleton/Skeleton.module.scss.js.map +1 -1
  70. package/dist/components/Stack/Stack.module.scss.cjs +14 -0
  71. package/dist/components/Stack/Stack.module.scss.cjs.map +1 -1
  72. package/dist/components/Stack/Stack.module.scss.js +14 -0
  73. package/dist/components/Stack/Stack.module.scss.js.map +1 -1
  74. package/dist/components/Table/Table.module.scss.cjs +21 -36
  75. package/dist/components/Table/Table.module.scss.cjs.map +1 -1
  76. package/dist/components/Table/Table.module.scss.js +21 -36
  77. package/dist/components/Table/Table.module.scss.js.map +1 -1
  78. package/dist/components/Table/index.d.ts +35 -55
  79. package/dist/components/Table/index.d.ts.map +1 -1
  80. package/dist/components/Text/Text.module.scss.cjs +14 -0
  81. package/dist/components/Text/Text.module.scss.cjs.map +1 -1
  82. package/dist/components/Text/Text.module.scss.js +14 -0
  83. package/dist/components/Text/Text.module.scss.js.map +1 -1
  84. package/dist/components/Textarea/Textarea.module.scss.cjs +4 -0
  85. package/dist/components/Textarea/Textarea.module.scss.cjs.map +1 -1
  86. package/dist/components/Textarea/Textarea.module.scss.js +4 -0
  87. package/dist/components/Textarea/Textarea.module.scss.js.map +1 -1
  88. package/dist/components/ToggleGroup/ToggleGroup.module.scss.cjs +5 -0
  89. package/dist/components/ToggleGroup/ToggleGroup.module.scss.cjs.map +1 -1
  90. package/dist/components/ToggleGroup/ToggleGroup.module.scss.js +5 -0
  91. package/dist/components/ToggleGroup/ToggleGroup.module.scss.js.map +1 -1
  92. package/dist/index.cjs +119 -117
  93. package/dist/index.cjs.map +1 -1
  94. package/dist/index.d.ts +2 -1
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +3 -1
  97. package/dist/index.js.map +1 -1
  98. package/dist/table.cjs +44 -262
  99. package/dist/table.cjs.map +1 -1
  100. package/dist/table.js +47 -248
  101. package/dist/table.js.map +1 -1
  102. package/fragments.json +1 -1
  103. package/package.json +110 -118
  104. package/src/blocks/components/index.ts +0 -3
  105. package/src/components/CodeBlock/CodeBlock.module.scss +16 -34
  106. package/src/components/CodeBlock/index.tsx +351 -345
  107. package/src/components/Combobox/Combobox.module.scss +13 -9
  108. package/src/components/ConversationList/ConversationList.fragment.tsx +96 -129
  109. package/src/components/DataTable/DataTable.fragment.tsx +754 -0
  110. package/src/components/DataTable/DataTable.module.scss +300 -0
  111. package/src/components/DataTable/DataTable.test.tsx +224 -0
  112. package/src/components/DataTable/index.tsx +533 -0
  113. package/src/components/Message/Message.fragment.tsx +34 -0
  114. package/src/components/Message/Message.module.scss +11 -0
  115. package/src/components/Message/index.tsx +12 -3
  116. package/src/components/Table/Table.fragment.tsx +190 -175
  117. package/src/components/Table/Table.module.scss +15 -88
  118. package/src/components/Table/Table.test.tsx +184 -94
  119. package/src/components/Table/index.tsx +105 -374
  120. package/src/index.ts +15 -4
  121. package/src/tokens/_computed.scss +7 -6
  122. package/src/tokens/_density.scss +87 -47
  123. package/src/tokens/_variables.scss +46 -31
  124. package/dist/blocks/components/DataTable.d.ts +0 -19
  125. package/dist/blocks/components/DataTable.d.ts.map +0 -1
  126. package/src/blocks/components/DataTable.tsx +0 -124
@@ -0,0 +1,300 @@
1
+ @use '../../tokens/variables' as *;
2
+ @use '../../tokens/mixins' as *;
3
+
4
+ .wrapper {
5
+ overflow-x: auto;
6
+ -webkit-overflow-scrolling: touch;
7
+ }
8
+
9
+ .table {
10
+ width: 100%;
11
+ border-collapse: collapse;
12
+ font-family: var(--fui-font-sans, $fui-font-sans);
13
+ -webkit-font-smoothing: antialiased;
14
+ -moz-osx-font-smoothing: grayscale;
15
+ }
16
+
17
+ // Caption for accessibility
18
+ .caption {
19
+ padding: var(--fui-space-3, $fui-space-3) 0;
20
+ font-size: var(--fui-font-size-sm, $fui-font-size-sm);
21
+ font-weight: var(--fui-font-weight-medium, $fui-font-weight-medium);
22
+ color: var(--fui-text-secondary, $fui-text-secondary);
23
+ text-align: left;
24
+ caption-side: top;
25
+ }
26
+
27
+ // Visually hidden caption (screen readers only)
28
+ .captionHidden {
29
+ @include visually-hidden;
30
+ }
31
+
32
+ // Size variants
33
+ .sm {
34
+ .th,
35
+ .td {
36
+ padding: var(--fui-padding-item-xs, $fui-padding-item-xs) var(--fui-padding-item-md, $fui-padding-item-md);
37
+ font-size: var(--fui-font-size-xs, $fui-font-size-xs);
38
+ }
39
+ }
40
+
41
+ .md {
42
+ .th,
43
+ .td {
44
+ padding: var(--fui-padding-item-xs, $fui-padding-item-xs) var(--fui-padding-item-md, $fui-padding-item-md);
45
+ font-size: var(--fui-font-size-sm, $fui-font-size-sm);
46
+ }
47
+ }
48
+
49
+ // Header
50
+ .thead {
51
+ position: sticky;
52
+ top: 0;
53
+ z-index: 1;
54
+ }
55
+
56
+ .headerRow {
57
+ border-bottom: 1px solid var(--fui-border, $fui-border);
58
+ }
59
+
60
+ .th {
61
+ text-align: left;
62
+ font-weight: var(--fui-font-weight-medium, $fui-font-weight-medium);
63
+ color: var(--fui-text-secondary, $fui-text-secondary);
64
+ white-space: nowrap;
65
+ vertical-align: middle;
66
+ user-select: none;
67
+
68
+ &:first-child {
69
+ border-top-left-radius: var(--fui-radius-md, $fui-radius-md);
70
+ }
71
+
72
+ &:last-child {
73
+ border-top-right-radius: var(--fui-radius-md, $fui-radius-md);
74
+ }
75
+ }
76
+
77
+ .headerContent {
78
+ display: flex;
79
+ align-items: center;
80
+ gap: var(--fui-space-1, $fui-space-1);
81
+ }
82
+
83
+ // Sortable header cell (for focus styles)
84
+ .thSortable {
85
+ padding: 0;
86
+ }
87
+
88
+ .sortButton {
89
+ @include button-reset;
90
+ @include interactive-base;
91
+
92
+ display: flex;
93
+ align-items: center;
94
+ justify-content: space-between;
95
+ gap: var(--fui-space-1, $fui-space-1);
96
+ width: 100%;
97
+ padding: var(--fui-padding-item-xs, $fui-padding-item-xs) var(--fui-padding-item-md, $fui-padding-item-md);
98
+ color: inherit;
99
+ text-align: left;
100
+ transition: color var(--fui-transition-fast, $fui-transition-fast);
101
+
102
+ &:hover {
103
+ color: var(--fui-text-primary, $fui-text-primary);
104
+ }
105
+ }
106
+
107
+ .sortIndicator {
108
+ display: inline-flex;
109
+ align-items: center;
110
+ color: var(--fui-text-tertiary, $fui-text-tertiary);
111
+ flex-shrink: 0;
112
+
113
+ .sortButton:hover & {
114
+ color: var(--fui-text-secondary, $fui-text-secondary);
115
+ }
116
+ }
117
+
118
+ // Body
119
+ .tbody {}
120
+
121
+ .row {
122
+ border-bottom: 1px solid var(--fui-border, $fui-border);
123
+ transition: background-color var(--fui-transition-fast, $fui-transition-fast);
124
+
125
+ &:last-child {
126
+ border-bottom: none;
127
+ }
128
+
129
+ &:hover {
130
+ background-color: var(--fui-bg-hover, $fui-bg-hover);
131
+ }
132
+ }
133
+
134
+ .clickable {
135
+ cursor: pointer;
136
+
137
+ &:hover {
138
+ background-color: var(--fui-bg-hover, $fui-bg-hover);
139
+ }
140
+
141
+ &:focus-visible {
142
+ @include focus-ring;
143
+ }
144
+
145
+ &:active {
146
+ background-color: var(--fui-bg-active, $fui-bg-active);
147
+ }
148
+ }
149
+
150
+ .selected {
151
+ background-color: rgba($fui-color-accent, 0.08);
152
+
153
+ &:hover {
154
+ background-color: rgba($fui-color-accent, 0.12);
155
+ }
156
+ }
157
+
158
+ .td {
159
+ color: var(--fui-text-primary, $fui-text-primary);
160
+ vertical-align: middle;
161
+ line-height: var(--fui-line-height-normal, $fui-line-height-normal);
162
+ }
163
+
164
+ // Striped rows
165
+ .striped {
166
+ .row:nth-child(even) {
167
+ background-color: var(--fui-bg-subtle, $fui-bg-subtle);
168
+ }
169
+
170
+ // Hover and selected override stripe
171
+ .clickable:hover {
172
+ background-color: var(--fui-bg-hover, $fui-bg-hover);
173
+ }
174
+
175
+ .clickable:active {
176
+ background-color: var(--fui-bg-active, $fui-bg-active);
177
+ }
178
+
179
+ .selected {
180
+ background-color: rgba($fui-color-accent, 0.08);
181
+
182
+ &:hover {
183
+ background-color: rgba($fui-color-accent, 0.12);
184
+ }
185
+ }
186
+ }
187
+
188
+ // Bordered
189
+ .bordered {
190
+ border: 1px solid var(--fui-border, $fui-border);
191
+ border-radius: var(--fui-radius-md, $fui-radius-md);
192
+ overflow: hidden;
193
+ }
194
+
195
+ // Fixed layout for explicit column sizes
196
+ .fixedLayout {
197
+ table-layout: fixed;
198
+ }
199
+
200
+ // Checkbox column
201
+ .checkboxCell {
202
+ display: flex;
203
+ align-items: center;
204
+ justify-content: center;
205
+ }
206
+
207
+ // Expandable rows
208
+ .expandCell {
209
+ display: inline-flex;
210
+ align-items: center;
211
+ gap: var(--fui-space-1, $fui-space-1);
212
+ }
213
+
214
+ .expandButton {
215
+ @include button-reset;
216
+
217
+ display: inline-flex;
218
+ align-items: center;
219
+ justify-content: center;
220
+ width: 20px;
221
+ height: 20px;
222
+ flex-shrink: 0;
223
+ border-radius: var(--fui-radius-sm, $fui-radius-sm);
224
+ color: var(--fui-text-tertiary, $fui-text-tertiary);
225
+ transition: color var(--fui-transition-fast, $fui-transition-fast),
226
+ background-color var(--fui-transition-fast, $fui-transition-fast);
227
+
228
+ &:hover {
229
+ color: var(--fui-text-primary, $fui-text-primary);
230
+ background-color: var(--fui-bg-hover, $fui-bg-hover);
231
+ }
232
+
233
+ &:focus-visible {
234
+ @include focus-ring;
235
+ }
236
+ }
237
+
238
+ .subRow {
239
+ background-color: var(--fui-bg-subtle, $fui-bg-subtle);
240
+
241
+ &:hover {
242
+ background-color: var(--fui-bg-hover, $fui-bg-hover);
243
+ }
244
+ }
245
+
246
+ // Empty state
247
+ .emptyState {
248
+ display: flex;
249
+ align-items: center;
250
+ justify-content: center;
251
+ padding: var(--fui-space-12, $fui-space-12) var(--fui-space-6, $fui-space-6);
252
+ }
253
+
254
+ .emptyMessage {
255
+ font-family: var(--fui-font-sans, $fui-font-sans);
256
+ font-size: var(--fui-font-size-sm, $fui-font-size-sm);
257
+ color: var(--fui-text-tertiary, $fui-text-tertiary);
258
+ }
259
+
260
+ // Responsive: allow horizontal scroll on small screens
261
+ @include below-sm {
262
+ .wrapper {
263
+ margin-left: calc(-1 * var(--fui-space-4, $fui-space-4));
264
+ margin-right: calc(-1 * var(--fui-space-4, $fui-space-4));
265
+ padding-left: var(--fui-space-4, $fui-space-4);
266
+ padding-right: var(--fui-space-4, $fui-space-4);
267
+ }
268
+ }
269
+
270
+ // ============================================
271
+ // Accessibility: High Contrast Mode
272
+ // ============================================
273
+
274
+ @media (prefers-contrast: more) {
275
+ .headerRow {
276
+ border-bottom-width: 2px;
277
+ }
278
+
279
+ .row {
280
+ border-bottom-width: 2px;
281
+ }
282
+
283
+ .sortButton:focus-visible {
284
+ outline-width: 3px;
285
+ }
286
+ }
287
+
288
+ // ============================================
289
+ // Accessibility: Reduced Motion
290
+ // ============================================
291
+
292
+ @media (prefers-reduced-motion: reduce) {
293
+ .row {
294
+ transition: none;
295
+ }
296
+
297
+ .sortButton {
298
+ transition: none;
299
+ }
300
+ }
@@ -0,0 +1,224 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen, userEvent, expectNoA11yViolations } from '../../test/utils';
3
+ import { DataTable, createColumns } from './index';
4
+
5
+ type Person = { id: string; name: string; age: number };
6
+
7
+ const columns = createColumns<Person>([
8
+ { key: 'name', header: 'Name' },
9
+ { key: 'age', header: 'Age' },
10
+ ]);
11
+
12
+ const data: Person[] = [
13
+ { id: '1', name: 'Alice', age: 30 },
14
+ { id: '2', name: 'Bob', age: 25 },
15
+ { id: '3', name: 'Carol', age: 35 },
16
+ ];
17
+
18
+ describe('DataTable', () => {
19
+ it('renders a table element with column headers', () => {
20
+ render(<DataTable columns={columns} data={data} aria-label="People" />);
21
+ expect(screen.getByRole('table')).toBeInTheDocument();
22
+ const headers = screen.getAllByRole('columnheader');
23
+ expect(headers).toHaveLength(2);
24
+ expect(headers[0]).toHaveAttribute('scope', 'col');
25
+ expect(headers[0]).toHaveTextContent('Name');
26
+ expect(headers[1]).toHaveTextContent('Age');
27
+ });
28
+
29
+ it('renders data rows', () => {
30
+ render(<DataTable columns={columns} data={data} aria-label="People" />);
31
+ const rows = screen.getAllByRole('row');
32
+ // 1 header row + 3 data rows
33
+ expect(rows).toHaveLength(4);
34
+ expect(screen.getByText('Alice')).toBeInTheDocument();
35
+ expect(screen.getByText('25')).toBeInTheDocument();
36
+ });
37
+
38
+ it('renders caption when provided', () => {
39
+ render(<DataTable columns={columns} data={data} caption="People Table" aria-label="People" />);
40
+ expect(screen.getByText('People Table')).toBeInTheDocument();
41
+ });
42
+
43
+ it('shows empty state message when data is empty', () => {
44
+ render(<DataTable columns={columns} data={[]} emptyMessage="Nothing here" aria-label="People" />);
45
+ expect(screen.getByText('Nothing here')).toBeInTheDocument();
46
+ expect(screen.queryByRole('table')).not.toBeInTheDocument();
47
+ });
48
+
49
+ it('defaults to "No data available" empty message', () => {
50
+ render(<DataTable columns={columns} data={[]} aria-label="People" />);
51
+ expect(screen.getByText('No data available')).toBeInTheDocument();
52
+ });
53
+
54
+ it('supports sortable columns with aria-sort', async () => {
55
+ const user = userEvent.setup();
56
+ render(<DataTable columns={columns} data={data} sortable aria-label="People" />);
57
+ const headers = screen.getAllByRole('columnheader');
58
+ // Initially aria-sort="none" for sortable columns
59
+ expect(headers[0]).toHaveAttribute('aria-sort', 'none');
60
+
61
+ // Click the sort button inside the first header
62
+ const sortButton = headers[0].querySelector('button')!;
63
+ await user.click(sortButton);
64
+ expect(headers[0]).toHaveAttribute('aria-sort', 'ascending');
65
+
66
+ await user.click(sortButton);
67
+ expect(headers[0]).toHaveAttribute('aria-sort', 'descending');
68
+ });
69
+
70
+ it('calls onRowClick when a row is clicked', async () => {
71
+ const user = userEvent.setup();
72
+ const handleClick = vi.fn();
73
+ render(<DataTable columns={columns} data={data} onRowClick={handleClick} aria-label="People" />);
74
+ const rows = screen.getAllByRole('row');
75
+ // rows[0] is header, rows[1] is first data row
76
+ await user.click(rows[1]);
77
+ expect(handleClick).toHaveBeenCalledWith(data[0]);
78
+ });
79
+
80
+ it('applies striped class when striped prop is true', () => {
81
+ const { container } = render(<DataTable columns={columns} data={data} striped aria-label="People" />);
82
+ expect(container.querySelector('.striped')).toBeInTheDocument();
83
+ });
84
+
85
+ it('createColumns helper generates proper column defs', () => {
86
+ const cols = createColumns<Person>([
87
+ { key: 'name', header: 'Full Name', width: 200 },
88
+ { key: 'age', header: 'Years', cell: (row) => `${row.age} years` },
89
+ ]);
90
+ expect(cols).toHaveLength(2);
91
+ expect(cols[0].id).toBe('name');
92
+ expect(cols[0].header).toBe('Full Name');
93
+ expect(cols[0].size).toBe(200);
94
+ expect(cols[1].id).toBe('age');
95
+ });
96
+
97
+ it('supports row selection', () => {
98
+ render(
99
+ <DataTable
100
+ columns={columns}
101
+ data={data}
102
+ selectable
103
+ rowSelection={{ '1': true }}
104
+ getRowId={(row) => row.id}
105
+ aria-label="People"
106
+ />
107
+ );
108
+ const rows = screen.getAllByRole('row');
109
+ // First data row (id='1') should have data-selected
110
+ expect(rows[1]).toHaveAttribute('data-selected');
111
+ });
112
+
113
+ it('supports keyboard activation of clickable rows', async () => {
114
+ const user = userEvent.setup();
115
+ const handleClick = vi.fn();
116
+ render(<DataTable columns={columns} data={data} onRowClick={handleClick} aria-label="People" />);
117
+ const rows = screen.getAllByRole('row');
118
+ rows[1].focus();
119
+ await user.keyboard('{Enter}');
120
+ expect(handleClick).toHaveBeenCalledWith(data[0]);
121
+ });
122
+
123
+ it('renders checkbox column when showCheckbox and selectable', async () => {
124
+ const user = userEvent.setup();
125
+ render(
126
+ <DataTable
127
+ columns={columns}
128
+ data={data}
129
+ selectable
130
+ showCheckbox
131
+ getRowId={(row) => row.id}
132
+ aria-label="People"
133
+ />
134
+ );
135
+ // 2 data columns + 1 checkbox column = 3 headers
136
+ const headers = screen.getAllByRole('columnheader');
137
+ expect(headers).toHaveLength(3);
138
+
139
+ // "Select all" checkbox in header
140
+ const selectAll = screen.getByRole('checkbox', { name: 'Select all rows' });
141
+ expect(selectAll).toBeInTheDocument();
142
+
143
+ // Individual row checkboxes
144
+ const rowCheckboxes = screen.getAllByRole('checkbox', { name: /Select row/ });
145
+ expect(rowCheckboxes).toHaveLength(3);
146
+
147
+ // Click a row checkbox toggles selection
148
+ await user.click(rowCheckboxes[0]);
149
+ expect(rowCheckboxes[0]).toHaveAttribute('aria-checked', 'true');
150
+ });
151
+
152
+ it('does not render checkbox column when only showCheckbox without selectable', () => {
153
+ render(
154
+ <DataTable
155
+ columns={columns}
156
+ data={data}
157
+ showCheckbox
158
+ aria-label="People"
159
+ />
160
+ );
161
+ // Should only have the 2 data columns
162
+ expect(screen.getAllByRole('columnheader')).toHaveLength(2);
163
+ expect(screen.queryByLabelText('Select all rows')).not.toBeInTheDocument();
164
+ });
165
+
166
+ it('renders expandable sub-rows with expand/collapse buttons', async () => {
167
+ type Node = { id: string; name: string; children?: Node[] };
168
+ const treeData: Node[] = [
169
+ {
170
+ id: '1', name: 'Parent',
171
+ children: [
172
+ { id: '1.1', name: 'Child A' },
173
+ { id: '1.2', name: 'Child B' },
174
+ ],
175
+ },
176
+ { id: '2', name: 'Standalone' },
177
+ ];
178
+ const treeCols = createColumns<Node>([
179
+ { key: 'name', header: 'Name' },
180
+ ]);
181
+
182
+ const user = userEvent.setup();
183
+ render(
184
+ <DataTable
185
+ columns={treeCols}
186
+ data={treeData}
187
+ getSubRows={(row) => row.children}
188
+ getRowId={(row) => row.id}
189
+ aria-label="Tree"
190
+ />
191
+ );
192
+
193
+ // Initially only top-level rows visible (1 header + 2 data)
194
+ expect(screen.getAllByRole('row')).toHaveLength(3);
195
+ expect(screen.getByText('Parent')).toBeInTheDocument();
196
+ expect(screen.getByText('Standalone')).toBeInTheDocument();
197
+
198
+ // Expand button present for parent row
199
+ const expandBtn = screen.getByLabelText('Expand row');
200
+ expect(expandBtn).toHaveAttribute('aria-expanded', 'false');
201
+
202
+ // Click expand to show children
203
+ await user.click(expandBtn);
204
+ expect(screen.getAllByRole('row')).toHaveLength(5); // 1 header + 2 top + 2 children
205
+ expect(screen.getByText('Child A')).toBeInTheDocument();
206
+ expect(screen.getByText('Child B')).toBeInTheDocument();
207
+
208
+ // Button now shows "Collapse row"
209
+ const collapseBtn = screen.getByLabelText('Collapse row');
210
+ expect(collapseBtn).toHaveAttribute('aria-expanded', 'true');
211
+
212
+ // Child rows have data-depth attribute
213
+ const childRows = screen.getAllByRole('row').filter(r => r.getAttribute('data-depth'));
214
+ expect(childRows).toHaveLength(2);
215
+ expect(childRows[0]).toHaveAttribute('data-depth', '1');
216
+ });
217
+
218
+ it('has no accessibility violations', async () => {
219
+ const { container } = render(
220
+ <DataTable columns={columns} data={data} caption="People Table" aria-label="People" />
221
+ );
222
+ await expectNoA11yViolations(container);
223
+ });
224
+ });