@gntik-ai/blocks 0.5.0 → 0.7.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 (76) hide show
  1. package/dist/index.d.ts +337 -97
  2. package/dist/index.js +1311 -384
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/auth/SignUpForm/SignUpForm.configured.test.tsx +316 -0
  6. package/src/auth/SignUpForm/SignUpForm.tsx +194 -50
  7. package/src/auth/SignUpForm/block.meta.ts +15 -2
  8. package/src/auth/SignUpForm/examples/CustomFields.tsx +56 -0
  9. package/src/auth/SignUpForm/examples/Error.tsx +5 -0
  10. package/src/auth/SignUpForm/examples/Loading.tsx +6 -0
  11. package/src/auth/SignUpForm/examples/NoTerms.tsx +6 -0
  12. package/src/auth/SignUpForm/examples/Submitting.tsx +5 -0
  13. package/src/auth/SignUpForm/examples/Unavailable.tsx +15 -0
  14. package/src/auth/index.ts +1 -0
  15. package/src/auth/shared.tsx +34 -0
  16. package/src/feedback/ConfirmDestructive/ConfirmDestructive.examples.test.tsx +25 -0
  17. package/src/feedback/ConfirmDestructive/ConfirmDestructive.extended.test.tsx +252 -0
  18. package/src/feedback/ConfirmDestructive/ConfirmDestructive.focus.test.tsx +178 -0
  19. package/src/feedback/ConfirmDestructive/ConfirmDestructive.tsx +219 -28
  20. package/src/feedback/ConfirmDestructive/block.meta.ts +2 -2
  21. package/src/feedback/ConfirmDestructive/examples/FailureStates.tsx +39 -0
  22. package/src/feedback/ConfirmDestructive/examples/PlainDeletion.tsx +6 -0
  23. package/src/feedback/ConfirmDestructive/examples/SecretRevocation.tsx +34 -0
  24. package/src/feedback/EmptyStates/EmptyStates.test.tsx +11 -1
  25. package/src/feedback/EmptyStates/EmptyStates.tsx +31 -1
  26. package/src/feedback/EmptyStates/block.meta.ts +1 -1
  27. package/src/feedback/EmptyStates/fixtures.ts +5 -0
  28. package/src/feedback/ErrorPanel/ErrorPanel.test.tsx +7 -0
  29. package/src/feedback/ErrorPanel/ErrorPanel.tsx +8 -3
  30. package/src/meta.ts +2 -0
  31. package/src/tables/CursorPagerFooter/CursorPagerFooter.examples.test.tsx +33 -0
  32. package/src/tables/CursorPagerFooter/CursorPagerFooter.test.tsx +51 -0
  33. package/src/tables/CursorPagerFooter/CursorPagerFooter.tsx +10 -0
  34. package/src/tables/CursorPagerFooter/block.meta.ts +23 -0
  35. package/src/tables/CursorPagerFooter/examples/Busy.tsx +7 -0
  36. package/src/tables/CursorPagerFooter/examples/CursorStack.tsx +40 -0
  37. package/src/tables/CursorPagerFooter/examples/Hidden.tsx +16 -0
  38. package/src/tables/CursorPagerFooter/examples/RowsPerPage.tsx +21 -0
  39. package/src/tables/CursorPagerFooter/fixtures.ts +13 -0
  40. package/src/tables/DataTable/DataTable.labels.test.tsx +291 -0
  41. package/src/tables/DataTable/DataTable.overflow-labels.test.tsx +59 -0
  42. package/src/tables/DataTable/DataTable.server-pagination.test.tsx +173 -0
  43. package/src/tables/DataTable/DataTable.tsx +40 -21
  44. package/src/tables/DataTable/block.meta.ts +104 -1
  45. package/src/tables/DataTable/column-menu.tsx +10 -7
  46. package/src/tables/DataTable/data-table-body.tsx +3 -2
  47. package/src/tables/DataTable/data-table-props.ts +30 -4
  48. package/src/tables/DataTable/data-table-row.tsx +15 -4
  49. package/src/tables/DataTable/density-toggle.tsx +7 -4
  50. package/src/tables/DataTable/editable-cell.tsx +21 -8
  51. package/src/tables/DataTable/examples/server-pagination.tsx +68 -0
  52. package/src/tables/DataTable/header-cell.tsx +4 -0
  53. package/src/tables/DataTable/labels.ts +54 -0
  54. package/src/tables/DataTable/resize-handle.tsx +6 -3
  55. package/src/tables/DataTable/views.tsx +21 -14
  56. package/src/tables/FilterBar/FilterBar.labels.test.tsx +69 -0
  57. package/src/tables/FilterBar/FilterBar.tsx +45 -12
  58. package/src/tables/FilterBar/block.meta.ts +22 -0
  59. package/src/tables/LoadMoreFooter/LoadMoreFooter.announcements.test.tsx +106 -0
  60. package/src/tables/LoadMoreFooter/LoadMoreFooter.examples.test.tsx +36 -0
  61. package/src/tables/LoadMoreFooter/LoadMoreFooter.labels.test.tsx +71 -0
  62. package/src/tables/LoadMoreFooter/LoadMoreFooter.test.tsx +184 -0
  63. package/src/tables/LoadMoreFooter/LoadMoreFooter.tsx +151 -0
  64. package/src/tables/LoadMoreFooter/block.meta.ts +28 -0
  65. package/src/tables/LoadMoreFooter/examples/cards.tsx +22 -0
  66. package/src/tables/LoadMoreFooter/examples/cursor.tsx +25 -0
  67. package/src/tables/LoadMoreFooter/examples/exhausted.tsx +6 -0
  68. package/src/tables/LoadMoreFooter/examples/failed.tsx +13 -0
  69. package/src/tables/LoadMoreFooter/examples/flag.tsx +33 -0
  70. package/src/tables/LoadMoreFooter/examples/useMockCursor.ts +30 -0
  71. package/src/tables/LoadMoreFooter/fixtures.ts +9 -0
  72. package/src/tables/LoadMoreFooter/useLoadMoreFooter.ts +56 -0
  73. package/src/tables/PaginationFooter/PaginationFooter.labels.test.tsx +57 -0
  74. package/src/tables/PaginationFooter/PaginationFooter.tsx +31 -7
  75. package/src/tables/PaginationFooter/block.meta.ts +23 -0
  76. package/src/tables/index.ts +4 -0
