@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,33 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { expectNoAxeViolations } from '../../test/a11y';
4
+ import CursorStack from './examples/CursorStack';
5
+ import Busy from './examples/Busy';
6
+ import Hidden from './examples/Hidden';
7
+ import RowsPerPage from './examples/RowsPerPage';
8
+ import { CursorPagerFooter } from './CursorPagerFooter';
9
+
10
+ it('lets a consumer navigate its own opaque cursor stack without numbered table pagination', async () => {
11
+ const user = userEvent.setup();
12
+ render(<CursorStack />);
13
+ expect(screen.getByRole('cell', { name: 'Record A' })).toBeVisible();
14
+ await user.click(screen.getByRole('button', { name: 'Next page' }));
15
+ expect(screen.getByRole('cell', { name: 'Record B' })).toBeVisible();
16
+ await user.click(screen.getByRole('button', { name: 'Next page' }));
17
+ expect(screen.getByRole('cell', { name: 'Record C' })).toBeVisible();
18
+ expect(screen.getByRole('button', { name: 'Next page' })).toHaveAttribute('aria-disabled', 'true');
19
+ await user.click(screen.getByRole('button', { name: 'Previous page' }));
20
+ expect(screen.getByRole('cell', { name: 'Record B' })).toBeVisible();
21
+ expect(screen.getAllByRole('navigation')).toHaveLength(1);
22
+ });
23
+
24
+ for (const theme of ['dark', 'light', 'high_contrast']) {
25
+ it.each([CursorPagerFooter, CursorStack, Busy, Hidden, RowsPerPage])(
26
+ `footer examples have no axe violations in ${theme}: %s`,
27
+ async (Example) => {
28
+ document.documentElement.className = theme;
29
+ const { container } = render(<Example />);
30
+ await expectNoAxeViolations(container);
31
+ },
32
+ );
33
+ }
@@ -0,0 +1,51 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { CursorPagerFooter } from './CursorPagerFooter';
4
+
5
+ describe('CursorPagerFooter', () => {
6
+ it('renders meaningful no-props fixtures for the catalogue', () => {
7
+ render(<CursorPagerFooter />);
8
+ expect(screen.getByRole('navigation', { name: 'Pagination' })).toBeVisible();
9
+ expect(screen.getByRole('status')).toHaveTextContent('25 records shown');
10
+ expect(screen.getByRole('button', { name: 'Previous page' })).toHaveAttribute('aria-disabled', 'true');
11
+ });
12
+
13
+ it('forwards navigation, count, labels, select, busy, summary and className props', async () => {
14
+ const user = userEvent.setup();
15
+ const onNext = vi.fn();
16
+ const props = {
17
+ hasPrevious: false,
18
+ hasNext: true,
19
+ onPrevious: vi.fn(),
20
+ onNext,
21
+ shownCount: 25,
22
+ total: 97,
23
+ pageSize: 25,
24
+ pageSizeOptions: [25, 50],
25
+ onPageSizeChange: vi.fn(),
26
+ unavailableBehaviour: 'hidden' as const,
27
+ previousLabel: 'Back',
28
+ nextLabel: 'Forward',
29
+ ariaLabel: 'Records',
30
+ labels: {
31
+ shownSummary: (n: number) => `${n} shown`,
32
+ totalSummary: (n: number) => `${n} exact`,
33
+ rowsPerPage: 'Response size',
34
+ rowsPerPageLabel: 'Choose size',
35
+ },
36
+ className: 'gap-4',
37
+ };
38
+ const { rerender } = render(<CursorPagerFooter {...props} />);
39
+ expect(screen.getByRole('navigation', { name: 'Records' })).toHaveClass('gap-4');
40
+ expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument();
41
+ expect(screen.getByRole('status')).toHaveTextContent('97 exact');
42
+ expect(screen.getByRole('combobox', { name: 'Choose size' })).toHaveTextContent('25');
43
+ await user.click(screen.getByRole('button', { name: 'Forward' }));
44
+ expect(onNext).toHaveBeenCalledExactlyOnceWith();
45
+ rerender(<CursorPagerFooter {...props} loading loadingLabel="Fetching" />);
46
+ expect(screen.getByRole('status')).toHaveTextContent('Fetching');
47
+ expect(screen.getByRole('button', { name: 'Forward' })).toHaveAttribute('aria-busy', 'true');
48
+ rerender(<CursorPagerFooter {...props} busy summary={null} loadingLabel={null} />);
49
+ expect(screen.getByRole('status')).toBeEmptyDOMElement();
50
+ });
51
+ });
@@ -0,0 +1,10 @@
1
+ import { CursorPagination, type CursorPaginationProps } from '@gntik-ai/ui';
2
+ import { CURSOR_PAGER_PREVIEW } from './fixtures';
3
+
4
+ /** The complete CursorPaginationProps contract, or no props for the fixture preview. */
5
+ export type CursorPagerFooterProps = CursorPaginationProps | { [K in keyof CursorPaginationProps]?: never };
6
+
7
+ /** Responsive table footer. Forwards every cursor-pagination prop without storing cursor state. */
8
+ export function CursorPagerFooter(props: CursorPagerFooterProps = {}) {
9
+ return <CursorPagination {...(props.hasPrevious === undefined ? CURSOR_PAGER_PREVIEW : props)} />;
10
+ }
@@ -0,0 +1,23 @@
1
+ import type { BlockMeta } from '../../meta';
2
+
3
+ export const meta: BlockMeta = {
4
+ name: 'Cursor pager footer',
5
+ family: 'tables',
6
+ status: 'beta',
7
+ description:
8
+ 'Responsive stateless cursor-pagination footer for data-table paginated={false}. Forwards CursorPaginationProps: hasPrevious, hasNext, onPrevious, onNext, loading, busy, summary, shownCount, total, unavailableBehaviour, pageSize, pageSizeOptions, onPageSizeChange, previousLabel, nextLabel, ariaLabel, loadingLabel, labels and className. Consumer-owned opaque cursors stay outside the kit. summary accepts plain text or null; the count form requires labels.shownSummary and labels.totalSummary; select options require labels.rowsPerPage and labels.rowsPerPageLabel. Flat names override labels.previous, labels.next and labels.navLabel, then catalog defaults. loadingLabel=null preserves the summary while busy. One persistent polite atomic status; buttons remain focusable when aria-disabled. Removal of a focused control moves focus to Next, Previous, select, then nav root, including while busy. No page numbers or ranges. No props renders fixtures.',
9
+ labels: [
10
+ { key: 'previous', type: 'string', state: 'Previous name, below previousLabel and above catalog defaults' },
11
+ { key: 'next', type: 'string', state: 'Next name, below nextLabel and above catalog defaults' },
12
+ { key: 'navLabel', type: 'string', state: 'Navigation name, below ariaLabel and above catalog defaults' },
13
+ {
14
+ key: 'shownSummary',
15
+ type: '(shownCount: number) => string',
16
+ state: 'Required in count form: no positive exact total',
17
+ },
18
+ { key: 'totalSummary', type: '(total: number) => string', state: 'Required in count form: positive exact total' },
19
+ { key: 'rowsPerPage', type: 'string', state: 'Required with pageSizeOptions: visible select label' },
20
+ { key: 'rowsPerPageLabel', type: 'string', state: 'Required with pageSizeOptions: select accessible name' },
21
+ ],
22
+ uses: ['CursorPagination'],
23
+ };
@@ -0,0 +1,7 @@
1
+ import { CursorPagerFooter } from '../CursorPagerFooter';
2
+
3
+ export default function Busy() {
4
+ return (
5
+ <CursorPagerFooter hasPrevious hasNext busy summary="25 records loaded" onPrevious={() => {}} onNext={() => {}} />
6
+ );
7
+ }
@@ -0,0 +1,40 @@
1
+ import { useState } from 'react';
2
+ import { DataTable } from '../../DataTable/DataTable';
3
+ import { CursorPagerFooter } from '../CursorPagerFooter';
4
+
5
+ // Mock API responses: only this consumer stores or interprets opaque continuation tokens.
6
+ const responses: Record<string, { rows: { name: string }[]; next?: string }> = {
7
+ start: { rows: [{ name: 'Record A' }], next: 'opaque-k7' },
8
+ 'opaque-k7': { rows: [{ name: 'Record B' }], next: 'opaque-m9' },
9
+ 'opaque-m9': { rows: [{ name: 'Record C' }] },
10
+ };
11
+
12
+ export default function CursorStack() {
13
+ const [stack, setStack] = useState<string[]>([]);
14
+ const response = responses[stack.at(-1) ?? 'start'] ?? responses.start!;
15
+ return (
16
+ <div className="space-y-4">
17
+ <DataTable
18
+ rows={response.rows}
19
+ columns={[{ id: 'name', header: 'Name', accessor: (row) => row.name }]}
20
+ caption="Records"
21
+ paginated={false}
22
+ selectable={false}
23
+ showColumnMenu={false}
24
+ showDensityToggle={false}
25
+ showViewSwitcher={false}
26
+ resizable={false}
27
+ />
28
+ <CursorPagerFooter
29
+ hasPrevious={stack.length > 0}
30
+ hasNext={response.next !== undefined}
31
+ onPrevious={() => setStack((current) => current.slice(0, -1))}
32
+ onNext={() => {
33
+ if (response.next) setStack((current) => [...current, response.next!]);
34
+ }}
35
+ shownCount={response.rows.length}
36
+ labels={{ shownSummary: (n) => `${n} records shown`, totalSummary: (n) => `${n} records in total` }}
37
+ />
38
+ </div>
39
+ );
40
+ }
@@ -0,0 +1,16 @@
1
+ import { CursorPagerFooter } from '../CursorPagerFooter';
2
+
3
+ export default function Hidden() {
4
+ return (
5
+ <CursorPagerFooter
6
+ hasPrevious
7
+ hasNext={false}
8
+ unavailableBehaviour="hidden"
9
+ shownCount={7}
10
+ total={57}
11
+ labels={{ shownSummary: (n) => `${n} records shown`, totalSummary: (n) => `${n} records in total` }}
12
+ onPrevious={() => {}}
13
+ onNext={() => {}}
14
+ />
15
+ );
16
+ }
@@ -0,0 +1,21 @@
1
+ import { useState } from 'react';
2
+ import { CursorPagerFooter } from '../CursorPagerFooter';
3
+
4
+ export default function RowsPerPage() {
5
+ const [pageSize, setPageSize] = useState(25);
6
+ return (
7
+ <CursorPagerFooter
8
+ hasPrevious={false}
9
+ hasNext
10
+ busy
11
+ summary={null}
12
+ loadingLabel={null}
13
+ pageSize={pageSize}
14
+ pageSizeOptions={[10, 25, 50, 100]}
15
+ onPageSizeChange={setPageSize}
16
+ labels={{ rowsPerPage: 'Rows per page', rowsPerPageLabel: 'Rows per page' }}
17
+ onPrevious={() => {}}
18
+ onNext={() => {}}
19
+ />
20
+ );
21
+ }
@@ -0,0 +1,13 @@
1
+ import type { CursorPaginationProps } from '@gntik-ai/ui';
2
+
3
+ export const CURSOR_PAGER_PREVIEW = {
4
+ hasPrevious: false,
5
+ hasNext: true,
6
+ onPrevious: () => {},
7
+ onNext: () => {},
8
+ shownCount: 25,
9
+ labels: {
10
+ shownSummary: (shownCount: number) => `${shownCount} records shown`,
11
+ totalSummary: (total: number) => `${total} records in total`,
12
+ },
13
+ } satisfies CursorPaginationProps;
@@ -0,0 +1,173 @@
1
+ import { fireEvent, render, screen, within } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { DataTable, type DataTableProps } from './DataTable';
5
+ import type { DataTableColumn } from './data-table-utils';
6
+
7
+ type Row = { id: number; name: string; region: string };
8
+ const rows: Row[] = Array.from({ length: 20 }, (_, i) => ({
9
+ id: 40 - i,
10
+ name: `Item ${40 - i}`,
11
+ region: i % 2 ? 'East' : 'West',
12
+ }));
13
+ const columns: DataTableColumn<Row>[] = [
14
+ { id: 'id', header: 'Id', accessor: (row) => row.id, sortable: true },
15
+ { id: 'name', header: 'Name', accessor: (row) => row.name, sortable: true },
16
+ { id: 'region', header: 'Region', accessor: (row) => row.region, sortable: true },
17
+ ];
18
+ const server = {
19
+ paginationMode: 'server' as const,
20
+ selectionMode: 'none' as const,
21
+ rows,
22
+ columns,
23
+ caption: 'Items',
24
+ page: 2,
25
+ pageSize: 20,
26
+ rowCount: 95,
27
+ pageSizeOptions: [10, 20, 50],
28
+ };
29
+ const renderedIds = () =>
30
+ screen
31
+ .getAllByRole('row')
32
+ .slice(1)
33
+ .map((row) => within(row).getAllByRole('cell')[0]!.textContent);
34
+ const summary = () => document.querySelector('[aria-live="polite"]');
35
+
36
+ // @ts-expect-error Server pagination needs a known total.
37
+ const missingTotal: DataTableProps<Row> = { paginationMode: 'server', page: 1, pageSize: 20 };
38
+ // @ts-expect-error Server pagination cannot be combined with single selection.
39
+ const singleServer: DataTableProps<Row> = { ...server, selectionMode: 'single', paginated: false };
40
+ void missingTotal;
41
+ void singleServer;
42
+
43
+ describe('DataTable controlled server pagination', () => {
44
+ it('renders the supplied page in order and counts the full result set', () => {
45
+ render(<DataTable {...server} />);
46
+ expect(renderedIds()).toEqual([
47
+ '40',
48
+ '39',
49
+ '38',
50
+ '37',
51
+ '36',
52
+ '35',
53
+ '34',
54
+ '33',
55
+ '32',
56
+ '31',
57
+ '30',
58
+ '29',
59
+ '28',
60
+ '27',
61
+ '26',
62
+ '25',
63
+ '24',
64
+ '23',
65
+ '22',
66
+ '21',
67
+ ]);
68
+ expect(summary()).toHaveTextContent('21–40 of 95');
69
+ expect(screen.getByRole('button', { name: 'Page 2', current: 'page' })).toBeInTheDocument();
70
+ expect(screen.getByRole('button', { name: 'Page 5' })).toBeInTheDocument();
71
+ expect(screen.queryByRole('button', { name: 'Page 6' })).not.toBeInTheDocument();
72
+ });
73
+
74
+ it('requests 1-based pages once and waits for consumer updates', async () => {
75
+ const user = userEvent.setup();
76
+ const onPageChange = vi.fn();
77
+ const { rerender } = render(<DataTable {...server} onPageChange={onPageChange} />);
78
+ await user.click(screen.getByRole('button', { name: 'Next page' }));
79
+ expect(onPageChange.mock.calls).toEqual([[3]]);
80
+ await user.click(screen.getByRole('button', { name: 'Previous page' }));
81
+ expect(onPageChange.mock.calls).toEqual([[3], [1]]);
82
+ expect(screen.getByRole('button', { name: 'Page 2', current: 'page' })).toBeInTheDocument();
83
+ expect(summary()).toHaveTextContent('21–40 of 95');
84
+ expect(renderedIds()).toHaveLength(20);
85
+ rerender(<DataTable {...server} page={1} onPageChange={onPageChange} />);
86
+ expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled();
87
+ rerender(<DataTable {...server} page={5} rows={rows.slice(0, 15)} onPageChange={onPageChange} />);
88
+ expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled();
89
+ expect(summary()).toHaveTextContent('81–95 of 95');
90
+ expect(renderedIds()).toHaveLength(15);
91
+ expect(onPageChange.mock.calls).toEqual([[3], [1]]);
92
+ });
93
+
94
+ it('requests a new size and only the footer keep-first-row page, without slicing', async () => {
95
+ const user = userEvent.setup();
96
+ const onPageChange = vi.fn();
97
+ const onPageSizeChange = vi.fn();
98
+ render(<DataTable {...server} onPageChange={onPageChange} onPageSizeChange={onPageSizeChange} />);
99
+ await user.click(screen.getByRole('combobox', { name: 'Rows per page' }));
100
+ await user.click(screen.getByRole('option', { name: '50' }));
101
+ expect(onPageSizeChange.mock.calls).toEqual([[50]]);
102
+ expect(onPageChange.mock.calls).toEqual([[1]]);
103
+ expect(renderedIds()).toHaveLength(20);
104
+ expect(summary()).toHaveTextContent('21–40 of 95');
105
+ });
106
+
107
+ it.each([false, true])('has inert headers even with controlled sort and resizable=%s', async (resizable) => {
108
+ const user = userEvent.setup();
109
+ const onSortChange = vi.fn();
110
+ render(
111
+ <DataTable
112
+ {...server}
113
+ resizable={resizable}
114
+ sort={{ columnId: 'id', direction: 'ascending' }}
115
+ onSortChange={onSortChange}
116
+ />,
117
+ );
118
+ const before = renderedIds();
119
+ for (const header of screen.getAllByRole('columnheader')) {
120
+ expect(header).not.toHaveAttribute('aria-sort');
121
+ expect(within(header).queryByRole('button')).not.toBeInTheDocument();
122
+ expect(header.querySelector('svg')).toBeNull();
123
+ await user.click(header);
124
+ fireEvent.keyDown(header, { key: 'Enter' });
125
+ }
126
+ expect(renderedIds()).toEqual(before);
127
+ expect(onSortChange).not.toHaveBeenCalled();
128
+ });
129
+
130
+ it('applying a sorted saved view does not request a sort or page', async () => {
131
+ const user = userEvent.setup();
132
+ const onSortChange = vi.fn();
133
+ const onPageChange = vi.fn();
134
+ render(
135
+ <DataTable
136
+ {...server}
137
+ defaultViews={[{ id: 'saved', name: 'Saved', state: { sort: { columnId: 'id', direction: 'ascending' } } }]}
138
+ onSortChange={onSortChange}
139
+ onPageChange={onPageChange}
140
+ />,
141
+ );
142
+ const before = renderedIds();
143
+ await user.click(screen.getByRole('button', { name: 'Default view' }));
144
+ await user.click(await screen.findByRole('menuitemradio', { name: 'Saved' }));
145
+ expect(renderedIds()).toEqual(before);
146
+ expect(onPageChange).not.toHaveBeenCalled();
147
+ expect(onSortChange).not.toHaveBeenCalled();
148
+ expect(summary()).toHaveTextContent('21–40 of 95');
149
+ });
150
+
151
+ it.each([1, 3])('keeps an empty server footer at page 1 without callbacks for supplied page %s', (page) => {
152
+ const onPageChange = vi.fn();
153
+ const onPageSizeChange = vi.fn();
154
+ render(
155
+ <DataTable
156
+ {...server}
157
+ rows={[]}
158
+ rowCount={0}
159
+ page={page}
160
+ onPageChange={onPageChange}
161
+ onPageSizeChange={onPageSizeChange}
162
+ />,
163
+ );
164
+ expect(screen.getByText('No results')).toBeInTheDocument();
165
+ expect(summary()).toHaveTextContent('0–0 of 0');
166
+ expect(screen.getByRole('button', { name: 'Page 1', current: 'page' })).toBeInTheDocument();
167
+ expect(screen.queryByRole('button', { name: 'Page 2' })).not.toBeInTheDocument();
168
+ expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled();
169
+ expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled();
170
+ expect(onPageChange).not.toHaveBeenCalled();
171
+ expect(onPageSizeChange).not.toHaveBeenCalled();
172
+ });
173
+ });
@@ -93,6 +93,12 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
93
93
  onSortChange,
