@gntik-ai/blocks 0.5.0 → 0.6.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 (36) hide show
  1. package/dist/index.d.ts +163 -31
  2. package/dist/index.js +663 -157
  3. package/dist/index.js.map +1 -1
  4. package/package.json +1 -1
  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/meta.ts +2 -0
  17. package/src/tables/DataTable/DataTable.labels.test.tsx +291 -0
  18. package/src/tables/DataTable/DataTable.overflow-labels.test.tsx +59 -0
  19. package/src/tables/DataTable/DataTable.tsx +20 -9
  20. package/src/tables/DataTable/block.meta.ts +102 -0
  21. package/src/tables/DataTable/column-menu.tsx +10 -7
  22. package/src/tables/DataTable/data-table-body.tsx +3 -2
  23. package/src/tables/DataTable/data-table-props.ts +3 -0
  24. package/src/tables/DataTable/data-table-row.tsx +15 -4
  25. package/src/tables/DataTable/density-toggle.tsx +7 -4
  26. package/src/tables/DataTable/editable-cell.tsx +21 -8
  27. package/src/tables/DataTable/header-cell.tsx +4 -0
  28. package/src/tables/DataTable/labels.ts +54 -0
  29. package/src/tables/DataTable/resize-handle.tsx +6 -3
  30. package/src/tables/DataTable/views.tsx +21 -14
  31. package/src/tables/FilterBar/FilterBar.labels.test.tsx +69 -0
  32. package/src/tables/FilterBar/FilterBar.tsx +45 -12
  33. package/src/tables/FilterBar/block.meta.ts +22 -0
  34. package/src/tables/PaginationFooter/PaginationFooter.labels.test.tsx +57 -0
  35. package/src/tables/PaginationFooter/PaginationFooter.tsx +31 -7
  36. package/src/tables/PaginationFooter/block.meta.ts +23 -0
@@ -0,0 +1,56 @@
1
+ import { SignUpForm } from '../SignUpForm';
2
+
3
+ /** Ordered text/email fields, kit validation, and values.fields keyed by these ids. */
4
+ export default function CustomFields() {
5
+ return (
6
+ <SignUpForm
7
+ sso={null}
8
+ minPasswordLength={12}
9
+ passwordHelp={(minimum) => `Use at least ${minimum} characters.`}
10
+ fields={[
11
+ {
12
+ id: 'username',
13
+ label: 'Username',
14
+ placeholder: 'Account name',
15
+ help: 'Choose your account name.',
16
+ required: true,
17
+ type: 'text',
18
+ autoComplete: 'username',
19
+ },
20
+ {
21
+ id: 'displayName',
22
+ label: 'Display name',
23
+ placeholder: 'Avery Collins',
24
+ help: 'The name others will see.',
25
+ required: true,
26
+ autoComplete: 'name',
27
+ },
28
+ {
29
+ id: 'email',
30
+ label: 'Email',
31
+ placeholder: 'you@example.com',
32
+ help: 'For account notifications.',
33
+ required: true,
34
+ type: 'email',
35
+ autoComplete: 'email',
36
+ },
37
+ {
38
+ id: 'tenant',
39
+ label: 'Tenant identifier',
40
+ placeholder: 'Organization',
41
+ help: 'Your organization identifier.',
42
+ required: true,
43
+ autoComplete: 'off',
44
+ },
45
+ {
46
+ id: 'workspace',
47
+ label: 'Workspace identifier',
48
+ placeholder: 'Workspace',
49
+ help: 'Optional. Leave empty to choose later.',
50
+ required: false,
51
+ autoComplete: 'off',
52
+ },
53
+ ]}
54
+ />
55
+ );
56
+ }
@@ -0,0 +1,5 @@
1
+ import { SignUpForm } from '../SignUpForm';
2
+
3
+ export default function Error() {
4
+ return <SignUpForm error="The account could not be created. Try again." sso={null} />;
5
+ }
@@ -0,0 +1,6 @@
1
+ import { SignUpForm } from '../SignUpForm';
2
+
3
+ /** Policy loading is separate from loading, which represents submitting an account. */
4
+ export default function Loading() {
5
+ return <SignUpForm policyLoading />;
6
+ }
@@ -0,0 +1,6 @@
1
+ import { SignUpForm } from '../SignUpForm';
2
+
3
+ /** Explicit opt-out; omitting terms continues to require consent. */
4
+ export default function NoTerms() {
5
+ return <SignUpForm terms={false} sso={null} passwordHelp={(minimum) => `Use at least ${minimum} characters.`} />;
6
+ }
@@ -0,0 +1,5 @@
1
+ import { SignUpForm } from '../SignUpForm';
2
+
3
+ export default function Submitting() {
4
+ return <SignUpForm loading sso={null} />;
5
+ }
@@ -0,0 +1,15 @@
1
+ import { Link } from '@gntik-ai/ui';
2
+ import { SignUpForm } from '../SignUpForm';
3
+
4
+ /** No account creation form or submit control while self-service is unavailable. */
5
+ export default function Unavailable() {
6
+ return (
7
+ <SignUpForm
8
+ unavailable={{
9
+ title: 'Registration unavailable',
10
+ message: 'Contact your administrator to request an account.',
11
+ action: <Link href="mailto:admin@example.com">Request access</Link>,
12
+ }}
13
+ />
14
+ );
15
+ }
package/src/auth/index.ts CHANGED
@@ -13,3 +13,4 @@ export { meta as ssoButtonsMeta } from './SsoButtons/block.meta';
13
13
  export { ssoProviders } from './SsoButtons/fixtures';
