@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.
- package/dist/index.d.ts +163 -31
- package/dist/index.js +663 -157
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/auth/SignUpForm/SignUpForm.configured.test.tsx +316 -0
- package/src/auth/SignUpForm/SignUpForm.tsx +194 -50
- package/src/auth/SignUpForm/block.meta.ts +15 -2
- package/src/auth/SignUpForm/examples/CustomFields.tsx +56 -0
- package/src/auth/SignUpForm/examples/Error.tsx +5 -0
- package/src/auth/SignUpForm/examples/Loading.tsx +6 -0
- package/src/auth/SignUpForm/examples/NoTerms.tsx +6 -0
- package/src/auth/SignUpForm/examples/Submitting.tsx +5 -0
- package/src/auth/SignUpForm/examples/Unavailable.tsx +15 -0
- package/src/auth/index.ts +1 -0
- package/src/auth/shared.tsx +34 -0
- package/src/meta.ts +2 -0
- package/src/tables/DataTable/DataTable.labels.test.tsx +291 -0
- package/src/tables/DataTable/DataTable.overflow-labels.test.tsx +59 -0
- package/src/tables/DataTable/DataTable.tsx +20 -9
- package/src/tables/DataTable/block.meta.ts +102 -0
- package/src/tables/DataTable/column-menu.tsx +10 -7
- package/src/tables/DataTable/data-table-body.tsx +3 -2
- package/src/tables/DataTable/data-table-props.ts +3 -0
- package/src/tables/DataTable/data-table-row.tsx +15 -4
- package/src/tables/DataTable/density-toggle.tsx +7 -4
- package/src/tables/DataTable/editable-cell.tsx +21 -8
- package/src/tables/DataTable/header-cell.tsx +4 -0
- package/src/tables/DataTable/labels.ts +54 -0
- package/src/tables/DataTable/resize-handle.tsx +6 -3
- package/src/tables/DataTable/views.tsx +21 -14
- package/src/tables/FilterBar/FilterBar.labels.test.tsx +69 -0
- package/src/tables/FilterBar/FilterBar.tsx +45 -12
- package/src/tables/FilterBar/block.meta.ts +22 -0
- package/src/tables/PaginationFooter/PaginationFooter.labels.test.tsx +57 -0
- package/src/tables/PaginationFooter/PaginationFooter.tsx +31 -7
- 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
|
|
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>({
|
|
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)
|
|
121
|
-
|
|
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
|
|
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>
|
|
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>
|