@happyvertical/smrt-ui 0.42.3 → 0.42.5
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/README.md +275 -0
- package/dist/components/data/DataTable.svelte +1444 -172
- package/dist/components/data/DataTable.svelte.d.ts +1 -1
- package/dist/components/data/DataTable.svelte.d.ts.map +1 -1
- package/dist/components/data/DataTableController.d.ts +235 -0
- package/dist/components/data/DataTableController.d.ts.map +1 -0
- package/dist/components/data/DataTableController.js +792 -0
- package/dist/components/data/DataTableIdentity.d.ts +21 -0
- package/dist/components/data/DataTableIdentity.d.ts.map +1 -0
- package/dist/components/data/DataTableIdentity.js +28 -0
- package/dist/components/data/DataTableLayout.d.ts +37 -0
- package/dist/components/data/DataTableLayout.d.ts.map +1 -0
- package/dist/components/data/DataTableLayout.js +135 -0
- package/dist/components/data/DataTablePerformance.d.ts +31 -0
- package/dist/components/data/DataTablePerformance.d.ts.map +1 -0
- package/dist/components/data/DataTablePerformance.js +46 -0
- package/dist/components/data/DataTableVirtualization.d.ts +71 -0
- package/dist/components/data/DataTableVirtualization.d.ts.map +1 -0
- package/dist/components/data/DataTableVirtualization.js +100 -0
- package/dist/components/data/__benchmarks__/DataTable.bench.d.ts +2 -0
- package/dist/components/data/__benchmarks__/DataTable.bench.d.ts.map +1 -0
- package/dist/components/data/__benchmarks__/DataTable.bench.js +53 -0
- package/dist/components/data/__fixtures__/DataTableConformanceFixture.d.ts +27 -0
- package/dist/components/data/__fixtures__/DataTableConformanceFixture.d.ts.map +1 -0
- package/dist/components/data/__fixtures__/DataTableConformanceFixture.js +141 -0
- package/dist/components/data/__fixtures__/DataTablePerformanceFixture.d.ts +10 -0
- package/dist/components/data/__fixtures__/DataTablePerformanceFixture.d.ts.map +1 -0
- package/dist/components/data/__fixtures__/DataTablePerformanceFixture.js +23 -0
- package/dist/components/data/__tests__/DataTable.test.js +644 -21
- package/dist/components/data/__tests__/DataTableConformance.test.js +212 -0
- package/dist/components/data/__tests__/DataTableController.test.js +336 -0
- package/dist/components/data/__tests__/DataTableIdentity.test.js +29 -0
- package/dist/components/data/__tests__/DataTableLayout.test.js +195 -0
- package/dist/components/data/__tests__/DataTablePerformance.test.js +21 -0
- package/dist/components/data/__tests__/DataTableVirtualization.test.js +80 -0
- package/dist/components/data/__tests__/DataTableVirtualizationComponent.test.js +255 -0
- package/dist/components/data/__tests__/data-surface.test.js +675 -0
- package/dist/components/data/data-surface.d.ts +246 -0
- package/dist/components/data/data-surface.d.ts.map +1 -0
- package/dist/components/data/data-surface.js +1102 -0
- package/dist/components/data/index.d.ts +5 -0
- package/dist/components/data/index.d.ts.map +1 -1
- package/dist/components/data/index.js +5 -0
- package/dist/components/data/types.d.ts +110 -2
- package/dist/components/data/types.d.ts.map +1 -1
- package/dist/i18n/strings.d.ts +26 -0
- package/dist/i18n/strings.d.ts.map +1 -1
- package/dist/i18n/strings.js +28 -2
- package/dist/svelte/playground/DataTablePreview.svelte +400 -10
- package/dist/svelte/playground/DataTablePreview.svelte.d.ts.map +1 -1
- package/dist/svelte/playground.js +2 -2
- package/package.json +3 -2
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { resolveDataTableLayout } from '../DataTableLayout.js';
|
|
3
|
+
const columns = [
|
|
4
|
+
{
|
|
5
|
+
id: 'account',
|
|
6
|
+
label: 'Account',
|
|
7
|
+
accessor: 'account',
|
|
8
|
+
width: '180px',
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
id: 'actual',
|
|
12
|
+
label: 'Actual',
|
|
13
|
+
accessor: 'actual',
|
|
14
|
+
headerPath: [{ id: 'measures', label: 'Measures' }],
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: 'budget',
|
|
18
|
+
label: 'Budget',
|
|
19
|
+
accessor: 'budget',
|
|
20
|
+
headerPath: [{ id: 'measures', label: 'Measures' }],
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'status',
|
|
24
|
+
label: 'Status',
|
|
25
|
+
accessor: 'status',
|
|
26
|
+
role: 'status',
|
|
27
|
+
responsive: { keepVisible: true, priority: 10 },
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
const state = {
|
|
31
|
+
search: '',
|
|
32
|
+
filters: [],
|
|
33
|
+
sorting: [],
|
|
34
|
+
page: 1,
|
|
35
|
+
pageSize: null,
|
|
36
|
+
columnOrder: ['account', 'actual', 'budget', 'status'],
|
|
37
|
+
columnVisibility: columns.map((column) => ({
|
|
38
|
+
columnId: column.id,
|
|
39
|
+
visible: true,
|
|
40
|
+
})),
|
|
41
|
+
columnWidths: [],
|
|
42
|
+
columnPinning: [],
|
|
43
|
+
selection: { scope: 'explicit', rowIds: [] },
|
|
44
|
+
selectedRowIds: [],
|
|
45
|
+
expandedRowIds: [],
|
|
46
|
+
};
|
|
47
|
+
describe('resolveDataTableLayout', () => {
|
|
48
|
+
it('rebuilds grouped headers after visibility, order, and pinning are resolved', () => {
|
|
49
|
+
const layout = resolveDataTableLayout(columns, {
|
|
50
|
+
...state,
|
|
51
|
+
columnOrder: ['budget', 'account', 'actual', 'status'],
|
|
52
|
+
columnVisibility: state.columnVisibility.map((entry) => entry.columnId === 'actual' ? { ...entry, visible: false } : entry),
|
|
53
|
+
columnPinning: [
|
|
54
|
+
{ columnId: 'account', position: 'start' },
|
|
55
|
+
{ columnId: 'status', position: 'end' },
|
|
56
|
+
],
|
|
57
|
+
columnWidths: [{ columnId: 'account', width: 220 }],
|
|
58
|
+
});
|
|
59
|
+
expect(layout.columns.map(({ column }) => column.id)).toEqual([
|
|
60
|
+
'account',
|
|
61
|
+
'budget',
|
|
62
|
+
'status',
|
|
63
|
+
]);
|
|
64
|
+
expect(layout.columns[0]).toMatchObject({
|
|
65
|
+
pin: 'start',
|
|
66
|
+
width: 220,
|
|
67
|
+
stickyOffset: '0px',
|
|
68
|
+
});
|
|
69
|
+
expect(layout.columns[2]).toMatchObject({
|
|
70
|
+
pin: 'end',
|
|
71
|
+
stickyOffset: '0px',
|
|
72
|
+
});
|
|
73
|
+
expect(layout.headerRows).toHaveLength(2);
|
|
74
|
+
expect(layout.headerRows[0]).toEqual([
|
|
75
|
+
expect.objectContaining({ kind: 'leaf', rowspan: 2 }),
|
|
76
|
+
expect.objectContaining({
|
|
77
|
+
kind: 'group',
|
|
78
|
+
label: 'Measures',
|
|
79
|
+
colspan: 1,
|
|
80
|
+
}),
|
|
81
|
+
expect.objectContaining({ kind: 'leaf', rowspan: 2 }),
|
|
82
|
+
]);
|
|
83
|
+
expect(layout.headerRows[1]).toEqual([
|
|
84
|
+
expect.objectContaining({
|
|
85
|
+
kind: 'leaf',
|
|
86
|
+
column: expect.objectContaining({
|
|
87
|
+
column: expect.objectContaining({ id: 'budget' }),
|
|
88
|
+
}),
|
|
89
|
+
}),
|
|
90
|
+
]);
|
|
91
|
+
});
|
|
92
|
+
it('splits a repeated group when restored order separates its leaves', () => {
|
|
93
|
+
const layout = resolveDataTableLayout(columns, {
|
|
94
|
+
...state,
|
|
95
|
+
columnOrder: ['actual', 'status', 'budget', 'account'],
|
|
96
|
+
});
|
|
97
|
+
const groups = layout.headerRows[0].filter((cell) => cell.kind === 'group');
|
|
98
|
+
expect(groups).toHaveLength(2);
|
|
99
|
+
expect(groups).toEqual([
|
|
100
|
+
expect.objectContaining({ label: 'Measures', colspan: 1 }),
|
|
101
|
+
expect.objectContaining({ label: 'Measures', colspan: 1 }),
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
it('keeps an uneven header path rectangular with leaf row spans', () => {
|
|
105
|
+
const layout = resolveDataTableLayout([
|
|
106
|
+
columns[0],
|
|
107
|
+
{
|
|
108
|
+
...columns[1],
|
|
109
|
+
headerPath: [{ id: 'performance', label: 'Performance' }],
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
...columns[2],
|
|
113
|
+
headerPath: [
|
|
114
|
+
{ id: 'performance', label: 'Performance' },
|
|
115
|
+
{ id: 'plan', label: 'Plan' },
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
], {
|
|
119
|
+
...state,
|
|
120
|
+
columnOrder: ['account', 'actual', 'budget'],
|
|
121
|
+
columnVisibility: [
|
|
122
|
+
{ columnId: 'account', visible: true },
|
|
123
|
+
{ columnId: 'actual', visible: true },
|
|
124
|
+
{ columnId: 'budget', visible: true },
|
|
125
|
+
],
|
|
126
|
+
});
|
|
127
|
+
expect(layout.headerRows).toHaveLength(3);
|
|
128
|
+
expect(layout.headerRows[0]).toEqual([
|
|
129
|
+
expect.objectContaining({ kind: 'leaf', rowspan: 3 }),
|
|
130
|
+
expect.objectContaining({
|
|
131
|
+
kind: 'group',
|
|
132
|
+
label: 'Performance',
|
|
133
|
+
colspan: 2,
|
|
134
|
+
rowspan: 1,
|
|
135
|
+
}),
|
|
136
|
+
]);
|
|
137
|
+
expect(layout.headerRows[1]).toEqual([
|
|
138
|
+
expect.objectContaining({ kind: 'leaf', rowspan: 2 }),
|
|
139
|
+
expect.objectContaining({ kind: 'group', label: 'Plan' }),
|
|
140
|
+
]);
|
|
141
|
+
expect(layout.headerRows[2]).toEqual([
|
|
142
|
+
expect.objectContaining({ kind: 'leaf', rowspan: 1 }),
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
it('uses measured auto widths for sibling pins and keeps an all-pinned group aligned', () => {
|
|
146
|
+
const layout = resolveDataTableLayout([columns[1], columns[2], columns[0]], {
|
|
147
|
+
...state,
|
|
148
|
+
columnOrder: ['actual', 'budget', 'account'],
|
|
149
|
+
columnVisibility: [
|
|
150
|
+
{ columnId: 'actual', visible: true },
|
|
151
|
+
{ columnId: 'budget', visible: true },
|
|
152
|
+
{ columnId: 'account', visible: true },
|
|
153
|
+
],
|
|
154
|
+
columnPinning: [
|
|
155
|
+
{ columnId: 'actual', position: 'start' },
|
|
156
|
+
{ columnId: 'budget', position: 'start' },
|
|
157
|
+
],
|
|
158
|
+
}, { actual: 104, budget: 116 });
|
|
159
|
+
expect(layout.columns[0]).toMatchObject({
|
|
160
|
+
column: { id: 'actual' },
|
|
161
|
+
pin: 'start',
|
|
162
|
+
stickyOffset: '0px',
|
|
163
|
+
});
|
|
164
|
+
expect(layout.columns[1]).toMatchObject({
|
|
165
|
+
column: { id: 'budget' },
|
|
166
|
+
pin: 'start',
|
|
167
|
+
stickyOffset: 'calc(0px + 104px)',
|
|
168
|
+
});
|
|
169
|
+
expect(layout.headerRows[0]).toContainEqual(expect.objectContaining({
|
|
170
|
+
kind: 'group',
|
|
171
|
+
label: 'Measures',
|
|
172
|
+
pin: 'start',
|
|
173
|
+
stickyOffset: '0px',
|
|
174
|
+
}));
|
|
175
|
+
});
|
|
176
|
+
it('uses a rendered width before a restored width for pinned offsets', () => {
|
|
177
|
+
const layout = resolveDataTableLayout([columns[1], columns[2]], {
|
|
178
|
+
...state,
|
|
179
|
+
columnOrder: ['actual', 'budget'],
|
|
180
|
+
columnVisibility: [
|
|
181
|
+
{ columnId: 'actual', visible: true },
|
|
182
|
+
{ columnId: 'budget', visible: true },
|
|
183
|
+
],
|
|
184
|
+
columnPinning: [
|
|
185
|
+
{ columnId: 'actual', position: 'start' },
|
|
186
|
+
{ columnId: 'budget', position: 'start' },
|
|
187
|
+
],
|
|
188
|
+
columnWidths: [{ columnId: 'actual', width: 80 }],
|
|
189
|
+
}, { actual: 200, budget: 120 });
|
|
190
|
+
expect(layout.columns[1]).toMatchObject({
|
|
191
|
+
column: { id: 'budget' },
|
|
192
|
+
stickyOffset: 'calc(0px + 200px)',
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createDataTablePerformanceColumns, createDataTablePerformanceRows, } from '../__fixtures__/DataTablePerformanceFixture.js';
|
|
3
|
+
import { DATA_TABLE_SCALE_THRESHOLDS, recommendDataTableScaleStrategy, } from '../DataTablePerformance.js';
|
|
4
|
+
describe('DataTable scale thresholds', () => {
|
|
5
|
+
it('uses deterministic, stable-identity benchmark fixtures', () => {
|
|
6
|
+
const rows = createDataTablePerformanceRows(3, 2);
|
|
7
|
+
expect(rows.map((row) => row.id)).toEqual(['row-0', 'row-1', 'row-2']);
|
|
8
|
+
expect(rows[2]?.values).toEqual({ value0: 6, value1: 8 });
|
|
9
|
+
expect(createDataTablePerformanceColumns(2).map((column) => column.id)).toEqual(['name', 'group', 'value0', 'value1']);
|
|
10
|
+
});
|
|
11
|
+
it('recommends a bounded scale strategy from documented thresholds', () => {
|
|
12
|
+
expect(recommendDataTableScaleStrategy(250, 20)).toBe('local');
|
|
13
|
+
expect(recommendDataTableScaleStrategy(251, 20)).toBe('virtualization');
|
|
14
|
+
expect(recommendDataTableScaleStrategy(1_001, 2)).toBe('manual-paging');
|
|
15
|
+
expect(DATA_TABLE_SCALE_THRESHOLDS.manualPaging.recommendedPageSize).toBe(100);
|
|
16
|
+
});
|
|
17
|
+
it('rejects ambiguous scale inputs', () => {
|
|
18
|
+
expect(() => recommendDataTableScaleStrategy(-1, 2)).toThrow(/non-negative/);
|
|
19
|
+
expect(() => recommendDataTableScaleStrategy(2.5, 2)).toThrow(/integers/);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { maximumDataTableVirtualScrollTop, resolveDataTableVirtualWindow, scrollTopForDataTableRow, } from '../DataTableVirtualization.js';
|
|
3
|
+
const options = { rowHeight: 20, viewportHeight: 100, overscan: 2 };
|
|
4
|
+
describe('DataTable virtualization window', () => {
|
|
5
|
+
it('renders a fixed-height window with bounded overscan', () => {
|
|
6
|
+
expect(resolveDataTableVirtualWindow({
|
|
7
|
+
options,
|
|
8
|
+
rowCount: 100,
|
|
9
|
+
scrollTop: 200,
|
|
10
|
+
})).toMatchObject({
|
|
11
|
+
enabled: true,
|
|
12
|
+
startIndex: 8,
|
|
13
|
+
endIndex: 17,
|
|
14
|
+
topSpacerHeight: 160,
|
|
15
|
+
bottomSpacerHeight: 1_660,
|
|
16
|
+
totalBodyHeight: 2_000,
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
it('clamps a restored scroll position to the body extent', () => {
|
|
20
|
+
expect(resolveDataTableVirtualWindow({
|
|
21
|
+
options,
|
|
22
|
+
rowCount: 10,
|
|
23
|
+
scrollTop: 1_000,
|
|
24
|
+
})).toMatchObject({
|
|
25
|
+
startIndex: 3,
|
|
26
|
+
endIndex: 10,
|
|
27
|
+
topSpacerHeight: 60,
|
|
28
|
+
bottomSpacerHeight: 0,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
it('falls back to a semantic full body for variable-height data rows', () => {
|
|
32
|
+
expect(resolveDataTableVirtualWindow({
|
|
33
|
+
options,
|
|
34
|
+
rowCount: 12,
|
|
35
|
+
scrollTop: 100,
|
|
36
|
+
hasVariableRowHeight: true,
|
|
37
|
+
headerRowCount: 2,
|
|
38
|
+
summaryRowCount: 1,
|
|
39
|
+
})).toEqual({
|
|
40
|
+
enabled: false,
|
|
41
|
+
reason: 'variable-row-height',
|
|
42
|
+
startIndex: 0,
|
|
43
|
+
endIndex: 12,
|
|
44
|
+
topSpacerHeight: 0,
|
|
45
|
+
bottomSpacerHeight: 0,
|
|
46
|
+
totalBodyHeight: 0,
|
|
47
|
+
headerRowCount: 2,
|
|
48
|
+
summaryRowCount: 1,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
it('keeps headers and summaries outside the body calculation', () => {
|
|
52
|
+
const withStructuralRows = resolveDataTableVirtualWindow({
|
|
53
|
+
options,
|
|
54
|
+
rowCount: 100,
|
|
55
|
+
scrollTop: 0,
|
|
56
|
+
headerRowCount: 2,
|
|
57
|
+
summaryRowCount: 3,
|
|
58
|
+
});
|
|
59
|
+
expect(withStructuralRows.totalBodyHeight).toBe(2_000);
|
|
60
|
+
expect(withStructuralRows.headerRowCount).toBe(2);
|
|
61
|
+
expect(withStructuralRows.summaryRowCount).toBe(3);
|
|
62
|
+
});
|
|
63
|
+
it('extends the virtual scroll range by a measured summary footer', () => {
|
|
64
|
+
expect(maximumDataTableVirtualScrollTop(100, options, 24)).toBe(1_924);
|
|
65
|
+
expect(() => maximumDataTableVirtualScrollTop(1, options, -1)).toThrow(/footerHeight/);
|
|
66
|
+
});
|
|
67
|
+
it('derives scroll restoration from a row position, not a display key', () => {
|
|
68
|
+
expect(scrollTopForDataTableRow(0, 100, options, 400)).toBe(0);
|
|
69
|
+
expect(scrollTopForDataTableRow(25, 100, options, 0)).toBe(420);
|
|
70
|
+
expect(scrollTopForDataTableRow(99, 100, options, 0)).toBe(1_900);
|
|
71
|
+
});
|
|
72
|
+
it('rejects invalid fixed-height configuration', () => {
|
|
73
|
+
expect(() => resolveDataTableVirtualWindow({
|
|
74
|
+
options: { rowHeight: 0, viewportHeight: 100 },
|
|
75
|
+
rowCount: 1,
|
|
76
|
+
scrollTop: 0,
|
|
77
|
+
})).toThrow(/rowHeight/);
|
|
78
|
+
expect(() => scrollTopForDataTableRow(2, 2, options)).toThrow(/rowIndex/);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/svelte';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { createRawSnippet } from 'svelte';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { expectNoA11yViolations } from '../../../test-support/a11y';
|
|
6
|
+
import DataTable from '../DataTable.svelte';
|
|
7
|
+
import { createDataTableController } from '../DataTableController.js';
|
|
8
|
+
const rows = Array.from({ length: 100 }, (_, index) => ({
|
|
9
|
+
id: `row-${index}`,
|
|
10
|
+
name: `Record ${index.toString().padStart(5, '0')}`,
|
|
11
|
+
}));
|
|
12
|
+
const columns = [{ id: 'name', label: 'Name', accessor: 'name' }];
|
|
13
|
+
const virtualization = { rowHeight: 20, viewportHeight: 100, overscan: 1 };
|
|
14
|
+
function getScrollContainer(container) {
|
|
15
|
+
const node = container.querySelector('.data-table-container');
|
|
16
|
+
if (!node)
|
|
17
|
+
throw new Error('Expected DataTable scroll container');
|
|
18
|
+
return node;
|
|
19
|
+
}
|
|
20
|
+
describe('DataTable virtualization seam', () => {
|
|
21
|
+
it('renders only a fixed-height body window with accessible row context', async () => {
|
|
22
|
+
const { container } = render(DataTable, {
|
|
23
|
+
props: { data: rows, columns, rowKey: 'id', virtualization },
|
|
24
|
+
});
|
|
25
|
+
const scrollContainer = getScrollContainer(container);
|
|
26
|
+
expect(screen.getByRole('columnheader', { name: 'Name' })).toBeInTheDocument();
|
|
27
|
+
expect(screen.getByRole('table')).toHaveAttribute('aria-rowcount', '101');
|
|
28
|
+
expect(getScrollContainer(container)).toHaveAttribute('role', 'region');
|
|
29
|
+
expect(getScrollContainer(container)).toHaveAttribute('tabindex', '0');
|
|
30
|
+
expect(screen.getByText('Record 00000')).toBeInTheDocument();
|
|
31
|
+
expect(screen.getByText('Record 00000').closest('tr')).toHaveAttribute('aria-rowindex', '2');
|
|
32
|
+
expect(screen.queryByText('Record 00020')).not.toBeInTheDocument();
|
|
33
|
+
expect(container.querySelectorAll('.data-table__virtual-spacer')).toHaveLength(1);
|
|
34
|
+
scrollContainer.scrollTop = 200;
|
|
35
|
+
await fireEvent.scroll(scrollContainer);
|
|
36
|
+
await vi.waitFor(() => {
|
|
37
|
+
expect(screen.queryByText('Record 00000')).not.toBeInTheDocument();
|
|
38
|
+
expect(screen.getByText('Record 00010')).toBeInTheDocument();
|
|
39
|
+
});
|
|
40
|
+
expect(screen.getByText('Record 00010').closest('tr')).toHaveAttribute('aria-rowindex', '12');
|
|
41
|
+
expect(container.querySelectorAll('.data-table__virtual-spacer')).toHaveLength(2);
|
|
42
|
+
});
|
|
43
|
+
it('keeps manual pages and selected stable IDs independent of the rendered window', async () => {
|
|
44
|
+
const footer = createRawSnippet(() => ({
|
|
45
|
+
render: () => '<strong>Remote summary</strong>',
|
|
46
|
+
}));
|
|
47
|
+
const controller = createDataTableController({
|
|
48
|
+
columnIds: ['name'],
|
|
49
|
+
modes: { filtering: 'manual', sorting: 'manual', pagination: 'manual' },
|
|
50
|
+
initialState: { page: 12, pageSize: 100 },
|
|
51
|
+
});
|
|
52
|
+
const { container } = render(DataTable, {
|
|
53
|
+
props: {
|
|
54
|
+
data: rows,
|
|
55
|
+
columns,
|
|
56
|
+
rowKey: 'id',
|
|
57
|
+
selectable: true,
|
|
58
|
+
controller,
|
|
59
|
+
totalRows: 10_000,
|
|
60
|
+
footer,
|
|
61
|
+
virtualization,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
const scrollContainer = getScrollContainer(container);
|
|
65
|
+
expect(screen.getByRole('table')).toHaveAttribute('aria-rowcount', '10002');
|
|
66
|
+
expect(screen.getByText('Remote summary').closest('tr')).toHaveAttribute('aria-rowindex', '10002');
|
|
67
|
+
await userEvent.click(screen.getAllByRole('checkbox', { name: 'Select row 1101' })[0]);
|
|
68
|
+
expect(controller.getState().selectedRowIds).toEqual(['row-0']);
|
|
69
|
+
scrollContainer.scrollTop = 400;
|
|
70
|
+
await fireEvent.scroll(scrollContainer);
|
|
71
|
+
await vi.waitFor(() => expect(screen.getByText('Record 00020')).toBeInTheDocument());
|
|
72
|
+
expect(screen.getByText('Record 00020').closest('tr')).toHaveAttribute('aria-rowindex', '1122');
|
|
73
|
+
expect(controller.getState().selectedRowIds).toEqual(['row-0']);
|
|
74
|
+
expect(screen.queryByText('Record 00099')).not.toBeInTheDocument();
|
|
75
|
+
});
|
|
76
|
+
it('restores a controlled focused row by stable identity and reports row focus', async () => {
|
|
77
|
+
const onFocusedRowIdChange = vi.fn();
|
|
78
|
+
const { container } = render(DataTable, {
|
|
79
|
+
props: {
|
|
80
|
+
data: rows,
|
|
81
|
+
columns,
|
|
82
|
+
rowKey: 'id',
|
|
83
|
+
onRowClick: () => undefined,
|
|
84
|
+
virtualization: {
|
|
85
|
+
...virtualization,
|
|
86
|
+
focusedRowId: 'row-40',
|
|
87
|
+
onFocusedRowIdChange,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
const scrollContainer = getScrollContainer(container);
|
|
92
|
+
const focusedRow = await screen.findByText('Record 00040');
|
|
93
|
+
expect(scrollContainer.scrollTop).toBeGreaterThan(0);
|
|
94
|
+
expect(document.activeElement).toBe(focusedRow.closest('tr'));
|
|
95
|
+
expect(onFocusedRowIdChange).toHaveBeenCalledWith('row-40');
|
|
96
|
+
});
|
|
97
|
+
it('restores focus without aliasing numeric and string row identities', async () => {
|
|
98
|
+
const identityRows = [
|
|
99
|
+
{ id: 1, name: 'Numeric row' },
|
|
100
|
+
{ id: '1', name: 'String row' },
|
|
101
|
+
];
|
|
102
|
+
const { container } = render(DataTable, {
|
|
103
|
+
props: {
|
|
104
|
+
data: identityRows,
|
|
105
|
+
columns,
|
|
106
|
+
rowKey: 'id',
|
|
107
|
+
virtualization: { ...virtualization, focusedRowId: '1' },
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
const stringRow = await screen.findByText('String row');
|
|
111
|
+
expect(stringRow.closest('tr')).toHaveAttribute('data-row-id', 'string:1');
|
|
112
|
+
expect(document.activeElement).toBe(stringRow.closest('tr'));
|
|
113
|
+
expect(container.querySelector('tr[data-row-id="number:1"]')).toBeTruthy();
|
|
114
|
+
});
|
|
115
|
+
it('scrolls the virtual body from the keyboard-accessible region', async () => {
|
|
116
|
+
const onScrollTopChange = vi.fn();
|
|
117
|
+
const { container } = render(DataTable, {
|
|
118
|
+
props: {
|
|
119
|
+
data: rows,
|
|
120
|
+
columns,
|
|
121
|
+
rowKey: 'id',
|
|
122
|
+
virtualization: { ...virtualization, onScrollTopChange },
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
const scrollContainer = getScrollContainer(container);
|
|
126
|
+
scrollContainer.focus();
|
|
127
|
+
await fireEvent.keyDown(scrollContainer, { key: 'PageDown' });
|
|
128
|
+
expect(scrollContainer.scrollTop).toBe(80);
|
|
129
|
+
expect(onScrollTopChange).toHaveBeenCalledWith(80);
|
|
130
|
+
await vi.waitFor(() => expect(screen.getByText('Record 00004')).toBeInTheDocument());
|
|
131
|
+
});
|
|
132
|
+
it('stripes virtual rows by their logical display index', async () => {
|
|
133
|
+
const { container } = render(DataTable, {
|
|
134
|
+
props: {
|
|
135
|
+
data: rows,
|
|
136
|
+
columns,
|
|
137
|
+
rowKey: 'id',
|
|
138
|
+
striped: true,
|
|
139
|
+
virtualization,
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
const scrollContainer = getScrollContainer(container);
|
|
143
|
+
scrollContainer.scrollTop = 40;
|
|
144
|
+
await fireEvent.scroll(scrollContainer);
|
|
145
|
+
await vi.waitFor(() => expect(screen.getByText('Record 00001')).toBeInTheDocument());
|
|
146
|
+
expect(screen.getByText('Record 00001').closest('tr')).toHaveClass('data-table__row--striped');
|
|
147
|
+
expect(screen.getByText('Record 00002').closest('tr')).not.toHaveClass('data-table__row--striped');
|
|
148
|
+
});
|
|
149
|
+
it('keeps the summary footer reachable at the end of the virtual scroll range', async () => {
|
|
150
|
+
const footer = createRawSnippet(() => ({
|
|
151
|
+
render: () => '<strong>Summary total</strong>',
|
|
152
|
+
}));
|
|
153
|
+
const offsetHeight = vi
|
|
154
|
+
.spyOn(HTMLElement.prototype, 'offsetHeight', 'get')
|
|
155
|
+
.mockImplementation(function () {
|
|
156
|
+
return this.tagName === 'TFOOT' ? 24 : 0;
|
|
157
|
+
});
|
|
158
|
+
const onScrollTopChange = vi.fn();
|
|
159
|
+
try {
|
|
160
|
+
const { container } = render(DataTable, {
|
|
161
|
+
props: {
|
|
162
|
+
data: rows,
|
|
163
|
+
columns,
|
|
164
|
+
rowKey: 'id',
|
|
165
|
+
footer,
|
|
166
|
+
virtualization: { ...virtualization, onScrollTopChange },
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
const scrollContainer = getScrollContainer(container);
|
|
170
|
+
await fireEvent.keyDown(scrollContainer, { key: 'End' });
|
|
171
|
+
expect(scrollContainer.scrollTop).toBe(1_924);
|
|
172
|
+
expect(onScrollTopChange).toHaveBeenCalledWith(1_924);
|
|
173
|
+
expect(screen.getByText('Summary total')).toBeInTheDocument();
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
offsetHeight.mockRestore();
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
it('accounts for grouped headers and structural summary rows in virtual context', async () => {
|
|
180
|
+
const offsetHeight = vi
|
|
181
|
+
.spyOn(HTMLElement.prototype, 'offsetHeight', 'get')
|
|
182
|
+
.mockImplementation(function () {
|
|
183
|
+
if (this.tagName === 'TFOOT')
|
|
184
|
+
return 24;
|
|
185
|
+
if (this.tagName === 'TBODY' &&
|
|
186
|
+
this.classList.contains('data-table__body--structural')) {
|
|
187
|
+
return 20;
|
|
188
|
+
}
|
|
189
|
+
return 0;
|
|
190
|
+
});
|
|
191
|
+
const groupedColumns = [
|
|
192
|
+
{
|
|
193
|
+
...columns[0],
|
|
194
|
+
headerPath: [{ id: 'identity', label: 'Identity' }],
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
try {
|
|
198
|
+
const { container } = render(DataTable, {
|
|
199
|
+
props: {
|
|
200
|
+
data: rows,
|
|
201
|
+
columns: groupedColumns,
|
|
202
|
+
rowKey: 'id',
|
|
203
|
+
structuralRows: [
|
|
204
|
+
{ id: 'subtotal', kind: 'subtotal', label: 'Current page' },
|
|
205
|
+
{ id: 'footer', kind: 'footer', label: 'All rows' },
|
|
206
|
+
],
|
|
207
|
+
virtualization,
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
const scrollContainer = getScrollContainer(container);
|
|
211
|
+
expect(screen.getByRole('table')).toHaveAttribute('aria-rowcount', '104');
|
|
212
|
+
expect(screen.getByText('Record 00000').closest('tr')).toHaveAttribute('aria-rowindex', '3');
|
|
213
|
+
expect(screen.getByText('Current page').closest('tr')).toHaveAttribute('aria-rowindex', '103');
|
|
214
|
+
expect(screen.getByText('All rows').closest('tr')).toHaveAttribute('aria-rowindex', '104');
|
|
215
|
+
await fireEvent.keyDown(scrollContainer, { key: 'End' });
|
|
216
|
+
expect(scrollContainer.scrollTop).toBe(1_944);
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
offsetHeight.mockRestore();
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
it('falls back for expansion without virtual scroll behavior', async () => {
|
|
223
|
+
const expandedContent = createRawSnippet(() => ({
|
|
224
|
+
render: () => '<p>Variable detail</p>',
|
|
225
|
+
}));
|
|
226
|
+
const footer = createRawSnippet(() => ({
|
|
227
|
+
render: () => '<strong>Summary total</strong>',
|
|
228
|
+
}));
|
|
229
|
+
const onScrollTopChange = vi.fn();
|
|
230
|
+
const { container } = render(DataTable, {
|
|
231
|
+
props: {
|
|
232
|
+
data: rows,
|
|
233
|
+
columns,
|
|
234
|
+
rowKey: 'id',
|
|
235
|
+
expandedContent,
|
|
236
|
+
footer,
|
|
237
|
+
virtualization: { ...virtualization, onScrollTopChange },
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
expect(screen.getByText('Record 00099')).toBeInTheDocument();
|
|
241
|
+
expect(screen.getByText('Summary total')).toBeInTheDocument();
|
|
242
|
+
expect(container.querySelector('.data-table__virtual-spacer')).toBeNull();
|
|
243
|
+
const scrollContainer = getScrollContainer(container);
|
|
244
|
+
expect(scrollContainer).not.toHaveAttribute('tabindex');
|
|
245
|
+
scrollContainer.scrollTop = 200;
|
|
246
|
+
await fireEvent.scroll(scrollContainer);
|
|
247
|
+
expect(onScrollTopChange).not.toHaveBeenCalled();
|
|
248
|
+
});
|
|
249
|
+
it('is axe-clean with virtual spacer rows', async () => {
|
|
250
|
+
const { container } = render(DataTable, {
|
|
251
|
+
props: { data: rows, columns, rowKey: 'id', virtualization },
|
|
252
|
+
});
|
|
253
|
+
await expectNoA11yViolations(container);
|
|
254
|
+
});
|
|
255
|
+
});
|