14
14
  export { AuthHeader, AuthDivider, type AuthHeaderProps } from './shared';
15
15
  export type { AuthIdentifierConfig, AuthValidationMessages, AuthFormAccessibilityProps } from './shared';
16
+ export type { AuthSignUpField, AuthUnavailableState, AuthSignUpValidationMessages } from './shared';
@@ -111,3 +111,37 @@ export function authIdentifierError(value: string, config?: AuthIdentifierConfig
111
111
  return messages?.identifierInvalid ?? fallback;
112
112
  return null;
113
113
  }
114
+
115
+ /** Ordered text/email fields collected before the sign-up password. Ids must be unique. */
116
+ export interface AuthSignUpField {
117
+ id: string;
118
+ label: string;
119
+ placeholder?: string;
120
+ help?: ReactNode;
121
+ required?: boolean;
122
+ type?: 'text' | 'email';
123
+ autoComplete?: string;
124
+ /** Consumer copy for kit-owned validation. */
125
+ messages?: { required?: string; invalid?: string };
126
+ }
127
+
128
+ export function authSignUpFieldError(value: string, field: AuthSignUpField) {
129
+ const trimmed = value.trim();
130
+ if (!trimmed) return field.required ? (field.messages?.required ?? `Enter ${field.label}.`) : null;
131
+ if (field.type === 'email' && !EMAIL_RE.test(trimmed))
132
+ return field.messages?.invalid ?? 'Enter a valid email address.';
133
+ return null;
134
+ }
135
+
136
+ /** Consumer-owned copy and optional action when account creation is unavailable. */
137
+ export interface AuthUnavailableState {
138
+ title: ReactNode;
139
+ message: ReactNode;
140
+ action?: ReactNode;
141
+ }
142
+
143
+ /** Kit-owned sign-up validation with consumer-owned copy. */
144
+ export interface AuthSignUpValidationMessages {
145
+ passwordTooShort?: (minimum: number) => string;
146
+ termsRequired?: string;
147
+ }
package/src/meta.ts CHANGED
@@ -20,4 +20,6 @@ export interface BlockMeta {
20
20
  description: string;
21
21
  /** @gntik-ai/ui (and other kit) components it composes, for docs and agents. */
22
22
  uses: string[];
23
+ /** Optional per-instance copy contract, listed in docs and kit metadata. */
24
+ labels?: Array<{ key: string; type: string; state: string }>;
23
25
  }
@@ -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
+ });
@@ -48,6 +48,7 @@ import { useControllable, useTableViews } from './use-table-views';
48
48
  import { useVirtualRows } from './use-virtual-rows';
49
49
  import { ViewSwitcher, type DataTableViewState } from './views';
50
50
 
51
+ export type { DataTableLabels } from './labels';
51
52
  export type { DataTableProps } from './data-table-props';
52
53
 
53
54
  const defaultRowId = (row: unknown): string => {
@@ -135,6 +136,7 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
135
136
  onFiltersChange,
136
137
  toolbar,
137
138
  className,
139
+ labels,
138
140
  }: DataTableProps<T>) {
139
141
  const { t } = useI18n();
140
142
  const singleMode = selectionMode === 'single';
@@ -224,7 +226,7 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
224
226
  setInnerCollapsed(next);
225
227
  onCollapsedGroupsChange?.([...next]);
226
228
  };
227
- const nameOf = (key: string) => key || 'No group';
229
+ const nameOf = (key: string) => key || (labels?.noGroup ?? 'No group');
228
230
  const bodyGroups: Array<DataTableBodyGroup<T>> | undefined =
