@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.
Files changed (180) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/COVERAGE.md +63 -0
  3. package/LICENSE +21 -0
  4. package/PERFORMANCE.md +59 -0
  5. package/REFINEMENT-NOTES.md +50 -0
  6. package/dist/index.esm.js +177 -67
  7. package/dist/src/components/DataGrid/DataGrid.d.ts.map +1 -1
  8. package/dist/src/components/DataGrid/filters/ColumnFilters.d.ts.map +1 -1
  9. package/dist/src/components/DataGrid/resize/useColumnResize.d.ts +7 -0
  10. package/dist/src/components/DataGrid/resize/useColumnResize.d.ts.map +1 -1
  11. package/dist/src/components/DataGrid/visibility/ColumnVisibilityMenu.d.ts +6 -2
  12. package/dist/src/components/DataGrid/visibility/ColumnVisibilityMenu.d.ts.map +1 -1
  13. package/dist/src/components/Dialog/Dialog.d.ts.map +1 -1
  14. package/dist/src/components/Table/Table.d.ts.map +1 -1
  15. package/dist/src/components/Table/cells/RenderChip.d.ts.map +1 -1
  16. package/dist/src/components/Table/table.types.d.ts +28 -3
  17. package/dist/src/components/Table/table.types.d.ts.map +1 -1
  18. package/dist/src/components/Typography/typography.variants.d.ts.map +1 -1
  19. package/eslint.config.mjs +16 -1
  20. package/package.json +9 -6
  21. package/src/components/Breadcrumbs/Breadcrumbs.perf.test.tsx +5 -2
  22. package/src/components/DataGrid/DataGrid.perf.test.tsx +114 -0
  23. package/src/components/DataGrid/DataGrid.test.tsx +240 -0
  24. package/src/components/DataGrid/DataGrid.tsx +104 -16
  25. package/src/components/DataGrid/filters/ColumnFilters.test.tsx +330 -0
  26. package/src/components/DataGrid/filters/ColumnFilters.tsx +26 -2
  27. package/src/components/DataGrid/reorder/useColumnReorder.test.ts +95 -3
  28. package/src/components/DataGrid/resize/useColumnResize.test.ts +144 -0
  29. package/src/components/DataGrid/resize/useColumnResize.ts +30 -1
  30. package/src/components/DataGrid/visibility/ColumnVisibilityMenu.tsx +42 -25
  31. package/src/components/Dialog/Dialog.tsx +9 -0
  32. package/src/components/Table/Table.perf.test.tsx +81 -0
  33. package/src/components/Table/Table.test.tsx +72 -1
  34. package/src/components/Table/Table.tsx +40 -4
  35. package/src/components/Table/cells/RenderChip.tsx +6 -9
  36. package/src/components/Table/cells/cells.test.tsx +247 -0
  37. package/src/components/Table/table.types.ts +28 -3
  38. package/src/components/Typography/Typography.test.tsx +15 -0
  39. package/src/components/Typography/typography.variants.ts +14 -1
  40. package/test-output/vitest/coverage/base.css +224 -0
  41. package/test-output/vitest/coverage/block-navigation.js +87 -0
  42. package/test-output/vitest/coverage/clover.xml +2418 -0
  43. package/test-output/vitest/coverage/coverage-final.json +89 -0
  44. package/test-output/vitest/coverage/favicon.png +0 -0
  45. package/test-output/vitest/coverage/index.html +746 -0
  46. package/test-output/vitest/coverage/prettify.css +1 -0
  47. package/test-output/vitest/coverage/prettify.js +2 -0
  48. package/test-output/vitest/coverage/sort-arrow-sprite.png +0 -0
  49. package/test-output/vitest/coverage/sorter.js +210 -0
  50. package/test-output/vitest/coverage/src/components/Accordion/Accordion.tsx.html +376 -0
  51. package/test-output/vitest/coverage/src/components/Accordion/accordion.variants.ts.html +181 -0
  52. package/test-output/vitest/coverage/src/components/Accordion/index.html +131 -0
  53. package/test-output/vitest/coverage/src/components/AppShell/AppShell.tsx.html +802 -0
  54. package/test-output/vitest/coverage/src/components/AppShell/appShell.variants.ts.html +247 -0
  55. package/test-output/vitest/coverage/src/components/AppShell/index.html +131 -0
  56. package/test-output/vitest/coverage/src/components/AspectRatio/AspectRatio.tsx.html +304 -0
  57. package/test-output/vitest/coverage/src/components/AspectRatio/index.html +116 -0
  58. package/test-output/vitest/coverage/src/components/Autocomplete/Autocomplete.tsx.html +3292 -0
  59. package/test-output/vitest/coverage/src/components/Autocomplete/autocomplete.variants.ts.html +508 -0
  60. package/test-output/vitest/coverage/src/components/Autocomplete/index.html +131 -0
  61. package/test-output/vitest/coverage/src/components/Box/Box.tsx.html +334 -0
  62. package/test-output/vitest/coverage/src/components/Box/box.variants.ts.html +775 -0
  63. package/test-output/vitest/coverage/src/components/Box/index.html +131 -0
  64. package/test-output/vitest/coverage/src/components/Breadcrumbs/Breadcrumbs.tsx.html +694 -0
  65. package/test-output/vitest/coverage/src/components/Breadcrumbs/breadcrumbs.variants.ts.html +247 -0
  66. package/test-output/vitest/coverage/src/components/Breadcrumbs/index.html +131 -0
  67. package/test-output/vitest/coverage/src/components/Button/Button.tsx.html +535 -0
  68. package/test-output/vitest/coverage/src/components/Button/button.variants.ts.html +562 -0
  69. package/test-output/vitest/coverage/src/components/Button/index.html +131 -0
  70. package/test-output/vitest/coverage/src/components/Checkbox/Checkbox.tsx.html +943 -0
  71. package/test-output/vitest/coverage/src/components/Checkbox/checkbox.variants.ts.html +289 -0
  72. package/test-output/vitest/coverage/src/components/Checkbox/index.html +131 -0
  73. package/test-output/vitest/coverage/src/components/ConfirmDialog/ConfirmDialog.tsx.html +841 -0
  74. package/test-output/vitest/coverage/src/components/ConfirmDialog/confirmDialog.variants.ts.html +313 -0
  75. package/test-output/vitest/coverage/src/components/ConfirmDialog/index.html +131 -0
  76. package/test-output/vitest/coverage/src/components/Container/Container.tsx.html +307 -0
  77. package/test-output/vitest/coverage/src/components/Container/container.variants.ts.html +328 -0
  78. package/test-output/vitest/coverage/src/components/Container/index.html +131 -0
  79. package/test-output/vitest/coverage/src/components/DataGrid/DataGrid.tsx.html +3937 -0
  80. package/test-output/vitest/coverage/src/components/DataGrid/dataGrid.variants.ts.html +541 -0
  81. package/test-output/vitest/coverage/src/components/DataGrid/filters/ColumnFilters.tsx.html +1453 -0
  82. package/test-output/vitest/coverage/src/components/DataGrid/filters/index.html +116 -0
  83. package/test-output/vitest/coverage/src/components/DataGrid/index.html +131 -0
  84. package/test-output/vitest/coverage/src/components/DataGrid/reorder/index.html +116 -0
  85. package/test-output/vitest/coverage/src/components/DataGrid/reorder/useColumnReorder.ts.html +517 -0
  86. package/test-output/vitest/coverage/src/components/DataGrid/resize/index.html +116 -0
  87. package/test-output/vitest/coverage/src/components/DataGrid/resize/useColumnResize.ts.html +490 -0
  88. package/test-output/vitest/coverage/src/components/DataGrid/visibility/ColumnVisibilityMenu.tsx.html +592 -0
  89. package/test-output/vitest/coverage/src/components/DataGrid/visibility/index.html +116 -0
  90. package/test-output/vitest/coverage/src/components/DateTimePicker/DateTimePicker.tsx.html +1042 -0
  91. package/test-output/vitest/coverage/src/components/DateTimePicker/dateTimePicker.variants.ts.html +337 -0
  92. package/test-output/vitest/coverage/src/components/DateTimePicker/index.html +131 -0
  93. package/test-output/vitest/coverage/src/components/Dialog/Dialog.tsx.html +391 -0
  94. package/test-output/vitest/coverage/src/components/Dialog/dialog.variants.ts.html +283 -0
  95. package/test-output/vitest/coverage/src/components/Dialog/index.html +131 -0
  96. package/test-output/vitest/coverage/src/components/Divider/Divider.tsx.html +505 -0
  97. package/test-output/vitest/coverage/src/components/Divider/divider.variants.ts.html +433 -0
  98. package/test-output/vitest/coverage/src/components/Divider/index.html +131 -0
  99. package/test-output/vitest/coverage/src/components/Grid/Grid.tsx.html +529 -0
  100. package/test-output/vitest/coverage/src/components/Grid/grid.variants.ts.html +484 -0
  101. package/test-output/vitest/coverage/src/components/Grid/index.html +131 -0
  102. package/test-output/vitest/coverage/src/components/LeftNav/LeftNav.tsx.html +1075 -0
  103. package/test-output/vitest/coverage/src/components/LeftNav/index.html +131 -0
  104. package/test-output/vitest/coverage/src/components/LeftNav/leftNav.variants.ts.html +406 -0
  105. package/test-output/vitest/coverage/src/components/NumberField/NumberField.tsx.html +1048 -0
  106. package/test-output/vitest/coverage/src/components/NumberField/index.html +131 -0
  107. package/test-output/vitest/coverage/src/components/NumberField/numberField.variants.ts.html +367 -0
  108. package/test-output/vitest/coverage/src/components/OTPField/OTPField.tsx.html +835 -0
  109. package/test-output/vitest/coverage/src/components/OTPField/index.html +131 -0
  110. package/test-output/vitest/coverage/src/components/OTPField/otpField.variants.ts.html +301 -0
  111. package/test-output/vitest/coverage/src/components/Pagination/Pagination.tsx.html +916 -0
  112. package/test-output/vitest/coverage/src/components/Pagination/index.html +146 -0
  113. package/test-output/vitest/coverage/src/components/Pagination/pagination.helpers.ts.html +391 -0
  114. package/test-output/vitest/coverage/src/components/Pagination/pagination.variants.ts.html +436 -0
  115. package/test-output/vitest/coverage/src/components/Popover/Popover.tsx.html +250 -0
  116. package/test-output/vitest/coverage/src/components/Popover/index.html +131 -0
  117. package/test-output/vitest/coverage/src/components/Popover/popover.variants.ts.html +157 -0
  118. package/test-output/vitest/coverage/src/components/RadioGroup/RadioGroup.tsx.html +934 -0
  119. package/test-output/vitest/coverage/src/components/RadioGroup/index.html +131 -0
  120. package/test-output/vitest/coverage/src/components/RadioGroup/radioGroup.variants.ts.html +346 -0
  121. package/test-output/vitest/coverage/src/components/Skeleton/Skeleton.tsx.html +232 -0
  122. package/test-output/vitest/coverage/src/components/Skeleton/index.html +131 -0
  123. package/test-output/vitest/coverage/src/components/Skeleton/skeleton.variants.ts.html +346 -0
  124. package/test-output/vitest/coverage/src/components/Snackbar/Snackbar.tsx.html +979 -0
  125. package/test-output/vitest/coverage/src/components/Snackbar/index.html +131 -0
  126. package/test-output/vitest/coverage/src/components/Snackbar/snackbar.variants.ts.html +307 -0
  127. package/test-output/vitest/coverage/src/components/Stack/Stack.tsx.html +430 -0
  128. package/test-output/vitest/coverage/src/components/Stack/index.html +131 -0
  129. package/test-output/vitest/coverage/src/components/Stack/stack.variants.ts.html +325 -0
  130. package/test-output/vitest/coverage/src/components/Switch/Switch.tsx.html +559 -0
  131. package/test-output/vitest/coverage/src/components/Switch/index.html +131 -0
  132. package/test-output/vitest/coverage/src/components/Switch/switch.variants.ts.html +295 -0
  133. package/test-output/vitest/coverage/src/components/Table/Table.tsx.html +2677 -0
  134. package/test-output/vitest/coverage/src/components/Table/cells/RenderButton.tsx.html +154 -0
  135. package/test-output/vitest/coverage/src/components/Table/cells/RenderChip.tsx.html +385 -0
  136. package/test-output/vitest/coverage/src/components/Table/cells/RenderText.tsx.html +196 -0
  137. package/test-output/vitest/coverage/src/components/Table/cells/RenderTwoLine.tsx.html +187 -0
  138. package/test-output/vitest/coverage/src/components/Table/cells/RowActionsMenu.tsx.html +526 -0
  139. package/test-output/vitest/coverage/src/components/Table/cells/index.html +191 -0
  140. package/test-output/vitest/coverage/src/components/Table/cells/index.ts.html +127 -0
  141. package/test-output/vitest/coverage/src/components/Table/index.html +131 -0
  142. package/test-output/vitest/coverage/src/components/Table/table.variants.ts.html +706 -0
  143. package/test-output/vitest/coverage/src/components/Tabs/Tabs.tsx.html +283 -0
  144. package/test-output/vitest/coverage/src/components/Tabs/index.html +131 -0
  145. package/test-output/vitest/coverage/src/components/Tabs/tabs.variants.ts.html +292 -0
  146. package/test-output/vitest/coverage/src/components/TextField/TextField.tsx.html +820 -0
  147. package/test-output/vitest/coverage/src/components/TextField/index.html +131 -0
  148. package/test-output/vitest/coverage/src/components/TextField/textField.variants.ts.html +400 -0
  149. package/test-output/vitest/coverage/src/components/Textarea/Textarea.tsx.html +655 -0
  150. package/test-output/vitest/coverage/src/components/Textarea/index.html +131 -0
  151. package/test-output/vitest/coverage/src/components/Textarea/textarea.variants.ts.html +340 -0
  152. package/test-output/vitest/coverage/src/components/Tooltip/Tooltip.tsx.html +295 -0
  153. package/test-output/vitest/coverage/src/components/Tooltip/index.html +131 -0
  154. package/test-output/vitest/coverage/src/components/Tooltip/tooltip.variants.ts.html +169 -0
  155. package/test-output/vitest/coverage/src/components/TopBar/TopBar.tsx.html +283 -0
  156. package/test-output/vitest/coverage/src/components/TopBar/index.html +131 -0
  157. package/test-output/vitest/coverage/src/components/TopBar/topBar.variants.ts.html +199 -0
  158. package/test-output/vitest/coverage/src/components/Typography/Typography.tsx.html +421 -0
  159. package/test-output/vitest/coverage/src/components/Typography/index.html +131 -0
  160. package/test-output/vitest/coverage/src/components/Typography/typography.variants.ts.html +436 -0
  161. package/test-output/vitest/coverage/src/components/VisuallyHidden/VisuallyHidden.tsx.html +262 -0
  162. package/test-output/vitest/coverage/src/components/VisuallyHidden/index.html +116 -0
  163. package/test-output/vitest/coverage/src/components/_shared/data/getNestedValue.ts.html +181 -0
  164. package/test-output/vitest/coverage/src/components/_shared/data/index.html +236 -0
  165. package/test-output/vitest/coverage/src/components/_shared/data/useColumnAutoDetect.ts.html +385 -0
  166. package/test-output/vitest/coverage/src/components/_shared/data/useControllableState.ts.html +229 -0
  167. package/test-output/vitest/coverage/src/components/_shared/data/useDebouncedValue.ts.html +157 -0
  168. package/test-output/vitest/coverage/src/components/_shared/data/useTableFilter.ts.html +439 -0
  169. package/test-output/vitest/coverage/src/components/_shared/data/useTableSearch.ts.html +283 -0
  170. package/test-output/vitest/coverage/src/components/_shared/data/useTableSelection.ts.html +385 -0
  171. package/test-output/vitest/coverage/src/components/_shared/data/useTableSort.ts.html +508 -0
  172. package/test-output/vitest/coverage/src/components/_shared/data/useVirtualizer.ts.html +682 -0
  173. package/test-output/vitest/coverage/src/components/_shared/index.html +116 -0
  174. package/test-output/vitest/coverage/src/components/_shared/resolveValidationState.ts.html +265 -0
  175. package/test-output/vitest/coverage/src/hooks/index.html +116 -0
  176. package/test-output/vitest/coverage/src/hooks/useAccessState.ts.html +328 -0
  177. package/test-output/vitest/coverage/src/index.html +116 -0
  178. package/test-output/vitest/coverage/src/index.ts.html +1153 -0
  179. package/test-output/vitest/coverage/src/utils/cn.ts.html +172 -0
  180. package/test-output/vitest/coverage/src/utils/index.html +116 -0
@@ -1,6 +1,27 @@
1
- // @vitest-environment node
2
- import { describe, it, expect } from 'vitest';
3
- import { moveColumn } from './useColumnReorder.js';
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
- return { startResize };
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
- columnsShowAll?: string;
35
- columnsHideAll?: string;
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
- columnsShowAll: 'Show all',
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
- const showAll = () => onChange([]);
74
- const hideAll = () => onChange(hideableCols.map((c) => c.field as string));
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-2 min-w-[220px]">
84
- <div className="flex items-center justify-between pb-2 border-b border-neutral-200">
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
- columnsShowAll: 'Show all',
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
- fallback: labels.noData,
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 + light text
57
- // solid neutral: bg + text both anchored to neutral palette
58
- // (auto-inverts). `text-white` would not invert and would break
59
- // contrast in light mode against a `bg-neutral-700` dark bg, so
60
- // we stick to `text-neutral-50` (light text in light mode, dark
61
- // in dark — wait, that's wrong too). Actually `text-neutral-50`
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' },