@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.
- package/dist/index.d.ts +337 -97
- package/dist/index.js +1311 -384
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- 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/feedback/ConfirmDestructive/ConfirmDestructive.examples.test.tsx +25 -0
- package/src/feedback/ConfirmDestructive/ConfirmDestructive.extended.test.tsx +252 -0
- package/src/feedback/ConfirmDestructive/ConfirmDestructive.focus.test.tsx +178 -0
- package/src/feedback/ConfirmDestructive/ConfirmDestructive.tsx +219 -28
- package/src/feedback/ConfirmDestructive/block.meta.ts +2 -2
- package/src/feedback/ConfirmDestructive/examples/FailureStates.tsx +39 -0
- package/src/feedback/ConfirmDestructive/examples/PlainDeletion.tsx +6 -0
- package/src/feedback/ConfirmDestructive/examples/SecretRevocation.tsx +34 -0
- package/src/feedback/EmptyStates/EmptyStates.test.tsx +11 -1
- package/src/feedback/EmptyStates/EmptyStates.tsx +31 -1
- package/src/feedback/EmptyStates/block.meta.ts +1 -1
- package/src/feedback/EmptyStates/fixtures.ts +5 -0
- package/src/feedback/ErrorPanel/ErrorPanel.test.tsx +7 -0
- package/src/feedback/ErrorPanel/ErrorPanel.tsx +8 -3
- package/src/meta.ts +2 -0
- package/src/tables/CursorPagerFooter/CursorPagerFooter.examples.test.tsx +33 -0
- package/src/tables/CursorPagerFooter/CursorPagerFooter.test.tsx +51 -0
- package/src/tables/CursorPagerFooter/CursorPagerFooter.tsx +10 -0
- package/src/tables/CursorPagerFooter/block.meta.ts +23 -0
- package/src/tables/CursorPagerFooter/examples/Busy.tsx +7 -0
- package/src/tables/CursorPagerFooter/examples/CursorStack.tsx +40 -0
- package/src/tables/CursorPagerFooter/examples/Hidden.tsx +16 -0
- package/src/tables/CursorPagerFooter/examples/RowsPerPage.tsx +21 -0
- package/src/tables/CursorPagerFooter/fixtures.ts +13 -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.server-pagination.test.tsx +173 -0
- package/src/tables/DataTable/DataTable.tsx +40 -21
- package/src/tables/DataTable/block.meta.ts +104 -1
- 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 +30 -4
- 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/examples/server-pagination.tsx +68 -0
- 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/LoadMoreFooter/LoadMoreFooter.announcements.test.tsx +106 -0
- package/src/tables/LoadMoreFooter/LoadMoreFooter.examples.test.tsx +36 -0
- package/src/tables/LoadMoreFooter/LoadMoreFooter.labels.test.tsx +71 -0
- package/src/tables/LoadMoreFooter/LoadMoreFooter.test.tsx +184 -0
- package/src/tables/LoadMoreFooter/LoadMoreFooter.tsx +151 -0
- package/src/tables/LoadMoreFooter/block.meta.ts +28 -0
- package/src/tables/LoadMoreFooter/examples/cards.tsx +22 -0
- package/src/tables/LoadMoreFooter/examples/cursor.tsx +25 -0
- package/src/tables/LoadMoreFooter/examples/exhausted.tsx +6 -0
- package/src/tables/LoadMoreFooter/examples/failed.tsx +13 -0
- package/src/tables/LoadMoreFooter/examples/flag.tsx +33 -0
- package/src/tables/LoadMoreFooter/examples/useMockCursor.ts +30 -0
- package/src/tables/LoadMoreFooter/fixtures.ts +9 -0
- package/src/tables/LoadMoreFooter/useLoadMoreFooter.ts +56 -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
- package/src/tables/index.ts +4 -0
|
@@ -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>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { DataTable } from '../DataTable';
|
|
3
|
+
|
|
4
|
+
interface Item {
|
|
5
|
+
id: number;
|
|
6
|
+
name: string;
|
|
7
|
+
}
|
|
8
|
+
interface Envelope {
|
|
9
|
+
items: Item[];
|
|
10
|
+
total: number;
|
|
11
|
+
page: number;
|
|
12
|
+
pageSize: number;
|
|
13
|
+
}
|
|
14
|
+
const columns = [
|
|
15
|
+
{ id: 'id', header: 'Id', accessor: (row: Item) => row.id },
|
|
16
|
+
{ id: 'name', header: 'Name', accessor: (row: Item) => row.name },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
// Demo endpoint adapter. Replace with the consumer's API returning this envelope.
|
|
20
|
+
function readPage(page: number, pageSize: number): Envelope {
|
|
21
|
+
const total = 95;
|
|
22
|
+
const from = (page - 1) * pageSize;
|
|
23
|
+
return {
|
|
24
|
+
items: Array.from({ length: Math.max(0, Math.min(pageSize, total - from)) }, (_, i) => ({
|
|
25
|
+
id: from + i + 1,
|
|
26
|
+
name: `Item ${from + i + 1}`,
|
|
27
|
+
})),
|
|
28
|
+
total,
|
|
29
|
+
page,
|
|
30
|
+
pageSize,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
async function requestPage(page: number, pageSize: number): Promise<Envelope> {
|
|
34
|
+
return readPage(page, pageSize);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Known-total, 1-based pages. The consumer owns requests and keeps the old envelope until they resolve. */
|
|
38
|
+
export default function ServerPaginationExample() {
|
|
39
|
+
const [envelope, setEnvelope] = useState(() => readPage(2, 20));
|
|
40
|
+
const [request, setRequest] = useState({ page: 2, pageSize: 20 });
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
let active = true;
|
|
43
|
+
void requestPage(request.page, request.pageSize).then((next) => {
|
|
44
|
+
if (active) setEnvelope(next);
|
|
45
|
+
});
|
|
46
|
+
return () => {
|
|
47
|
+
active = false;
|
|
48
|
+
};
|
|
49
|
+
}, [request]);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<DataTable
|
|
53
|
+
caption="Items"
|
|
54
|
+
selectionMode="none"
|
|
55
|
+
columns={columns}
|
|
56
|
+
rows={envelope.items}
|
|
57
|
+
paginationMode="server"
|
|
58
|
+
rowCount={envelope.total}
|
|
59
|
+
page={envelope.page}
|
|
60
|
+
pageSize={envelope.pageSize}
|
|
61
|
+
pageSizeOptions={[10, 20, 50]}
|
|
62
|
+
onPageChange={(page) => setRequest((previous) => ({ ...previous, page }))}
|
|
63
|
+
// The footer can also call onPageChange to keep the first row visible (20 to 50 at page 2 requests page 1).
|
|
64
|
+
// Functional updates batch both callbacks into one request with the new size and page.
|
|
65
|
+
onPageSizeChange={(pageSize) => setRequest((previous) => ({ ...previous, pageSize }))}
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -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>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { I18nProvider } from '@gntik-ai/ui';
|
|
4
|
+
import { FilterBar, type FilterBarLabels } from './FilterBar';
|
|
5
|
+
|
|
6
|
+
it('replaces filter chrome including removed chips, saved views and the live result count', async () => {
|
|
7
|
+
const user = userEvent.setup();
|
|
8
|
+
const removeFilter = vi.fn((field: string, value: string) => `Quitar ${field}|${value}`);
|
|
9
|
+
const resultCount = vi.fn((count: number) => `Encontrados: ${count}`);
|
|
10
|
+
const labels: FilterBarLabels = {
|
|
11
|
+
searchLandmark: 'Filtrar',
|
|
12
|
+
searchLabel: 'Buscar registros',
|
|
13
|
+
searchPlaceholder: 'Escriba…',
|
|
14
|
+
savedViewLabel: 'Vista guardada',
|
|
15
|
+
savedViewPlaceholder: 'Vistas guardadas',
|
|
16
|
+
addFilter: 'Añadir filtro',
|
|
17
|
+
activeFilters: 'Filtros activos',
|
|
18
|
+
filters: 'Filtros',
|
|
19
|
+
clearAll: 'Borrar todo',
|
|
20
|
+
removeFilter,
|
|
21
|
+
resultCount,
|
|
22
|
+
};
|
|
23
|
+
const { container } = render(<FilterBar labels={labels} resultCount={1200} />);
|
|
24
|
+
expect(screen.getByRole('search', { name: 'Filtrar' })).toBeVisible();
|
|
25
|
+
expect(screen.getByRole('searchbox', { name: 'Buscar registros' })).toHaveAttribute('placeholder', 'Escriba…');
|
|
26
|
+
expect(screen.getByRole('combobox', { name: 'Vista guardada' })).toHaveTextContent('Vistas guardadas');
|
|
27
|
+
expect(screen.getByRole('list', { name: 'Filtros activos' })).toBeVisible();
|
|
28
|
+
expect(screen.getByText('Filtros', { exact: true })).toBeVisible();
|
|
29
|
+
expect(container.querySelector('[aria-live]')?.textContent).toBe('Encontrados: 1200');
|
|
30
|
+
expect(resultCount).toHaveBeenCalledWith(1200);
|
|
31
|
+
expect(removeFilter).toHaveBeenCalledWith('Status', 'Running');
|
|
32
|
+
const remove = screen.getByRole('button', { name: 'Quitar Status|Running' });
|
|
33
|
+
remove.focus();
|
|
34
|
+
expect(remove).toHaveFocus();
|
|
35
|
+
await user.keyboard('{Delete}');
|
|
36
|
+
expect(screen.queryByRole('button', { name: 'Quitar Status|Running' })).toBeNull();
|
|
37
|
+
await user.click(screen.getByRole('button', { name: 'Añadir filtro' }));
|
|
38
|
+
await user.click(await screen.findByRole('menuitemcheckbox', { name: 'Failed' }));
|
|
39
|
+
await user.keyboard('{Escape}');
|
|
40
|
+
expect(screen.getByRole('button', { name: 'Quitar Status|Failed' })).toBeVisible();
|
|
41
|
+
await user.click(screen.getByRole('button', { name: 'Borrar todo' }));
|
|
42
|
+
expect(screen.queryByRole('list', { name: 'Filtros activos' })).toBeNull();
|
|
43
|
+
await user.click(screen.getByRole('combobox', { name: 'Vista guardada' }));
|
|
44
|
+
expect(await screen.findByRole('option', { name: 'Needs attention' })).toBeVisible();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('retains legacy props, default markup and provider fallbacks for keys that are omitted', () => {
|
|
48
|
+
const { container, rerender } = render(
|
|
49
|
+
<I18nProvider locale="es">
|
|
50
|
+
<FilterBar resultCount={1200} searchLabel="Find" placeholder="Type" />
|
|
51
|
+
</I18nProvider>,
|
|
52
|
+
);
|
|
53
|
+
const original = container.querySelector('[aria-live]')?.outerHTML;
|
|
54
|
+
const landmark = screen.getByRole('search').getAttribute('aria-label');
|
|
55
|
+
rerender(
|
|
56
|
+
<I18nProvider locale="es">
|
|
57
|
+
<FilterBar
|
|
58
|
+
resultCount={1200}
|
|
59
|
+
searchLabel="Find"
|
|
60
|
+
placeholder="Type"
|
|
61
|
+
labels={{ clearAll: 'Clear', searchLabel: 'Override', searchPlaceholder: 'Override type' }}
|
|
62
|
+
/>
|
|
63
|
+
</I18nProvider>,
|
|
64
|
+
);
|
|
65
|
+
expect(screen.getByRole('searchbox', { name: 'Override' })).toHaveAttribute('placeholder', 'Override type');
|
|
66
|
+
expect(screen.getByRole('search')).toHaveAttribute('aria-label', landmark);
|
|
67
|
+
expect(container.querySelector('[aria-live]')?.outerHTML).toBe(original);
|
|
68
|
+
expect(screen.getByRole('button', { name: 'Clear' })).toBeVisible();
|
|
69
|
+
});
|
|
@@ -35,7 +35,23 @@ export interface SavedView {
|
|
|
35
35
|
filters: readonly ActiveFilter[];
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
export interface FilterBarLabels {
|
|
39
|
+
searchLandmark: string;
|
|
40
|
+
searchLabel: string;
|
|
41
|
+
searchPlaceholder: string;
|
|
42
|
+
savedViewLabel: string;
|
|
43
|
+
savedViewPlaceholder: string;
|
|
44
|
+
addFilter: string;
|
|
45
|
+
activeFilters: string;
|
|
46
|
+
filters: string;
|
|
47
|
+
clearAll: string;
|
|
48
|
+
removeFilter: (field: string, value: string) => string;
|
|
49
|
+
resultCount: (count: number) => string;
|
|
50
|
+
}
|
|
51
|
+
|
|
38
52
|
export interface FilterBarProps {
|
|
53
|
+
/** Per-key overrides take precedence over the legacy search props and provider catalog. */
|
|
54
|
+
labels?: Partial<FilterBarLabels>;
|
|
39
55
|
fields?: readonly FilterField[];
|
|
40
56
|
/** Search text (controlled). */
|
|
41
57
|
query?: string;
|
|
@@ -74,12 +90,14 @@ export function FilterBar({
|
|
|
74
90
|
defaultView = null,
|
|
75
91
|
onViewChange,
|
|
76
92
|
searchLabel: searchLabelProp,
|
|
77
|
-
placeholder
|
|
93
|
+
placeholder: placeholderProp,
|
|
78
94
|
resultCount,
|
|
79
95
|
className,
|
|
96
|
+
labels,
|
|
80
97
|
}: FilterBarProps) {
|
|
81
98
|
const { t } = useI18n();
|
|
82
|
-
const searchLabel = searchLabelProp ?? t('common.search');
|
|
99
|
+
const searchLabel = labels?.searchLabel ?? searchLabelProp ?? t('common.search');
|
|
100
|
+
const placeholder = labels?.searchPlaceholder ?? placeholderProp ?? 'Search by name…';
|
|
83
101
|
const [innerQuery, setInnerQuery] = useState(defaultQuery);
|
|
84
102
|
const [innerFilters, setInnerFilters] = useState<readonly ActiveFilter[]>(defaultFilters);
|
|
85
103
|
const [view, setView] = useState<string | null>(defaultView);
|
|
@@ -113,7 +131,11 @@ export function FilterBar({
|
|
|
113
131
|
const hasAny = filters.length > 0 || query !== '';
|
|
114
132
|
|
|
115
133
|
return (
|
|
116
|
-
<div
|
|
134
|
+
<div
|
|
135
|
+
role="search"
|
|
136
|
+
aria-label={labels?.searchLandmark ?? t('filters.label')}
|
|
137
|
+
className={cn('flex flex-col gap-3', className)}
|
|
138
|
+
>
|
|
117
139
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
118
140
|
<Input
|
|
119
141
|
aria-label={searchLabel}
|
|
@@ -128,9 +150,9 @@ export function FilterBar({
|
|
|
128
150
|
<div className="flex flex-wrap items-center gap-2">
|
|
129
151
|
{views.length > 0 && (
|
|
130
152
|
<SimpleSelect
|
|
131
|
-
aria-label={t('filters.savedView')}
|
|
153
|
+
aria-label={labels?.savedViewLabel ?? t('filters.savedView')}
|
|
132
154
|
size="sm"
|
|
133
|
-
placeholder={t('filters.savedViews')}
|
|
155
|
+
placeholder={labels?.savedViewPlaceholder ?? t('filters.savedViews')}
|
|
134
156
|
value={view}
|
|
135
157
|
onValueChange={applyView}
|
|
136
158
|
items={views.map((v) => ({ value: v.value, label: v.label }))}
|
|
@@ -138,7 +160,9 @@ export function FilterBar({
|
|
|
138
160
|
/>
|
|
139
161
|
)}
|
|
140
162
|
<Menu>
|
|
141
|
-
<MenuTrigger render={<Button variant="secondary" size="sm" icon={Plus} />}>
|
|
163
|
+
<MenuTrigger render={<Button variant="secondary" size="sm" icon={Plus} />}>
|
|
164
|
+
{labels?.addFilter ?? t('filters.add')}
|
|
165
|
+
</MenuTrigger>
|
|
142
166
|
<MenuContent className="max-h-80 min-w-[220px] overflow-y-auto">
|
|
143
167
|
{fields.map((field, i) => (
|
|
144
168
|
<Fragment key={field.id}>
|
|
@@ -165,8 +189,14 @@ export function FilterBar({
|
|
|
165
189
|
</div>
|
|
166
190
|
{resultCount !== undefined && (
|
|
167
191
|
<p className="text-[12.5px] text-muted-foreground sm:ms-auto" aria-live="polite">
|
|
168
|
-
|
|
169
|
-
|
|
192
|
+
{labels?.resultCount ? (
|
|
193
|
+
labels.resultCount(resultCount)
|
|
194
|
+
) : (
|
|
195
|
+
<>
|
|
196
|
+
<span className="font-mono text-foreground tabular-nums">{resultCount.toLocaleString('en-US')}</span>{' '}
|
|
197
|
+
{resultCount === 1 ? 'result' : 'results'}
|
|
198
|
+
</>
|
|
199
|
+
)}
|
|
170
200
|
</p>
|
|
171
201
|
)}
|
|
172
202
|
</div>
|
|
@@ -174,23 +204,26 @@ export function FilterBar({
|
|
|
174
204
|
<div className="flex flex-wrap items-center gap-2">
|
|
175
205
|
<span className="inline-flex items-center gap-1.5 font-mono text-[10px] tracking-[0.14em] text-muted-foreground uppercase">
|
|
176
206
|
<ListFilter size={12} aria-hidden />
|
|
177
|
-
Filters
|
|
207
|
+
{labels?.filters ?? 'Filters'}
|
|
178
208
|
</span>
|
|
179
|
-
<ul aria-label={t('filters.active')} className="flex flex-wrap items-center gap-2">
|
|
209
|
+
<ul aria-label={labels?.activeFilters ?? t('filters.active')} className="flex flex-wrap items-center gap-2">
|
|
180
210
|
{filters.map((f) => (
|
|
181
211
|
<li key={`${f.field}:${f.value}`} className="flex">
|
|
182
212
|
<Token
|
|
183
213
|
size="sm"
|
|
184
214
|
prefix={`${labelOf(f.field)}:`}
|
|
185
215
|
label={f.value}
|
|
186
|
-
removeLabel={
|
|
216
|
+
removeLabel={
|
|
217
|
+
labels?.removeFilter?.(labelOf(f.field), f.value) ??
|
|
218
|
+
t('filters.remove', { field: labelOf(f.field), value: f.value })
|
|
219
|
+
}
|
|
187
220
|
onRemove={() => toggle(f, false)}
|
|
188
221
|
/>
|
|
189
222
|
</li>
|
|
190
223
|
))}
|
|
191
224
|
</ul>
|
|
192
225
|
<Button variant="ghost" size="sm" className="h-7 px-2 hover:text-destructive-text" onClick={clearAll}>
|
|
193
|
-
Clear all
|
|
226
|
+
{labels?.clearAll ?? 'Clear all'}
|
|
194
227
|
</Button>
|
|
195
228
|
</div>
|
|
196
229
|
)}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { BlockMeta } from '../../meta';
|
|
2
|
+
import type { FilterBarLabels } from './FilterBar';
|
|
2
3
|
|
|
3
4
|
export const meta: BlockMeta = {
|
|
4
5
|
name: 'Filter bar',
|
|
@@ -6,5 +7,26 @@ export const meta: BlockMeta = {
|
|
|
6
7
|
status: 'beta',
|
|
7
8
|
description:
|
|
8
9
|
'Search Input, saved views Select, an Add filter Menu with multi-select per field, removable filter chips and Clear all.',
|
|
10
|
+
labels: [
|
|
11
|
+
{ key: 'searchLandmark', type: 'string', state: 'Always: search landmark name' },
|
|
12
|
+
{ key: 'searchLabel', type: 'string', state: 'Always: search input accessible name' },
|
|
13
|
+
{ key: 'searchPlaceholder', type: 'string', state: 'Always: search input placeholder' },
|
|
14
|
+
{ key: 'savedViewLabel', type: 'string', state: 'When views exist: Select accessible name' },
|
|
15
|
+
{ key: 'savedViewPlaceholder', type: 'string', state: 'When no view selected: Select placeholder' },
|
|
16
|
+
{ key: 'addFilter', type: 'string', state: 'Always: Add filter trigger text and name' },
|
|
17
|
+
{ key: 'activeFilters', type: 'string', state: 'When filters or search exist: active-filters list name' },
|
|
18
|
+
{ key: 'filters', type: 'string', state: 'When filters or search exist: chip section heading' },
|
|
19
|
+
{ key: 'clearAll', type: 'string', state: 'When filters or search exist: clear button text and name' },
|
|
20
|
+
{
|
|
21
|
+
key: 'removeFilter',
|
|
22
|
+
type: '(field: string, value: string) => string',
|
|
23
|
+
state: 'When chip exists: remove button name; field and value are caller content',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
key: 'resultCount',
|
|
27
|
+
type: '(count: number) => string',
|
|
28
|
+
state: 'When resultCount supplied: visible count and polite live region',
|
|
29
|
+
},
|
|
30
|
+
] satisfies Array<{ key: keyof FilterBarLabels; type: string; state: string }>,
|
|
9
31
|
uses: ['Input', 'SimpleSelect', 'Menu', 'MenuCheckboxItem', 'Token', 'Button'],
|
|
10
32
|
};
|