229
231
  effectiveGroupBy && allGroups
230
232
  ? allGroups
@@ -349,7 +351,11 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
349
351
  <>
350
352
  {selectionSummary && (
351
353
  <span className="text-[12.5px] font-semibold text-foreground" aria-live="polite">
352
- {selectedList.length} selected
354
+ {labels?.selectionCount ? (
355
+ labels.selectionCount(selectedList.length)
356
+ ) : (
357
+ <>{selectedList.length} selected</>
358
+ )}
353
359
  </span>
354
360
  )}
355
361
  {selectionSummary && bulkActions && <span aria-hidden className="h-4 w-px bg-border" />}
@@ -362,6 +368,7 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
362
368
  <div className="flex items-center gap-2">
363
369
  {showViews && (
364
370
  <ViewSwitcher
371
+ labels={labels}
365
372
  views={viewsApi.views}
366
373
  activeId={viewsApi.activeId}
367
374
  dirty={viewsApi.dirty}
@@ -373,9 +380,10 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
373
380
  onReset={viewsApi.reset}
374
381
  />
375
382
  )}
376
- {showDensityToggle && <DensityToggle value={density} onChange={changeDensity} />}
383
+ {showDensityToggle && <DensityToggle labels={labels} value={density} onChange={changeDensity} />}
377
384
  {showColumnMenu && (
378
385
  <ColumnVisibilityMenu
386
+ labels={labels}
379
387
  columns={columns}
380
388
  hidden={hidden}
381
389
  onToggle={toggleColumn}
@@ -388,7 +396,7 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
388
396
  )}
389
397
  {anyEditable && (
390
398
  <span id={editHintId} className="sr-only">
391
- Press Enter or F2 to edit.
399
+ {labels?.editHint ?? 'Press Enter or F2 to edit.'}
392
400
  </span>
393
401
  )}
394
402
  {/* The toggle drives a density context for the table: its rows and the controls inside them. */}
@@ -400,7 +408,7 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
400
408
  aria-rowcount={virtual ? items + 1 : undefined}
401
409
  stickyHeader={virtualOn}
402
410
  containerClassName={virtualOn ? virtualContainerClassName : undefined}
403
- containerLabel={virtualOn ? caption : undefined}
411
+ containerLabel={virtualOn ? caption : labels?.scrollRegion}
404
412
  // Keep single-mode resize handles inside their cells. Overflow at the
405
413
  // final column would add a scroll-container stop before the header.
406
414
  className={cn(
@@ -423,10 +431,10 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
423
431
  )}
424
432
  style={layout.lead.style}
425
433
  >
426
- {singleMode && <span className="sr-only">{t('table.selection')}</span>}
434
+ {singleMode && <span className="sr-only">{labels?.selection ?? t('table.selection')}</span>}
427
435
  {selectable && (
428
436
  <Checkbox
429
- aria-label={t('table.selectAllPage')}
437
+ aria-label={labels?.selectAll ?? t('table.selectAllPage')}
430
438
  checked={header.checked}
431
439
  indeterminate={header.indeterminate}
432
440
  disabled={loading || pageIds.length === 0}
@@ -439,6 +447,7 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
439
447
  const bounds = widthBounds(c);
440
448
  return (
441
449
  <DataTableHeadCell
450
+ labels={labels}
442
451
  key={c.id}
443
452
  column={c}
444
453
  layout={layout.columns.get(c.id)}
@@ -467,12 +476,13 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
467
476
  )}
468
477
  style={layout.trail.style}
469
478
  >
470
- <span className="sr-only">{t('table.actions')}</span>
479
+ <span className="sr-only">{labels?.actions ?? t('table.actions')}</span>
471
480
  </TableHead>
472
481
  )}
473
482
  </TableRow>
474
483
  </TableHeader>
475
484
  <DataTableBody
485
+ labels={labels}
476
486
  rows={pageRows}
477
487
  columns={visibleColumns}
478
488
  getRowId={getRowId}
@@ -500,11 +510,12 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
500
510
  </SelectionGroup>
501
511
  {loading && (
502
512
  <p role="status" className="sr-only">
503
- Loading {caption.toLowerCase()}…
513
+ {labels?.loading ? labels.loading(caption) : <>Loading {caption.toLowerCase()}…</>}
504
514
  </p>
505
515
  )}
506
516
  {paginated && !loading && rows.length > 0 && (
507
517
  <PaginationFooter
518
+ labels={labels}
508
519
  className="border-t border-border px-4 py-3"
509
520
  total={rows.length}
510
521
  page={page}