@gntik-ai/blocks 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/index.d.ts +337 -97
  2. package/dist/index.js +1311 -384
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/auth/SignUpForm/SignUpForm.configured.test.tsx +316 -0
  6. package/src/auth/SignUpForm/SignUpForm.tsx +194 -50
  7. package/src/auth/SignUpForm/block.meta.ts +15 -2
  8. package/src/auth/SignUpForm/examples/CustomFields.tsx +56 -0
  9. package/src/auth/SignUpForm/examples/Error.tsx +5 -0
  10. package/src/auth/SignUpForm/examples/Loading.tsx +6 -0
  11. package/src/auth/SignUpForm/examples/NoTerms.tsx +6 -0
  12. package/src/auth/SignUpForm/examples/Submitting.tsx +5 -0
  13. package/src/auth/SignUpForm/examples/Unavailable.tsx +15 -0
  14. package/src/auth/index.ts +1 -0
  15. package/src/auth/shared.tsx +34 -0
  16. package/src/feedback/ConfirmDestructive/ConfirmDestructive.examples.test.tsx +25 -0
  17. package/src/feedback/ConfirmDestructive/ConfirmDestructive.extended.test.tsx +252 -0
  18. package/src/feedback/ConfirmDestructive/ConfirmDestructive.focus.test.tsx +178 -0
  19. package/src/feedback/ConfirmDestructive/ConfirmDestructive.tsx +219 -28
  20. package/src/feedback/ConfirmDestructive/block.meta.ts +2 -2
  21. package/src/feedback/ConfirmDestructive/examples/FailureStates.tsx +39 -0
  22. package/src/feedback/ConfirmDestructive/examples/PlainDeletion.tsx +6 -0
  23. package/src/feedback/ConfirmDestructive/examples/SecretRevocation.tsx +34 -0
  24. package/src/feedback/EmptyStates/EmptyStates.test.tsx +11 -1
  25. package/src/feedback/EmptyStates/EmptyStates.tsx +31 -1
  26. package/src/feedback/EmptyStates/block.meta.ts +1 -1
  27. package/src/feedback/EmptyStates/fixtures.ts +5 -0
  28. package/src/feedback/ErrorPanel/ErrorPanel.test.tsx +7 -0
  29. package/src/feedback/ErrorPanel/ErrorPanel.tsx +8 -3
  30. package/src/meta.ts +2 -0
  31. package/src/tables/CursorPagerFooter/CursorPagerFooter.examples.test.tsx +33 -0
  32. package/src/tables/CursorPagerFooter/CursorPagerFooter.test.tsx +51 -0
  33. package/src/tables/CursorPagerFooter/CursorPagerFooter.tsx +10 -0
  34. package/src/tables/CursorPagerFooter/block.meta.ts +23 -0
  35. package/src/tables/CursorPagerFooter/examples/Busy.tsx +7 -0
  36. package/src/tables/CursorPagerFooter/examples/CursorStack.tsx +40 -0
  37. package/src/tables/CursorPagerFooter/examples/Hidden.tsx +16 -0
  38. package/src/tables/CursorPagerFooter/examples/RowsPerPage.tsx +21 -0
  39. package/src/tables/CursorPagerFooter/fixtures.ts +13 -0
  40. package/src/tables/DataTable/DataTable.labels.test.tsx +291 -0
  41. package/src/tables/DataTable/DataTable.overflow-labels.test.tsx +59 -0
  42. package/src/tables/DataTable/DataTable.server-pagination.test.tsx +173 -0
  43. package/src/tables/DataTable/DataTable.tsx +40 -21
  44. package/src/tables/DataTable/block.meta.ts +104 -1
  45. package/src/tables/DataTable/column-menu.tsx +10 -7
  46. package/src/tables/DataTable/data-table-body.tsx +3 -2
  47. package/src/tables/DataTable/data-table-props.ts +30 -4
  48. package/src/tables/DataTable/data-table-row.tsx +15 -4
  49. package/src/tables/DataTable/density-toggle.tsx +7 -4
  50. package/src/tables/DataTable/editable-cell.tsx +21 -8
  51. package/src/tables/DataTable/examples/server-pagination.tsx +68 -0
  52. package/src/tables/DataTable/header-cell.tsx +4 -0
  53. package/src/tables/DataTable/labels.ts +54 -0
  54. package/src/tables/DataTable/resize-handle.tsx +6 -3
  55. package/src/tables/DataTable/views.tsx +21 -14
  56. package/src/tables/FilterBar/FilterBar.labels.test.tsx +69 -0
  57. package/src/tables/FilterBar/FilterBar.tsx +45 -12
  58. package/src/tables/FilterBar/block.meta.ts +22 -0
  59. package/src/tables/LoadMoreFooter/LoadMoreFooter.announcements.test.tsx +106 -0
  60. package/src/tables/LoadMoreFooter/LoadMoreFooter.examples.test.tsx +36 -0
  61. package/src/tables/LoadMoreFooter/LoadMoreFooter.labels.test.tsx +71 -0
  62. package/src/tables/LoadMoreFooter/LoadMoreFooter.test.tsx +184 -0
  63. package/src/tables/LoadMoreFooter/LoadMoreFooter.tsx +151 -0
  64. package/src/tables/LoadMoreFooter/block.meta.ts +28 -0
  65. package/src/tables/LoadMoreFooter/examples/cards.tsx +22 -0
  66. package/src/tables/LoadMoreFooter/examples/cursor.tsx +25 -0
  67. package/src/tables/LoadMoreFooter/examples/exhausted.tsx +6 -0
  68. package/src/tables/LoadMoreFooter/examples/failed.tsx +13 -0
  69. package/src/tables/LoadMoreFooter/examples/flag.tsx +33 -0
  70. package/src/tables/LoadMoreFooter/examples/useMockCursor.ts +30 -0
  71. package/src/tables/LoadMoreFooter/fixtures.ts +9 -0
  72. package/src/tables/LoadMoreFooter/useLoadMoreFooter.ts +56 -0
  73. package/src/tables/PaginationFooter/PaginationFooter.labels.test.tsx +57 -0
  74. package/src/tables/PaginationFooter/PaginationFooter.tsx +31 -7
  75. package/src/tables/PaginationFooter/block.meta.ts +23 -0
  76. package/src/tables/index.ts +4 -0
@@ -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>
@@ -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…</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>
@@ -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 = 'Search by name…',
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 role="search" aria-label={t('filters.label')} className={cn('flex flex-col gap-3', className)}>
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} />}>{t('filters.add')}</MenuTrigger>
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
- <span className="font-mono text-foreground tabular-nums">{resultCount.toLocaleString('en-US')}</span>{' '}
169
- {resultCount === 1 ? 'result' : 'results'}
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={t('filters.remove', { field: labelOf(f.field), value: f.value })}
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
  };