@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
@@ -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',
@@ -9,6 +10,107 @@ export const meta: BlockMeta = {
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
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.',
11
12
 
13
+ labels: [
14
+ {
15
+ key: 'scrollRegion',
16
+ type: 'string',
17
+ state: 'Non-virtual table overflow: focusable scroll region name; virtualized regions use the caller caption',
18
+ },
19
+ {
20
+ key: 'selectionCount',
21
+ type: '(count: number) => string',
22
+ state: 'Multiple selection: toolbar count and polite live region',
23
+ },
24
+ { key: 'columns', type: 'string', state: 'Column visibility trigger text and name' },
25
+ { key: 'visibleColumns', type: 'string', state: 'Open column menu: visibility heading' },
26
+ { key: 'pinColumns', type: 'string', state: 'Open column menu with pinning: pin heading' },
27
+ { key: 'pinnedLeft', type: 'string', state: 'Open pin submenu trigger: left side suffix' },
28
+ { key: 'pinnedRight', type: 'string', state: 'Open pin submenu trigger: right side suffix' },
29
+ { key: 'pinLeft', type: 'string', state: 'Open pin submenu: left option' },
30
+ { key: 'pinRight', type: 'string', state: 'Open pin submenu: right option' },
31
+ { key: 'unpinned', type: 'string', state: 'Open pin submenu: unpinned option' },
32
+ { key: 'density', type: 'string', state: 'Density group accessible name' },
33
+ { key: 'comfortable', type: 'string', state: 'Comfortable density button name' },
34
+ { key: 'compact', type: 'string', state: 'Compact density button name' },
35
+ {
36
+ key: 'resizeColumn',
37
+ type: '(label: string) => string',
38
+ state: 'Resizable header: separator name; label is caller column header',
39
+ },
40
+ {
41
+ key: 'columnWidth',
42
+ type: '(width: number) => string',
43
+ state: 'Resizable header: separator aria-valuetext in pixels',
44
+ },
45
+ { key: 'defaultView', type: 'string', state: 'View trigger when no active view' },
46
+ { key: 'defaultViewOption', type: 'string', state: 'Open views menu: default option' },
47
+ { key: 'modified', type: 'string', state: 'Dirty view trigger: screen reader suffix' },
48
+ { key: 'views', type: 'string', state: 'Open views menu: heading' },
49
+ { key: 'saveChanges', type: 'string', state: 'Open views menu: save current view action' },
50
+ { key: 'saveAs', type: 'string', state: 'Open views menu: save as action' },
51
+ { key: 'renameView', type: 'string', state: 'Open views menu: rename action' },
52
+ { key: 'deleteView', type: 'string', state: 'Open views menu: immediate delete action; no confirmation dialog' },
53
+ { key: 'resetChanges', type: 'string', state: 'Open views menu: reset action' },
54
+ { key: 'saveViewTitle', type: 'string', state: 'Save as dialog: title and accessible name' },
55
+ { key: 'renameViewTitle', type: 'string', state: 'Rename dialog: title and accessible name' },
56
+ { key: 'viewName', type: 'string', state: 'Save as and Rename dialogs: input label' },
57
+ { key: 'cancel', type: 'string', state: 'Save as and Rename dialogs: cancel button' },
58
+ { key: 'saveView', type: 'string', state: 'Save as dialog: submit button' },
59
+ { key: 'rename', type: 'string', state: 'Rename dialog: submit button' },
60
+ { key: 'closeDialog', type: 'string', state: 'Save as and Rename dialogs: close button name' },
61
+ { key: 'selection', type: 'string', state: 'Single selection: screen reader column heading' },
62
+ { key: 'selectAll', type: 'string', state: 'Multiple selection: header checkbox name' },
63
+ {
64
+ key: 'selectRow',
65
+ type: '(label: string) => string',
66
+ state: 'Multiple selection: row checkbox name; label is caller row name',
67
+ },
68
+ { key: 'selectGroup', type: '(group: string) => string', state: 'Grouped multiple selection: group checkbox name' },
69
+ {
70
+ key: 'groupRows',
71
+ type: '(count: number) => string',
72
+ state: 'Grouped header: screen reader row-count unit; numeric count stays unchanged',
73
+ },
74
+ { key: 'noGroup', type: 'string', state: 'Grouped header: fallback when group key is empty' },
75
+ { key: 'actions', type: 'string', state: 'Row actions present: screen reader column heading' },
76
+ {
77
+ key: 'rowActions',
78
+ type: '(label: string) => string',
79
+ state: 'Row action menu trigger name; label is caller row name',
80
+ },
81
+ { key: 'emptyTitle', type: 'string', state: 'Empty rows: default empty-state title' },
82
+ { key: 'emptyDescription', type: 'string', state: 'Empty rows: default empty-state description' },
83
+ {
84
+ key: 'loading',
85
+ type: '(caption: string) => string',
86
+ state: 'Loading: status announcement; receives original caller caption',
87
+ },
88
+ { key: 'editHint', type: 'string', state: 'Editable table: shared keyboard instruction description' },
89
+ { key: 'editLabel', type: '(column: string, row: string) => string', state: 'Editing cell: input accessible name' },
90
+ { key: 'editError', type: 'string', state: 'Rejected cell edit without caller error message: alert' },
91
+ { key: 'enterNumber', type: 'string', state: 'Editing number: invalid numeric value alert' },
92
+ { key: 'minimumValue', type: '(min: number) => string', state: 'Editing number below minimum: alert' },
93
+ { key: 'maximumValue', type: '(max: number) => string', state: 'Editing number above maximum: alert' },
94
+ { key: 'rowsPerPage', type: 'string', state: 'Always: visible page-size text' },
95
+ { key: 'rowsPerPageLabel', type: 'string', state: 'Always: page-size Select accessible name' },
96
+ {
97
+ key: 'rangeSummary',
98
+ type: '(from: number, to: number, total: number) => string',
99
+ state: 'Every page and page-size change: visible summary and polite live region',
100
+ },
101
+ {
102
+ key: 'paginationNav',
103
+ type: '(noun: string) => string',
104
+ state: 'Always: navigation accessible name; noun is the caller caption',
105
+ },
106
+ { key: 'previous', type: 'string', state: 'Always: previous-page button name' },
107
+ { key: 'next', type: 'string', state: 'Always: next-page button name' },
108
+ {
109
+ key: 'page',
110
+ type: '(page: number) => string',
111
+ state: 'Always: numbered-page button name; number stays caller content',
112
+ },
113
+ ] satisfies Array<{ key: keyof DataTableLabels; type: string; state: string }>,
12
114
  uses: [
13
115
  'Table',
14
116
  '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`). */
@@ -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>
@@ -9,6 +9,7 @@ import {
9
9
  type KeyboardEvent,
10
10
  type ReactNode,
11
11
  } from 'react';
12
+ import type { DataTableLabels } from './labels';
12
13
  import type { DataTableColumn } from './data-table-utils';
13
14
 
14
15
  /** One committed cell edit, as `onCellEdit` receives it. */
@@ -34,6 +35,7 @@ export type DataTableCellEditHandler<T> = (
34
35
  ) => DataTableCellEditResult | Promise<DataTableCellEditResult>;
35
36
 
36
37
  export interface EditableCellProps<T> {
38
+ labels?: Partial<DataTableLabels>;
37
39
  row: T;
38
40
  rowId: string;
39
41
  rowLabel: string;
@@ -72,7 +74,16 @@ function focusSibling(from: HTMLElement | null, dir: 'next' | 'prev') {
72
74
  * edits); while editing, an input or a select: Enter or Tab commits, Escape cancels. `onCellEdit`
73
75
  * can reject the value with a message, shown inline under the editor.
74
76
  */
75
- export function EditableCell<T>({ row, rowId, rowLabel, column, onCellEdit, hintId, children }: EditableCellProps<T>) {
77
+ export function EditableCell<T>({
78
+ row,
79
+ rowId,
80
+ rowLabel,
81
+ column,
82
+ onCellEdit,
83
+ hintId,
84
+ children,
85
+ labels,
86
+ }: EditableCellProps<T>) {
76
87
  const errorId = useId();
77
88
  const editor = column.editor ?? { type: 'text' as const };
78
89
  const [editing, setEditing] = useState(false);
@@ -116,9 +127,11 @@ export function EditableCell<T>({ row, rowId, rowLabel, column, onCellEdit, hint
116
127
  const text = draft.trim();
117
128
  if (text === '') return { ok: true, value: null };
118
129
  const n = Number(text);
119
- if (!Number.isFinite(n)) return { ok: false, message: 'Enter a number.' };
120
- if (editor.min != null && n < editor.min) return { ok: false, message: `Enter ${editor.min} or more.` };
121
- if (editor.max != null && n > editor.max) return { ok: false, message: `Enter ${editor.max} or less.` };
130
+ if (!Number.isFinite(n)) return { ok: false, message: labels?.enterNumber ?? 'Enter a number.' };
131
+ if (editor.min != null && n < editor.min)
132
+ return { ok: false, message: labels?.minimumValue?.(editor.min) ?? `Enter ${editor.min} or more.` };
133
+ if (editor.max != null && n > editor.max)
134
+ return { ok: false, message: labels?.maximumValue?.(editor.max) ?? `Enter ${editor.max} or less.` };
122
135
  return { ok: true, value: n };
123
136
  };
124
137
 
@@ -146,11 +159,11 @@ export function EditableCell<T>({ row, rowId, rowLabel, column, onCellEdit, hint
146
159
  try {
147
160
  result = await onCellEdit({ row, rowId, columnId: column.id, value: parsed.value, previous });
148
161
  } catch (err) {
149
- result = err instanceof Error && err.message ? err.message : GENERIC_ERROR;
162
+ result = err instanceof Error && err.message ? err.message : (labels?.editError ?? GENERIC_ERROR);
150
163
  }
151
164
  busy.current = false;
152
165
  setPending(false);
153
- if (result === false) result = GENERIC_ERROR;
166
+ if (result === false) result = labels?.editError ?? GENERIC_ERROR;
154
167
  if (typeof result === 'string') {
155
168
  setError(result);
156
169
  if (after) field.current?.focus();
@@ -205,7 +218,7 @@ export function EditableCell<T>({ row, rowId, rowLabel, column, onCellEdit, hint
205
218
  );
206
219
  }
207
220
 
208
- const label = `${column.header} for ${rowLabel}`;
221
+ const label = labels?.editLabel?.(column.header, rowLabel) ?? `${column.header} for ${rowLabel}`;
209
222
  const common = {
210
223
  'aria-label': label,
211
224
  'aria-invalid': error ? true : undefined,
@@ -259,7 +272,7 @@ export function EditableCell<T>({ row, rowId, rowLabel, column, onCellEdit, hint
259
272
  <p
260
273
  id={errorId}
261
274
  role="alert"
262
- className="mt-1 text-start text-[11.5px] font-medium whitespace-normal text-destructive-text"
275
+ className="mt-1 rounded-sm border border-border bg-popover px-1.5 py-1 text-start text-[11.5px] font-medium whitespace-normal text-destructive-text"
263
276
  >
264
277
  {error}
265
278
  </p>
@@ -2,9 +2,11 @@ import { ArrowDown, ArrowUp, ChevronsUpDown } from '@gntik-ai/icons';
2
2
  import { cn, nextSortDirection, TableHead, tableVariants, type SortDirection } from '@gntik-ai/ui';
3
3
  import { stickyTint, type CellLayout } from './column-sizing';
4
4
  import { columnAlign, type DataTableColumn } from './data-table-utils';
5
+ import type { DataTableLabels } from './labels';
5
6
  import { ColumnResizeHandle } from './resize-handle';
6
7
 
7
8
  export interface DataTableHeadCellProps<T> {
9
+ labels?: Partial<DataTableLabels>;
8
10
  column: DataTableColumn<T>;
9
11
  sortDirection: SortDirection;
10
12
  onSortChange: (direction: Exclude<SortDirection, 'none'>) => void;
@@ -23,6 +25,7 @@ export function DataTableHeadCell<T>({
23
25
  onSortChange,
24
26
  layout,
25
27
  resize,
28
+ labels,
26
29
  }: DataTableHeadCellProps<T>) {
27
30
  const align = columnAlign(column);
28
31
  const pinnedClass = layout?.pinned
@@ -75,6 +78,7 @@ export function DataTableHeadCell<T>({
75
78
  )}
76
79
  </span>
77
80
  <ColumnResizeHandle
81
+ labels={labels}
78
82
  label={column.header}
79
83
  width={resize.width}
80
84
  min={resize.min}
@@ -0,0 +1,54 @@
1
+ import type { PaginationFooterLabels } from '../PaginationFooter/PaginationFooter';
2
+
3
+ /** Per-instance table chrome, including the composed PaginationFooter. Caller data stays caller-owned. */
4
+ export interface DataTableLabels extends PaginationFooterLabels {
5
+ /** Non-virtual table overflow region; virtualized regions keep the caller caption. */
6
+ scrollRegion: string;
7
+ selectionCount: (count: number) => string;
8
+ columns: string;
9
+ visibleColumns: string;
10
+ pinColumns: string;
11
+ pinnedLeft: string;
12
+ pinnedRight: string;
13
+ pinLeft: string;
14
+ pinRight: string;
15
+ unpinned: string;
16
+ density: string;
17
+ comfortable: string;
18
+ compact: string;
19
+ resizeColumn: (label: string) => string;
20
+ columnWidth: (width: number) => string;
21
+ defaultView: string;
22
+ defaultViewOption: string;
23
+ modified: string;
24
+ views: string;
25
+ saveChanges: string;
26
+ saveAs: string;
27
+ renameView: string;
28
+ deleteView: string;
29
+ resetChanges: string;
30
+ saveViewTitle: string;
31
+ renameViewTitle: string;
32
+ viewName: string;
33
+ cancel: string;
34
+ saveView: string;
35
+ rename: string;
36
+ closeDialog: string;
37
+ selection: string;
38
+ selectAll: string;
39
+ selectRow: (label: string) => string;
40
+ selectGroup: (group: string) => string;
41
+ groupRows: (count: number) => string;
42
+ noGroup: string;
43
+ actions: string;
44
+ rowActions: (label: string) => string;
45
+ emptyTitle: string;
46
+ emptyDescription: string;
47
+ loading: (caption: string) => string;
48
+ editHint: string;
49
+ editLabel: (column: string, row: string) => string;
50
+ editError: string;
51
+ enterNumber: string;
52
+ minimumValue: (min: number) => string;
53
+ maximumValue: (max: number) => string;
54
+ }
@@ -1,7 +1,10 @@
1
1
  import { cn } from '@gntik-ai/ui';
2
2
  import { useRef, type KeyboardEvent, type PointerEvent } from 'react';
3
3
 
4
+ import type { DataTableLabels } from './labels';
5
+
4
6
  export interface ColumnResizeHandleProps {
7
+ labels?: Partial<DataTableLabels>;
5
8
  /** Column name for the accessible label ("Resize Region column"). */
6
9
  label: string;
7
10
  width: number;
@@ -20,7 +23,7 @@ const BIG_STEP = 50;
20
23
  * aria-valuenow/min/max (the width in px). Drag it with a pointer; from the keyboard,
21
24
  * ArrowLeft/ArrowRight change the width by 10px (Shift: 50px), Home/End jump to the limits.
22
25
  */
23
- export function ColumnResizeHandle({ label, width, min, max, onResize, onResizeEnd }: ColumnResizeHandleProps) {
26
+ export function ColumnResizeHandle({ label, width, min, max, onResize, onResizeEnd, labels }: ColumnResizeHandleProps) {
24
27
  const drag = useRef<{ x: number; width: number; dir: 1 | -1 } | null>(null);
25
28
  const clamp = (w: number) => Math.round(Math.min(max, Math.max(min, w)));
26
29
 
@@ -63,11 +66,11 @@ export function ColumnResizeHandle({ label, width, min, max, onResize, onResizeE
63
66
  role="separator"
64
67
  tabIndex={0}
65
68
  aria-orientation="vertical"
66
- aria-label={`Resize ${label} column`}
69
+ aria-label={labels?.resizeColumn?.(label) ?? `Resize ${label} column`}
67
70
  aria-valuenow={width}
68
71
  aria-valuemin={min}
69
72
  aria-valuemax={max}
70
- aria-valuetext={`${width} pixels`}
73
+ aria-valuetext={labels?.columnWidth?.(width) ?? `${width} pixels`}
71
74
  data-column-resizer=""
72
75
  onPointerDown={onPointerDown}
73
76
  onPointerMove={onPointerMove}
@@ -21,6 +21,7 @@ import {
21
21
  } from '@gntik-ai/ui';
22
22
  import { useId, useState } from 'react';
23
23
  import type { ColumnWidths, PinnedColumns } from './column-sizing';
24
+ import type { DataTableLabels } from './labels';
24
25
  import type { SortState } from './data-table-utils';
25
26
 
26
27
  /** What a saved view captures. Every field is optional: a missing field keeps the table's default. */
@@ -70,6 +71,7 @@ export function newViewId(name: string, views: readonly DataTableView[]): string
70
71
  }
71
72
 
72
73
  export interface ViewSwitcherProps {
74
+ labels?: Partial<DataTableLabels>;
73
75
  views: readonly DataTableView[];
74
76
  activeId: string | null;
75
77
  /** The table differs from the active view (or from the defaults when none is active). */
@@ -86,6 +88,7 @@ const DEFAULT_VALUE = '__default__';
86
88
 
87
89
  /** "Views" menu: switch between saved views, save the current table as a view, rename, delete, reset. */
88
90
  export function ViewSwitcher({
91
+ labels,
89
92
  views,
90
93
  activeId,
91
94
  dirty,
@@ -115,23 +118,23 @@ export function ViewSwitcher({
115
118
  <>
116
119
  <Menu>
117
120
  <MenuTrigger render={<Button variant="secondary" size="sm" icon={Bookmark} trailingIcon={ChevronDown} />}>
118
- <span className="max-w-40 truncate">{active ? active.name : 'Default view'}</span>
121
+ <span className="max-w-40 truncate">{active ? active.name : (labels?.defaultView ?? 'Default view')}</span>
119
122
  {dirty && (
120
123
  <>
121
124
  {' '}
122
125
  <span aria-hidden className="size-1.5 rounded-full bg-primary" />{' '}
123
- <span className="sr-only">(modified)</span>
126
+ <span className="sr-only">{labels?.modified ?? '(modified)'}</span>
124
127
  </>
125
128
  )}
126
129
  </MenuTrigger>
127
130
  <MenuContent align="end" className="min-w-[220px]">
128
131
  <MenuGroup>
129
- <MenuGroupLabel>Views</MenuGroupLabel>
132
+ <MenuGroupLabel>{labels?.views ?? 'Views'}</MenuGroupLabel>
130
133
  <MenuRadioGroup
131
134
  value={activeId ?? DEFAULT_VALUE}
132
135
  onValueChange={(v: unknown) => onSelect(v === DEFAULT_VALUE ? null : String(v))}
133
136
  >
134
- <MenuRadioItem value={DEFAULT_VALUE}>Default view</MenuRadioItem>
137
+ <MenuRadioItem value={DEFAULT_VALUE}>{labels?.defaultViewOption ?? 'Default view'}</MenuRadioItem>
135
138
  {views.map((v) => (
136
139
  <MenuRadioItem key={v.id} value={v.id}>
137
140
  {v.name}
@@ -141,22 +144,22 @@ export function ViewSwitcher({
141
144
  </MenuGroup>
142
145
  <MenuSeparator />
143
146
  <MenuItem disabled={!active || !dirty} onClick={onSave}>
144
- Save changes
147
+ {labels?.saveChanges ?? 'Save changes'}
145
148
  </MenuItem>
146
- <MenuItem onClick={() => open('create')}>Save as new view…</MenuItem>
149
+ <MenuItem onClick={() => open('create')}>{labels?.saveAs ?? 'Save as new view…'}</MenuItem>
147
150
  <MenuItem disabled={!active} onClick={() => open('rename')}>
148
- Rename view…
151
+ {labels?.renameView ?? 'Rename view…'}
149
152
  </MenuItem>
150
153
  <MenuItem disabled={!active} onClick={() => active && onDelete(active.id)}>
151
- Delete view
154
+ {labels?.deleteView ?? 'Delete view'}
152
155
  </MenuItem>
153
156
  <MenuItem disabled={!dirty} onClick={onReset}>
154
- Reset changes
157
+ {labels?.resetChanges ?? 'Reset changes'}
155
158
  </MenuItem>
156
159
  </MenuContent>
157
160
  </Menu>
158
161
  <Dialog open={dialog != null} onOpenChange={(o) => !o && setDialog(null)}>
159
- <DialogContent size="sm">
162
+ <DialogContent size="sm" closeLabel={labels?.closeDialog}>
160
163
  <form
161
164
  onSubmit={(e) => {
162
165
  e.preventDefault();
@@ -164,18 +167,22 @@ export function ViewSwitcher({
164
167
  }}
165
168
  >
166
169
  <DialogHeader>
167
- <DialogTitle>{dialog === 'rename' ? 'Rename view' : 'Save view'}</DialogTitle>
170
+ <DialogTitle>
171
+ {dialog === 'rename'
172
+ ? (labels?.renameViewTitle ?? 'Rename view')
173
+ : (labels?.saveViewTitle ?? 'Save view')}
174
+ </DialogTitle>
168
175
  </DialogHeader>
169
176
  <DialogBody>
170
177
  <label htmlFor={inputId} className="mb-1.5 block text-[12.5px] font-medium text-foreground">
171
- View name
178
+ {labels?.viewName ?? 'View name'}
172
179
  </label>
173
180
  <Input id={inputId} value={name} onChange={(e) => setName(e.target.value)} autoComplete="off" required />
174
181
  </DialogBody>
175
182
  <DialogFooter>
176
- <DialogClose render={<Button variant="ghost" />}>Cancel</DialogClose>
183
+ <DialogClose render={<Button variant="ghost" />}>{labels?.cancel ?? 'Cancel'}</DialogClose>
177
184
  <Button type="submit" disabled={!name.trim()}>
178
- {dialog === 'rename' ? 'Rename' : 'Save view'}
185
+ {dialog === 'rename' ? (labels?.rename ?? 'Rename') : (labels?.saveView ?? 'Save view')}
179
186
  </Button>
180
187
  </DialogFooter>
181
188
  </form>