@gntik-ai/blocks 0.6.0 → 0.8.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 (58) hide show
  1. package/dist/index.d.ts +202 -74
  2. package/dist/index.js +849 -283
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/feedback/ConfirmDestructive/ConfirmDestructive.examples.test.tsx +25 -0
  6. package/src/feedback/ConfirmDestructive/ConfirmDestructive.extended.test.tsx +252 -0
  7. package/src/feedback/ConfirmDestructive/ConfirmDestructive.focus.test.tsx +178 -0
  8. package/src/feedback/ConfirmDestructive/ConfirmDestructive.tsx +219 -28
  9. package/src/feedback/ConfirmDestructive/block.meta.ts +2 -2
  10. package/src/feedback/ConfirmDestructive/examples/FailureStates.tsx +39 -0
  11. package/src/feedback/ConfirmDestructive/examples/PlainDeletion.tsx +6 -0
  12. package/src/feedback/ConfirmDestructive/examples/SecretRevocation.tsx +34 -0
  13. package/src/feedback/EmptyStates/EmptyStates.test.tsx +11 -1
  14. package/src/feedback/EmptyStates/EmptyStates.tsx +31 -1
  15. package/src/feedback/EmptyStates/block.meta.ts +1 -1
  16. package/src/feedback/EmptyStates/fixtures.ts +5 -0
  17. package/src/feedback/ErrorPanel/ErrorPanel.test.tsx +7 -0
  18. package/src/feedback/ErrorPanel/ErrorPanel.tsx +8 -3
  19. package/src/forms/CredentialDisclosureDialog/CredentialDisclosureDialog.additional.test.tsx +467 -0
  20. package/src/forms/CredentialDisclosureDialog/CredentialDisclosureDialog.tsx +175 -106
  21. package/src/forms/CredentialDisclosureDialog/IssueOnClick.test.tsx +56 -0
  22. package/src/forms/CredentialDisclosureDialog/SecretCopy.tsx +83 -0
  23. package/src/forms/CredentialDisclosureDialog/block.meta.ts +1 -1
  24. package/src/forms/CredentialDisclosureDialog/examples/Inline.tsx +19 -0
  25. package/src/forms/CredentialDisclosureDialog/examples/IssueOnClick.tsx +87 -0
  26. package/src/forms/CredentialDisclosureDialog/examples/MultiSecret.tsx +29 -0
  27. package/src/forms/CredentialDisclosureDialog/focus.ts +18 -0
  28. package/src/forms/CredentialDisclosureDialog/types.ts +5 -0
  29. package/src/tables/CursorPagerFooter/CursorPagerFooter.examples.test.tsx +33 -0
  30. package/src/tables/CursorPagerFooter/CursorPagerFooter.test.tsx +51 -0
  31. package/src/tables/CursorPagerFooter/CursorPagerFooter.tsx +10 -0
  32. package/src/tables/CursorPagerFooter/block.meta.ts +23 -0
  33. package/src/tables/CursorPagerFooter/examples/Busy.tsx +7 -0
  34. package/src/tables/CursorPagerFooter/examples/CursorStack.tsx +40 -0
  35. package/src/tables/CursorPagerFooter/examples/Hidden.tsx +16 -0
  36. package/src/tables/CursorPagerFooter/examples/RowsPerPage.tsx +21 -0
  37. package/src/tables/CursorPagerFooter/fixtures.ts +13 -0
  38. package/src/tables/DataTable/DataTable.server-pagination.test.tsx +173 -0
  39. package/src/tables/DataTable/DataTable.tsx +20 -12
  40. package/src/tables/DataTable/block.meta.ts +2 -1
  41. package/src/tables/DataTable/data-table-props.ts +27 -4
  42. package/src/tables/DataTable/examples/server-pagination.tsx +68 -0
  43. package/src/tables/LoadMoreFooter/LoadMoreFooter.announcements.test.tsx +106 -0
  44. package/src/tables/LoadMoreFooter/LoadMoreFooter.examples.test.tsx +36 -0
  45. package/src/tables/LoadMoreFooter/LoadMoreFooter.labels.test.tsx +71 -0
  46. package/src/tables/LoadMoreFooter/LoadMoreFooter.test.tsx +184 -0
  47. package/src/tables/LoadMoreFooter/LoadMoreFooter.tsx +151 -0
  48. package/src/tables/LoadMoreFooter/block.meta.ts +28 -0
  49. package/src/tables/LoadMoreFooter/examples/cards.tsx +22 -0
  50. package/src/tables/LoadMoreFooter/examples/cursor.tsx +25 -0
  51. package/src/tables/LoadMoreFooter/examples/exhausted.tsx +6 -0
  52. package/src/tables/LoadMoreFooter/examples/failed.tsx +13 -0
  53. package/src/tables/LoadMoreFooter/examples/flag.tsx +33 -0
  54. package/src/tables/LoadMoreFooter/examples/useMockCursor.ts +30 -0
  55. package/src/tables/LoadMoreFooter/fixtures.ts +9 -0
  56. package/src/tables/LoadMoreFooter/useLoadMoreFooter.ts +56 -0
  57. package/src/tables/index.ts +4 -0
  58. package/src/test/CredentialDisclosureDialog.compatibility.tsx +30 -0
