@dashforge/tw 0.8.0-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 (183) hide show
  1. package/CHANGELOG.md +188 -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 +235 -160
  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 +44 -0
  12. package/dist/src/components/DataGrid/visibility/ColumnVisibilityMenu.d.ts.map +1 -0
  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 +29 -6
  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 +11 -8
  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 +253 -17
  24. package/src/components/DataGrid/DataGrid.tsx +107 -24
  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 +186 -0
  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 +41 -8
  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 +29 -6
  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
  181. package/dist/src/components/DataGrid/visibility/ColumnVisibilityDialog.d.ts +0 -33
  182. package/dist/src/components/DataGrid/visibility/ColumnVisibilityDialog.d.ts.map +0 -1
  183. package/src/components/DataGrid/visibility/ColumnVisibilityDialog.tsx +0 -200
@@ -0,0 +1,330 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Unit tests for the per-column filter UI (`ColumnFilterTrigger`).
4
+ *
5
+ * Sprint 6 P4 — the filter UI shipped in 0.8.0-beta with only
6
+ * DataGrid-integration coverage. This file exercises each filter
7
+ * type (text / number / boolean / date) directly, including the
8
+ * apply / clear branches + edge cases.
9
+ */
10
+ import * as React from 'react';
11
+ import { describe, it, expect, afterEach, beforeAll, vi } from 'vitest';
12
+ import { render, screen, fireEvent, cleanup } from '@testing-library/react';
13
+ import { ColumnFilterTrigger } from './ColumnFilters.js';
14
+ import type { TableFilterModel } from '../../Table/table.types.js';
15
+
16
+ void React;
17
+ afterEach(() => cleanup());
18
+
19
+ beforeAll(() => {
20
+ if (typeof globalThis.ResizeObserver === 'undefined') {
21
+ (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver =
22
+ class {
23
+ observe() {}
24
+ unobserve() {}
25
+ disconnect() {}
26
+ };
27
+ }
28
+ });
29
+
30
+ const EMPTY: TableFilterModel = [];
31
+
32
+ describe('ColumnFilterTrigger — trigger button', () => {
33
+ it('renders a filter button', () => {
34
+ render(
35
+ <ColumnFilterTrigger
36
+ field="name"
37
+ filterType="text"
38
+ filterModel={EMPTY}
39
+ onFilterChange={() => {}}
40
+ />,
41
+ );
42
+ expect(screen.getByRole('button', { name: 'Filter' })).not.toBeNull();
43
+ });
44
+
45
+ it('shows aria-pressed=false when no filter is active', () => {
46
+ render(
47
+ <ColumnFilterTrigger
48
+ field="name"
49
+ filterType="text"
50
+ filterModel={EMPTY}
51
+ onFilterChange={() => {}}
52
+ />,
53
+ );
54
+ expect(
55
+ screen.getByRole('button', { name: 'Filter' }).getAttribute('aria-pressed'),
56
+ ).toBe('false');
57
+ });
58
+
59
+ it('shows aria-pressed=true when a filter is active on the field', () => {
60
+ render(
61
+ <ColumnFilterTrigger
62
+ field="name"
63
+ filterType="text"
64
+ filterModel={[{ field: 'name', op: 'contains', value: 'x' }]}
65
+ onFilterChange={() => {}}
66
+ />,
67
+ );
68
+ expect(
69
+ screen.getByRole('button', { name: 'Filter' }).getAttribute('aria-pressed'),
70
+ ).toBe('true');
71
+ });
72
+
73
+ it('translates the trigger label via labels', () => {
74
+ render(
75
+ <ColumnFilterTrigger
76
+ field="name"
77
+ filterType="text"
78
+ filterModel={EMPTY}
79
+ onFilterChange={() => {}}
80
+ labels={{ filterColumn: 'Filtra' }}
81
+ />,
82
+ );
83
+ expect(screen.getByRole('button', { name: 'Filtra' })).not.toBeNull();
84
+ });
85
+
86
+ it('is disabled when disabled is set', () => {
87
+ render(
88
+ <ColumnFilterTrigger
89
+ field="name"
90
+ filterType="text"
91
+ filterModel={EMPTY}
92
+ onFilterChange={() => {}}
93
+ disabled
94
+ />,
95
+ );
96
+ expect(
97
+ (screen.getByRole('button', { name: 'Filter' }) as HTMLButtonElement)
98
+ .disabled,
99
+ ).toBe(true);
100
+ });
101
+ });
102
+
103
+ describe('ColumnFilterTrigger — text filter', () => {
104
+ it('Apply commits a contains filter', () => {
105
+ const onFilterChange = vi.fn();
106
+ render(
107
+ <ColumnFilterTrigger
108
+ field="name"
109
+ filterType="text"
110
+ filterModel={EMPTY}
111
+ onFilterChange={onFilterChange}
112
+ />,
113
+ );
114
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
115
+ fireEvent.change(screen.getByPlaceholderText('…'), {
116
+ target: { value: 'abc' },
117
+ });
118
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
119
+ expect(onFilterChange).toHaveBeenCalledWith([
120
+ { field: 'name', op: 'contains', value: 'abc' },
121
+ ]);
122
+ });
123
+
124
+ it('Apply with an empty value commits a removal (null → no filter)', () => {
125
+ const onFilterChange = vi.fn();
126
+ render(
127
+ <ColumnFilterTrigger
128
+ field="name"
129
+ filterType="text"
130
+ filterModel={[{ field: 'name', op: 'contains', value: 'old' }]}
131
+ onFilterChange={onFilterChange}
132
+ />,
133
+ );
134
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
135
+ fireEvent.change(screen.getByDisplayValue('old'), {
136
+ target: { value: '' },
137
+ });
138
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
139
+ expect(onFilterChange).toHaveBeenCalledWith([]);
140
+ });
141
+
142
+ it('Clear removes the filter for the field', () => {
143
+ const onFilterChange = vi.fn();
144
+ render(
145
+ <ColumnFilterTrigger
146
+ field="name"
147
+ filterType="text"
148
+ filterModel={[{ field: 'name', op: 'contains', value: 'x' }]}
149
+ onFilterChange={onFilterChange}
150
+ />,
151
+ );
152
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
153
+ fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
154
+ expect(onFilterChange).toHaveBeenCalledWith([]);
155
+ });
156
+
157
+ it('Enter in the text input applies the filter', () => {
158
+ const onFilterChange = vi.fn();
159
+ render(
160
+ <ColumnFilterTrigger
161
+ field="name"
162
+ filterType="text"
163
+ filterModel={EMPTY}
164
+ onFilterChange={onFilterChange}
165
+ />,
166
+ );
167
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
168
+ const input = screen.getByPlaceholderText('…');
169
+ fireEvent.change(input, { target: { value: 'typed' } });
170
+ fireEvent.keyDown(input, { key: 'Enter' });
171
+ expect(onFilterChange).toHaveBeenCalledWith([
172
+ { field: 'name', op: 'contains', value: 'typed' },
173
+ ]);
174
+ });
175
+ });
176
+
177
+ describe('ColumnFilterTrigger — number range filter', () => {
178
+ it('Apply with min + max commits a between filter', () => {
179
+ const onFilterChange = vi.fn();
180
+ render(
181
+ <ColumnFilterTrigger
182
+ field="age"
183
+ filterType="number"
184
+ filterModel={EMPTY}
185
+ onFilterChange={onFilterChange}
186
+ />,
187
+ );
188
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
189
+ const inputs = screen.getAllByRole('spinbutton');
190
+ fireEvent.change(inputs[0]!, { target: { value: '10' } });
191
+ fireEvent.change(inputs[1]!, { target: { value: '50' } });
192
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
193
+ expect(onFilterChange).toHaveBeenCalledWith([
194
+ { field: 'age', op: 'between', value: [10, 50] },
195
+ ]);
196
+ });
197
+
198
+ it('Apply with only a min commits an open-ended range', () => {
199
+ const onFilterChange = vi.fn();
200
+ render(
201
+ <ColumnFilterTrigger
202
+ field="age"
203
+ filterType="number"
204
+ filterModel={EMPTY}
205
+ onFilterChange={onFilterChange}
206
+ />,
207
+ );
208
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
209
+ const inputs = screen.getAllByRole('spinbutton');
210
+ fireEvent.change(inputs[0]!, { target: { value: '18' } });
211
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
212
+ expect(onFilterChange).toHaveBeenCalledWith([
213
+ { field: 'age', op: 'between', value: [18, null] },
214
+ ]);
215
+ });
216
+
217
+ it('Apply with both ends empty removes the filter', () => {
218
+ const onFilterChange = vi.fn();
219
+ render(
220
+ <ColumnFilterTrigger
221
+ field="age"
222
+ filterType="number"
223
+ filterModel={[{ field: 'age', op: 'between', value: [1, 2] }]}
224
+ onFilterChange={onFilterChange}
225
+ />,
226
+ );
227
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
228
+ // The two inputs are seeded from the current value — clear them.
229
+ const inputs = screen.getAllByRole('spinbutton');
230
+ fireEvent.change(inputs[0]!, { target: { value: '' } });
231
+ fireEvent.change(inputs[1]!, { target: { value: '' } });
232
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
233
+ expect(onFilterChange).toHaveBeenCalledWith([]);
234
+ });
235
+ });
236
+
237
+ describe('ColumnFilterTrigger — boolean filter', () => {
238
+ it('Apply with True selected commits an equals=true filter', () => {
239
+ const onFilterChange = vi.fn();
240
+ render(
241
+ <ColumnFilterTrigger
242
+ field="active"
243
+ filterType="boolean"
244
+ filterModel={EMPTY}
245
+ onFilterChange={onFilterChange}
246
+ />,
247
+ );
248
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
249
+ fireEvent.click(screen.getByLabelText('True'));
250
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
251
+ expect(onFilterChange).toHaveBeenCalledWith([
252
+ { field: 'active', op: 'equals', value: true },
253
+ ]);
254
+ });
255
+
256
+ it('Apply with False selected commits an equals=false filter', () => {
257
+ const onFilterChange = vi.fn();
258
+ render(
259
+ <ColumnFilterTrigger
260
+ field="active"
261
+ filterType="boolean"
262
+ filterModel={EMPTY}
263
+ onFilterChange={onFilterChange}
264
+ />,
265
+ );
266
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
267
+ fireEvent.click(screen.getByLabelText('False'));
268
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
269
+ expect(onFilterChange).toHaveBeenCalledWith([
270
+ { field: 'active', op: 'equals', value: false },
271
+ ]);
272
+ });
273
+
274
+ it('Apply with All selected removes the filter', () => {
275
+ const onFilterChange = vi.fn();
276
+ render(
277
+ <ColumnFilterTrigger
278
+ field="active"
279
+ filterType="boolean"
280
+ filterModel={[{ field: 'active', op: 'equals', value: true }]}
281
+ onFilterChange={onFilterChange}
282
+ />,
283
+ );
284
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
285
+ fireEvent.click(screen.getByLabelText('All'));
286
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
287
+ expect(onFilterChange).toHaveBeenCalledWith([]);
288
+ });
289
+ });
290
+
291
+ describe('ColumnFilterTrigger — date range filter', () => {
292
+ it('Apply with from + to commits a between filter (ISO strings)', () => {
293
+ const onFilterChange = vi.fn();
294
+ render(
295
+ <ColumnFilterTrigger
296
+ field="joined"
297
+ filterType="date"
298
+ filterModel={EMPTY}
299
+ onFilterChange={onFilterChange}
300
+ />,
301
+ );
302
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
303
+ const dateInputs = document.querySelectorAll('input[type="date"]');
304
+ fireEvent.change(dateInputs[0]!, { target: { value: '2024-01-01' } });
305
+ fireEvent.change(dateInputs[1]!, { target: { value: '2024-12-31' } });
306
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
307
+ expect(onFilterChange).toHaveBeenCalledWith([
308
+ { field: 'joined', op: 'between', value: ['2024-01-01', '2024-12-31'] },
309
+ ]);
310
+ });
311
+
312
+ it('Apply with both date ends empty removes the filter', () => {
313
+ const onFilterChange = vi.fn();
314
+ render(
315
+ <ColumnFilterTrigger
316
+ field="joined"
317
+ filterType="date"
318
+ filterModel={[
319
+ { field: 'joined', op: 'between', value: ['2024-01-01', null] },
320
+ ]}
321
+ onFilterChange={onFilterChange}
322
+ />,
323
+ );
324
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
325
+ const dateInputs = document.querySelectorAll('input[type="date"]');
326
+ fireEvent.change(dateInputs[0]!, { target: { value: '' } });
327
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
328
+ expect(onFilterChange).toHaveBeenCalledWith([]);
329
+ });
330
+ });
@@ -1,4 +1,4 @@
1
- import { useState, type ReactNode } from 'react';
1
+ import { useState, type ReactNode, type KeyboardEvent } from 'react';
2
2
  import { Popover } from '../../Popover/Popover.js';
3
3
  import { cn } from '../../../utils/cn.js';
4
4
  import type {
@@ -40,6 +40,22 @@ function setFilter(
40
40
  return [...rest, next];
41
41
  }
42
42
 
43
+ /**
44
+ * Shared `onKeyDown` for filter inputs — pressing Enter commits the
45
+ * filter (equivalent to clicking Apply). Without this the user has to
46
+ * leave the keyboard and click the button, which reads as broken for
47
+ * a single-field text filter.
48
+ */
49
+ function onEnterApply(
50
+ e: KeyboardEvent<HTMLInputElement>,
51
+ apply: () => void,
52
+ ): void {
53
+ if (e.key === 'Enter') {
54
+ e.preventDefault();
55
+ apply();
56
+ }
57
+ }
58
+
43
59
  // ───── Filter labels (i18n) ─────
44
60
 
45
61
  export interface ColumnFilterLabels {
@@ -173,18 +189,22 @@ function TextFilter(props: {
173
189
  typeof current?.value === 'string' ? current.value : '',
174
190
  );
175
191
 
192
+ const apply = () =>
193
+ onCommit(value.trim() ? { field, op: 'contains', value } : null);
194
+
176
195
  return (
177
196
  <div className="flex flex-col gap-2 min-w-[200px]">
178
197
  <input
179
198
  type="text"
180
199
  value={value}
181
200
  onChange={(e) => setValue(e.target.value)}
201
+ onKeyDown={(e) => onEnterApply(e, apply)}
182
202
  placeholder="…"
183
203
  autoFocus
184
204
  className="rounded-md border border-neutral-300 bg-neutral-50 px-2 py-1.5 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
185
205
  />
186
206
  <FilterActions
187
- onApply={() => onCommit(value.trim() ? { field, op: 'contains', value } : null)}
207
+ onApply={apply}
188
208
  onClear={() => {
189
209
  setValue('');
190
210
  onCommit(null);
@@ -236,6 +256,7 @@ function NumberRangeFilter(props: {
236
256
  type="number"
237
257
  value={min}
238
258
  onChange={(e) => setMin(e.target.value)}
259
+ onKeyDown={(e) => onEnterApply(e, apply)}
239
260
  autoFocus
240
261
  className="rounded-md border border-neutral-300 bg-neutral-50 px-2 py-1.5 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
241
262
  />
@@ -246,6 +267,7 @@ function NumberRangeFilter(props: {
246
267
  type="number"
247
268
  value={max}
248
269
  onChange={(e) => setMax(e.target.value)}
270
+ onKeyDown={(e) => onEnterApply(e, apply)}
249
271
  className="rounded-md border border-neutral-300 bg-neutral-50 px-2 py-1.5 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
250
272
  />
251
273
  </label>
@@ -357,6 +379,7 @@ function DateRangeFilter(props: {
357
379
  type="date"
358
380
  value={from}
359
381
  onChange={(e) => setFrom(e.target.value)}
382
+ onKeyDown={(e) => onEnterApply(e, apply)}
360
383
  autoFocus
361
384
  className="rounded-md border border-neutral-300 bg-neutral-50 px-2 py-1.5 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
362
385
  />
@@ -367,6 +390,7 @@ function DateRangeFilter(props: {
367
390
  type="date"
368
391
  value={to}
369
392
  onChange={(e) => setTo(e.target.value)}
393
+ onKeyDown={(e) => onEnterApply(e, apply)}
370
394
  className="rounded-md border border-neutral-300 bg-neutral-50 px-2 py-1.5 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
371
395
  />
372
396
  </label>
@@ -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
+ });