@@ -0,0 +1,23 @@
1
+ import type { BlockMeta } from '../../meta';
2
+
3
+ export const meta: BlockMeta = {
4
+ name: 'Cursor pager footer',
5
+ family: 'tables',
6
+ status: 'beta',
7
+ description:
8
+ 'Responsive stateless cursor-pagination footer for data-table paginated={false}. Forwards CursorPaginationProps: hasPrevious, hasNext, onPrevious, onNext, loading, busy, summary, shownCount, total, unavailableBehaviour, pageSize, pageSizeOptions, onPageSizeChange, previousLabel, nextLabel, ariaLabel, loadingLabel, labels and className. Consumer-owned opaque cursors stay outside the kit. summary accepts plain text or null; the count form requires labels.shownSummary and labels.totalSummary; select options require labels.rowsPerPage and labels.rowsPerPageLabel. Flat names override labels.previous, labels.next and labels.navLabel, then catalog defaults. loadingLabel=null preserves the summary while busy. One persistent polite atomic status; buttons remain focusable when aria-disabled. Removal of a focused control moves focus to Next, Previous, select, then nav root, including while busy. No page numbers or ranges. No props renders fixtures.',
9
+ labels: [
10
+ { key: 'previous', type: 'string', state: 'Previous name, below previousLabel and above catalog defaults' },
11
+ { key: 'next', type: 'string', state: 'Next name, below nextLabel and above catalog defaults' },
12
+ { key: 'navLabel', type: 'string', state: 'Navigation name, below ariaLabel and above catalog defaults' },
13
+ {
14
+ key: 'shownSummary',
15
+ type: '(shownCount: number) => string',
16
+ state: 'Required in count form: no positive exact total',
17
+ },
18
+ { key: 'totalSummary', type: '(total: number) => string', state: 'Required in count form: positive exact total' },
19
+ { key: 'rowsPerPage', type: 'string', state: 'Required with pageSizeOptions: visible select label' },
20
+ { key: 'rowsPerPageLabel', type: 'string', state: 'Required with pageSizeOptions: select accessible name' },
21
+ ],
22
+ uses: ['CursorPagination'],
23
+ };
@@ -0,0 +1,7 @@
1
+ import { CursorPagerFooter } from '../CursorPagerFooter';
2
+
3
+ export default function Busy() {
4
+ return (
5
+ <CursorPagerFooter hasPrevious hasNext busy summary="25 records loaded" onPrevious={() => {}} onNext={() => {}} />
6
+ );
7
+ }
@@ -0,0 +1,40 @@
1
+ import { useState } from 'react';
2
+ import { DataTable } from '../../DataTable/DataTable';
3
+ import { CursorPagerFooter } from '../CursorPagerFooter';
4
+
5
+ // Mock API responses: only this consumer stores or interprets opaque continuation tokens.
6
+ const responses: Record<string, { rows: { name: string }[]; next?: string }> = {
7
+ start: { rows: [{ name: 'Record A' }], next: 'opaque-k7' },
8
+ 'opaque-k7': { rows: [{ name: 'Record B' }], next: 'opaque-m9' },
9
+ 'opaque-m9': { rows: [{ name: 'Record C' }] },
10
+ };
11
+
12
+ export default function CursorStack() {
13
+ const [stack, setStack] = useState<string[]>([]);
14
+ const response = responses[stack.at(-1) ?? 'start'] ?? responses.start!;
15
+ return (
16
+ <div className="space-y-4">
17
+ <DataTable
18
+ rows={response.rows}
19
+ columns={[{ id: 'name', header: 'Name', accessor: (row) => row.name }]}
20
+ caption="Records"
21
+ paginated={false}
22
+ selectable={false}
23
+ showColumnMenu={false}
24
+ showDensityToggle={false}
25
+ showViewSwitcher={false}
26
+ resizable={false}
27
+ />
28
+ <CursorPagerFooter
29
+ hasPrevious={stack.length > 0}
30
+ hasNext={response.next !== undefined}
31
+ onPrevious={() => setStack((current) => current.slice(0, -1))}
32
+ onNext={() => {
33
+ if (response.next) setStack((current) => [...current, response.next!]);
34
+ }}
35
+ shownCount={response.rows.length}
36
+ labels={{ shownSummary: (n) => `${n} records shown`, totalSummary: (n) => `${n} records in total` }}
37
+ />
38
+ </div>
39
+ );
40
+ }
@@ -0,0 +1,16 @@
1
+ import { CursorPagerFooter } from '../CursorPagerFooter';
2
+
3
+ export default function Hidden() {
4
+ return (
5
+ <CursorPagerFooter
6
+ hasPrevious
7
+ hasNext={false}
8
+ unavailableBehaviour="hidden"
9
+ shownCount={7}
10
+ total={57}
11
+ labels={{ shownSummary: (n) => `${n} records shown`, totalSummary: (n) => `${n} records in total` }}
12
+ onPrevious={() => {}}
13
+ onNext={() => {}}
14
+ />
15
+ );
16
+ }
@@ -0,0 +1,21 @@
1
+ import { useState } from 'react';
2
+ import { CursorPagerFooter } from '../CursorPagerFooter';
3
+
4
+ export default function RowsPerPage() {
5
+ const [pageSize, setPageSize] = useState(25);
6
+ return (
7
+ <CursorPagerFooter
8
+ hasPrevious={false}
9
+ hasNext
10
+ busy
11
+ summary={null}
12
+ loadingLabel={null}
13
+ pageSize={pageSize}
14
+ pageSizeOptions={[10, 25, 50, 100]}
15
+ onPageSizeChange={setPageSize}
16
+ labels={{ rowsPerPage: 'Rows per page', rowsPerPageLabel: 'Rows per page' }}
17
+ onPrevious={() => {}}
18
+ onNext={() => {}}
19
+ />
20
+ );
21
+ }
@@ -0,0 +1,13 @@
1
+ import type { CursorPaginationProps } from '@gntik-ai/ui';
2
+
3
+ export const CURSOR_PAGER_PREVIEW = {
4
+ hasPrevious: false,
5
+ hasNext: true,
6
+ onPrevious: () => {},
7
+ onNext: () => {},
8
+ shownCount: 25,
9
+ labels: {
10
+ shownSummary: (shownCount: number) => `${shownCount} records shown`,
11
+ totalSummary: (total: number) => `${total} records in total`,
12
+ },
13
+ } satisfies CursorPaginationProps;
@@ -0,0 +1,291 @@
1
+ import { act, render, screen, waitFor } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { I18nProvider } from '@gntik-ai/ui';
4
+ import { DataTable } from './DataTable';
5
+ import type { DataTableLabels } from './labels';
6
+
7
+ const rows = [
8
+ { id: 'a', name: 'Alpha', region: 'West' },
9
+ { id: 'b', name: 'Beta', region: 'West' },
10
+ ];
11
+ const columns = [
12
+ { id: 'name', header: 'Name', accessor: (r: (typeof rows)[number]) => r.name, width: 200 },
13
+ { id: 'region', header: 'Region', accessor: (r: (typeof rows)[number]) => r.region, width: 160 },
14
+ ];
15
+ const labels = {
16
+ scrollRegion: 'Tabla con desplazamiento',
17
+ columns: 'Columnas',
18
+ visibleColumns: 'Columnas visibles',
19
+ pinColumns: 'Fijar columnas',
20
+ pinnedLeft: 'Izquierda',
21
+ pinnedRight: 'Derecha',
22
+ pinLeft: 'Fijar izquierda',
23
+ pinRight: 'Fijar derecha',
24
+ unpinned: 'Sin fijar',
25
+ density: 'Densidad',
26
+ comfortable: 'Cómoda',
27
+ compact: 'Compacta',
28
+ defaultView: 'Vista predeterminada',
29
+ defaultViewOption: 'Sin vista',
30
+ modified: '(modificada)',
31
+ views: 'Vistas',
32
+ saveChanges: 'Guardar cambios',
33
+ saveAs: 'Guardar como…',
34
+ renameView: 'Renombrar vista…',
35
+ deleteView: 'Eliminar vista',
36
+ resetChanges: 'Restablecer cambios',
37
+ saveViewTitle: 'Nueva vista',
38
+ renameViewTitle: 'Cambiar nombre',
39
+ viewName: 'Nombre de vista',
40
+ cancel: 'Cancelar',
41
+ saveView: 'Guardar vista',
42
+ rename: 'Renombrar',
43
+ closeDialog: 'Cerrar diálogo',
44
+ selectionCount: vi.fn((n: number) => `${n} seleccionadas`),
45
+ selectAll: 'Seleccionar página',
46
+ selection: 'Selección',
47
+ actions: 'Acciones',
48
+ selectRow: vi.fn((name: string) => `Seleccionar ${name}`),
49
+ rowActions: vi.fn((name: string) => `Acciones de ${name}`),
50
+ resizeColumn: vi.fn((name: string) => `Cambiar ancho de ${name}`),
51
+ columnWidth: vi.fn((width: number) => `${width} píxeles`),
52
+ } satisfies Partial<DataTableLabels>;
53
+
54
+ it('replaces table menus, selection announcements, resize names and saved-view dialogs', async () => {
55
+ const user = userEvent.setup();
56
+ const { container } = render(
57
+ <DataTable
58
+ rows={rows}
59
+ columns={columns}
60
+ caption="Items"
61
+ paginated={false}
62
+ resizable
63
+ showPinMenu
64
+ defaultViews={[]}
65
+ labels={labels}
66
+ rowActions={() => [{ label: 'Inspect', onClick: () => {} }]}
67
+ />,
68
+ );
69
+ expect(screen.getByRole('group', { name: 'Densidad' })).toBeVisible();
70
+ await user.click(screen.getByRole('button', { name: 'Compacta' }));
71
+ await user.click(screen.getByRole('button', { name: 'Cómoda' }));
72
+ expect(screen.getByRole('checkbox', { name: 'Seleccionar página' })).toBeVisible();
73
+ await user.click(screen.getByRole('checkbox', { name: 'Seleccionar Alpha' }));
74
+ expect(container.querySelector('[aria-live]')?.textContent).toBe('1 seleccionadas');
75
+ expect(labels.selectionCount).toHaveBeenLastCalledWith(1);
76
+ expect(labels.selectRow).toHaveBeenCalledWith('Alpha');
77
+ expect(screen.getByRole('button', { name: 'Acciones de Alpha' })).toBeVisible();
78
+ const handle = screen.getByRole('separator', { name: 'Cambiar ancho de Name' });
79
+ expect(handle).toHaveAttribute('aria-valuetext', '200 píxeles');
80
+ expect(labels.resizeColumn).toHaveBeenCalledWith('Name');
81
+ expect(labels.columnWidth).toHaveBeenCalledWith(200);
82
+ await user.click(screen.getByRole('button', { name: 'Columnas' }));
83
+ expect(await screen.findByText('Columnas visibles')).toBeVisible();
84
+ expect(screen.getByText('Fijar columnas')).toBeVisible();
85
+ await user.click(screen.getByRole('menuitemcheckbox', { name: 'Region' }));
86
+ await user.keyboard('{Escape}');
87
+ await user.click(screen.getByRole('button', { name: 'Vista predeterminada (modificada)' }));
88
+ expect(await screen.findByText('Vistas', { exact: true })).toBeVisible();
89
+ expect(screen.getByRole('menuitemradio', { name: 'Sin vista' })).toBeVisible();
90
+ for (const name of ['Guardar cambios', 'Renombrar vista…', 'Eliminar vista', 'Restablecer cambios'])
91
+ expect(screen.getByRole('menuitem', { name })).toBeVisible();
92
+ await user.click(screen.getByRole('menuitem', { name: 'Guardar como…' }));
93
+ expect(await screen.findByRole('dialog', { name: 'Nueva vista' })).toBeVisible();
94
+ expect(screen.getByRole('button', { name: 'Cerrar diálogo' })).toBeVisible();
95
+ await user.type(screen.getByRole('textbox', { name: 'Nombre de vista' }), 'My view');
96
+ await user.click(screen.getByRole('button', { name: 'Guardar vista' }));
97
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
98
+ await user.click(screen.getByRole('button', { name: 'My view' }));
99
+ await user.click(await screen.findByRole('menuitem', { name: 'Renombrar vista…' }));
100
+ expect(await screen.findByRole('dialog', { name: 'Cambiar nombre' })).toBeVisible();
101
+ expect(screen.getByRole('button', { name: 'Renombrar' })).toBeVisible();
102
+ await user.click(screen.getByRole('button', { name: 'Cancelar' }));
103
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
104
+ });
105
+
106
+ it('replaces group counts, ungrouped names, empty and loading states, and the nested footer', async () => {
107
+ const selectGroup = vi.fn((name: string) => `Todo en ${name}`);
108
+ const groupRows = vi.fn((count: number) => `filas: ${count}`);
109
+ const loading = vi.fn((caption: string) => `Cargando ${caption}`);
110
+ const extra: Partial<DataTableLabels> = {
111
+ selectGroup,
112
+ groupRows,
113
+ noGroup: 'Sin grupo',
114
+ emptyTitle: 'Sin resultados',
115
+ emptyDescription: 'Cambie los filtros',
116
+ loading,
117
+ rowsPerPage: 'Filas',
118
+ rowsPerPageLabel: 'Por página',
119
+ rangeSummary: (a, b, n) => `${a}/${b}/${n}`,
120
+ paginationNav: (noun) => `Páginas de ${noun}`,
121
+ previous: 'Atrás',
122
+ next: 'Adelante',
123
+ page: (n) => `Ir a ${n}`,
124
+ };
125
+ const { rerender } = render(
126
+ <DataTable rows={rows} columns={columns} groupBy={() => ''} labels={extra} caption="Items" />,
127
+ );
128
+ expect(screen.getByRole('checkbox', { name: 'Todo en Sin grupo' })).toBeVisible();
129
+ expect(screen.getByRole('button', { name: 'Sin grupo 2 filas: 2' })).toBeVisible();
130
+ expect(selectGroup).toHaveBeenCalledWith('Sin grupo');
131
+ expect(groupRows).toHaveBeenCalledWith(2);
132
+ expect(screen.getByRole('navigation', { name: 'Páginas de Items' })).toBeVisible();
133
+ expect(screen.getByRole('combobox', { name: 'Por página' })).toBeVisible();
134
+ expect(screen.getByText('1/2/2')).toBeVisible();
135
+ rerender(<DataTable rows={[]} columns={columns} labels={extra} caption="Items" />);
136
+ expect(screen.getByText('Sin resultados')).toBeVisible();
137
+ expect(screen.getByText('Cambie los filtros')).toBeVisible();
138
+ rerender(<DataTable rows={rows} columns={columns} labels={extra} caption="Items" loading />);
139
+ expect(screen.getByRole('status').textContent).toBe('Cargando Items');
140
+ expect(loading).toHaveBeenCalledWith('Items');
141
+ });
142
+
143
+ it('replaces built-in inline editor names, hints and validation errors', async () => {
144
+ const user = userEvent.setup();
145
+ const editLabel = vi.fn((column: string, row: string) => `${column} de ${row}`);
146
+ const minimumValue = vi.fn((n: number) => `Mínimo ${n}`);
147
+ const maximumValue = vi.fn((n: number) => `Máximo ${n}`);
148
+ const editable = [
149
+ {
150
+ id: 'name',
151
+ header: 'Name',
152
+ accessor: (r: (typeof rows)[number]) => r.name,
153
+ editable: true,
154
+ editor: { type: 'number' as const, min: 1, max: 10 },
155
+ },
156
+ ];
157
+ render(
158
+ <DataTable
159
+ rows={rows}
160
+ columns={editable}
161
+ paginated={false}
162
+ onCellEdit={() => false}
163
+ labels={{
164
+ editHint: 'Pulse Intro para editar',
165
+ editLabel,
166
+ editError: 'No se guardó',
167
+ enterNumber: 'Escriba un número',
168
+ minimumValue,
169
+ maximumValue,
170
+ }}
171
+ />,
172
+ );
173
+ expect(screen.getByRole('button', { name: 'Alpha' })).toHaveAccessibleDescription('Pulse Intro para editar');
174
+ await user.click(screen.getByRole('button', { name: 'Alpha' }));
175
+ const input = screen.getByRole('spinbutton', { name: 'Name de Alpha' });
176
+ expect(editLabel).toHaveBeenCalledWith('Name', 'Alpha');
177
+ await user.clear(input);
178
+ await user.type(input, '0{Enter}');
179
+ expect(await screen.findByRole('alert')).toHaveTextContent('Mínimo 1');
180
+ expect(minimumValue).toHaveBeenCalledWith(1);
181
+ await user.clear(input);
182
+ await user.type(input, '11{Enter}');
183
+ expect(screen.getByRole('alert')).toHaveTextContent('Máximo 10');
184
+ expect(maximumValue).toHaveBeenCalledWith(10);
185
+ await user.clear(input);
186
+ await user.type(input, '5{Enter}');
187
+ expect(await screen.findByText('No se guardó')).toBeVisible();
188
+ });
189
+
190
+ it('keeps provider translations and legacy literals under a partial override', () => {
191
+ const { container, rerender } = render(
192
+ <I18nProvider locale="es">
193
+ <DataTable rows={rows} columns={columns} paginated={false} defaultViews={[]} defaultSelectedIds={['a']} />
194
+ </I18nProvider>,
195
+ );
196
+ const table = container.querySelector('table')?.outerHTML;
197
+ const groupName = screen.getByRole('group').getAttribute('aria-label');
198
+ rerender(
199
+ <I18nProvider locale="es">
200
+ <DataTable
201
+ rows={rows}
202
+ columns={columns}
203
+ paginated={false}
204
+ defaultViews={[]}
205
+ defaultSelectedIds={['a']}
206
+ labels={{ defaultView: 'Custom view' }}
207
+ />
208
+ </I18nProvider>,
209
+ );
210
+ expect(container.querySelector('table')?.outerHTML).toBe(table);
211
+ expect(screen.getByRole('group')).toHaveAttribute('aria-label', groupName);
212
+ expect(screen.getByRole('button', { name: 'Custom view' })).toBeVisible();
213
+ expect(screen.getByText('1 selected')).toBeVisible();
214
+ });
215
+
216
+ it('retains the published text-node boundaries for default selection and loading summaries', () => {
217
+ const { container, rerender } = render(
218
+ <DataTable rows={rows} columns={columns} defaultSelectedIds={['a']} paginated={false} caption="Items" />,
219
+ );
220
+ expect([...container.querySelector('[aria-live]')!.childNodes].map((node) => node.textContent)).toEqual([
221
+ '1',
222
+ ' selected',
223
+ ]);
224
+ rerender(<DataTable rows={rows} columns={columns} loading caption="Items" />);
225
+ expect([...screen.getByRole('status').childNodes].map((node) => node.textContent)).toEqual([
226
+ 'Loading ',
227
+ 'items',
228
+ '…',
229
+ ]);
230
+ });
231
+
232
+ it('replaces every pin option and the single-selection heading without translating caller radio names', async () => {
233
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
234
+ const full: DataTableLabels = {
235
+ ...labels,
236
+ selectGroup: (group) => `Grupo ${group}`,
237
+ groupRows: (count) => `${count} filas`,
238
+ noGroup: 'Sin grupo',
239
+ emptyTitle: 'Vacío',
240
+ emptyDescription: 'Sin coincidencias',
241
+ loading: (caption) => `Cargando ${caption}`,
242
+ editHint: 'Editar',
243
+ editLabel: (column, row) => `${column} de ${row}`,
244
+ editError: 'Error al guardar',
245
+ enterNumber: 'Número requerido',
246
+ minimumValue: (min) => `Mínimo ${min}`,
247
+ maximumValue: (max) => `Máximo ${max}`,
248
+ rowsPerPage: 'Filas por página',
249
+ rowsPerPageLabel: 'Elegir tamaño',
250
+ rangeSummary: (from, to, total) => `${from}|${to}|${total}`,
251
+ paginationNav: (noun) => `Páginas de ${noun}`,
252
+ previous: 'Anterior',
253
+ next: 'Siguiente',
254
+ page: (n) => `Página ${n}`,
255
+ };
256
+ const { rerender } = render(
257
+ <I18nProvider locale="es">
258
+ <DataTable rows={rows} columns={columns} paginated={false} showPinMenu labels={full} />
259
+ </I18nProvider>,
260
+ );
261
+ await user.click(screen.getByRole('button', { name: 'Columnas' }));
262
+ const submenu = await screen.findByRole('menuitem', { name: 'Name' });
263
+ act(() => submenu.focus());
264
+ await user.keyboard('{ArrowRight}');
265
+ for (const name of ['Fijar izquierda', 'Fijar derecha', 'Sin fijar'])
266
+ expect(await screen.findByRole('menuitemradio', { name })).toBeVisible();
267
+ await user.click(screen.getByRole('menuitemradio', { name: 'Fijar izquierda' }));
268
+ await waitFor(() => expect(screen.queryByRole('menu')).toBeNull());
269
+ await waitFor(() => expect(document.querySelector('[data-base-ui-focus-guard]')).toBeNull());
270
+ await user.click(screen.getByRole('button', { name: 'Columnas' }));
271
+ await screen.findByRole('menu');
272
+ expect(await screen.findByRole('menuitem', { name: /Name.*Izquierda/ })).toHaveAccessibleName('NameIzquierda');
273
+ act(() => screen.getByRole('menuitem', { name: /Name.*Izquierda/ }).focus());
274
+ await user.keyboard('{ArrowRight}');
275
+ await user.click(await screen.findByRole('menuitemradio', { name: 'Fijar derecha' }));
276
+ await waitFor(() => expect(screen.queryByRole('menu')).toBeNull());
277
+ await waitFor(() => expect(document.querySelector('[data-base-ui-focus-guard]')).toBeNull());
278
+ await user.click(screen.getByRole('button', { name: 'Columnas' }));
279
+ expect(await screen.findByRole('menuitem', { name: /Name.*Derecha/ })).toBeVisible();
280
+ await user.keyboard('{Escape}');
281
+ await waitFor(() => expect(screen.queryByRole('menu')).toBeNull());
282
+ await waitFor(() => expect(document.querySelector('[data-base-ui-focus-guard]')).toBeNull());
283
+ rerender(
284
+ <I18nProvider locale="es">
285
+ <DataTable rows={rows} columns={columns} labels={full} selectionMode="single" legend="Items" />
286
+ </I18nProvider>,
287
+ );
288
+ expect(screen.getByRole('columnheader', { name: 'Selección' })).toBeInTheDocument();
289
+ expect(screen.getByRole('radio', { name: 'Alpha' })).toBeVisible();
290
+ expect(screen.queryByRole('radio', { name: 'Seleccionar Alpha' })).toBeNull();
291
+ });
@@ -0,0 +1,59 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { I18nProvider } from '@gntik-ai/ui';
3
+ import { DataTable } from './DataTable';
4
+
5
+ beforeEach(() => {
6
+ // jsdom has no layout. Supply the browser geometry/notification boundary so the
7
+ // real Table overflow detection exposes its keyboard-focusable scroll region.
8
+ vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(800);
9
+ vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(300);
10
+ vi.stubGlobal(
11
+ 'ResizeObserver',
12
+ class {
13
+ constructor(private readonly notify: () => void) {}
14
+ observe() {
15
+ this.notify();
16
+ }
17
+ unobserve() {}
18
+ disconnect() {}
19
+ },
20
+ );
21
+ });
22
+
23
+ afterEach(() => {
24
+ vi.restoreAllMocks();
25
+ vi.unstubAllGlobals();
26
+ });
27
+
28
+ it.each([
29
+ ['en', 'Scrollable table'],
30
+ ['es', 'Tabla desplazable'],
31
+ ] as const)('labels the overflowing region per instance over the %s provider default', (locale, defaultName) => {
32
+ const { rerender } = render(
33
+ <I18nProvider locale={locale}>
34
+ <DataTable paginated={false} />
35
+ </I18nProvider>,
36
+ );
37
+ expect(screen.getByRole('region', { name: defaultName })).toHaveAttribute('tabindex', '0');
38
+ rerender(
39
+ <I18nProvider locale={locale}>
40
+ <DataTable paginated={false} labels={{ scrollRegion: 'Tabla con desplazamiento' }} />
41
+ </I18nProvider>,
42
+ );
43
+ expect(screen.getByRole('region', { name: 'Tabla con desplazamiento' })).toHaveAttribute('tabindex', '0');
44
+ expect(screen.queryByRole('region', { name: defaultName })).toBeNull();
45
+ rerender(
46
+ <I18nProvider locale={locale}>
47
+ <DataTable paginated={false} labels={{ columns: 'Elegir columnas' }} />
48
+ </I18nProvider>,
49
+ );
50
+ expect(screen.getByRole('region', { name: defaultName })).toHaveAttribute('tabindex', '0');
51
+ });
52
+
53
+ it('keeps the caller caption as the virtualized region name', () => {
54
+ render(
55
+ <DataTable paginated={false} virtualize caption="Items" labels={{ scrollRegion: 'Tabla con desplazamiento' }} />,
56
+ );
57
+ expect(screen.getByRole('region', { name: 'Items' })).toHaveAttribute('tabindex', '0');
58
+ expect(screen.queryByRole('region', { name: 'Tabla con desplazamiento' })).toBeNull();
59
+ });
@@ -0,0 +1,173 @@
1
+ import { fireEvent, render, screen, within } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { DataTable, type DataTableProps } from './DataTable';
5
+ import type { DataTableColumn } from './data-table-utils';
6
+
7
+ type Row = { id: number; name: string; region: string };
8
+ const rows: Row[] = Array.from({ length: 20 }, (_, i) => ({
9
+ id: 40 - i,
10
+ name: `Item ${40 - i}`,
11
+ region: i % 2 ? 'East' : 'West',
12
+ }));
13
+ const columns: DataTableColumn<Row>[] = [
14
+ { id: 'id', header: 'Id', accessor: (row) => row.id, sortable: true },
15
+ { id: 'name', header: 'Name', accessor: (row) => row.name, sortable: true },
16
+ { id: 'region', header: 'Region', accessor: (row) => row.region, sortable: true },
17
+ ];
18
+ const server = {
19
+ paginationMode: 'server' as const,
20
+ selectionMode: 'none' as const,
21
+ rows,
22
+ columns,
23
+ caption: 'Items',
24
+ page: 2,
25
+ pageSize: 20,
26
+ rowCount: 95,
27
+ pageSizeOptions: [10, 20, 50],
28
+ };
29
+ const renderedIds = () =>
30
+ screen
31
+ .getAllByRole('row')
32
+ .slice(1)
33
+ .map((row) => within(row).getAllByRole('cell')[0]!.textContent);
34
+ const summary = () => document.querySelector('[aria-live="polite"]');
35
+
36
+ // @ts-expect-error Server pagination needs a known total.
37
+ const missingTotal: DataTableProps<Row> = { paginationMode: 'server', page: 1, pageSize: 20 };
38
+ // @ts-expect-error Server pagination cannot be combined with single selection.
39
+ const singleServer: DataTableProps<Row> = { ...server, selectionMode: 'single', paginated: false };
40
+ void missingTotal;
41
+ void singleServer;
42
+
43
+ describe('DataTable controlled server pagination', () => {
44
+ it('renders the supplied page in order and counts the full result set', () => {
45
+ render(<DataTable {...server} />);
46
+ expect(renderedIds()).toEqual([
47
+ '40',
48
+ '39',
49
+ '38',
50
+ '37',
51
+ '36',
52
+ '35',
53
+ '34',
54
+ '33',
55
+ '32',
56
+ '31',
57
+ '30',
58
+ '29',
59
+ '28',
60
+ '27',
61
+ '26',
62
+ '25',
63
+ '24',
64
+ '23',
65
+ '22',
66
+ '21',
67
+ ]);
68
+ expect(summary()).toHaveTextContent('21–40 of 95');
69
+ expect(screen.getByRole('button', { name: 'Page 2', current: 'page' })).toBeInTheDocument();
70
+ expect(screen.getByRole('button', { name: 'Page 5' })).toBeInTheDocument();
71
+ expect(screen.queryByRole('button', { name: 'Page 6' })).not.toBeInTheDocument();
72
+ });
73
+
74
+ it('requests 1-based pages once and waits for consumer updates', async () => {
75
+ const user = userEvent.setup();
76
+ const onPageChange = vi.fn();
77
+ const { rerender } = render(<DataTable {...server} onPageChange={onPageChange} />);
78
+ await user.click(screen.getByRole('button', { name: 'Next page' }));
79
+ expect(onPageChange.mock.calls).toEqual([[3]]);
80
+ await user.click(screen.getByRole('button', { name: 'Previous page' }));
81
+ expect(onPageChange.mock.calls).toEqual([[3], [1]]);
82
+ expect(screen.getByRole('button', { name: 'Page 2', current: 'page' })).toBeInTheDocument();
83
+ expect(summary()).toHaveTextContent('21–40 of 95');
84
+ expect(renderedIds()).toHaveLength(20);
85
+ rerender(<DataTable {...server} page={1} onPageChange={onPageChange} />);
86
+ expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled();
87
+ rerender(<DataTable {...server} page={5} rows={rows.slice(0, 15)} onPageChange={onPageChange} />);
88
+ expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled();
89
+ expect(summary()).toHaveTextContent('81–95 of 95');
90
+ expect(renderedIds()).toHaveLength(15);
91
+ expect(onPageChange.mock.calls).toEqual([[3], [1]]);
92
+ });
93
+
94
+ it('requests a new size and only the footer keep-first-row page, without slicing', async () => {
95
+ const user = userEvent.setup();
96
+ const onPageChange = vi.fn();
97
+ const onPageSizeChange = vi.fn();
98
+ render(<DataTable {...server} onPageChange={onPageChange} onPageSizeChange={onPageSizeChange} />);
99
+ await user.click(screen.getByRole('combobox', { name: 'Rows per page' }));
100
+ await user.click(screen.getByRole('option', { name: '50' }));
101
+ expect(onPageSizeChange.mock.calls).toEqual([[50]]);
102
+ expect(onPageChange.mock.calls).toEqual([[1]]);
103
+ expect(renderedIds()).toHaveLength(20);
104
+ expect(summary()).toHaveTextContent('21–40 of 95');
105
+ });
106
+
107
+ it.each([false, true])('has inert headers even with controlled sort and resizable=%s', async (resizable) => {
108
+ const user = userEvent.setup();
109
+ const onSortChange = vi.fn();
110
+ render(
111
+ <DataTable
112
+ {...server}
113
+ resizable={resizable}
114
+ sort={{ columnId: 'id', direction: 'ascending' }}
115
+ onSortChange={onSortChange}
116
+ />,
117
+ );
118
+ const before = renderedIds();
119
+ for (const header of screen.getAllByRole('columnheader')) {
120
+ expect(header).not.toHaveAttribute('aria-sort');
121
+ expect(within(header).queryByRole('button')).not.toBeInTheDocument();
122
+ expect(header.querySelector('svg')).toBeNull();
123
+ await user.click(header);
124
+ fireEvent.keyDown(header, { key: 'Enter' });
125
+ }
126
+ expect(renderedIds()).toEqual(before);
127
+ expect(onSortChange).not.toHaveBeenCalled();
128
+ });
129
+
130
+ it('applying a sorted saved view does not request a sort or page', async () => {
131
+ const user = userEvent.setup();
132
+ const onSortChange = vi.fn();
133
+ const onPageChange = vi.fn();
134
+ render(
135
+ <DataTable
136
+ {...server}
137
+ defaultViews={[{ id: 'saved', name: 'Saved', state: { sort: { columnId: 'id', direction: 'ascending' } } }]}
138
+ onSortChange={onSortChange}
139
+ onPageChange={onPageChange}
140
+ />,
141
+ );
142
+ const before = renderedIds();
143
+ await user.click(screen.getByRole('button', { name: 'Default view' }));
144
+ await user.click(await screen.findByRole('menuitemradio', { name: 'Saved' }));
145
+ expect(renderedIds()).toEqual(before);
146
+ expect(onPageChange).not.toHaveBeenCalled();
147
+ expect(onSortChange).not.toHaveBeenCalled();
148
+ expect(summary()).toHaveTextContent('21–40 of 95');
149
+ });
150
+
151
+ it.each([1, 3])('keeps an empty server footer at page 1 without callbacks for supplied page %s', (page) => {
152
+ const onPageChange = vi.fn();
153
+ const onPageSizeChange = vi.fn();
154
+ render(
155
+ <DataTable
156
+ {...server}
157
+ rows={[]}
158
+ rowCount={0}
159
+ page={page}
160
+ onPageChange={onPageChange}
161
+ onPageSizeChange={onPageSizeChange}
162
+ />,
163
+ );
164
+ expect(screen.getByText('No results')).toBeInTheDocument();
165
+ expect(summary()).toHaveTextContent('0–0 of 0');
166
+ expect(screen.getByRole('button', { name: 'Page 1', current: 'page' })).toBeInTheDocument();
167
+ expect(screen.queryByRole('button', { name: 'Page 2' })).not.toBeInTheDocument();
168
+ expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled();
169
+ expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled();
170
+ expect(onPageChange).not.toHaveBeenCalled();
171
+ expect(onPageSizeChange).not.toHaveBeenCalled();
172
+ });
173
+ });