@@ -0,0 +1,106 @@
1
+ import { act, render } from '@testing-library/react';
2
+ import { LiveAnnouncer } from '@gntik-ai/ui';
3
+ import { StrictMode } from 'react';
4
+ import { LoadMoreFooter, type LoadMoreFooterProps } from './LoadMoreFooter';
5
+
6
+ function announcements(initial: LoadMoreFooterProps, outer = false) {
7
+ const wrap = (props: LoadMoreFooterProps) => (
8
+ <StrictMode>
9
+ {outer ? (
10
+ <LiveAnnouncer>
11
+ <LoadMoreFooter {...props} />
12
+ </LiveAnnouncer>
13
+ ) : (
14
+ <LoadMoreFooter {...props} />
15
+ )}
16
+ </StrictMode>
17
+ );
18
+ const { container, rerender } = render(wrap(initial));
19
+ const region = container.querySelector('[aria-live="assertive"]');
20
+ expect(region).not.toBeNull();
21
+ expect(region).toHaveTextContent('');
22
+ const messages: string[] = [];
23
+ const observer = new MutationObserver(() => messages.push(region?.textContent?.trim() ?? ''));
24
+ observer.observe(region!, { childList: true, characterData: true, subtree: true });
25
+ return {
26
+ container,
27
+ messages,
28
+ async update(props: LoadMoreFooterProps) {
29
+ rerender(wrap(props));
30
+ await act(async () => {});
31
+ },
32
+ disconnect: () => observer.disconnect(),
33
+ };
34
+ }
35
+
36
+ describe('LoadMoreFooter announcements', () => {
37
+ it.each([true, false])('announces new appended sequences only, with outer provider=%s', async (outer) => {
38
+ const h = announcements({ hasMore: true, lastAppend: { seq: 1, count: 20 } }, outer);
39
+ expect(h.container.querySelectorAll('[aria-live="polite"]')).toHaveLength(1);
40
+ const polite = h.container.querySelector('[aria-live="polite"]');
41
+ expect(polite?.textContent).toBe('');
42
+ await h.update({ hasMore: true, lastAppend: { seq: 2, count: 3 } });
43
+ expect(polite?.textContent).toBe('3 items added.');
44
+ await h.update({ hasMore: true, lastAppend: { seq: 2, count: 3 } });
45
+ expect(polite?.textContent).toBe('3 items added.');
46
+ await h.update({ hasMore: true, lastAppend: { seq: 3, count: 0 } });
47
+ await h.update({ hasMore: true, lastAppend: { seq: 3, count: 9 } });
48
+ expect(polite?.textContent).toBe('3 items added.');
49
+ await h.update({ hasMore: false, lastAppend: { seq: 4, count: 1 } });
50
+ expect(polite?.textContent).toBe('1 item added.');
51
+ h.disconnect();
52
+ });
53
+
54
+ it('announces a first append when lastAppend was initially absent', async () => {
55
+ const h = announcements({ hasMore: true });
56
+ await h.update({ hasMore: true, lastAppend: { seq: 1, count: 2 } });
57
+ expect(h.container.querySelector('[aria-live="polite"]')?.textContent).toBe('2 items added.');
58
+ h.disconnect();
59
+ });
60
+
61
+ it.each(['cursor', 'flag'])('announces an exhausted error exactly once in %s mode', async (mode) => {
62
+ const props = { nextCursor: mode === 'cursor' ? 'c1' : undefined, hasMore: true };
63
+ const h = announcements(props);
64
+ await h.update({ ...props, loading: true });
65
+ await h.update({ ...props, loading: false, hasMore: false, error: 'E1' });
66
+ await h.update({ ...props, loading: false, hasMore: false, error: 'E1' });
67
+ expect(h.messages).toEqual(['E1']);
68
+ h.disconnect();
69
+ });
70
+
71
+ it('announces settled failures, including the same error again, but not retained errors on success', async () => {
72
+ const h = announcements({ hasMore: true });
73
+ await h.update({ hasMore: true, loading: true });
74
+ await h.update({ hasMore: true, loading: false, error: 'E1' });
75
+ expect(h.messages).toEqual(['E1']);
76
+ await h.update({ hasMore: true, loading: true, error: 'E1' });
77
+ await h.update({ hasMore: true, loading: false, error: '' });
78
+ expect(h.messages).toEqual(['E1']);
79
+ await h.update({ hasMore: true, loading: true, error: 'E1' });
80
+ await h.update({ hasMore: true, loading: false, error: 'E1' });
81
+ expect(h.messages).toEqual(['E1', 'E1']);
82
+ h.disconnect();
83
+ });
84
+
85
+ it('ignores mount errors and changes while loading, then announces the final error on settle', async () => {
86
+ const h = announcements({ nextCursor: 'c1', error: 'E1', loading: true });
87
+ await h.update({ nextCursor: 'c1', error: 'E2', loading: true });
88
+ expect(h.messages).toEqual([]);
89
+ await h.update({ nextCursor: 'c1', error: 'E2', loading: false });
90
+ await h.update({ nextCursor: 'c1', error: 'E2', loading: false });
91
+ expect(h.messages).toEqual(['E2']);
92
+ h.disconnect();
93
+ });
94
+
95
+ it('announces new non-empty idle errors, but not mount or identical values', async () => {
96
+ const h = announcements({ hasMore: true, error: 'mount' });
97
+ await h.update({ hasMore: true, error: 'mount' });
98
+ await h.update({ hasMore: true });
99
+ expect(h.messages).toEqual([]);
100
+ await h.update({ hasMore: true, error: 'E1' });
101
+ await h.update({ hasMore: true, error: 'E1' });
102
+ await h.update({ hasMore: true, error: 'E2' });
103
+ expect(h.messages).toEqual(['E1', 'E2']);
104
+ h.disconnect();
105
+ });
106
+ });
@@ -0,0 +1,36 @@
1
+ import { render, screen, within } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import CursorExample from './examples/cursor';
4
+ import FlagExample from './examples/flag';
5
+ import CardsExample from './examples/cards';
6
+ import ExhaustedExample from './examples/exhausted';
7
+
8
+ describe('LoadMoreFooter consumer examples', () => {
9
+ it('keeps a settled error visible without retry in the exhausted example', () => {
10
+ render(<ExhaustedExample />);
11
+ expect(screen.getByRole('heading', { name: 'Request failed' })).toBeVisible();
12
+ expect(screen.queryByRole('button')).toBeNull();
13
+ expect(screen.getByText(/40 items loaded/)).toBeVisible();
14
+ });
15
+
16
+ it.each([
17
+ ['table', CursorExample],
18
+ ['cards', CardsExample],
19
+ ] as const)('appends the next cursor batch in the %s example', async (_name, Example) => {
20
+ render(<Example />);
21
+ expect(screen.getByText('Record 1')).toBeVisible();
22
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Load more' }));
23
+ expect(await screen.findByText('Record 3')).toBeVisible();
24
+ expect(screen.getByText('Record 1')).toBeVisible();
25
+ const footer = screen.getByRole('group', { name: 'Load more items' });
26
+ expect(within(footer).queryByRole('button')).toBeNull();
27
+ expect(screen.getByText(/4 items loaded/)).toHaveFocus();
28
+ });
29
+
30
+ it('appends a flag-mode batch and uses the root exhaustion target', async () => {
31
+ render(<FlagExample />);
32
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Load more' }));
33
+ expect(await screen.findByText(/4 items loaded/)).toBeVisible();
34
+ expect(screen.getByRole('group')).toHaveFocus();
35
+ });
36
+ });
@@ -0,0 +1,71 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { I18nProvider } from '@gntik-ai/ui';
3
+ import { LoadMoreFooter, type LoadMoreFooterProps } from './LoadMoreFooter';
4
+ import { expectNoAxeViolations } from '../../test/a11y';
5
+
6
+ describe('LoadMoreFooter copy and accessibility', () => {
7
+ it('inherits Spanish defaults, including retry, summary and append announcements', () => {
8
+ const wrap = (props: LoadMoreFooterProps) => (
9
+ <I18nProvider locale="es">
10
+ <LoadMoreFooter {...props} />
11
+ </I18nProvider>
12
+ );
13
+ const { container, rerender } = render(wrap({ hasMore: true, loadedCount: 3 }));
14
+ expect(screen.getByRole('group', { name: 'Cargar más elementos' })).toBeVisible();
15
+ expect(screen.getByRole('button', { name: 'Cargar más' })).toBeVisible();
16
+ expect(screen.getByText(/3 elementos cargados/)).toBeVisible();
17
+ expect(screen.getByText('Hay más elementos disponibles.')).toBeVisible();
18
+ rerender(wrap({ hasMore: true, loading: true }));
19
+ expect(screen.getByRole('button', { name: 'Cargando…' })).toBeVisible();
20
+ rerender(wrap({ hasMore: true, error: 'E1' }));
21
+ expect(screen.getByRole('button', { name: 'Reintentar' })).toBeVisible();
22
+ rerender(wrap({ hasMore: true, lastAppend: { seq: 1, count: 2 } }));
23
+ expect(container.querySelector('[aria-live="polite"]')).toHaveTextContent('2 elementos añadidos.');
24
+ });
25
+
26
+ it('uses every supplied label exactly and preserves empty overrides', () => {
27
+ const labels = {
28
+ loadMore: 'Fetch again',
29
+ loading: 'Fetching records',
30
+ retry: 'Repeat request',
31
+ group: 'Records footer',
32
+ moreAvailable: 'Another batch exists',
33
+ summary: (n: number) => `Seen ${n}`,
34
+ loadedAnnouncement: (n: number) => `Appended ${n}`,
35
+ };
36
+ const wrap = (props: LoadMoreFooterProps) => (
37
+ <I18nProvider locale="es">
38
+ <LoadMoreFooter hasMore labels={labels} {...props} />
39
+ </I18nProvider>
40
+ );
41
+ const { container, rerender } = render(wrap({ loadedCount: 7 }));
42
+ expect(screen.getByRole('group', { name: 'Records footer' })).toBeVisible();
43
+ expect(screen.getByRole('button', { name: 'Fetch again' })).toBeVisible();
44
+ expect(screen.getByText(/Seen 7/)).toBeVisible();
45
+ expect(screen.getByText('Another batch exists')).toBeVisible();
46
+ rerender(wrap({ loading: true }));
47
+ expect(screen.getByRole('button', { name: 'Fetching records' })).toBeVisible();
48
+ rerender(wrap({ error: 'E1' }));
49
+ expect(screen.getByRole('button', { name: 'Repeat request' })).toBeVisible();
50
+ rerender(wrap({ lastAppend: { seq: 1, count: 4 } }));
51
+ expect(container.querySelector('[aria-live="polite"]')?.textContent).toBe('Appended 4');
52
+ rerender(wrap({ labels: { ...labels, moreAvailable: '', summary: () => '' } }));
53
+ expect(screen.queryByText('Another batch exists')).toBeNull();
54
+ expect(screen.queryByText(/elementos cargados/)).toBeNull();
55
+ });
56
+
57
+ for (const theme of ['dark', 'light', 'high_contrast']) {
58
+ it.each([
59
+ { nextCursor: 'c1' },
60
+ { nextCursor: 'c1', loading: true },
61
+ { nextCursor: 'c1', error: 'Request failed' },
62
+ { hasMore: true, error: 'Request failed' },
63
+ { nextCursor: null, error: 'Request failed' },
64
+ { hasMore: false },
65
+ ])(`has no axe violations in ${theme} with %j`, async (props) => {
66
+ document.documentElement.className = theme;
67
+ const { container } = render(<LoadMoreFooter {...props} />);
68
+ await expectNoAxeViolations(container);
69
+ });
70
+ }
71
+ });
@@ -0,0 +1,184 @@
1
+ import userEvent from '@testing-library/user-event';
2
+ import { render, screen, within } from '@testing-library/react';
3
+ import { LoadMoreFooter } from './LoadMoreFooter';
4
+
5
+ describe('LoadMoreFooter availability', () => {
6
+ it.each([
7
+ ['c1', undefined, true],
8
+ ['c1', true, true],
9
+ ['c1', false, false],
10
+ ['', true, false],
11
+ ['', undefined, false],
12
+ [null, true, false],
13
+ [undefined, true, true],
14
+ [undefined, false, false],
15
+ [undefined, undefined, false],
16
+ ] as const)('nextCursor=%s hasMore=%s available=%s', (nextCursor, hasMore, available) => {
17
+ render(<LoadMoreFooter nextCursor={nextCursor} hasMore={hasMore} loadedCount={37} />);
18
+ expect(screen.queryAllByRole('button')).toHaveLength(available ? 1 : 0);
19
+ const root = screen.getByRole('group', { name: 'Load more items' });
20
+ expect(root).toHaveAttribute('tabindex', '-1');
21
+ expect(screen.getByText(/37 items loaded/)).toHaveAttribute('tabindex', '-1');
22
+ expect(root).not.toHaveTextContent(/total|page|of \d/i);
23
+ expect(screen.queryByText('More items available.')).toBe(
24
+ available ? screen.getByText('More items available.') : null,
25
+ );
26
+ });
27
+
28
+ it('renders meaningful English defaults without props', () => {
29
+ render(<LoadMoreFooter />);
30
+ expect(screen.getByRole('button', { name: 'Load more' })).toBeVisible();
31
+ expect(screen.getByText(/20 items loaded/)).toBeVisible();
32
+ });
33
+ });
34
+
35
+ describe('LoadMoreFooter loading', () => {
36
+ it.each(['cursor', 'flag'])('loads exactly once and retains focus while loading in %s mode', async (mode) => {
37
+ const user = userEvent.setup();
38
+ const onLoadMore = vi.fn();
39
+ const props = { nextCursor: mode === 'cursor' ? 'c1' : undefined, hasMore: true, onLoadMore };
40
+ const { rerender } = render(<LoadMoreFooter {...props} />);
41
+ const button = screen.getByRole('button', { name: 'Load more' });
42
+ await user.click(button);
43
+ expect(onLoadMore.mock.calls).toEqual(mode === 'cursor' ? [['c1']] : [[]]);
44
+ rerender(<LoadMoreFooter {...props} loading />);
45
+ expect(button).toHaveFocus();
46
+ expect(button).toHaveAttribute('aria-disabled', 'true');
47
+ expect(button).not.toHaveAttribute('disabled');
48
+ expect(button).toHaveAccessibleName('Loading…');
49
+ expect(button.querySelector('svg')).not.toBeNull();
50
+ expect(screen.getByRole('group')).toHaveAttribute('aria-busy', 'true');
51
+ await user.click(button);
52
+ await user.keyboard('{Enter} ');
53
+ expect(onLoadMore).toHaveBeenCalledTimes(1);
54
+ expect(button).toHaveFocus();
55
+ });
56
+ });
57
+
58
+ describe('LoadMoreFooter failures and focus', () => {
59
+ it.each([true, false])(
60
+ 'retries the recorded cursor and returns focus after success (onRetry=%s)',
61
+ async (explicitRetry) => {
62
+ const user = userEvent.setup();
63
+ const onLoadMore = vi.fn();
64
+ const onRetry = vi.fn();
65
+ const props = { nextCursor: 'c1', onLoadMore, onRetry: explicitRetry ? onRetry : undefined };
66
+ const { rerender } = render(<LoadMoreFooter {...props} />);
67
+ await user.click(screen.getByRole('button'));
68
+ rerender(<LoadMoreFooter {...props} loading />);
69
+ rerender(<LoadMoreFooter {...props} nextCursor="c2" error="Request failed" />);
70
+ const retry = screen.getByRole('button', { name: 'Try again' });
71
+ expect(retry).toHaveFocus();
72
+ expect(screen.getByRole('heading', { name: 'Request failed' })).toBeVisible();
73
+ expect(screen.queryByText(/503|req_|Contact support|Details|Your data/)).toBeNull();
74
+ await user.click(retry);
75
+ expect((explicitRetry ? onRetry : onLoadMore).mock.calls).toEqual(explicitRetry ? [['c1']] : [['c1'], ['c1']]);
76
+ rerender(<LoadMoreFooter {...props} error="Request failed" loading />);
77
+ expect(retry).toHaveFocus();
78
+ expect(retry).toHaveAttribute('aria-disabled', 'true');
79
+ await user.click(retry);
80
+ await user.keyboard('{Enter} ');
81
+ expect(explicitRetry ? onRetry : onLoadMore).toHaveBeenCalledTimes(explicitRetry ? 1 : 2);
82
+ rerender(<LoadMoreFooter {...props} nextCursor="c2" error="" loading={false} />);
83
+ expect(screen.getByRole('button', { name: 'Load more' })).toHaveFocus();
84
+ },
85
+ );
86
+
87
+ it.each([true, false])(
88
+ 'retains the same flag button with a visible error and no cursor argument (onRetry=%s)',
89
+ async (explicitRetry) => {
90
+ const user = userEvent.setup();
91
+ const onLoadMore = vi.fn();
92
+ const onRetry = vi.fn();
93
+ const props = { hasMore: true, onLoadMore, onRetry: explicitRetry ? onRetry : undefined };
94
+ const { rerender } = render(<LoadMoreFooter {...props} />);
95
+ const button = screen.getByRole('button');
96
+ await user.click(button);
97
+ rerender(<LoadMoreFooter {...props} loading />);
98
+ rerender(<LoadMoreFooter {...props} error="Request failed" />);
99
+ expect(screen.getByRole('button', { name: 'Try again' })).toBe(button);
100
+ expect(button).toHaveFocus();
101
+ expect(button).toHaveAccessibleDescription('Request failed');
102
+ expect(within(screen.getByRole('group')).getByText('Request failed')).toBeVisible();
103
+ expect(screen.queryByRole('heading')).toBeNull();
104
+ await user.click(button);
105
+ expect((explicitRetry ? onRetry : onLoadMore).mock.calls).toEqual(explicitRetry ? [[]] : [[], []]);
106
+ },
107
+ );
108
+
109
+ it('uses the current cursor when no attempt was recorded', async () => {
110
+ const onRetry = vi.fn();
111
+ render(<LoadMoreFooter nextCursor="c2" error="Request failed" onRetry={onRetry} />);
112
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Try again' }));
113
+ expect(onRetry.mock.calls).toEqual([['c2']]);
114
+ });
115
+
116
+ it.each([true, false])(
117
+ 'uses the current cursor for an external failure after a successful load (onRetry=%s)',
118
+ async (explicitRetry) => {
119
+ const user = userEvent.setup();
120
+ const onLoadMore = vi.fn();
121
+ const onRetry = vi.fn();
122
+ const props = { onLoadMore, onRetry: explicitRetry ? onRetry : undefined };
123
+ const { rerender } = render(<LoadMoreFooter {...props} nextCursor="c1" />);
124
+ await user.click(screen.getByRole('button', { name: 'Load more' }));
125
+ rerender(<LoadMoreFooter {...props} nextCursor="c1" loading />);
126
+ rerender(<LoadMoreFooter {...props} nextCursor="c2" loading={false} error="" />);
127
+ rerender(<LoadMoreFooter {...props} nextCursor="c2" error="External request failed" />);
128
+ await user.click(screen.getByRole('button', { name: 'Try again' }));
129
+ expect((explicitRetry ? onRetry : onLoadMore).mock.calls).toEqual(explicitRetry ? [['c2']] : [['c1'], ['c2']]);
130
+ },
131
+ );
132
+
133
+ it.each(['cursor', 'flag'])('exhaustion overrides an error and removes every control in %s mode', async (mode) => {
134
+ const props = { nextCursor: mode === 'cursor' ? 'c1' : undefined, hasMore: true, loadedCount: 37 };
135
+ const { rerender } = render(<LoadMoreFooter {...props} />);
136
+ await userEvent.setup().click(screen.getByRole('button'));
137
+ rerender(<LoadMoreFooter {...props} loading />);
138
+ rerender(<LoadMoreFooter {...props} hasMore={false} error="Request failed" />);
139
+ expect(screen.queryAllByRole('button')).toHaveLength(0);
140
+ expect(screen.getByText(/37 items loaded/)).toHaveFocus();
141
+ expect(within(screen.getByRole('group')).getByText('Request failed')).toBeVisible();
142
+ });
143
+
144
+ it.each(['summary', 'root'] as const)('focuses %s after an exhausted successful retry', async (target) => {
145
+ const props = { nextCursor: 'c1', error: 'Request failed', exhaustedFocusTarget: target };
146
+ const { rerender } = render(<LoadMoreFooter {...props} />);
147
+ await userEvent.setup().click(screen.getByRole('button'));
148
+ rerender(<LoadMoreFooter {...props} loading />);
149
+ rerender(<LoadMoreFooter {...props} hasMore={false} error="" />);
150
+ expect(target === 'root' ? screen.getByRole('group') : screen.getByText(/items loaded/)).toHaveFocus();
151
+ });
152
+
153
+ it.each(['cursor', 'flag'])(
154
+ 'moves a focused retry to the root on an exhausted failed attempt in %s mode',
155
+ async (mode) => {
156
+ const props = {
157
+ nextCursor: mode === 'cursor' ? 'c1' : undefined,
158
+ hasMore: true,
159
+ error: 'E1',
160
+ exhaustedFocusTarget: 'root' as const,
161
+ };
162
+ const { rerender } = render(<LoadMoreFooter {...props} />);
163
+ await userEvent.setup().click(screen.getByRole('button'));
164
+ rerender(<LoadMoreFooter {...props} loading />);
165
+ rerender(<LoadMoreFooter {...props} hasMore={false} />);
166
+ expect(screen.queryAllByRole('button')).toHaveLength(0);
167
+ expect(screen.getByRole('group')).toHaveFocus();
168
+ },
169
+ );
170
+
171
+ it('never takes focus from elsewhere on any control transition', () => {
172
+ const outside = document.createElement('button');
173
+ document.body.append(outside);
174
+ outside.focus();
175
+ const { rerender } = render(<LoadMoreFooter nextCursor="c1" />);
176
+ rerender(<LoadMoreFooter nextCursor="c1" error="Request failed" />);
177
+ expect(outside).toHaveFocus();
178
+ rerender(<LoadMoreFooter nextCursor="c2" />);
179
+ expect(outside).toHaveFocus();
180
+ rerender(<LoadMoreFooter nextCursor={null} />);
181
+ expect(outside).toHaveFocus();
182
+ outside.remove();
183
+ });
184
+ });
@@ -0,0 +1,151 @@
1
+ import { Button, LiveAnnouncer, cn, useHasLiveAnnouncer, useI18n } from '@gntik-ai/ui';
2
+ import { useId } from 'react';
3
+ import { ErrorPanel } from '../../feedback/ErrorPanel/ErrorPanel';
4
+ import { useLoadMoreFooter, useLoadMoreAnnouncements } from './useLoadMoreFooter';
5
+ import { LOAD_MORE_PREVIEW } from './fixtures';
6
+
7
+ export interface LoadMoreFooterLabels {
8
+ loadMore: string;
9
+ loading: string;
10
+ retry: string;
11
+ group: string;
12
+ moreAvailable: string;
13
+ summary: (count: number) => string;
14
+ loadedAnnouncement: (count: number) => string;
15
+ }
16
+
17
+ /**
18
+ * Each attempt must settle in one update: loading=false together with its result
19
+ * (error, or an empty error on success). Split settles are unsupported.
20
+ */
21
+ export interface LoadMoreFooterProps {
22
+ /** Undefined selects flag mode; null or an empty string exhausts cursor mode. */
23
+ nextCursor?: string | null;
24
+ hasMore?: boolean;
25
+ loading?: boolean;
26
+ error?: string;
27
+ onLoadMore?: (cursor?: string) => void;
28
+ onRetry?: (cursor?: string) => void;
29
+ loadedCount?: number;
30
+ /** Increasing sequence for successful appends only; leave unchanged on replacement or failure. */
31
+ lastAppend?: { seq: number; count: number };
32
+ exhaustedFocusTarget?: 'summary' | 'root';
33
+ labels?: Partial<LoadMoreFooterLabels>;
34
+ className?: string;
35
+ }
36
+
37
+ /**
38
+ * Load more for appended lists without a total or page count. The consumer owns rows and requests.
39
+ * With no props, renders a static catalogue preview using fixture data. Supplying any prop selects
40
+ * the consumer contract: undefined nextCursor means flag mode and requires hasMore=true.
41
+ * loadedCount defaults to the preview fixture; consumers should pass their actual loaded count.
42
+ */
43
+ export function LoadMoreFooter(props: LoadMoreFooterProps = {}) {
44
+ const hasAnnouncer = useHasLiveAnnouncer();
45
+ return hasAnnouncer ? (
46
+ <LoadMoreFooterContent {...props} />
47
+ ) : (
48
+ <LiveAnnouncer>
49
+ <LoadMoreFooterContent {...props} />
50
+ </LiveAnnouncer>
51
+ );
52
+ }
53
+
54
+ function LoadMoreFooterContent(props: LoadMoreFooterProps) {
55
+ const { t } = useI18n();
56
+ const {
57
+ nextCursor,
58
+ hasMore,
59
+ loading = false,
60
+ error = '',
61
+ exhaustedFocusTarget = 'summary',
62
+ loadedCount = LOAD_MORE_PREVIEW.loadedCount,
63
+ labels,
64
+ className,
65
+ } = props;
66
+ // Only the no-input preview uses a fixture cursor. Explicit undefined remains flag mode.
67
+ const cursor = Object.keys(props).length === 0 ? LOAD_MORE_PREVIEW.nextCursor : nextCursor;
68
+ const available =
69
+ cursor === undefined ? hasMore === true : typeof cursor === 'string' && cursor.length > 0 && hasMore !== false;
70
+ const failed = error.length > 0;
71
+ const cursorMode = cursor !== undefined;
72
+ const panel = cursorMode && failed;
73
+ const control = !available ? 'none' : panel ? 'retry' : 'button';
74
+ const { rootRef, summaryRef, controlRef, inFlightCursorRef } = useLoadMoreFooter(
75
+ control,
76
+ exhaustedFocusTarget,
77
+ loading,
78
+ error,
79
+ );
80
+ const errorId = useId();
81
+ useLoadMoreAnnouncements(
82
+ loading,
83
+ error,
84
+ props.lastAppend,
85
+ labels?.loadedAnnouncement ?? ((count) => t('table.loadedAnnouncement', { count })),
86
+ );
87
+ const load = () => {
88
+ if (loading || !available) return;
89
+ inFlightCursorRef.current = cursor ?? undefined;
90
+ if (cursor === undefined) props.onLoadMore?.();
91
+ else props.onLoadMore?.(cursor ?? undefined);
92
+ };
93
+ const retry = () => {
94
+ if (loading || !available) return;
95
+ const handler = props.onRetry ?? props.onLoadMore;
96
+ if (cursorMode) handler?.(inFlightCursorRef.current ?? cursor ?? undefined);
97
+ else handler?.();
98
+ };
99
+ return (
100
+ <div
101
+ ref={rootRef}
102
+ role="group"
103
+ aria-label={labels?.group ?? t('table.loadMoreGroup')}
104
+ tabIndex={-1}
105
+ aria-busy={loading}
106
+ className={cn('flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between', className)}
107
+ >
108
+ <p ref={summaryRef} tabIndex={-1} className="text-sm text-muted-foreground tabular-nums">
109
+ {labels?.summary?.(loadedCount) ?? t('table.loadedSummary', { count: loadedCount })}{' '}
110
+ {available && <span>{labels?.moreAvailable ?? t('table.moreAvailable')}</span>}
111
+ </p>
112
+ {panel ? (
113
+ <div key={available ? 'retry' : 'exhausted'} ref={available ? controlRef : undefined}>
114
+ <ErrorPanel
115
+ title={error}
116
+ message={null}
117
+ code={null}
118
+ requestId=""
119
+ details=""
120
+ secondaryAction={null}
121
+ onRetry={available ? retry : undefined}
122
+ retrying={loading}
123
+ retryLabel={labels?.retry ?? t('error.retry')}
124
+ />
125
+ </div>
126
+ ) : (
127
+ <>
128
+ {failed && (
129
+ <p id={errorId} className="text-sm text-destructive-text">
130
+ {error}
131
+ </p>
132
+ )}
133
+ {available && (
134
+ <Button
135
+ ref={controlRef}
136
+ loading={loading}
137
+ onClick={failed ? retry : load}
138
+ aria-describedby={failed ? errorId : undefined}
139
+ >
140
+ {loading
141
+ ? (labels?.loading ?? t('table.loadingMore'))
142
+ : failed
143
+ ? (labels?.retry ?? t('error.retry'))
144
+ : (labels?.loadMore ?? t('table.loadMore'))}
145
+ </Button>
146
+ )}
147
+ </>
148
+ )}
149
+ </div>
150
+ );
151
+ }
@@ -0,0 +1,28 @@
1
+ import type { BlockMeta } from '../../meta';
2
+ import type { LoadMoreFooterLabels } from './LoadMoreFooter';
3
+
4
+ export const meta: BlockMeta = {
5
+ name: 'Load more footer',
6
+ family: 'tables',
7
+ status: 'beta',
8
+ description:
9
+ 'A load more button and loaded-count summary for appended lists without a total or page count. Cursor mode uses nextCursor and optional hasMore; flag mode omits nextCursor and uses hasMore. Compose with DataTable paginated={false} or Collapsible and JsonViewer cards. The consumer owns rows, filters and cursor normalization. Each attempt must settle loading=false together with its result in one update; split settles are unsupported. Keep cursor, hasMore, counts and lastAppend unchanged on failure. Cursor failures replace the button with ErrorPanel; flag failures keep the same button. Exhaustion removes every control even on failure. Focus moves only when the focused control is removed, to its replacement or exhaustedFocusTarget (summary by default, root optionally). lastAppend uses increasing seq values for successful appends, never initial loads, replacements or failures. New appended counts are announced politely; settled errors assertively. Self-wraps in LiveAnnouncer when needed. Every label has translated defaults and per-key overrides.',
10
+ labels: [
11
+ { key: 'loadMore', type: 'string', state: 'Available and idle: button name' },
12
+ { key: 'loading', type: 'string', state: 'Loading: button name; ErrorPanel uses its own translated busy text' },
13
+ { key: 'retry', type: 'string', state: 'Failed and available: retry button name' },
14
+ { key: 'group', type: 'string', state: 'Always: root group accessible name' },
15
+ { key: 'moreAvailable', type: 'string', state: 'Available: visible summary note' },
16
+ {
17
+ key: 'summary',
18
+ type: '(count: number) => string',
19
+ state: 'Always: loaded-count summary and default exhaustion focus target',
20
+ },
21
+ {
22
+ key: 'loadedAnnouncement',
23
+ type: '(count: number) => string',
24
+ state: 'New lastAppend.seq with positive count: polite announcement',
25
+ },
26
+ ] satisfies Array<{ key: keyof LoadMoreFooterLabels; type: string; state: string }>,
27
+ uses: ['Button', 'ErrorPanel', 'LiveAnnouncer'],
28
+ };
@@ -0,0 +1,22 @@
1
+ import { Collapsible, CollapsiblePanel, CollapsibleTrigger, JsonViewer } from '@gntik-ai/ui';
2
+ import { LoadMoreFooter } from '../LoadMoreFooter';
3
+ import { useMockCursor } from './useMockCursor';
4
+
5
+ /** A mocked cursor consumer can append cards just as it appends table rows. */
6
+ export default function CardsExample() {
7
+ const { rows, load, ...state } = useMockCursor();
8
+ return (
9
+ <div className="space-y-4">
10
+ {rows.map((row) => (
11
+ <Collapsible key={row.id} variant="row">
12
+ <CollapsibleTrigger>{row.name}</CollapsibleTrigger>
13
+ <CollapsiblePanel>
14
+ <JsonViewer label={`${row.name} data`} data={row} />
15
+ </CollapsiblePanel>
16
+ </Collapsible>
17
+ ))}
18
+ {/* Settle loading=false and its result in one update; split settles are unsupported. */}
19
+ <LoadMoreFooter {...state} loadedCount={rows.length} onLoadMore={load} onRetry={load} />
20
+ </div>
21
+ );
22
+ }
@@ -0,0 +1,25 @@
1
+ import { DataTable } from '../../DataTable/DataTable';
2
+ import { LoadMoreFooter } from '../LoadMoreFooter';
3
+ import { useMockCursor } from './useMockCursor';
4
+
5
+ /** The consumer appends rows; DataTable must use paginated={false}. */
6
+ export default function CursorExample() {
7
+ const { rows, load, ...state } = useMockCursor();
8
+ return (
9
+ <div className="space-y-4">
10
+ <DataTable
11
+ rows={rows}
12
+ columns={[{ id: 'name', header: 'Name', accessor: (row) => row.name }]}
13
+ caption="Records"
14
+ paginated={false}
15
+ selectable={false}
16
+ showColumnMenu={false}
17
+ showDensityToggle={false}
18
+ showViewSwitcher={false}
19
+ resizable={false}
20
+ />
21
+ {/* Each attempt settles loading=false together with its result in one update; split settles are unsupported. */}
22
+ <LoadMoreFooter {...state} loadedCount={rows.length} onLoadMore={load} onRetry={load} />
23
+ </div>
24
+ );
25
+ }
@@ -0,0 +1,6 @@
1
+ import { LoadMoreFooter } from '../LoadMoreFooter';
2
+
3
+ /** Exhausted lists have no controls, including when a settled error remains visible. */
4
+ export default function ExhaustedExample() {
5
+ return <LoadMoreFooter nextCursor={null} loadedCount={40} error="Request failed" />;
6
+ }
@@ -0,0 +1,13 @@
1
+ import { useState } from 'react';
2
+ import { LoadMoreFooter } from '../LoadMoreFooter';
3
+
4
+ export default function FailedExample() {
5
+ const [state, setState] = useState({ loading: false, error: 'Could not load more items.' });
6
+ const retry = async () => {
7
+ setState((previous) => ({ ...previous, loading: true }));
8
+ await Promise.resolve();
9
+ // Settle loading=false with its result in one update. Split settles are unsupported.
10
+ setState({ loading: false, error: '' });
11
+ };
12
+ return <LoadMoreFooter nextCursor="c1" loadedCount={20} {...state} onLoadMore={retry} onRetry={retry} />;
13
+ }