@gntik-ai/blocks 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/index.d.ts +163 -31
  2. package/dist/index.js +663 -157
  3. package/dist/index.js.map +1 -1
  4. package/package.json +1 -1
  5. package/src/auth/SignUpForm/SignUpForm.configured.test.tsx +316 -0
  6. package/src/auth/SignUpForm/SignUpForm.tsx +194 -50
  7. package/src/auth/SignUpForm/block.meta.ts +15 -2
  8. package/src/auth/SignUpForm/examples/CustomFields.tsx +56 -0
  9. package/src/auth/SignUpForm/examples/Error.tsx +5 -0
  10. package/src/auth/SignUpForm/examples/Loading.tsx +6 -0
  11. package/src/auth/SignUpForm/examples/NoTerms.tsx +6 -0
  12. package/src/auth/SignUpForm/examples/Submitting.tsx +5 -0
  13. package/src/auth/SignUpForm/examples/Unavailable.tsx +15 -0
  14. package/src/auth/index.ts +1 -0
  15. package/src/auth/shared.tsx +34 -0
  16. package/src/meta.ts +2 -0
  17. package/src/tables/DataTable/DataTable.labels.test.tsx +291 -0
  18. package/src/tables/DataTable/DataTable.overflow-labels.test.tsx +59 -0
  19. package/src/tables/DataTable/DataTable.tsx +20 -9
  20. package/src/tables/DataTable/block.meta.ts +102 -0
  21. package/src/tables/DataTable/column-menu.tsx +10 -7
  22. package/src/tables/DataTable/data-table-body.tsx +3 -2
  23. package/src/tables/DataTable/data-table-props.ts +3 -0
  24. package/src/tables/DataTable/data-table-row.tsx +15 -4
  25. package/src/tables/DataTable/density-toggle.tsx +7 -4
  26. package/src/tables/DataTable/editable-cell.tsx +21 -8
  27. package/src/tables/DataTable/header-cell.tsx +4 -0
  28. package/src/tables/DataTable/labels.ts +54 -0
  29. package/src/tables/DataTable/resize-handle.tsx +6 -3
  30. package/src/tables/DataTable/views.tsx +21 -14
  31. package/src/tables/FilterBar/FilterBar.labels.test.tsx +69 -0
  32. package/src/tables/FilterBar/FilterBar.tsx +45 -12
  33. package/src/tables/FilterBar/block.meta.ts +22 -0
  34. package/src/tables/PaginationFooter/PaginationFooter.labels.test.tsx +57 -0
  35. package/src/tables/PaginationFooter/PaginationFooter.tsx +31 -7
  36. package/src/tables/PaginationFooter/block.meta.ts +23 -0
@@ -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
  };
@@ -0,0 +1,57 @@
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 { PaginationFooter, type PaginationFooterLabels } from './PaginationFooter';
5
+
6
+ it('replaces pagination chrome and renders formatter results verbatim through page and size changes', async () => {
7
+ const user = userEvent.setup();
8
+ const rangeSummary = vi.fn((from: number, to: number, total: number) => `${from}|${to}|${total} registros`);
9
+ const paginationNav = vi.fn((noun: string) => `Paginación: ${noun}`);
10
+ const page = vi.fn((n: number) => `Página ${n}`);
11
+ const labels: PaginationFooterLabels = {
12
+ rowsPerPage: 'Filas por página',
13
+ rowsPerPageLabel: 'Tamaño de página',
14
+ rangeSummary,
15
+ paginationNav,
16
+ previous: 'Anterior',
17
+ next: 'Siguiente',
18
+ page,
19
+ };
20
+ const { container } = render(<PaginationFooter labels={labels} noun="Items" />);
21
+ expect(screen.getByText('Filas por página')).toBeVisible();
22
+ expect(screen.getByRole('navigation', { name: 'Paginación: Items' })).toBeVisible();
23
+ expect(paginationNav).toHaveBeenCalledWith('Items');
24
+ expect(page).toHaveBeenCalledWith(10);
25
+ const summary = container.querySelector('[aria-live]');
26
+ expect(summary?.textContent).toBe('1|10|97 registros');
27
+ expect(rangeSummary).toHaveBeenLastCalledWith(1, 10, 97);
28
+ await user.click(screen.getByRole('button', { name: 'Siguiente' }));
29
+ expect(summary?.textContent).toBe('11|20|97 registros');
30
+ expect(rangeSummary).toHaveBeenLastCalledWith(11, 20, 97);
31
+ await user.click(screen.getByRole('button', { name: 'Anterior' }));
32
+ await user.click(screen.getByRole('button', { name: 'Página 3' }));
33
+ expect(summary?.textContent).toBe('21|30|97 registros');
34
+ await user.click(screen.getByRole('combobox', { name: 'Tamaño de página' }));
35
+ await user.click(await screen.findByRole('option', { name: '25' }));
36
+ expect(summary?.textContent).toBe('1|25|97 registros');
37
+ expect(rangeSummary).toHaveBeenLastCalledWith(1, 25, 97);
38
+ });
39
+
40
+ it('merges individual overrides over provider translations and preserves default summary markup', () => {
41
+ const { container, rerender } = render(
42
+ <I18nProvider locale="es">
43
+ <PaginationFooter />
44
+ </I18nProvider>,
45
+ );
46
+ const original = container.querySelector('[aria-live]')?.outerHTML;
47
+ const nextName = screen.getAllByRole('button').at(-1)?.getAttribute('aria-label');
48
+ rerender(
49
+ <I18nProvider locale="es">
50
+ <PaginationFooter labels={{ rowsPerPage: 'Custom' }} />
51
+ </I18nProvider>,
52
+ );
53
+ expect(screen.getByText('Custom')).toBeVisible();
54
+ expect(container.querySelector('[aria-live]')?.outerHTML).toBe(original);
55
+ expect(screen.getAllByRole('button').at(-1)?.getAttribute('aria-label')).toBe(nextName);
56
+ expect(screen.getByRole('combobox')).not.toHaveAccessibleName('Custom');
57
+ });
@@ -1,7 +1,19 @@
1
1
  import { cn, Pagination, SimpleSelect, useI18n } from '@gntik-ai/ui';
