@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
@@ -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 => {
@@ -92,6 +93,12 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
92
93
  onSortChange,
93
94
  manualSorting = false,
94
95
  paginated: paginatedProp,
96
+ paginationMode = 'client',
97
+ rowCount,
98
+ page: controlledPage,
99
+ pageSize: controlledPageSize,
100
+ onPageChange,
101
+ onPageSizeChange,
95
102
  defaultPageSize = 10,
96
103
  pageSizeOptions = [10, 25, 50],
97
104
  density: densityProp,
@@ -135,9 +142,11 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
135
142
  onFiltersChange,
136
143
  toolbar,
137
144
  className,
145
+ labels,
138
146
  }: DataTableProps<T>) {
139
147
  const { t } = useI18n();
140
148
  const singleMode = selectionMode === 'single';
149
+ const serverMode = paginationMode === 'server';
141
150
  const selectable = selectionMode === undefined ? selectableProp : selectionMode === 'multiple';
142
151
  const selectionColumn = selectable || singleMode;
143
152
  const paginated = !singleMode && (paginatedProp ?? true);
@@ -196,10 +205,10 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
196
205
  const [innerCollapsed, setInnerCollapsed] = useState<ReadonlySet<string>>(() => new Set(defaultCollapsedGroups));
197
206
  const collapsed: ReadonlySet<string> = collapsedProp ? new Set(collapsedProp) : innerCollapsed;
198
207
 
199
- const sortedRows = manualSorting ? [...rows] : sortRows(rows, columns, sort);
208
+ const sortedRows = serverMode ? rows : manualSorting ? [...rows] : sortRows(rows, columns, sort);
200
209
  const allGroups = effectiveGroupBy ? groupRows(sortedRows, effectiveGroupBy, rows) : null;
201
210
  const sorted = allGroups ? allGroups.flatMap((g) => g.rows) : sortedRows;
202
- const pageRows = paginated ? paginate(sorted, page, pageSize) : sorted;
211
+ const pageRows = serverMode ? rows : paginated ? paginate(sorted, page, pageSize) : sorted;
203
212
  // Rows in collapsed groups are hidden, so select-all leaves them alone.
204
213
  const pageIds = (
205
214
  effectiveGroupBy ? pageRows.filter((row) => !collapsed.has(groupKeyOf(row, effectiveGroupBy))) : pageRows
@@ -212,6 +221,7 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
212
221
  onSelectionChange?.(rows.map(getRowId).filter((id) => next.has(id)));
213
222
  };
214
223
  const changeSort = (columnId: string, direction: SortState['direction']) => {
224
+ if (serverMode) return;
215
225
  const next: SortState = { columnId, direction: sort?.columnId === columnId ? direction : 'ascending' };
216
226
  setInnerSort(next);
217
227
  onSortChange?.(next);
@@ -224,7 +234,7 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
224
234
  setInnerCollapsed(next);
225
235
  onCollapsedGroupsChange?.([...next]);
226
236
  };
227
- const nameOf = (key: string) => key || 'No group';
237
+ const nameOf = (key: string) => key || (labels?.noGroup ?? 'No group');
228
238
  const bodyGroups: Array<DataTableBodyGroup<T>> | undefined =
229
239
  effectiveGroupBy && allGroups
230
240
  ? allGroups
@@ -291,7 +301,7 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
291
301
  const applyState = (state: DataTableViewState) => {
292
302
  const nextSort = state.sort ?? null;
293
303
  setInnerSort(nextSort);
294
- if (nextSort) onSortChange?.(nextSort);
304
+ if (nextSort && !serverMode) onSortChange?.(nextSort);
295
305
  setHiddenList(state.hiddenColumns ?? defaultHidden);
296
306
  setWidths(state.columnWidths ?? defaultColumnWidths);
297
307
  setPinned(state.pinnedColumns ?? defaultPinnedColumns);
@@ -299,7 +309,7 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
299
309
  setGroupOverride(nextGroup === groupName(groupBy) && typeof groupBy !== 'function' ? undefined : nextGroup);
300
310
  if (nextGroup !== currentState.groupBy) onGroupByChange?.(nextGroup);
301
311
  if (state.filters !== filters) onFiltersChange?.(state.filters);
302
- setPage(1);
312
+ if (!serverMode) setPage(1);
303
313
  };
304
314
  const viewsApi = useTableViews({
305
315
  views: viewsProp,
@@ -349,7 +359,11 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
349
359
  <>
350
360
  {selectionSummary && (
351
361
  <span className="text-[12.5px] font-semibold text-foreground" aria-live="polite">
352
- {selectedList.length} selected
362
+ {labels?.selectionCount ? (
363
+ labels.selectionCount(selectedList.length)
364
+ ) : (
365
+ <>{selectedList.length} selected</>
366
+ )}
353
367
  </span>
354
368
  )}
355
369
  {selectionSummary && bulkActions && <span aria-hidden className="h-4 w-px bg-border" />}
@@ -362,6 +376,7 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
362
376
  <div className="flex items-center gap-2">
363
377
  {showViews && (
364
378
  <ViewSwitcher
379
+ labels={labels}
365
380
  views={viewsApi.views}
366
381
  activeId={viewsApi.activeId}
367
382
  dirty={viewsApi.dirty}
@@ -373,9 +388,10 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
373
388
  onReset={viewsApi.reset}
374
389
  />
375
390
  )}
376
- {showDensityToggle && <DensityToggle value={density} onChange={changeDensity} />}
391
+ {showDensityToggle && <DensityToggle labels={labels} value={density} onChange={changeDensity} />}
377
392
  {showColumnMenu && (
378
393
  <ColumnVisibilityMenu
394
+ labels={labels}
379
395
  columns={columns}
380
396
  hidden={hidden}
381
397
  onToggle={toggleColumn}
@@ -388,7 +404,7 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
388
404
  )}
389
405
  {anyEditable && (
390
406
  <span id={editHintId} className="sr-only">
391
- Press Enter or F2 to edit.
407
+ {labels?.editHint ?? 'Press Enter or F2 to edit.'}
392
408
  </span>
393
409
  )}
394
410
  {/* The toggle drives a density context for the table: its rows and the controls inside them. */}
@@ -400,7 +416,7 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
400
416
  aria-rowcount={virtual ? items + 1 : undefined}
401
417
  stickyHeader={virtualOn}
402
418
  containerClassName={virtualOn ? virtualContainerClassName : undefined}
403
- containerLabel={virtualOn ? caption : undefined}
419
+ containerLabel={virtualOn ? caption : labels?.scrollRegion}
404
420
  // Keep single-mode resize handles inside their cells. Overflow at the
405
421
  // final column would add a scroll-container stop before the header.
406
422
  className={cn(
@@ -423,10 +439,10 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
423
439
  )}
424
440
  style={layout.lead.style}
425
441
  >
426
- {singleMode && <span className="sr-only">{t('table.selection')}</span>}
442
+ {singleMode && <span className="sr-only">{labels?.selection ?? t('table.selection')}</span>}
427
443
  {selectable && (
428
444
  <Checkbox
429
- aria-label={t('table.selectAllPage')}
445
+ aria-label={labels?.selectAll ?? t('table.selectAllPage')}
430
446
  checked={header.checked}
431
447
  indeterminate={header.indeterminate}
432
448
  disabled={loading || pageIds.length === 0}
@@ -439,10 +455,11 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
439
455
  const bounds = widthBounds(c);
440
456
  return (
441
457
  <DataTableHeadCell
458
+ labels={labels}
442
459
  key={c.id}
443
- column={c}
460
+ column={serverMode ? { ...c, sortable: false } : c}
444
461
  layout={layout.columns.get(c.id)}
445
- sortDirection={sort?.columnId === c.id ? sort.direction : 'none'}
462
+ sortDirection={!serverMode && sort?.columnId === c.id ? sort.direction : 'none'}
446
463
  onSortChange={(dir) => changeSort(c.id, dir)}
447
464
  resize={
448
465
  resizable && c.resizable !== false
@@ -467,12 +484,13 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
467
484
  )}
468
485
  style={layout.trail.style}
469
486
  >
470
- <span className="sr-only">{t('table.actions')}</span>
487
+ <span className="sr-only">{labels?.actions ?? t('table.actions')}</span>
471
488
  </TableHead>
472
489
  )}
473
490
  </TableRow>
474
491
  </TableHeader>
475
492
  <DataTableBody
493
+ labels={labels}
476
494
  rows={pageRows}
477
495
  columns={visibleColumns}
478
496
  getRowId={getRowId}
@@ -500,17 +518,18 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
500
518
  </SelectionGroup>
501
519
  {loading && (
502
520
  <p role="status" className="sr-only">
503
- Loading {caption.toLowerCase()}…
521
+ {labels?.loading ? labels.loading(caption) : <>Loading {caption.toLowerCase()}…</>}
504
522
  </p>
505
523
  )}
506
- {paginated && !loading && rows.length > 0 && (
524
+ {paginated && !loading && (serverMode || rows.length > 0) && (
507
525
  <PaginationFooter
526
+ labels={labels}
508
527
  className="border-t border-border px-4 py-3"
509
- total={rows.length}
510
- page={page}
511
- onPageChange={setPage}
512
- pageSize={pageSize}
513
- onPageSizeChange={setPageSize}
528
+ total={serverMode ? rowCount : rows.length}
529
+ page={serverMode ? controlledPage : page}
530
+ onPageChange={serverMode ? onPageChange : setPage}
531
+ pageSize={serverMode ? controlledPageSize : pageSize}
532
+ onPageSizeChange={serverMode ? onPageSizeChange : setPageSize}
514
533
  pageSizeOptions={pageSizeOptions}
515
534
  noun={caption}
516
535
  />
@@ -1,4 +1,5 @@
1
1
  import type { BlockMeta } from '../../meta';
2
+ import type { DataTableLabels } from './labels';
2
3
 
3
4
  export const meta: BlockMeta = {
4
5
  name: 'Data table',
@@ -7,8 +8,110 @@ export const meta: BlockMeta = {
7
8
  description:
8
9
  'Generic typed table: sortable headers with aria-sort, row selection with select-all and indeterminate by default (selectionMode="multiple"), selectionMode="none" to hide selection, column visibility menu, density toggle, row actions, loading and empty states, client-side sort and pagination, optional row groups (groupBy) with rowgroup header rows, counts, keyboard-accessible collapse buttons and group selection; column resize (pointer drag or a focusable role="separator" handle with arrow keys, min/max widths, controlled columnWidths), column pinning to either edge with sticky cells (column menu or pinnedColumns), row virtualization for large datasets (aria-rowcount/aria-rowindex, works with selection and grouping), inline cell editing (text, number and select editors; Enter/F2 to edit, Enter/Tab to commit, Escape to cancel, onCellEdit can reject with an inline error) and saved views (filters, sort, visible columns, widths, pinning and grouping) with a view switcher to save as, rename, delete and reset. ' +
9
10
  'Opt-in selectionMode="single" uses native radios in a labelled fieldset (never paginated or virtualized; paginated defaults to false and paginated=true is a type error). Single mode uses selectedId/onSelectedIdChange or defaultSelectedId; isRowDisabled skips unavailable rows and preserves a checked disabled choice without callbacks. Supply legend, aria-label or aria-labelledby for the group and getRowLabel for each radio (otherwise the first column labels it). selectionName is used verbatim and must be unique per page; omitted names use an instance-unique useId name, and duplicate supplied names or missing group labels warn in development. Tab enters once on the checked enabled row or first enabled row; arrows wrap and skip disabled rows, Space selects, Shift+Tab exits, and all-disabled groups have no tab stop. No select-all, group checkboxes, bulk actions or selection summary appear in single mode. ' +
10
- 'Examples in examples/single-selection.tsx: an uncontrolled non-paginated list with legend="Available items" and a controlled versions table with legend="Available versions", selectedId="v2" and a checked disabled version.',
11
+ 'Examples in examples/single-selection.tsx: an uncontrolled non-paginated list with legend="Available items" and a controlled versions table with legend="Available versions", selectedId="v2" and a checked disabled version. ' +
12
+ 'Opt-in paginationMode="server" accepts a controlled 1-based page, pageSize and required rowCount (known total), with onPageChange/onPageSizeChange requests. Supplied rows render in order without client sorting or slicing, and headers have no sort affordance. Single selection cannot use server mode. The footer stays visible for empty results, clamps stale pages for display without callbacks, and hides during loading. Page-size changes may also request a page to keep the first visible row on screen. The consumer owns fetching and reconciles stale pages. See examples/server-pagination.tsx for an items/total/page/pageSize envelope consumer that handles both callbacks.',
11
13
 
14
+ labels: [
15
+ {
16
+ key: 'scrollRegion',
17
+ type: 'string',
18
+ state: 'Non-virtual table overflow: focusable scroll region name; virtualized regions use the caller caption',
19
+ },
20
+ {
21
+ key: 'selectionCount',
22
+ type: '(count: number) => string',
23
+ state: 'Multiple selection: toolbar count and polite live region',
24
+ },
25
+ { key: 'columns', type: 'string', state: 'Column visibility trigger text and name' },
26
+ { key: 'visibleColumns', type: 'string', state: 'Open column menu: visibility heading' },
27
+ { key: 'pinColumns', type: 'string', state: 'Open column menu with pinning: pin heading' },
28
+ { key: 'pinnedLeft', type: 'string', state: 'Open pin submenu trigger: left side suffix' },
29
+ { key: 'pinnedRight', type: 'string', state: 'Open pin submenu trigger: right side suffix' },
30
+ { key: 'pinLeft', type: 'string', state: 'Open pin submenu: left option' },
31
+ { key: 'pinRight', type: 'string', state: 'Open pin submenu: right option' },
32
+ { key: 'unpinned', type: 'string', state: 'Open pin submenu: unpinned option' },
33
+ { key: 'density', type: 'string', state: 'Density group accessible name' },
34
+ { key: 'comfortable', type: 'string', state: 'Comfortable density button name' },
35
+ { key: 'compact', type: 'string', state: 'Compact density button name' },
36
+ {
37
+ key: 'resizeColumn',
38
+ type: '(label: string) => string',
39
+ state: 'Resizable header: separator name; label is caller column header',
40
+ },
41
+ {
42
+ key: 'columnWidth',
43
+ type: '(width: number) => string',
44
+ state: 'Resizable header: separator aria-valuetext in pixels',
45
+ },
46
+ { key: 'defaultView', type: 'string', state: 'View trigger when no active view' },
47
+ { key: 'defaultViewOption', type: 'string', state: 'Open views menu: default option' },
48
+ { key: 'modified', type: 'string', state: 'Dirty view trigger: screen reader suffix' },
49
+ { key: 'views', type: 'string', state: 'Open views menu: heading' },
50
+ { key: 'saveChanges', type: 'string', state: 'Open views menu: save current view action' },
51
+ { key: 'saveAs', type: 'string', state: 'Open views menu: save as action' },
52
+ { key: 'renameView', type: 'string', state: 'Open views menu: rename action' },
53
+ { key: 'deleteView', type: 'string', state: 'Open views menu: immediate delete action; no confirmation dialog' },
54
+ { key: 'resetChanges', type: 'string', state: 'Open views menu: reset action' },
55
+ { key: 'saveViewTitle', type: 'string', state: 'Save as dialog: title and accessible name' },
56
+ { key: 'renameViewTitle', type: 'string', state: 'Rename dialog: title and accessible name' },
57
+ { key: 'viewName', type: 'string', state: 'Save as and Rename dialogs: input label' },
58
+ { key: 'cancel', type: 'string', state: 'Save as and Rename dialogs: cancel button' },
59
+ { key: 'saveView', type: 'string', state: 'Save as dialog: submit button' },
60
+ { key: 'rename', type: 'string', state: 'Rename dialog: submit button' },
61
+ { key: 'closeDialog', type: 'string', state: 'Save as and Rename dialogs: close button name' },
62
+ { key: 'selection', type: 'string', state: 'Single selection: screen reader column heading' },
63
+ { key: 'selectAll', type: 'string', state: 'Multiple selection: header checkbox name' },
64
+ {
65
+ key: 'selectRow',
66
+ type: '(label: string) => string',
67
+ state: 'Multiple selection: row checkbox name; label is caller row name',
68
+ },
69
+ { key: 'selectGroup', type: '(group: string) => string', state: 'Grouped multiple selection: group checkbox name' },
70
+ {
71
+ key: 'groupRows',
72
+ type: '(count: number) => string',
73
+ state: 'Grouped header: screen reader row-count unit; numeric count stays unchanged',
74
+ },
75
+ { key: 'noGroup', type: 'string', state: 'Grouped header: fallback when group key is empty' },
76
+ { key: 'actions', type: 'string', state: 'Row actions present: screen reader column heading' },
77
+ {
78
+ key: 'rowActions',
79
+ type: '(label: string) => string',
80
+ state: 'Row action menu trigger name; label is caller row name',
81
+ },
82
+ { key: 'emptyTitle', type: 'string', state: 'Empty rows: default empty-state title' },
83
+ { key: 'emptyDescription', type: 'string', state: 'Empty rows: default empty-state description' },
84
+ {
85
+ key: 'loading',
86
+ type: '(caption: string) => string',
87
+ state: 'Loading: status announcement; receives original caller caption',
88
+ },
89
+ { key: 'editHint', type: 'string', state: 'Editable table: shared keyboard instruction description' },
90
+ { key: 'editLabel', type: '(column: string, row: string) => string', state: 'Editing cell: input accessible name' },
91
+ { key: 'editError', type: 'string', state: 'Rejected cell edit without caller error message: alert' },
92
+ { key: 'enterNumber', type: 'string', state: 'Editing number: invalid numeric value alert' },
93
+ { key: 'minimumValue', type: '(min: number) => string', state: 'Editing number below minimum: alert' },
94
+ { key: 'maximumValue', type: '(max: number) => string', state: 'Editing number above maximum: alert' },
95
+ { key: 'rowsPerPage', type: 'string', state: 'Always: visible page-size text' },
96
+ { key: 'rowsPerPageLabel', type: 'string', state: 'Always: page-size Select accessible name' },
97
+ {
98
+ key: 'rangeSummary',
99
+ type: '(from: number, to: number, total: number) => string',
100
+ state: 'Every page and page-size change: visible summary and polite live region',
101
+ },
102
+ {
103
+ key: 'paginationNav',
104
+ type: '(noun: string) => string',
105
+ state: 'Always: navigation accessible name; noun is the caller caption',
106
+ },
107
+ { key: 'previous', type: 'string', state: 'Always: previous-page button name' },
108
+ { key: 'next', type: 'string', state: 'Always: next-page button name' },
109
+ {
110
+ key: 'page',
111
+ type: '(page: number) => string',
112
+ state: 'Always: numbered-page button name; number stays caller content',
113
+ },
114
+ ] satisfies Array<{ key: keyof DataTableLabels; type: string; state: string }>,
12
115
  uses: [
13
116
  'Table',
14
117
  'Checkbox',
@@ -15,6 +15,7 @@ import {
15
15
  MenuTrigger,
16
16
  useI18n,
17
17
  } from '@gntik-ai/ui';
18
+ import type { DataTableLabels } from './labels';
18
19
  import type { PinSide } from './column-sizing';
19
20
  import type { DataTableColumn } from './data-table-utils';
20
21
 
@@ -23,6 +24,7 @@ export interface ColumnVisibilityMenuProps<T> {
23
24
  hidden: ReadonlySet<string>;
24
25
  onToggle: (columnId: string, visible: boolean) => void;
25
26
  label?: string;
27
+ labels?: Partial<DataTableLabels>;
26
28
  /** Pin side of a column; with `onPin`, the menu gets a "Pin columns" section. */
27
29
  pinOf?: (columnId: string) => PinSide | null;
28
30
  onPin?: (columnId: string, side: PinSide | null) => void;
@@ -39,11 +41,12 @@ export function ColumnVisibilityMenu<T>({
39
41
  hidden,
40
42
  onToggle,
41
43
  label: labelProp,
44
+ labels,
42
45
  pinOf,
43
46
  onPin,
44
47
  }: ColumnVisibilityMenuProps<T>) {
45
48
  const { t } = useI18n();
46
- const label = labelProp ?? t('table.columns');
49
+ const label = labels?.columns ?? labelProp ?? t('table.columns');
47
50
  const visibleCount = columns.filter((c) => !hidden.has(c.id)).length;
48
51
  const pinnable = onPin && pinOf ? columns.filter((c) => c.pinnable !== false && !hidden.has(c.id)) : [];
49
52
  return (
@@ -53,7 +56,7 @@ export function ColumnVisibilityMenu<T>({
53
56
  </MenuTrigger>
54
57
  <MenuContent align="end" className="min-w-[200px]">
55
58
  <MenuGroup>
56
- <MenuGroupLabel>{t('table.visibleColumns')}</MenuGroupLabel>
59
+ <MenuGroupLabel>{labels?.visibleColumns ?? t('table.visibleColumns')}</MenuGroupLabel>
57
60
  {columns
58
61
  .filter((c) => c.hideable !== false)
59
62
  .map((c) => {
@@ -74,7 +77,7 @@ export function ColumnVisibilityMenu<T>({
74
77
  <>
75
78
  <MenuSeparator />
76
79
  <MenuGroup>
77
- <MenuGroupLabel>Pin columns</MenuGroupLabel>
80
+ <MenuGroupLabel>{labels?.pinColumns ?? 'Pin columns'}</MenuGroupLabel>
78
81
  {pinnable.map((c) => {
79
82
  const side = pinOf(c.id);
80
83
  return (
@@ -83,7 +86,7 @@ export function ColumnVisibilityMenu<T>({
83
86
  {c.header}
84
87
  {side && (
85
88
  <span className="ms-auto ps-3 text-[11.5px] text-muted-foreground">
86
- {side === 'left' ? 'Left' : 'Right'}
89
+ {side === 'left' ? (labels?.pinnedLeft ?? 'Left') : (labels?.pinnedRight ?? 'Right')}
87
90
  </span>
88
91
  )}
89
92
  </MenuSubTrigger>
@@ -92,9 +95,9 @@ export function ColumnVisibilityMenu<T>({
92
95
  value={side ?? PIN_NONE}
93
96
  onValueChange={(v: unknown) => onPin(c.id, v === 'left' || v === 'right' ? v : null)}
94
97
  >
95
- <MenuRadioItem value="left">Pin left</MenuRadioItem>
96
- <MenuRadioItem value="right">Pin right</MenuRadioItem>
97
- <MenuRadioItem value={PIN_NONE}>Unpinned</MenuRadioItem>
98
+ <MenuRadioItem value="left">{labels?.pinLeft ?? 'Pin left'}</MenuRadioItem>
99
+ <MenuRadioItem value="right">{labels?.pinRight ?? 'Pin right'}</MenuRadioItem>
100
+ <MenuRadioItem value={PIN_NONE}>{labels?.unpinned ?? 'Unpinned'}</MenuRadioItem>
98
101
  </MenuRadioGroup>
99
102
  </MenuSubContent>
100
103
  </MenuSub>
@@ -108,8 +108,8 @@ export function DataTableBody<T>(props: DataTableBodyProps<T>) {
108
108
  <EmptyState
109
109
  icon={Inbox}
110
110
  size="sm"
111
- title={t('common.noResults')}
112
- description={t('table.emptyDescription')}
111
+ title={props.labels?.emptyTitle ?? t('common.noResults')}
112
+ description={props.labels?.emptyDescription ?? t('table.emptyDescription')}
113
113
  />
114
114
  )}
115
115
  </TableCell>
@@ -130,6 +130,7 @@ export function DataTableBody<T>(props: DataTableBodyProps<T>) {
130
130
  <GroupHeaderRow
131
131
  key={`group-${group.key}`}
132
132
  group={group}
133
+ labels={props.labels}
133
134
  open={open}
134
135
  selectable={selectable}
135
136
  selected={selected}
@@ -3,9 +3,12 @@ import type { ReactNode } from 'react';
3
3
  import type { ColumnWidths, PinnedColumns } from './column-sizing';
4
4
  import type { DataTableColumn, DataTableGroupBy, SortState } from './data-table-utils';
5
5
  import type { DataTableCellEditHandler } from './editable-cell';
6
+ import type { DataTableLabels } from './labels';
6
7
  import type { DataTableView } from './views';
7
8
 
8
9
  interface DataTableCommonProps<T> {
10
+ /** Per-key overrides; omitted keys keep existing defaults and provider translations. */
11
+ labels?: Partial<DataTableLabels>;
9
12
  rows?: readonly T[];
10
13
  columns?: ReadonlyArray<DataTableColumn<T>>;
11
14
  /** Stable row id (defaults to `row.id`). */
@@ -136,21 +139,44 @@ interface DataTableCommonProps<T> {
136
139
  className?: string;
137
140
  }
138
141
 
142
+ interface ClientPaginationProps {
143
+ paginationMode?: 'client';
144
+ rowCount?: never;
145
+ page?: never;
146
+ pageSize?: never;
147
+ onPageChange?: never;
148
+ onPageSizeChange?: never;
149
+ }
150
+
151
+ interface ServerPaginationProps {
152
+ /** Rows arrive as one server page: no client sorting or slicing, and no sortable headers. */
153
+ paginationMode: 'server';
154
+ /** Total number of rows across all pages; required even for an empty result. */
155
+ rowCount: number;
156
+ /** Current page, 1-based. Out-of-range pages are clamped for display without callbacks. */
157
+ page: number;
158
+ pageSize: number;
159
+ onPageChange?: (page: number) => void;
160
+ /** The footer may also request a page change to keep the first visible row on screen. */
161
+ onPageSizeChange?: (size: number) => void;
162
+ }
163
+
139
164
  export type DataTableProps<T> = DataTableCommonProps<T> &
140
165
  (
141
- | {
166
+ | ({
142
167
  selectionMode: 'single';
143
168
  paginated?: false;
144
169
  /** Consumer-supplied accessible group label; the kit adds no default. */
145
170
  legend?: ReactNode;
146
171
  'aria-label'?: string;
147
172
  'aria-labelledby'?: string;
148
- }
149
- | {
173
+ paginationMode?: never;
174
+ } & ClientPaginationProps)
175
+ | ({
150
176
  selectionMode?: 'none' | 'multiple';
151
177
  paginated?: boolean;
152
178
  legend?: never;
153
179
  'aria-label'?: never;
154
180
  'aria-labelledby'?: never;
155
- }
181
+ } & (ClientPaginationProps | ServerPaginationProps))
156
182
  );
@@ -10,6 +10,7 @@ import {
10
10
  type DataTableColumn,
11
11
  } from './data-table-utils';
12
12
  import { EditableCell, isCellEditable, type DataTableCellEditHandler } from './editable-cell';
13
+ import type { DataTableLabels } from './labels';
13
14
  import type { SingleSelection } from './single-selection';
14
15
 
15
16
  /** A group as the body renders it: its rows on the current page plus header data. */
@@ -27,6 +28,7 @@ export interface DataTableBodyGroup<T> {
27
28
 
28
29
  /** Row rendering shared by the plain, grouped and virtualized bodies. */
29
30
  export interface RowContext<T> {
31
+ labels?: Partial<DataTableLabels>;
30
32
  columns: ReadonlyArray<DataTableColumn<T>>;
31
33
  getRowId: (row: T) => string;
32
34
  getRowLabel: (row: T) => string;
@@ -49,6 +51,7 @@ export interface DataTableRowProps<T> extends RowContext<T> {
49
51
  }
50
52
 
51
53
  export function DataTableRow<T>({
54
+ labels,
52
55
  row,
53
56
  columns,
54
57
  getRowId,
@@ -83,7 +86,7 @@ export function DataTableRow<T>({
83
86
  </span>
84
87
  ) : (
85
88
  <Checkbox
86
- aria-label={t('table.selectRow', { label })}
89
+ aria-label={labels?.selectRow?.(label) ?? t('table.selectRow', { label })}
87
90
  checked={isSelected}
88
91
  onCheckedChange={() => onToggleRow(id)}
89
92
  />
@@ -110,6 +113,7 @@ export function DataTableRow<T>({
110
113
  >
111
114
  {editable ? (
112
115
  <EditableCell
116
+ labels={labels}
113
117
  row={row}
114
118
  rowId={id}
115
119
  rowLabel={label}
@@ -131,7 +135,12 @@ export function DataTableRow<T>({
131
135
  className={cn('w-12 py-0', layout?.trail.className, tint(layout?.trail))}
132
136
  style={layout?.trail.style}
133
137
  >
134
- <MoreMenu items={rowActions(row)} label={t('table.rowActions', { label })} variant="ghost" size="sm" />
138
+ <MoreMenu
139
+ items={rowActions(row)}
140
+ label={labels?.rowActions?.(label) ?? t('table.rowActions', { label })}
141
+ variant="ghost"
142
+ size="sm"
143
+ />
135
144
  </TableCell>
136
145
  )}
137
146
  </TableRow>
@@ -142,6 +151,7 @@ export const groupHeadClass =
142
151
  'h-9 bg-secondary/50 text-[12.5px] font-semibold tracking-normal normal-case text-foreground';
143
152
 
144
153
  export interface GroupHeaderRowProps<T> {
154
+ labels?: Partial<DataTableLabels>;
145
155
  group: DataTableBodyGroup<T>;
146
156
  open: boolean;
147
157
  selectable: boolean;
@@ -157,6 +167,7 @@ export interface GroupHeaderRowProps<T> {
157
167
 
158
168
  /** The header row of a group: group checkbox, collapse button, label and count. */
159
169
  export function GroupHeaderRow<T>({
170
+ labels,
160
171
  group,
161
172
  open,
162
173
  selectable,
@@ -177,7 +188,7 @@ export function GroupHeaderRow<T>({
177
188
  <span className="rounded-full bg-secondary px-1.5 font-mono text-[11px] font-medium text-muted-foreground tabular-nums">
178
189
  {count}
179
190
  </span>{' '}
180
- <span className="sr-only">{count === 1 ? 'row' : 'rows'}</span>
191
+ <span className="sr-only">{labels?.groupRows?.(count) ?? (count === 1 ? 'row' : 'rows')}</span>
181
192
  </>
182
193
  );
183
194
  return (
@@ -185,7 +196,7 @@ export function GroupHeaderRow<T>({
185
196
  {selectable && (
186
197
  <TableCell className={cn('w-10 pe-0', groupHeadClass)}>
187
198
  <Checkbox
188
- aria-label={t('table.selectRow', { label: `all in ${group.name}` })}
199
+ aria-label={labels?.selectGroup?.(group.name) ?? t('table.selectRow', { label: `all in ${group.name}` })}
189
200
  checked={state.checked}
190
201
  indeterminate={state.indeterminate}
191
202
  disabled={count === 0}
@@ -1,17 +1,20 @@
1
1
  import { Rows2, Rows3 } from '@gntik-ai/icons';
2
2
  import { Toggle, ToggleGroup, type TableDensity, useI18n } from '@gntik-ai/ui';
3
3
 
4
+ import type { DataTableLabels } from './labels';
5
+
4
6
  export interface DensityToggleProps {
7
+ labels?: Partial<DataTableLabels>;
5
8
  value: TableDensity;
6
9
  onChange: (density: TableDensity) => void;
7
10
  }
8
11
 
9
12
  /** Two-icon segmented control: comfortable or compact rows. */
10
- export function DensityToggle({ value, onChange }: DensityToggleProps) {
13
+ export function DensityToggle({ value, onChange, labels }: DensityToggleProps) {
11
14
  const { t } = useI18n();
12
15
  return (
13
16
  <ToggleGroup
14
- aria-label={t('table.density')}
17
+ aria-label={labels?.density ?? t('table.density')}
15
18
  size="sm"
16
19
  value={[value]}
17
20
  onValueChange={(v) => {
@@ -19,10 +22,10 @@ export function DensityToggle({ value, onChange }: DensityToggleProps) {
19
22
  if (next === 'compact' || next === 'comfortable') onChange(next);
20
23
  }}
21
24
  >
22
- <Toggle value="comfortable" iconOnly aria-label={t('table.comfortable')}>
25
+ <Toggle value="comfortable" iconOnly aria-label={labels?.comfortable ?? t('table.comfortable')}>
23
26
  <Rows2 size={15} aria-hidden />
24
27
  </Toggle>
25
- <Toggle value="compact" iconOnly aria-label={t('table.compact')}>
28
+ <Toggle value="compact" iconOnly aria-label={labels?.compact ?? t('table.compact')}>
26
29
  <Rows3 size={15} aria-hidden />
27
30
  </Toggle>
28
31
  </ToggleGroup>