@dashforge/tw 0.8.1-beta → 0.9.0-beta
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/CHANGELOG.md +126 -0
- package/COVERAGE.md +63 -0
- package/LICENSE +21 -0
- package/PERFORMANCE.md +59 -0
- package/REFINEMENT-NOTES.md +50 -0
- package/dist/index.esm.js +177 -67
- package/dist/src/components/DataGrid/DataGrid.d.ts.map +1 -1
- package/dist/src/components/DataGrid/filters/ColumnFilters.d.ts.map +1 -1
- package/dist/src/components/DataGrid/resize/useColumnResize.d.ts +7 -0
- package/dist/src/components/DataGrid/resize/useColumnResize.d.ts.map +1 -1
- package/dist/src/components/DataGrid/visibility/ColumnVisibilityMenu.d.ts +6 -2
- package/dist/src/components/DataGrid/visibility/ColumnVisibilityMenu.d.ts.map +1 -1
- package/dist/src/components/Dialog/Dialog.d.ts.map +1 -1
- package/dist/src/components/Table/Table.d.ts.map +1 -1
- package/dist/src/components/Table/cells/RenderChip.d.ts.map +1 -1
- package/dist/src/components/Table/table.types.d.ts +28 -3
- package/dist/src/components/Table/table.types.d.ts.map +1 -1
- package/dist/src/components/Typography/typography.variants.d.ts.map +1 -1
- package/eslint.config.mjs +16 -1
- package/package.json +9 -6
- package/src/components/Breadcrumbs/Breadcrumbs.perf.test.tsx +5 -2
- package/src/components/DataGrid/DataGrid.perf.test.tsx +114 -0
- package/src/components/DataGrid/DataGrid.test.tsx +240 -0
- package/src/components/DataGrid/DataGrid.tsx +104 -16
- package/src/components/DataGrid/filters/ColumnFilters.test.tsx +330 -0
- package/src/components/DataGrid/filters/ColumnFilters.tsx +26 -2
- package/src/components/DataGrid/reorder/useColumnReorder.test.ts +95 -3
- package/src/components/DataGrid/resize/useColumnResize.test.ts +144 -0
- package/src/components/DataGrid/resize/useColumnResize.ts +30 -1
- package/src/components/DataGrid/visibility/ColumnVisibilityMenu.tsx +42 -25
- package/src/components/Dialog/Dialog.tsx +9 -0
- package/src/components/Table/Table.perf.test.tsx +81 -0
- package/src/components/Table/Table.test.tsx +72 -1
- package/src/components/Table/Table.tsx +40 -4
- package/src/components/Table/cells/RenderChip.tsx +6 -9
- package/src/components/Table/cells/cells.test.tsx +247 -0
- package/src/components/Table/table.types.ts +28 -3
- package/src/components/Typography/Typography.test.tsx +15 -0
- package/src/components/Typography/typography.variants.ts +14 -1
- package/test-output/vitest/coverage/base.css +224 -0
- package/test-output/vitest/coverage/block-navigation.js +87 -0
- package/test-output/vitest/coverage/clover.xml +2418 -0
- package/test-output/vitest/coverage/coverage-final.json +89 -0
- package/test-output/vitest/coverage/favicon.png +0 -0
- package/test-output/vitest/coverage/index.html +746 -0
- package/test-output/vitest/coverage/prettify.css +1 -0
- package/test-output/vitest/coverage/prettify.js +2 -0
- package/test-output/vitest/coverage/sort-arrow-sprite.png +0 -0
- package/test-output/vitest/coverage/sorter.js +210 -0
- package/test-output/vitest/coverage/src/components/Accordion/Accordion.tsx.html +376 -0
- package/test-output/vitest/coverage/src/components/Accordion/accordion.variants.ts.html +181 -0
- package/test-output/vitest/coverage/src/components/Accordion/index.html +131 -0
- package/test-output/vitest/coverage/src/components/AppShell/AppShell.tsx.html +802 -0
- package/test-output/vitest/coverage/src/components/AppShell/appShell.variants.ts.html +247 -0
- package/test-output/vitest/coverage/src/components/AppShell/index.html +131 -0
- package/test-output/vitest/coverage/src/components/AspectRatio/AspectRatio.tsx.html +304 -0
- package/test-output/vitest/coverage/src/components/AspectRatio/index.html +116 -0
- package/test-output/vitest/coverage/src/components/Autocomplete/Autocomplete.tsx.html +3292 -0
- package/test-output/vitest/coverage/src/components/Autocomplete/autocomplete.variants.ts.html +508 -0
- package/test-output/vitest/coverage/src/components/Autocomplete/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Box/Box.tsx.html +334 -0
- package/test-output/vitest/coverage/src/components/Box/box.variants.ts.html +775 -0
- package/test-output/vitest/coverage/src/components/Box/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Breadcrumbs/Breadcrumbs.tsx.html +694 -0
- package/test-output/vitest/coverage/src/components/Breadcrumbs/breadcrumbs.variants.ts.html +247 -0
- package/test-output/vitest/coverage/src/components/Breadcrumbs/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Button/Button.tsx.html +535 -0
- package/test-output/vitest/coverage/src/components/Button/button.variants.ts.html +562 -0
- package/test-output/vitest/coverage/src/components/Button/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Checkbox/Checkbox.tsx.html +943 -0
- package/test-output/vitest/coverage/src/components/Checkbox/checkbox.variants.ts.html +289 -0
- package/test-output/vitest/coverage/src/components/Checkbox/index.html +131 -0
- package/test-output/vitest/coverage/src/components/ConfirmDialog/ConfirmDialog.tsx.html +841 -0
- package/test-output/vitest/coverage/src/components/ConfirmDialog/confirmDialog.variants.ts.html +313 -0
- package/test-output/vitest/coverage/src/components/ConfirmDialog/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Container/Container.tsx.html +307 -0
- package/test-output/vitest/coverage/src/components/Container/container.variants.ts.html +328 -0
- package/test-output/vitest/coverage/src/components/Container/index.html +131 -0
- package/test-output/vitest/coverage/src/components/DataGrid/DataGrid.tsx.html +3937 -0
- package/test-output/vitest/coverage/src/components/DataGrid/dataGrid.variants.ts.html +541 -0
- package/test-output/vitest/coverage/src/components/DataGrid/filters/ColumnFilters.tsx.html +1453 -0
- package/test-output/vitest/coverage/src/components/DataGrid/filters/index.html +116 -0
- package/test-output/vitest/coverage/src/components/DataGrid/index.html +131 -0
- package/test-output/vitest/coverage/src/components/DataGrid/reorder/index.html +116 -0
- package/test-output/vitest/coverage/src/components/DataGrid/reorder/useColumnReorder.ts.html +517 -0
- package/test-output/vitest/coverage/src/components/DataGrid/resize/index.html +116 -0
- package/test-output/vitest/coverage/src/components/DataGrid/resize/useColumnResize.ts.html +490 -0
- package/test-output/vitest/coverage/src/components/DataGrid/visibility/ColumnVisibilityMenu.tsx.html +592 -0
- package/test-output/vitest/coverage/src/components/DataGrid/visibility/index.html +116 -0
- package/test-output/vitest/coverage/src/components/DateTimePicker/DateTimePicker.tsx.html +1042 -0
- package/test-output/vitest/coverage/src/components/DateTimePicker/dateTimePicker.variants.ts.html +337 -0
- package/test-output/vitest/coverage/src/components/DateTimePicker/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Dialog/Dialog.tsx.html +391 -0
- package/test-output/vitest/coverage/src/components/Dialog/dialog.variants.ts.html +283 -0
- package/test-output/vitest/coverage/src/components/Dialog/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Divider/Divider.tsx.html +505 -0
- package/test-output/vitest/coverage/src/components/Divider/divider.variants.ts.html +433 -0
- package/test-output/vitest/coverage/src/components/Divider/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Grid/Grid.tsx.html +529 -0
- package/test-output/vitest/coverage/src/components/Grid/grid.variants.ts.html +484 -0
- package/test-output/vitest/coverage/src/components/Grid/index.html +131 -0
- package/test-output/vitest/coverage/src/components/LeftNav/LeftNav.tsx.html +1075 -0
- package/test-output/vitest/coverage/src/components/LeftNav/index.html +131 -0
- package/test-output/vitest/coverage/src/components/LeftNav/leftNav.variants.ts.html +406 -0
- package/test-output/vitest/coverage/src/components/NumberField/NumberField.tsx.html +1048 -0
- package/test-output/vitest/coverage/src/components/NumberField/index.html +131 -0
- package/test-output/vitest/coverage/src/components/NumberField/numberField.variants.ts.html +367 -0
- package/test-output/vitest/coverage/src/components/OTPField/OTPField.tsx.html +835 -0
- package/test-output/vitest/coverage/src/components/OTPField/index.html +131 -0
- package/test-output/vitest/coverage/src/components/OTPField/otpField.variants.ts.html +301 -0
- package/test-output/vitest/coverage/src/components/Pagination/Pagination.tsx.html +916 -0
- package/test-output/vitest/coverage/src/components/Pagination/index.html +146 -0
- package/test-output/vitest/coverage/src/components/Pagination/pagination.helpers.ts.html +391 -0
- package/test-output/vitest/coverage/src/components/Pagination/pagination.variants.ts.html +436 -0
- package/test-output/vitest/coverage/src/components/Popover/Popover.tsx.html +250 -0
- package/test-output/vitest/coverage/src/components/Popover/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Popover/popover.variants.ts.html +157 -0
- package/test-output/vitest/coverage/src/components/RadioGroup/RadioGroup.tsx.html +934 -0
- package/test-output/vitest/coverage/src/components/RadioGroup/index.html +131 -0
- package/test-output/vitest/coverage/src/components/RadioGroup/radioGroup.variants.ts.html +346 -0
- package/test-output/vitest/coverage/src/components/Skeleton/Skeleton.tsx.html +232 -0
- package/test-output/vitest/coverage/src/components/Skeleton/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Skeleton/skeleton.variants.ts.html +346 -0
- package/test-output/vitest/coverage/src/components/Snackbar/Snackbar.tsx.html +979 -0
- package/test-output/vitest/coverage/src/components/Snackbar/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Snackbar/snackbar.variants.ts.html +307 -0
- package/test-output/vitest/coverage/src/components/Stack/Stack.tsx.html +430 -0
- package/test-output/vitest/coverage/src/components/Stack/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Stack/stack.variants.ts.html +325 -0
- package/test-output/vitest/coverage/src/components/Switch/Switch.tsx.html +559 -0
- package/test-output/vitest/coverage/src/components/Switch/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Switch/switch.variants.ts.html +295 -0
- package/test-output/vitest/coverage/src/components/Table/Table.tsx.html +2677 -0
- package/test-output/vitest/coverage/src/components/Table/cells/RenderButton.tsx.html +154 -0
- package/test-output/vitest/coverage/src/components/Table/cells/RenderChip.tsx.html +385 -0
- package/test-output/vitest/coverage/src/components/Table/cells/RenderText.tsx.html +196 -0
- package/test-output/vitest/coverage/src/components/Table/cells/RenderTwoLine.tsx.html +187 -0
- package/test-output/vitest/coverage/src/components/Table/cells/RowActionsMenu.tsx.html +526 -0
- package/test-output/vitest/coverage/src/components/Table/cells/index.html +191 -0
- package/test-output/vitest/coverage/src/components/Table/cells/index.ts.html +127 -0
- package/test-output/vitest/coverage/src/components/Table/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Table/table.variants.ts.html +706 -0
- package/test-output/vitest/coverage/src/components/Tabs/Tabs.tsx.html +283 -0
- package/test-output/vitest/coverage/src/components/Tabs/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Tabs/tabs.variants.ts.html +292 -0
- package/test-output/vitest/coverage/src/components/TextField/TextField.tsx.html +820 -0
- package/test-output/vitest/coverage/src/components/TextField/index.html +131 -0
- package/test-output/vitest/coverage/src/components/TextField/textField.variants.ts.html +400 -0
- package/test-output/vitest/coverage/src/components/Textarea/Textarea.tsx.html +655 -0
- package/test-output/vitest/coverage/src/components/Textarea/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Textarea/textarea.variants.ts.html +340 -0
- package/test-output/vitest/coverage/src/components/Tooltip/Tooltip.tsx.html +295 -0
- package/test-output/vitest/coverage/src/components/Tooltip/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Tooltip/tooltip.variants.ts.html +169 -0
- package/test-output/vitest/coverage/src/components/TopBar/TopBar.tsx.html +283 -0
- package/test-output/vitest/coverage/src/components/TopBar/index.html +131 -0
- package/test-output/vitest/coverage/src/components/TopBar/topBar.variants.ts.html +199 -0
- package/test-output/vitest/coverage/src/components/Typography/Typography.tsx.html +421 -0
- package/test-output/vitest/coverage/src/components/Typography/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Typography/typography.variants.ts.html +436 -0
- package/test-output/vitest/coverage/src/components/VisuallyHidden/VisuallyHidden.tsx.html +262 -0
- package/test-output/vitest/coverage/src/components/VisuallyHidden/index.html +116 -0
- package/test-output/vitest/coverage/src/components/_shared/data/getNestedValue.ts.html +181 -0
- package/test-output/vitest/coverage/src/components/_shared/data/index.html +236 -0
- package/test-output/vitest/coverage/src/components/_shared/data/useColumnAutoDetect.ts.html +385 -0
- package/test-output/vitest/coverage/src/components/_shared/data/useControllableState.ts.html +229 -0
- package/test-output/vitest/coverage/src/components/_shared/data/useDebouncedValue.ts.html +157 -0
- package/test-output/vitest/coverage/src/components/_shared/data/useTableFilter.ts.html +439 -0
- package/test-output/vitest/coverage/src/components/_shared/data/useTableSearch.ts.html +283 -0
- package/test-output/vitest/coverage/src/components/_shared/data/useTableSelection.ts.html +385 -0
- package/test-output/vitest/coverage/src/components/_shared/data/useTableSort.ts.html +508 -0
- package/test-output/vitest/coverage/src/components/_shared/data/useVirtualizer.ts.html +682 -0
- package/test-output/vitest/coverage/src/components/_shared/index.html +116 -0
- package/test-output/vitest/coverage/src/components/_shared/resolveValidationState.ts.html +265 -0
- package/test-output/vitest/coverage/src/hooks/index.html +116 -0
- package/test-output/vitest/coverage/src/hooks/useAccessState.ts.html +328 -0
- package/test-output/vitest/coverage/src/index.html +116 -0
- package/test-output/vitest/coverage/src/index.ts.html +1153 -0
- package/test-output/vitest/coverage/src/utils/cn.ts.html +172 -0
- package/test-output/vitest/coverage/src/utils/index.html +116 -0
|
@@ -1,6 +1,27 @@
|
|
|
1
|
-
// @vitest-environment
|
|
2
|
-
import { describe, it, expect } from 'vitest';
|
|
3
|
-
import {
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
import { renderHook, act } from '@testing-library/react';
|
|
4
|
+
import { moveColumn, useColumnReorder } from './useColumnReorder.js';
|
|
5
|
+
|
|
6
|
+
/** Minimal fake of the bits of a drag event the hook reads. */
|
|
7
|
+
function fakeDragStart() {
|
|
8
|
+
return {
|
|
9
|
+
dataTransfer: { effectAllowed: '', setData: vi.fn() },
|
|
10
|
+
} as unknown as React.DragEvent<HTMLElement>;
|
|
11
|
+
}
|
|
12
|
+
function fakeDragOver(clientX: number) {
|
|
13
|
+
return {
|
|
14
|
+
preventDefault: vi.fn(),
|
|
15
|
+
dataTransfer: { dropEffect: '' },
|
|
16
|
+
currentTarget: {
|
|
17
|
+
getBoundingClientRect: () => ({ left: 0, width: 100 }),
|
|
18
|
+
},
|
|
19
|
+
clientX,
|
|
20
|
+
} as unknown as React.DragEvent<HTMLElement>;
|
|
21
|
+
}
|
|
22
|
+
function fakeDrop() {
|
|
23
|
+
return { preventDefault: vi.fn() } as unknown as React.DragEvent<HTMLElement>;
|
|
24
|
+
}
|
|
4
25
|
|
|
5
26
|
describe('moveColumn', () => {
|
|
6
27
|
const order = ['a', 'b', 'c', 'd'];
|
|
@@ -36,3 +57,74 @@ describe('moveColumn', () => {
|
|
|
36
57
|
expect(result.sort()).toEqual(['a', 'b', 'c', 'd']);
|
|
37
58
|
});
|
|
38
59
|
});
|
|
60
|
+
|
|
61
|
+
describe('useColumnReorder — drag handlers', () => {
|
|
62
|
+
const order = ['a', 'b', 'c', 'd'];
|
|
63
|
+
|
|
64
|
+
it('dragStart records the source field + dragging state', () => {
|
|
65
|
+
const { result } = renderHook(() =>
|
|
66
|
+
useColumnReorder({ order, onChange: vi.fn() }),
|
|
67
|
+
);
|
|
68
|
+
const e = fakeDragStart();
|
|
69
|
+
act(() => result.current.dragStart('a')(e));
|
|
70
|
+
expect(result.current.draggingField).toBe('a');
|
|
71
|
+
expect(e.dataTransfer.setData).toHaveBeenCalledWith('text/plain', 'a');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('dragOver on the RIGHT half marks a right-side drop target', () => {
|
|
75
|
+
const { result } = renderHook(() =>
|
|
76
|
+
useColumnReorder({ order, onChange: vi.fn() }),
|
|
77
|
+
);
|
|
78
|
+
act(() => result.current.dragStart('a')(fakeDragStart()));
|
|
79
|
+
// clientX 80 of a 0..100 cell → past the midpoint → 'right'.
|
|
80
|
+
act(() => result.current.dragOver('c')(fakeDragOver(80)));
|
|
81
|
+
expect(result.current.dropTarget).toEqual({ field: 'c', side: 'right' });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('dragOver on the LEFT half marks a left-side drop target', () => {
|
|
85
|
+
const { result } = renderHook(() =>
|
|
86
|
+
useColumnReorder({ order, onChange: vi.fn() }),
|
|
87
|
+
);
|
|
88
|
+
act(() => result.current.dragStart('a')(fakeDragStart()));
|
|
89
|
+
act(() => result.current.dragOver('c')(fakeDragOver(20)));
|
|
90
|
+
expect(result.current.dropTarget).toEqual({ field: 'c', side: 'left' });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('dragOver onto the source column itself is a no-op', () => {
|
|
94
|
+
const { result } = renderHook(() =>
|
|
95
|
+
useColumnReorder({ order, onChange: vi.fn() }),
|
|
96
|
+
);
|
|
97
|
+
act(() => result.current.dragStart('a')(fakeDragStart()));
|
|
98
|
+
act(() => result.current.dragOver('a')(fakeDragOver(80)));
|
|
99
|
+
expect(result.current.dropTarget).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('drop commits the reordered list via onChange', () => {
|
|
103
|
+
const onChange = vi.fn();
|
|
104
|
+
const { result } = renderHook(() => useColumnReorder({ order, onChange }));
|
|
105
|
+
act(() => result.current.dragStart('a')(fakeDragStart()));
|
|
106
|
+
act(() => result.current.dragOver('c')(fakeDragOver(80)));
|
|
107
|
+
act(() => result.current.drop('c')(fakeDrop()));
|
|
108
|
+
// 'a' dropped on the RIGHT of 'c' → ['b','c','a','d'].
|
|
109
|
+
expect(onChange).toHaveBeenCalledWith(['b', 'c', 'a', 'd']);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('drop on the source column does not call onChange', () => {
|
|
113
|
+
const onChange = vi.fn();
|
|
114
|
+
const { result } = renderHook(() => useColumnReorder({ order, onChange }));
|
|
115
|
+
act(() => result.current.dragStart('a')(fakeDragStart()));
|
|
116
|
+
act(() => result.current.drop('a')(fakeDrop()));
|
|
117
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('dragEnd clears the dragging + drop-target state', () => {
|
|
121
|
+
const { result } = renderHook(() =>
|
|
122
|
+
useColumnReorder({ order, onChange: vi.fn() }),
|
|
123
|
+
);
|
|
124
|
+
act(() => result.current.dragStart('a')(fakeDragStart()));
|
|
125
|
+
act(() => result.current.dragOver('c')(fakeDragOver(80)));
|
|
126
|
+
act(() => result.current.dragEnd());
|
|
127
|
+
expect(result.current.draggingField).toBeNull();
|
|
128
|
+
expect(result.current.dropTarget).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* Unit tests for `useColumnResize` — the pointer-drag + keyboard
|
|
4
|
+
* column-resize hook (Sprint 4.2-bis, keyboard `nudge` added in
|
|
5
|
+
* Sprint 6 P2). Sprint 6 P4 — direct coverage of the clamp logic
|
|
6
|
+
* and the drag commit path.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
9
|
+
import { renderHook } from '@testing-library/react';
|
|
10
|
+
import { useColumnResize } from './useColumnResize.js';
|
|
11
|
+
|
|
12
|
+
describe('useColumnResize — nudge (keyboard path)', () => {
|
|
13
|
+
it('commits currentWidth + delta', () => {
|
|
14
|
+
const onChange = vi.fn();
|
|
15
|
+
const { result } = renderHook(() =>
|
|
16
|
+
useColumnResize({ widths: { a: 100 }, onChange }),
|
|
17
|
+
);
|
|
18
|
+
result.current.nudge('a', 100, 16, 40, 400);
|
|
19
|
+
expect(onChange).toHaveBeenCalledWith({ a: 116 });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('clamps to the effective minimum', () => {
|
|
23
|
+
const onChange = vi.fn();
|
|
24
|
+
const { result } = renderHook(() =>
|
|
25
|
+
useColumnResize({ widths: { a: 50 }, onChange }),
|
|
26
|
+
);
|
|
27
|
+
// currentWidth 50, delta -64 → 14, clamped up to min 40.
|
|
28
|
+
result.current.nudge('a', 50, -64, 40, 400);
|
|
29
|
+
expect(onChange).toHaveBeenCalledWith({ a: 40 });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('clamps to the effective maximum', () => {
|
|
33
|
+
const onChange = vi.fn();
|
|
34
|
+
const { result } = renderHook(() =>
|
|
35
|
+
useColumnResize({ widths: { a: 380 }, onChange }),
|
|
36
|
+
);
|
|
37
|
+
// currentWidth 380, delta +64 → 444, clamped down to max 400.
|
|
38
|
+
result.current.nudge('a', 380, 64, 40, 400);
|
|
39
|
+
expect(onChange).toHaveBeenCalledWith({ a: 400 });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('honors the hook-level minWidth/maxWidth floor/ceiling', () => {
|
|
43
|
+
const onChange = vi.fn();
|
|
44
|
+
const { result } = renderHook(() =>
|
|
45
|
+
useColumnResize({
|
|
46
|
+
widths: {},
|
|
47
|
+
onChange,
|
|
48
|
+
minWidth: 80,
|
|
49
|
+
maxWidth: 200,
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
// clampMin 40 < hook min 80 → effective min is 80.
|
|
53
|
+
result.current.nudge('a', 100, -64, 40, 1000);
|
|
54
|
+
expect(onChange).toHaveBeenCalledWith({ a: 80 });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('preserves the other columns in the width map', () => {
|
|
58
|
+
const onChange = vi.fn();
|
|
59
|
+
const { result } = renderHook(() =>
|
|
60
|
+
useColumnResize({ widths: { a: 100, b: 200 }, onChange }),
|
|
61
|
+
);
|
|
62
|
+
result.current.nudge('a', 100, 16, 40, 400);
|
|
63
|
+
expect(onChange).toHaveBeenCalledWith({ a: 116, b: 200 });
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('useColumnResize — startResize (pointer-drag path)', () => {
|
|
68
|
+
it('returns an event handler from the factory', () => {
|
|
69
|
+
const { result } = renderHook(() =>
|
|
70
|
+
useColumnResize({ widths: {}, onChange: () => {} }),
|
|
71
|
+
);
|
|
72
|
+
const handler = result.current.startResize('a', 100, 40, 400);
|
|
73
|
+
expect(typeof handler).toBe('function');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('a pointer drag commits a clamped new width via pointermove', () => {
|
|
77
|
+
const onChange = vi.fn();
|
|
78
|
+
const { result } = renderHook(() =>
|
|
79
|
+
useColumnResize({ widths: { a: 100 }, onChange }),
|
|
80
|
+
);
|
|
81
|
+
const el = document.createElement('span');
|
|
82
|
+
document.body.appendChild(el);
|
|
83
|
+
|
|
84
|
+
// Synthetic pointerdown — shape mirrors React's PointerEvent.
|
|
85
|
+
const downEvent = {
|
|
86
|
+
pointerType: 'mouse',
|
|
87
|
+
button: 0,
|
|
88
|
+
clientX: 500,
|
|
89
|
+
pointerId: 1,
|
|
90
|
+
currentTarget: el,
|
|
91
|
+
preventDefault: () => {},
|
|
92
|
+
stopPropagation: () => {},
|
|
93
|
+
} as unknown as React.PointerEvent<HTMLElement>;
|
|
94
|
+
|
|
95
|
+
result.current.startResize('a', 100, 40, 400)(downEvent);
|
|
96
|
+
|
|
97
|
+
// Drag 30px to the right → 100 + 30 = 130.
|
|
98
|
+
const move = new Event('pointermove') as Event & { clientX: number };
|
|
99
|
+
move.clientX = 530;
|
|
100
|
+
el.dispatchEvent(move);
|
|
101
|
+
expect(onChange).toHaveBeenLastCalledWith({ a: 130 });
|
|
102
|
+
|
|
103
|
+
// Drag far left → clamps to min 40.
|
|
104
|
+
const move2 = new Event('pointermove') as Event & { clientX: number };
|
|
105
|
+
move2.clientX = 0;
|
|
106
|
+
el.dispatchEvent(move2);
|
|
107
|
+
expect(onChange).toHaveBeenLastCalledWith({ a: 40 });
|
|
108
|
+
|
|
109
|
+
// pointerup tears the listeners down — a later move is a no-op.
|
|
110
|
+
const up = new Event('pointerup') as Event & { pointerId: number };
|
|
111
|
+
up.pointerId = 1;
|
|
112
|
+
el.dispatchEvent(up);
|
|
113
|
+
onChange.mockClear();
|
|
114
|
+
const move3 = new Event('pointermove') as Event & { clientX: number };
|
|
115
|
+
move3.clientX = 999;
|
|
116
|
+
el.dispatchEvent(move3);
|
|
117
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
118
|
+
|
|
119
|
+
document.body.removeChild(el);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('ignores a non-primary mouse button', () => {
|
|
123
|
+
const onChange = vi.fn();
|
|
124
|
+
const { result } = renderHook(() =>
|
|
125
|
+
useColumnResize({ widths: {}, onChange }),
|
|
126
|
+
);
|
|
127
|
+
const el = document.createElement('span');
|
|
128
|
+
const downEvent = {
|
|
129
|
+
pointerType: 'mouse',
|
|
130
|
+
button: 2, // right-click
|
|
131
|
+
clientX: 0,
|
|
132
|
+
pointerId: 1,
|
|
133
|
+
currentTarget: el,
|
|
134
|
+
preventDefault: () => {},
|
|
135
|
+
stopPropagation: () => {},
|
|
136
|
+
} as unknown as React.PointerEvent<HTMLElement>;
|
|
137
|
+
result.current.startResize('a', 100, 40, 400)(downEvent);
|
|
138
|
+
const move = new Event('pointermove') as Event & { clientX: number };
|
|
139
|
+
move.clientX = 200;
|
|
140
|
+
el.dispatchEvent(move);
|
|
141
|
+
// No listener was attached → no commit.
|
|
142
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -35,6 +35,19 @@ export interface ColumnResizeHandlers {
|
|
|
35
35
|
clampMin: number,
|
|
36
36
|
clampMax: number,
|
|
37
37
|
) => (e: RPointerEvent<HTMLElement>) => void;
|
|
38
|
+
/**
|
|
39
|
+
* Commit a single clamped width delta — the keyboard-resize path.
|
|
40
|
+
* Wired to ArrowLeft/ArrowRight on the focused resize handle so the
|
|
41
|
+
* feature is reachable without a pointer (WCAG 2.1 — keyboard
|
|
42
|
+
* operability). Same clamping as the drag path.
|
|
43
|
+
*/
|
|
44
|
+
nudge: (
|
|
45
|
+
field: string,
|
|
46
|
+
currentWidth: number,
|
|
47
|
+
delta: number,
|
|
48
|
+
clampMin: number,
|
|
49
|
+
clampMax: number,
|
|
50
|
+
) => void;
|
|
38
51
|
}
|
|
39
52
|
|
|
40
53
|
export function useColumnResize(args: UseColumnResizeArgs): ColumnResizeHandlers {
|
|
@@ -96,7 +109,23 @@ export function useColumnResize(args: UseColumnResizeArgs): ColumnResizeHandlers
|
|
|
96
109
|
[onChange, minWidth, maxWidth],
|
|
97
110
|
);
|
|
98
111
|
|
|
99
|
-
|
|
112
|
+
const nudge = useCallback(
|
|
113
|
+
(
|
|
114
|
+
field: string,
|
|
115
|
+
currentWidth: number,
|
|
116
|
+
delta: number,
|
|
117
|
+
clampMin: number,
|
|
118
|
+
clampMax: number,
|
|
119
|
+
) => {
|
|
120
|
+
const effectiveMin = Math.max(minWidth, clampMin);
|
|
121
|
+
const effectiveMax = Math.min(maxWidth, clampMax);
|
|
122
|
+
const next = clamp(currentWidth + delta, effectiveMin, effectiveMax);
|
|
123
|
+
onChange({ ...widthsRef.current, [field]: next });
|
|
124
|
+
},
|
|
125
|
+
[onChange, minWidth, maxWidth],
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
return { startResize, nudge };
|
|
100
129
|
}
|
|
101
130
|
|
|
102
131
|
function clamp(n: number, min: number, max: number): number {
|
|
@@ -31,15 +31,18 @@ import type { TableColumn } from '../../Table/table.types.js';
|
|
|
31
31
|
export interface ColumnVisibilityLabels {
|
|
32
32
|
columnsButton?: string;
|
|
33
33
|
columnsTitle?: string;
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Accessible label for the tri-state master checkbox that
|
|
36
|
+
* shows/hides every hideable column at once. Not shown as visible
|
|
37
|
+
* text — surfaced to assistive tech via `aria-label`.
|
|
38
|
+
*/
|
|
39
|
+
columnsToggleAll?: string;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
const DEFAULT_VISIBILITY_LABELS: Required<ColumnVisibilityLabels> = {
|
|
39
43
|
columnsButton: 'Columns',
|
|
40
44
|
columnsTitle: 'Toggle columns',
|
|
41
|
-
|
|
42
|
-
columnsHideAll: 'Hide all',
|
|
45
|
+
columnsToggleAll: 'Toggle all columns',
|
|
43
46
|
};
|
|
44
47
|
|
|
45
48
|
export interface ColumnVisibilityTriggerProps<T extends object> {
|
|
@@ -70,8 +73,21 @@ export function ColumnVisibilityTrigger<T extends object>(
|
|
|
70
73
|
);
|
|
71
74
|
};
|
|
72
75
|
|
|
73
|
-
|
|
74
|
-
|
|
76
|
+
// Tri-state master: checked = every hideable column visible,
|
|
77
|
+
// unchecked = every one hidden, indeterminate = mixed.
|
|
78
|
+
const allVisible = hideableCols.every(
|
|
79
|
+
(c) => !hiddenColumns.includes(c.field as string),
|
|
80
|
+
);
|
|
81
|
+
const allHidden =
|
|
82
|
+
hideableCols.length > 0 &&
|
|
83
|
+
hideableCols.every((c) => hiddenColumns.includes(c.field as string));
|
|
84
|
+
const someHidden = !allVisible && !allHidden;
|
|
85
|
+
|
|
86
|
+
// Standard select-all semantics: when everything is visible the
|
|
87
|
+
// master hides all; otherwise it shows all.
|
|
88
|
+
const toggleAll = () => {
|
|
89
|
+
onChange(allVisible ? hideableCols.map((c) => c.field as string) : []);
|
|
90
|
+
};
|
|
75
91
|
|
|
76
92
|
return (
|
|
77
93
|
<Popover
|
|
@@ -80,28 +96,29 @@ export function ColumnVisibilityTrigger<T extends object>(
|
|
|
80
96
|
side="bottom"
|
|
81
97
|
align="end"
|
|
82
98
|
content={
|
|
83
|
-
<div className="flex flex-col gap-
|
|
84
|
-
|
|
99
|
+
<div className="flex flex-col gap-1 min-w-[220px]">
|
|
100
|
+
{/*
|
|
101
|
+
Header row — the tri-state master checkbox + the title.
|
|
102
|
+
The master replaces the old "Show all · Hide all" text
|
|
103
|
+
links: one control, no extra copy, consistent with the
|
|
104
|
+
DataGrid row select-all checkbox. The title is a heading
|
|
105
|
+
(not part of the checkbox label) and stays consumer-
|
|
106
|
+
customizable via `labels.columnsTitle`.
|
|
107
|
+
*/}
|
|
108
|
+
<div className="flex items-center gap-2 px-1 pb-2 mb-1 border-b border-neutral-200">
|
|
109
|
+
<input
|
|
110
|
+
type="checkbox"
|
|
111
|
+
aria-label={labels.columnsToggleAll}
|
|
112
|
+
checked={allVisible}
|
|
113
|
+
ref={(el) => {
|
|
114
|
+
if (el) el.indeterminate = someHidden;
|
|
115
|
+
}}
|
|
116
|
+
onChange={toggleAll}
|
|
117
|
+
className="cursor-pointer"
|
|
118
|
+
/>
|
|
85
119
|
<span className="text-xs font-semibold text-neutral-700 uppercase tracking-wide">
|
|
86
120
|
{labels.columnsTitle}
|
|
87
121
|
</span>
|
|
88
|
-
<div className="flex items-center gap-2">
|
|
89
|
-
<button
|
|
90
|
-
type="button"
|
|
91
|
-
onClick={showAll}
|
|
92
|
-
className="text-xs text-neutral-600 hover:text-neutral-900 underline-offset-2 hover:underline focus:outline-none focus-visible:underline"
|
|
93
|
-
>
|
|
94
|
-
{labels.columnsShowAll}
|
|
95
|
-
</button>
|
|
96
|
-
<span className="text-neutral-300" aria-hidden="true">·</span>
|
|
97
|
-
<button
|
|
98
|
-
type="button"
|
|
99
|
-
onClick={hideAll}
|
|
100
|
-
className="text-xs text-neutral-600 hover:text-neutral-900 underline-offset-2 hover:underline focus:outline-none focus-visible:underline"
|
|
101
|
-
>
|
|
102
|
-
{labels.columnsHideAll}
|
|
103
|
-
</button>
|
|
104
|
-
</div>
|
|
105
122
|
</div>
|
|
106
123
|
<ul className="flex flex-col gap-0.5 max-h-72 overflow-auto">
|
|
107
124
|
{hideableCols.map((col) => {
|
|
@@ -42,6 +42,15 @@ export function Dialog(props: DialogProps) {
|
|
|
42
42
|
/>
|
|
43
43
|
<RadixDialog.Content
|
|
44
44
|
className={cn(v.content(), sx, slotProps?.content?.className)}
|
|
45
|
+
// When no `description` is supplied we render no
|
|
46
|
+
// `<RadixDialog.Description>`. Radix then logs a dev warning
|
|
47
|
+
// unless the Content explicitly opts out via
|
|
48
|
+
// `aria-describedby={undefined}` — the documented escape
|
|
49
|
+
// hatch for description-less dialogs. Spread it ONLY in that
|
|
50
|
+
// case so a present description still auto-links its id.
|
|
51
|
+
{...(description == null
|
|
52
|
+
? { 'aria-describedby': undefined }
|
|
53
|
+
: {})}
|
|
45
54
|
onPointerDownOutside={(event) => {
|
|
46
55
|
if (disableBackdropClose) event.preventDefault();
|
|
47
56
|
}}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* Performance guardrails for `<Table>` (non-virtualized).
|
|
4
|
+
*
|
|
5
|
+
* Table renders EVERY row in the DOM — its design ceiling is ~500
|
|
6
|
+
* rows (past that, the consumer should switch to `<DataGrid>`). These
|
|
7
|
+
* tests pin the mount cost at that ceiling so a regression in the
|
|
8
|
+
* render path (e.g. an accidental O(n²) in the sort / search pipeline)
|
|
9
|
+
* surfaces in CI.
|
|
10
|
+
*
|
|
11
|
+
* Budgets are deliberately generous — they catch order-of-magnitude
|
|
12
|
+
* regressions under a cold workspace-wide test run, not micro-jitter.
|
|
13
|
+
*/
|
|
14
|
+
import * as React from 'react';
|
|
15
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
16
|
+
import { render, cleanup } from '@testing-library/react';
|
|
17
|
+
import { Table } from './Table.js';
|
|
18
|
+
import type { TableColumn } from './table.types.js';
|
|
19
|
+
|
|
20
|
+
void React;
|
|
21
|
+
afterEach(() => cleanup());
|
|
22
|
+
|
|
23
|
+
interface Row {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
email: string;
|
|
27
|
+
age: number;
|
|
28
|
+
city: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const COLS: TableColumn<Row>[] = [
|
|
32
|
+
{ field: 'name', header: 'Name', sortable: true, searchable: true },
|
|
33
|
+
{ field: 'email', header: 'Email', searchable: true },
|
|
34
|
+
{ field: 'age', header: 'Age', sortable: true },
|
|
35
|
+
{ field: 'city', header: 'City' },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
function makeRows(n: number): Row[] {
|
|
39
|
+
const cities = ['Roma', 'Milano', 'Berlin'];
|
|
40
|
+
return Array.from({ length: n }).map((_, i) => ({
|
|
41
|
+
id: `r${i}`,
|
|
42
|
+
name: `User ${i}`,
|
|
43
|
+
email: `user${i}@example.com`,
|
|
44
|
+
age: 20 + (i % 50),
|
|
45
|
+
city: cities[i % 3] as string,
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('<Table> performance', () => {
|
|
50
|
+
it("mounts 500 rows (Table's row-count ceiling) under 800ms", () => {
|
|
51
|
+
const rows = makeRows(500);
|
|
52
|
+
const t0 = performance.now();
|
|
53
|
+
render(<Table rows={rows} cols={COLS} getRowId={(r) => r.id} />);
|
|
54
|
+
const t1 = performance.now();
|
|
55
|
+
expect(t1 - t0).toBeLessThan(800);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('renders every row in the DOM (non-virtualized contract)', () => {
|
|
59
|
+
const rows = makeRows(500);
|
|
60
|
+
const { container } = render(
|
|
61
|
+
<Table rows={rows} cols={COLS} getRowId={(r) => r.id} />,
|
|
62
|
+
);
|
|
63
|
+
// Non-virtualized: all 500 data rows are mounted.
|
|
64
|
+
expect(container.querySelectorAll('tbody tr').length).toBe(500);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('applies a default sort over 500 rows within the mount budget', () => {
|
|
68
|
+
const rows = makeRows(500);
|
|
69
|
+
const t0 = performance.now();
|
|
70
|
+
render(
|
|
71
|
+
<Table
|
|
72
|
+
rows={rows}
|
|
73
|
+
cols={COLS}
|
|
74
|
+
getRowId={(r) => r.id}
|
|
75
|
+
defaultSortModel={[{ field: 'name', direction: 'asc' }]}
|
|
76
|
+
/>,
|
|
77
|
+
);
|
|
78
|
+
const t1 = performance.now();
|
|
79
|
+
expect(t1 - t0).toBeLessThan(800);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import * as React from 'react';
|
|
3
|
-
import { describe, it, expect, afterEach, beforeAll } from 'vitest';
|
|
3
|
+
import { describe, it, expect, afterEach, beforeAll, vi } from 'vitest';
|
|
4
4
|
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
|
5
5
|
import { Table } from './Table.js';
|
|
6
6
|
import type { TableColumn } from './table.types.js';
|
|
@@ -601,3 +601,74 @@ describe('Table — i18n', () => {
|
|
|
601
601
|
expect(screen.queryByLabelText('Seleziona tutte')).not.toBeNull();
|
|
602
602
|
});
|
|
603
603
|
});
|
|
604
|
+
|
|
605
|
+
describe('Table — Sprint 6 P2 refinements', () => {
|
|
606
|
+
// ───── D7: no-results vs no-data empty state ─────
|
|
607
|
+
|
|
608
|
+
it('shows the noData message when the dataset is genuinely empty', () => {
|
|
609
|
+
render(<Table rows={[]} cols={baseCols} getRowId={getRowId} />);
|
|
610
|
+
expect(screen.queryByText('No data')).not.toBeNull();
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it('shows the noResults message when a search excludes every row', () => {
|
|
614
|
+
render(
|
|
615
|
+
<Table
|
|
616
|
+
rows={baseRows}
|
|
617
|
+
cols={baseCols}
|
|
618
|
+
getRowId={getRowId}
|
|
619
|
+
enableSearch
|
|
620
|
+
searchQuery="zzz-no-match"
|
|
621
|
+
searchDebounceMs={0}
|
|
622
|
+
/>,
|
|
623
|
+
);
|
|
624
|
+
expect(screen.queryByText('No matching results')).not.toBeNull();
|
|
625
|
+
expect(screen.queryByText('No data')).toBeNull();
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it('shows the noResults message when a filter excludes every row', () => {
|
|
629
|
+
render(
|
|
630
|
+
<Table
|
|
631
|
+
rows={baseRows}
|
|
632
|
+
cols={baseCols}
|
|
633
|
+
getRowId={getRowId}
|
|
634
|
+
filterModel={[{ field: 'name', op: 'contains', value: 'zzz-no-match' }]}
|
|
635
|
+
/>,
|
|
636
|
+
);
|
|
637
|
+
expect(screen.queryByText('No matching results')).not.toBeNull();
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
// ───── T1: dev-warn when getRowId omitted + identity-sensitive feature ─────
|
|
641
|
+
|
|
642
|
+
it('warns in dev when getRowId is omitted and a sortable column exists', () => {
|
|
643
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
644
|
+
// baseCols has sortable columns; no getRowId passed.
|
|
645
|
+
render(<Table rows={baseRows} cols={baseCols} />);
|
|
646
|
+
expect(warn).toHaveBeenCalledWith(
|
|
647
|
+
expect.stringContaining('`getRowId` is not set'),
|
|
648
|
+
);
|
|
649
|
+
warn.mockRestore();
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it('does NOT warn when getRowId is provided', () => {
|
|
653
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
654
|
+
render(<Table rows={baseRows} cols={baseCols} getRowId={getRowId} />);
|
|
655
|
+
expect(warn).not.toHaveBeenCalledWith(
|
|
656
|
+
expect.stringContaining('`getRowId` is not set'),
|
|
657
|
+
);
|
|
658
|
+
warn.mockRestore();
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it('does NOT warn when getRowId is omitted but no identity-sensitive feature is active', () => {
|
|
662
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
663
|
+
// Plain columns: not sortable, not searchable, no selection/expansion.
|
|
664
|
+
const plainCols: TableColumn<User>[] = [
|
|
665
|
+
{ field: 'name', header: 'Name' },
|
|
666
|
+
{ field: 'email', header: 'Email' },
|
|
667
|
+
];
|
|
668
|
+
render(<Table rows={baseRows} cols={plainCols} />);
|
|
669
|
+
expect(warn).not.toHaveBeenCalledWith(
|
|
670
|
+
expect.stringContaining('`getRowId` is not set'),
|
|
671
|
+
);
|
|
672
|
+
warn.mockRestore();
|
|
673
|
+
});
|
|
674
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useMemo, type ReactNode, type MouseEvent } from 'react';
|
|
1
|
+
import { useMemo, useEffect, type ReactNode, type MouseEvent } from 'react';
|
|
2
2
|
import { cn } from '../../utils/cn.js';
|
|
3
3
|
import { useAccessState } from '../../hooks/useAccessState.js';
|
|
4
4
|
import { Skeleton } from '../Skeleton/Skeleton.js';
|
|
@@ -27,6 +27,7 @@ import type {
|
|
|
27
27
|
const DEFAULT_LABELS: Required<TableLabels> = {
|
|
28
28
|
searchPlaceholder: 'Search...',
|
|
29
29
|
noData: 'No data',
|
|
30
|
+
noResults: 'No matching results',
|
|
30
31
|
loading: 'Loading…',
|
|
31
32
|
ariaSortNone: 'Not sorted. Click to sort ascending.',
|
|
32
33
|
ariaSortAscending: 'Sorted ascending. Click to sort descending.',
|
|
@@ -53,8 +54,7 @@ const DEFAULT_LABELS: Required<TableLabels> = {
|
|
|
53
54
|
densitySpacious: 'Spacious',
|
|
54
55
|
columnsButton: 'Columns',
|
|
55
56
|
columnsTitle: 'Toggle columns',
|
|
56
|
-
|
|
57
|
-
columnsHideAll: 'Hide all',
|
|
57
|
+
columnsToggleAll: 'Toggle all columns',
|
|
58
58
|
};
|
|
59
59
|
|
|
60
60
|
const DEFAULT_GET_ROW_ID = <T extends object>(_row: T, index: number) => String(index);
|
|
@@ -132,6 +132,31 @@ export function Table<T extends object>(props: TableProps<T>) {
|
|
|
132
132
|
const labels = { ...DEFAULT_LABELS, ...labelsProp };
|
|
133
133
|
const tableAccessState = useAccessState(tableAccess);
|
|
134
134
|
|
|
135
|
+
// Dev-only guard: `getRowId` is optional (positional-index fallback)
|
|
136
|
+
// but that fallback breaks row identity once rows reorder (sort) or
|
|
137
|
+
// change presence (search / filter) — selection + expansion state
|
|
138
|
+
// then jump to the wrong rows. Warn when it's omitted AND a feature
|
|
139
|
+
// that relies on stable identity is active. Stripped in production.
|
|
140
|
+
const getRowIdProvided = props.getRowId !== undefined;
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (process.env.NODE_ENV === 'production') return;
|
|
143
|
+
if (getRowIdProvided) return;
|
|
144
|
+
const identityMatters =
|
|
145
|
+
rowSelection !== 'none' ||
|
|
146
|
+
expandable != null ||
|
|
147
|
+
enableSearch ||
|
|
148
|
+
cols.some((c) => Boolean(c.sortable));
|
|
149
|
+
if (identityMatters) {
|
|
150
|
+
console.warn(
|
|
151
|
+
'[Dashforge Table] `getRowId` is not set — falling back to the ' +
|
|
152
|
+
'positional row index. With sort / search / selection / ' +
|
|
153
|
+
'expandable rows active, the index-based key breaks row ' +
|
|
154
|
+
'identity when rows reorder. Pass a stable resolver, e.g. ' +
|
|
155
|
+
'`getRowId={(row) => row.id}`.',
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}, [getRowIdProvided, rowSelection, expandable, enableSearch, cols]);
|
|
159
|
+
|
|
135
160
|
// ───── Controllable state machinery ─────
|
|
136
161
|
|
|
137
162
|
const [sortModel, setSortModel] = useControllableState<TableSortModel>({
|
|
@@ -341,7 +366,14 @@ export function Table<T extends object>(props: TableProps<T>) {
|
|
|
341
366
|
? renderEmptyState({
|
|
342
367
|
totalColumnCount,
|
|
343
368
|
emptyState,
|
|
344
|
-
|
|
369
|
+
// Distinguish a genuinely empty dataset from one
|
|
370
|
+
// emptied by an active search / filter.
|
|
371
|
+
fallback:
|
|
372
|
+
rows.length > 0 &&
|
|
373
|
+
(debouncedSearchQuery.trim().length > 0 ||
|
|
374
|
+
filterModel.length > 0)
|
|
375
|
+
? labels.noResults
|
|
376
|
+
: labels.noData,
|
|
345
377
|
cellClass: cn(v.cell(), v.emptyState(), slotProps?.emptyState?.className),
|
|
346
378
|
})
|
|
347
379
|
: sortedRows.map((row, idx) => {
|
|
@@ -733,6 +765,10 @@ function renderLoadingRows<T extends object>(args: {
|
|
|
733
765
|
cellClass: string;
|
|
734
766
|
}): ReactNode {
|
|
735
767
|
const { count, visibleCols, showSelectionColumn, showExpandColumn, showRowActionsColumn, rowClass, cellClass } = args;
|
|
768
|
+
// No explicit row height here (unlike DataGrid's loading rows, which
|
|
769
|
+
// pin `rowHeight` for the virtualization math): Table is auto-height
|
|
770
|
+
// by design. Skeleton rows inherit the same density padding via
|
|
771
|
+
// `cellClass`, so they line up with real rows without a fixed height.
|
|
736
772
|
return Array.from({ length: count }).map((_, i) => (
|
|
737
773
|
<tr key={`loading-${i}`} className={rowClass} aria-busy="true">
|
|
738
774
|
{showSelectionColumn && <td className={cellClass}><Skeleton variant="rectangle" width="16px" height="16px" /></td>}
|
|
@@ -53,15 +53,12 @@ const chipVariants = tv({
|
|
|
53
53
|
{ variant: 'soft', color: 'danger', class: 'bg-danger-100 dark:bg-danger-950 text-danger-700 dark:text-danger-300' },
|
|
54
54
|
{ variant: 'soft', color: 'info', class: 'bg-info-100 dark:bg-info-950 text-info-700 dark:text-info-300' },
|
|
55
55
|
{ variant: 'soft', color: 'secondary', class: 'bg-secondary-100 dark:bg-secondary-950 text-secondary-700 dark:text-secondary-300' },
|
|
56
|
-
// solid = full bg +
|
|
57
|
-
// solid neutral: bg
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
// auto-inverts: light text in light mode (against dark bg) is
|
|
63
|
-
// what we want; in dark mode `bg-neutral-700` becomes light AND
|
|
64
|
-
// `text-neutral-50` becomes dark — contrast preserved.
|
|
56
|
+
// solid = full bg + contrasting text.
|
|
57
|
+
// solid neutral: both `bg-neutral-700` and `text-neutral-50` sit
|
|
58
|
+
// on the neutral palette, so they auto-invert together via the
|
|
59
|
+
// CSS-var swap — bg/text contrast is preserved in BOTH modes.
|
|
60
|
+
// (`text-white` is deliberately NOT used here: white does not
|
|
61
|
+
// auto-invert, which would flip the contrast in dark mode.)
|
|
65
62
|
{ variant: 'solid', color: 'neutral', class: 'bg-neutral-700 text-neutral-50' },
|
|
66
63
|
{ variant: 'solid', color: 'primary', class: 'bg-primary-500 text-white' },
|
|
67
64
|
{ variant: 'solid', color: 'success', class: 'bg-success-500 text-white' },
|