2
2
  import { useState } from 'react';
3
3
 
4
+ export interface PaginationFooterLabels {
5
+ rowsPerPage: string;
6
+ rowsPerPageLabel: string;
7
+ rangeSummary: (from: number, to: number, total: number) => string;
8
+ paginationNav: (noun: string) => string;
9
+ previous: string;
10
+ next: string;
11
+ page: (page: number) => string;
12
+ }
13
+
4
14
  export interface PaginationFooterProps {
15
+ /** Per-key overrides; omitted keys retain the existing provider translations and defaults. */
16
+ labels?: Partial<PaginationFooterLabels>;
5
17
  /** Total number of rows. */
6
18
  total?: number;
7
19
  /** Current page, 1-based (controlled). */
@@ -32,6 +44,7 @@ export function PaginationFooter({
32
44
  noun: nounProp,
33
45
  locale: localeProp,
34
46
  className,
47
+ labels,
35
48
  }: PaginationFooterProps) {
36
49
  const { t, locale: providerLocale } = useI18n();
37
50
  const locale = localeProp ?? providerLocale;
@@ -63,10 +76,10 @@ export function PaginationFooter({
63
76
  <div className={cn('flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between', className)}>
64
77
  <div className="flex items-center gap-2.5">
65
78
  <span className="text-[12.5px] text-muted-foreground" aria-hidden>
66
- {t('table.rowsPerPage')}
79
+ {labels?.rowsPerPage ?? t('table.rowsPerPage')}
67
80
  </span>
68
81
  <SimpleSelect
69
- aria-label={t('table.rowsPerPage')}
82
+ aria-label={labels?.rowsPerPageLabel ?? t('table.rowsPerPage')}
70
83
  size="sm"
71
84
  className="w-[76px] font-mono"
72
85
  value={String(pageSize)}
@@ -76,17 +89,28 @@ export function PaginationFooter({
76
89
  </div>
77
90
  <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
78
91
  <p className="text-[12.5px] text-muted-foreground tabular-nums" aria-live="polite">
79
- <span className="font-mono text-foreground">
80
- {fmt(from)}–{fmt(to)}
81
- </span>{' '}
82
- {t('pagination.of')} <span className="font-mono text-foreground">{fmt(total)}</span>
92
+ {labels?.rangeSummary ? (
93
+ labels.rangeSummary(from, to, total)
94
+ ) : (
95
+ <>
96
+ <span className="font-mono text-foreground">
97
+ {fmt(from)}–{fmt(to)}
98
+ </span>{' '}
99
+ {t('pagination.of')} <span className="font-mono text-foreground">{fmt(total)}</span>
100
+ </>
101
+ )}
83
102
  </p>
84
103
  <Pagination
85
104
  page={page}
86
105
  pageCount={pageCount}
87
106
  onPageChange={goTo}
88
107
  size="sm"
89
- labels={{ nav: t('table.pagination', { noun }) }}
108
+ labels={{
109
+ nav: labels?.paginationNav?.(noun) ?? t('table.pagination', { noun }),
110
+ previous: labels?.previous,
111
+ next: labels?.next,
112
+ page: labels?.page,
113
+ }}
90
114
  />
91
115
  </div>
92
116
  </div>
@@ -1,4 +1,5 @@
1
1
  import type { BlockMeta } from '../../meta';
2
+ import type { PaginationFooterLabels } from './PaginationFooter';
2
3
 
3
4
  export const meta: BlockMeta = {
4
5
  name: 'Pagination footer',
@@ -6,5 +7,27 @@ export const meta: BlockMeta = {
6
7
  status: 'beta',
7
8
  description:
8
9
  'Table footer with a rows-per-page Select, a "1–10 of 97" summary and numbered Pagination; controlled or uncontrolled.',
10
+ labels: [
11
+ { key: 'rowsPerPage', type: 'string', state: 'Always: visible page-size text' },
12
+ { key: 'rowsPerPageLabel', type: 'string', state: 'Always: page-size Select accessible name' },
13
+ {
14
+ key: 'rangeSummary',
15
+ type: '(from: number, to: number, total: number) => string',
16
+ state: 'Every page and page-size change: visible summary and polite live region',
17
+ },
18
+ {
19
+ key: 'paginationNav',
20
+ type: '(noun: string) => string',
21
+ state:
22
+ 'Always: navigation accessible name; noun is caller content or defaults to the provider translation of rows',
23
+ },
24
+ { key: 'previous', type: 'string', state: 'Always: previous-page button name' },
25
+ { key: 'next', type: 'string', state: 'Always: next-page button name' },
26
+ {
27
+ key: 'page',
28
+ type: '(page: number) => string',
29
+ state: 'Always: numbered-page button name; number stays caller content',
30
+ },
31
+ ] satisfies Array<{ key: keyof PaginationFooterLabels; type: string; state: string }>,
9
32
  uses: ['SimpleSelect', 'Pagination'],
10
33
  };