94
94
  manualSorting = false,
95
95
  paginated: paginatedProp,
96
+ paginationMode = 'client',
97
+ rowCount,
98
+ page: controlledPage,
99
+ pageSize: controlledPageSize,
100
+ onPageChange,
101
+ onPageSizeChange,
96
102
  defaultPageSize = 10,
97
103
  pageSizeOptions = [10, 25, 50],
98
104
  density: densityProp,
@@ -140,6 +146,7 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
140
146
  }: DataTableProps<T>) {
141
147
  const { t } = useI18n();
142
148
  const singleMode = selectionMode === 'single';
149
+ const serverMode = paginationMode === 'server';
143
150
  const selectable = selectionMode === undefined ? selectableProp : selectionMode === 'multiple';
144
151
  const selectionColumn = selectable || singleMode;
145
152
  const paginated = !singleMode && (paginatedProp ?? true);
@@ -198,10 +205,10 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
198
205
  const [innerCollapsed, setInnerCollapsed] = useState<ReadonlySet<string>>(() => new Set(defaultCollapsedGroups));
199
206
  const collapsed: ReadonlySet<string> = collapsedProp ? new Set(collapsedProp) : innerCollapsed;
200
207
 
201
- const sortedRows = manualSorting ? [...rows] : sortRows(rows, columns, sort);
208
+ const sortedRows = serverMode ? rows : manualSorting ? [...rows] : sortRows(rows, columns, sort);
202
209
  const allGroups = effectiveGroupBy ? groupRows(sortedRows, effectiveGroupBy, rows) : null;
203
210
  const sorted = allGroups ? allGroups.flatMap((g) => g.rows) : sortedRows;
204
- const pageRows = paginated ? paginate(sorted, page, pageSize) : sorted;
211
+ const pageRows = serverMode ? rows : paginated ? paginate(sorted, page, pageSize) : sorted;
205
212
  // Rows in collapsed groups are hidden, so select-all leaves them alone.
206
213
  const pageIds = (
207
214
  effectiveGroupBy ? pageRows.filter((row) => !collapsed.has(groupKeyOf(row, effectiveGroupBy))) : pageRows
@@ -214,6 +221,7 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
214
221
  onSelectionChange?.(rows.map(getRowId).filter((id) => next.has(id)));
215
222
  };
216
223
  const changeSort = (columnId: string, direction: SortState['direction']) => {
224
+ if (serverMode) return;
217
225
  const next: SortState = { columnId, direction: sort?.columnId === columnId ? direction : 'ascending' };
218
226
  setInnerSort(next);
219
227
  onSortChange?.(next);
@@ -293,7 +301,7 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
293
301
  const applyState = (state: DataTableViewState) => {
294
302
  const nextSort = state.sort ?? null;
295
303
  setInnerSort(nextSort);
296
- if (nextSort) onSortChange?.(nextSort);
304
+ if (nextSort && !serverMode) onSortChange?.(nextSort);
297
305
  setHiddenList(state.hiddenColumns ?? defaultHidden);
298
306
  setWidths(state.columnWidths ?? defaultColumnWidths);
299
307
  setPinned(state.pinnedColumns ?? defaultPinnedColumns);
@@ -301,7 +309,7 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
301
309
  setGroupOverride(nextGroup === groupName(groupBy) && typeof groupBy !== 'function' ? undefined : nextGroup);
302
310
  if (nextGroup !== currentState.groupBy) onGroupByChange?.(nextGroup);
303
311
  if (state.filters !== filters) onFiltersChange?.(state.filters);
304
- setPage(1);
312
+ if (!serverMode) setPage(1);
305
313
  };
306
314
  const viewsApi = useTableViews({
307
315
  views: viewsProp,
@@ -449,9 +457,9 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
449
457
  <DataTableHeadCell
450
458
  labels={labels}
451
459
  key={c.id}
452
- column={c}
460
+ column={serverMode ? { ...c, sortable: false } : c}
453
461
  layout={layout.columns.get(c.id)}
454
- sortDirection={sort?.columnId === c.id ? sort.direction : 'none'}
462
+ sortDirection={!serverMode && sort?.columnId === c.id ? sort.direction : 'none'}
455
463
  onSortChange={(dir) => changeSort(c.id, dir)}
456
464
  resize={
457
465
  resizable && c.resizable !== false
@@ -513,15 +521,15 @@ export function DataTable<T = (typeof DEPLOYMENT_ROWS)[number]>({
513
521
  {labels?.loading ? labels.loading(caption) : <>Loading {caption.toLowerCase()}…</>}
514
522
  </p>
515
523
  )}
516
- {paginated && !loading && rows.length > 0 && (
524
+ {paginated && !loading && (serverMode || rows.length > 0) && (
517
525
  <PaginationFooter
518
526
  labels={labels}
519
527
  className="border-t border-border px-4 py-3"
520
- total={rows.length}
521
- page={page}
522
- onPageChange={setPage}
523
- pageSize={pageSize}
524
- onPageSizeChange={setPageSize}
528
+ total={serverMode ? rowCount : rows.length}
529
+ page={serverMode ? controlledPage : page}
530
+ onPageChange={serverMode ? onPageChange : setPage}
531
+ pageSize={serverMode ? controlledPageSize : pageSize}
532
+ onPageSizeChange={serverMode ? onPageSizeChange : setPageSize}
525
533
  pageSizeOptions={pageSizeOptions}
526
534
  noun={caption}
527
535
  />
@@ -8,7 +8,8 @@ export const meta: BlockMeta = {
8
8
  description:
9
9
  'Generic typed table: sortable headers with aria-sort, row selection with select-all and indeterminate by default (selectionMode="multiple"), selectionMode="none" to hide selection, column visibility menu, density toggle, row actions, loading and empty states, client-side sort and pagination, optional row groups (groupBy) with rowgroup header rows, counts, keyboard-accessible collapse buttons and group selection; column resize (pointer drag or a focusable role="separator" handle with arrow keys, min/max widths, controlled columnWidths), column pinning to either edge with sticky cells (column menu or pinnedColumns), row virtualization for large datasets (aria-rowcount/aria-rowindex, works with selection and grouping), inline cell editing (text, number and select editors; Enter/F2 to edit, Enter/Tab to commit, Escape to cancel, onCellEdit can reject with an inline error) and saved views (filters, sort, visible columns, widths, pinning and grouping) with a view switcher to save as, rename, delete and reset. ' +
10
10
  'Opt-in selectionMode="single" uses native radios in a labelled fieldset (never paginated or virtualized; paginated defaults to false and paginated=true is a type error). Single mode uses selectedId/onSelectedIdChange or defaultSelectedId; isRowDisabled skips unavailable rows and preserves a checked disabled choice without callbacks. Supply legend, aria-label or aria-labelledby for the group and getRowLabel for each radio (otherwise the first column labels it). selectionName is used verbatim and must be unique per page; omitted names use an instance-unique useId name, and duplicate supplied names or missing group labels warn in development. Tab enters once on the checked enabled row or first enabled row; arrows wrap and skip disabled rows, Space selects, Shift+Tab exits, and all-disabled groups have no tab stop. No select-all, group checkboxes, bulk actions or selection summary appear in single mode. ' +
11
- 'Examples in examples/single-selection.tsx: an uncontrolled non-paginated list with legend="Available items" and a controlled versions table with legend="Available versions", selectedId="v2" and a checked disabled version.',
11
+ 'Examples in examples/single-selection.tsx: an uncontrolled non-paginated list with legend="Available items" and a controlled versions table with legend="Available versions", selectedId="v2" and a checked disabled version. ' +
12
+ 'Opt-in paginationMode="server" accepts a controlled 1-based page, pageSize and required rowCount (known total), with onPageChange/onPageSizeChange requests. Supplied rows render in order without client sorting or slicing, and headers have no sort affordance. Single selection cannot use server mode. The footer stays visible for empty results, clamps stale pages for display without callbacks, and hides during loading. Page-size changes may also request a page to keep the first visible row on screen. The consumer owns fetching and reconciles stale pages. See examples/server-pagination.tsx for an items/total/page/pageSize envelope consumer that handles both callbacks.',
12
13
 
13
14
  labels: [
14
15
  {
@@ -139,21 +139,44 @@ interface DataTableCommonProps<T> {
139
139
  className?: string;
140
140
  }
141
141
 
142
+ interface ClientPaginationProps {
143
+ paginationMode?: 'client';
144
+ rowCount?: never;
145
+ page?: never;
146
+ pageSize?: never;
147
+ onPageChange?: never;
148
+ onPageSizeChange?: never;
149
+ }
150
+
151
+ interface ServerPaginationProps {
152
+ /** Rows arrive as one server page: no client sorting or slicing, and no sortable headers. */
153
+ paginationMode: 'server';
154
+ /** Total number of rows across all pages; required even for an empty result. */
155
+ rowCount: number;
156
+ /** Current page, 1-based. Out-of-range pages are clamped for display without callbacks. */
157
+ page: number;
158
+ pageSize: number;
159
+ onPageChange?: (page: number) => void;
160
+ /** The footer may also request a page change to keep the first visible row on screen. */
161
+ onPageSizeChange?: (size: number) => void;
162
+ }
163
+
142
164
  export type DataTableProps<T> = DataTableCommonProps<T> &
143
165
  (
144
- | {
166
+ | ({
145
167
  selectionMode: 'single';
146
168
  paginated?: false;
147
169
  /** Consumer-supplied accessible group label; the kit adds no default. */
148
170
  legend?: ReactNode;
149
171
  'aria-label'?: string;
150
172
  'aria-labelledby'?: string;
151
- }
152
- | {
173
+ paginationMode?: never;
174
+ } & ClientPaginationProps)
175
+ | ({
153
176
  selectionMode?: 'none' | 'multiple';
154
177
  paginated?: boolean;
155
178
  legend?: never;
156
179
  'aria-label'?: never;
157
180
  'aria-labelledby'?: never;
158
- }
181
+ } & (ClientPaginationProps | ServerPaginationProps))
159
182
  );
@@ -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
+ }