@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
@@ -0,0 +1,33 @@
1
+ import { useState } from 'react';
2
+ import { LoadMoreFooter } from '../LoadMoreFooter';
3
+ import { LOAD_MORE_RECORDS } from '../fixtures';
4
+
5
+ export default function FlagExample() {
6
+ const [state, setState] = useState({ rows: LOAD_MORE_RECORDS.slice(0, 2), hasMore: true, loading: false, error: '' });
7
+ const [lastAppend, setLastAppend] = useState<{ seq: number; count: number }>();
8
+ const load = async () => {
9
+ setState((previous) => ({ ...previous, loading: true }));
10
+ const batch = await Promise.resolve(LOAD_MORE_RECORDS.slice(2));
11
+ // Same-tick updates are batched: settle loading=false together with the result. Split settles are unsupported.
12
+ setState((previous) => ({ rows: [...previous.rows, ...batch], hasMore: false, loading: false, error: '' }));
13
+ setLastAppend({ seq: 1, count: batch.length });
14
+ };
15
+ return (
16
+ <div className="space-y-4">
17
+ <ul>
18
+ {state.rows.map((row) => (
19
+ <li key={row.id}>{row.name}</li>
20
+ ))}
21
+ </ul>
22
+ <LoadMoreFooter
23
+ hasMore={state.hasMore}
24
+ loading={state.loading}
25
+ error={state.error}
26
+ loadedCount={state.rows.length}
27
+ lastAppend={lastAppend}
28
+ onLoadMore={load}
29
+ exhaustedFocusTarget="root"
30
+ />
31
+ </div>
32
+ );
33
+ }
@@ -0,0 +1,30 @@
1
+ import { useState } from 'react';
2
+ import { LOAD_MORE_RECORDS } from '../fixtures';
3
+
4
+ /** Mock consumer: real consumers normalize cursors and preserve their filters here. */
5
+ export function useMockCursor() {
6
+ const [state, setState] = useState({
7
+ rows: LOAD_MORE_RECORDS.slice(0, 2),
8
+ nextCursor: 'c1' as string | null,
9
+ loading: false,
10
+ error: '',
11
+ lastAppend: undefined as { seq: number; count: number } | undefined,
12
+ });
13
+ const load = async (cursor?: string) => {
14
+ setState((previous) => ({ ...previous, loading: true }));
15
+ try {
16
+ const batch = await Promise.resolve(cursor === 'c1' ? LOAD_MORE_RECORDS.slice(2) : []);
17
+ // Settle loading=false and the result together in one update. Split settles are unsupported.
18
+ setState((previous) => ({
19
+ rows: [...previous.rows, ...batch],
20
+ nextCursor: null,
21
+ loading: false,
22
+ error: '',
23
+ lastAppend: { seq: (previous.lastAppend?.seq ?? 0) + 1, count: batch.length },
24
+ }));
25
+ } catch {
26
+ setState((previous) => ({ ...previous, loading: false, error: 'Could not load more items.' }));
27
+ }
28
+ };
29
+ return { ...state, load };
30
+ }
@@ -0,0 +1,9 @@
1
+ /** Meaningful defaults for the no-props catalogue preview. */
2
+ export const LOAD_MORE_PREVIEW = { nextCursor: 'page-2', loadedCount: 20 } as const;
3
+
4
+ export const LOAD_MORE_RECORDS = [
5
+ { id: 'r1', name: 'Record 1', status: 'Ready' },
6
+ { id: 'r2', name: 'Record 2', status: 'Ready' },
7
+ { id: 'r3', name: 'Record 3', status: 'Ready' },
8
+ { id: 'r4', name: 'Record 4', status: 'Ready' },
9
+ ];
@@ -0,0 +1,56 @@
1
+ import { useAnnounce } from '@gntik-ai/ui';
2
+ import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
3
+
4
+ /** Capture focus before React removes the control, then restore it after the replacement commits. */
5
+ export function useLoadMoreFooter(
6
+ control: 'button' | 'retry' | 'none',
7
+ target: 'summary' | 'root',
8
+ loading: boolean,
9
+ error: string,
10
+ ) {
11
+ const rootRef = useRef<HTMLDivElement>(null);
12
+ const summaryRef = useRef<HTMLParagraphElement>(null);
13
+ const currentControl = useRef<HTMLElement | null>(null);
14
+ const moveFocus = useRef(false);
15
+ const inFlightCursorRef = useRef<string | undefined>(undefined);
16
+ useEffect(() => {
17
+ // A successful settle releases the cursor; later external failures use nextCursor.
18
+ if (!loading && !error) inFlightCursorRef.current = undefined;
19
+ }, [loading, error]);
20
+ const controlRef = useCallback((node: HTMLElement | null) => {
21
+ if (!node && currentControl.current?.contains(document.activeElement)) moveFocus.current = true;
22
+ currentControl.current = node;
23
+ }, []);
24
+
25
+ useLayoutEffect(() => {
26
+ if (!moveFocus.current) return;
27
+ moveFocus.current = false;
28
+ const nextControl = currentControl.current;
29
+ const next = nextControl?.matches('button') ? nextControl : nextControl?.querySelector('button');
30
+ (next ?? (target === 'root' ? rootRef.current : summaryRef.current))?.focus();
31
+ }, [control, target]);
32
+
33
+ return { rootRef, summaryRef, controlRef, inFlightCursorRef };
34
+ }
35
+
36
+ /** Track committed results; mounting and intermediate loading states remain silent. */
37
+ export function useLoadMoreAnnouncements(
38
+ loading: boolean,
39
+ error: string,
40
+ lastAppend: { seq: number; count: number } | undefined,
41
+ loadedAnnouncement: (count: number) => string,
42
+ ) {
43
+ const announce = useAnnounce();
44
+ const previous = useRef({ loading, error });
45
+ const lastSeq = useRef(lastAppend?.seq);
46
+ useEffect(() => {
47
+ const before = previous.current;
48
+ previous.current = { loading, error };
49
+ if (!loading && error && (before.loading || before.error !== error)) announce(error, 'assertive');
50
+ }, [loading, error, announce]);
51
+ useEffect(() => {
52
+ if (!lastAppend || (lastSeq.current !== undefined && lastAppend.seq <= lastSeq.current)) return;
53
+ lastSeq.current = lastAppend.seq;
54
+ if (lastAppend.count > 0) announce(loadedAnnouncement(lastAppend.count), 'polite');
55
+ }, [lastAppend, loadedAnnouncement, announce]);
56
+ }
@@ -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
  };
@@ -35,7 +35,11 @@ export * from './FilterBar/FilterBar';
35
35
  export { meta as filterBarMeta } from './FilterBar/block.meta';
36
36
  export { DEPLOYMENT_DEFAULT_FILTERS, DEPLOYMENT_FILTER_FIELDS, DEPLOYMENT_SAVED_VIEWS } from './FilterBar/fixtures';
37
37
  export * from './PaginationFooter/PaginationFooter';
38
+ export * from './CursorPagerFooter/CursorPagerFooter';
39
+ export { meta as cursorPagerFooterMeta } from './CursorPagerFooter/block.meta';
38
40
  export { meta as paginationFooterMeta } from './PaginationFooter/block.meta';
41
+ export * from './LoadMoreFooter/LoadMoreFooter';
42
+ export { meta as loadMoreFooterMeta } from './LoadMoreFooter/block.meta';
39
43
  export * from './TruncatedCollectionFooter/TruncatedCollectionFooter';
40
44
  export { meta as truncatedCollectionFooterMeta } from './TruncatedCollectionFooter/block.meta';
41
45
  export * from './InlineEditRow/InlineEditRow';