@gntik-ai/blocks 0.6.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.
- package/dist/index.d.ts +178 -70
- package/dist/index.js +621 -200
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/feedback/ConfirmDestructive/ConfirmDestructive.examples.test.tsx +25 -0
- package/src/feedback/ConfirmDestructive/ConfirmDestructive.extended.test.tsx +252 -0
- package/src/feedback/ConfirmDestructive/ConfirmDestructive.focus.test.tsx +178 -0
- package/src/feedback/ConfirmDestructive/ConfirmDestructive.tsx +219 -28
- package/src/feedback/ConfirmDestructive/block.meta.ts +2 -2
- package/src/feedback/ConfirmDestructive/examples/FailureStates.tsx +39 -0
- package/src/feedback/ConfirmDestructive/examples/PlainDeletion.tsx +6 -0
- package/src/feedback/ConfirmDestructive/examples/SecretRevocation.tsx +34 -0
- package/src/feedback/EmptyStates/EmptyStates.test.tsx +11 -1
- package/src/feedback/EmptyStates/EmptyStates.tsx +31 -1
- package/src/feedback/EmptyStates/block.meta.ts +1 -1
- package/src/feedback/EmptyStates/fixtures.ts +5 -0
- package/src/feedback/ErrorPanel/ErrorPanel.test.tsx +7 -0
- package/src/feedback/ErrorPanel/ErrorPanel.tsx +8 -3
- package/src/tables/CursorPagerFooter/CursorPagerFooter.examples.test.tsx +33 -0
- package/src/tables/CursorPagerFooter/CursorPagerFooter.test.tsx +51 -0
- package/src/tables/CursorPagerFooter/CursorPagerFooter.tsx +10 -0
- package/src/tables/CursorPagerFooter/block.meta.ts +23 -0
- package/src/tables/CursorPagerFooter/examples/Busy.tsx +7 -0
- package/src/tables/CursorPagerFooter/examples/CursorStack.tsx +40 -0
- package/src/tables/CursorPagerFooter/examples/Hidden.tsx +16 -0
- package/src/tables/CursorPagerFooter/examples/RowsPerPage.tsx +21 -0
- package/src/tables/CursorPagerFooter/fixtures.ts +13 -0
- package/src/tables/DataTable/DataTable.server-pagination.test.tsx +173 -0
- package/src/tables/DataTable/DataTable.tsx +20 -12
- package/src/tables/DataTable/block.meta.ts +2 -1
- package/src/tables/DataTable/data-table-props.ts +27 -4
- package/src/tables/DataTable/examples/server-pagination.tsx +68 -0
- package/src/tables/LoadMoreFooter/LoadMoreFooter.announcements.test.tsx +106 -0
- package/src/tables/LoadMoreFooter/LoadMoreFooter.examples.test.tsx +36 -0
- package/src/tables/LoadMoreFooter/LoadMoreFooter.labels.test.tsx +71 -0
- package/src/tables/LoadMoreFooter/LoadMoreFooter.test.tsx +184 -0
- package/src/tables/LoadMoreFooter/LoadMoreFooter.tsx +151 -0
- package/src/tables/LoadMoreFooter/block.meta.ts +28 -0
- package/src/tables/LoadMoreFooter/examples/cards.tsx +22 -0
- package/src/tables/LoadMoreFooter/examples/cursor.tsx +25 -0
- package/src/tables/LoadMoreFooter/examples/exhausted.tsx +6 -0
- package/src/tables/LoadMoreFooter/examples/failed.tsx +13 -0
- package/src/tables/LoadMoreFooter/examples/flag.tsx +33 -0
- package/src/tables/LoadMoreFooter/examples/useMockCursor.ts +30 -0
- package/src/tables/LoadMoreFooter/fixtures.ts +9 -0
- package/src/tables/LoadMoreFooter/useLoadMoreFooter.ts +56 -0
- package/src/tables/index.ts +4 -0
|
@@ -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,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
|
+
}
|
|
@@ -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
|
+
});
|