@dashforge/tw 0.6.0-beta → 0.8.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 (107) hide show
  1. package/CHANGELOG.md +451 -0
  2. package/THEME-AUDIT.md +175 -0
  3. package/dist/index.esm.js +2009 -87
  4. package/dist/src/components/Accordion/accordion.variants.d.ts.map +1 -1
  5. package/dist/src/components/Box/box.variants.d.ts.map +1 -1
  6. package/dist/src/components/DataGrid/DataGrid.d.ts +29 -0
  7. package/dist/src/components/DataGrid/DataGrid.d.ts.map +1 -0
  8. package/dist/src/components/DataGrid/dataGrid.types.d.ts +214 -0
  9. package/dist/src/components/DataGrid/dataGrid.types.d.ts.map +1 -0
  10. package/dist/src/components/DataGrid/dataGrid.variants.d.ts +222 -0
  11. package/dist/src/components/DataGrid/dataGrid.variants.d.ts.map +1 -0
  12. package/dist/src/components/DataGrid/filters/ColumnFilters.d.ts +23 -0
  13. package/dist/src/components/DataGrid/filters/ColumnFilters.d.ts.map +1 -0
  14. package/dist/src/components/DataGrid/reorder/useColumnReorder.d.ts +50 -0
  15. package/dist/src/components/DataGrid/reorder/useColumnReorder.d.ts.map +1 -0
  16. package/dist/src/components/DataGrid/resize/useColumnResize.d.ts +32 -0
  17. package/dist/src/components/DataGrid/resize/useColumnResize.d.ts.map +1 -0
  18. package/dist/src/components/DataGrid/visibility/ColumnVisibilityDialog.d.ts +33 -0
  19. package/dist/src/components/DataGrid/visibility/ColumnVisibilityDialog.d.ts.map +1 -0
  20. package/dist/src/components/Dialog/dialog.variants.d.ts.map +1 -1
  21. package/dist/src/components/Divider/divider.variants.d.ts.map +1 -1
  22. package/dist/src/components/Pagination/Pagination.d.ts.map +1 -1
  23. package/dist/src/components/Pagination/pagination.variants.d.ts.map +1 -1
  24. package/dist/src/components/Popover/popover.variants.d.ts.map +1 -1
  25. package/dist/src/components/Skeleton/skeleton.variants.d.ts.map +1 -1
  26. package/dist/src/components/Table/Table.d.ts.map +1 -1
  27. package/dist/src/components/Table/table.types.d.ts +77 -5
  28. package/dist/src/components/Table/table.types.d.ts.map +1 -1
  29. package/dist/src/components/Tabs/tabs.variants.d.ts.map +1 -1
  30. package/dist/src/components/Tooltip/tooltip.variants.d.ts.map +1 -1
  31. package/dist/src/components/Typography/typography.variants.d.ts.map +1 -1
  32. package/dist/src/components/_shared/data/getNestedValue.d.ts.map +1 -0
  33. package/dist/src/components/{Table/_internal → _shared/data}/useColumnAutoDetect.d.ts +1 -1
  34. package/dist/src/components/_shared/data/useColumnAutoDetect.d.ts.map +1 -0
  35. package/dist/src/components/_shared/data/useControllableState.d.ts.map +1 -0
  36. package/dist/src/components/_shared/data/useDebouncedValue.d.ts.map +1 -0
  37. package/dist/src/components/_shared/data/useTableFilter.d.ts +36 -0
  38. package/dist/src/components/_shared/data/useTableFilter.d.ts.map +1 -0
  39. package/dist/src/components/{Table/_internal → _shared/data}/useTableSearch.d.ts +1 -1
  40. package/dist/src/components/_shared/data/useTableSearch.d.ts.map +1 -0
  41. package/dist/src/components/{Table/_internal → _shared/data}/useTableSelection.d.ts +1 -1
  42. package/dist/src/components/_shared/data/useTableSelection.d.ts.map +1 -0
  43. package/dist/src/components/{Table/_internal → _shared/data}/useTableSort.d.ts +1 -1
  44. package/dist/src/components/_shared/data/useTableSort.d.ts.map +1 -0
  45. package/dist/src/components/_shared/data/useVirtualizer.d.ts +107 -0
  46. package/dist/src/components/_shared/data/useVirtualizer.d.ts.map +1 -0
  47. package/dist/src/index.d.ts +6 -3
  48. package/dist/src/index.d.ts.map +1 -1
  49. package/package.json +5 -5
  50. package/src/components/Accordion/accordion.variants.ts +5 -3
  51. package/src/components/Box/Box.test.tsx +32 -11
  52. package/src/components/Box/box.variants.ts +23 -4
  53. package/src/components/DataGrid/DataGrid.test.tsx +917 -0
  54. package/src/components/DataGrid/DataGrid.tsx +1199 -0
  55. package/src/components/DataGrid/dataGrid.types.ts +238 -0
  56. package/src/components/DataGrid/dataGrid.variants.ts +152 -0
  57. package/src/components/DataGrid/filters/ColumnFilters.tsx +432 -0
  58. package/src/components/DataGrid/reorder/useColumnReorder.test.ts +38 -0
  59. package/src/components/DataGrid/reorder/useColumnReorder.ts +144 -0
  60. package/src/components/DataGrid/resize/useColumnResize.ts +106 -0
  61. package/src/components/DataGrid/visibility/ColumnVisibilityDialog.tsx +200 -0
  62. package/src/components/Dialog/dialog.variants.ts +10 -6
  63. package/src/components/Divider/Divider.test.tsx +3 -1
  64. package/src/components/Divider/Divider.tsx +1 -1
  65. package/src/components/Divider/divider.variants.ts +2 -1
  66. package/src/components/Pagination/Pagination.tsx +3 -1
  67. package/src/components/Pagination/pagination.variants.ts +19 -13
  68. package/src/components/Popover/popover.variants.ts +10 -4
  69. package/src/components/Skeleton/skeleton.variants.ts +2 -1
  70. package/src/components/Table/Table.tsx +22 -8
  71. package/src/components/Table/table.types.ts +83 -5
  72. package/src/components/Tabs/tabs.variants.ts +19 -8
  73. package/src/components/Tooltip/tooltip.variants.ts +12 -2
  74. package/src/components/Typography/Typography.test.tsx +3 -1
  75. package/src/components/Typography/typography.variants.ts +6 -2
  76. package/src/components/{Table/_internal → _shared/data}/useColumnAutoDetect.test.ts +1 -1
  77. package/src/components/{Table/_internal → _shared/data}/useColumnAutoDetect.ts +1 -1
  78. package/src/components/_shared/data/useTableFilter.test.ts +166 -0
  79. package/src/components/_shared/data/useTableFilter.ts +118 -0
  80. package/src/components/{Table/_internal → _shared/data}/useTableSearch.test.ts +1 -1
  81. package/src/components/{Table/_internal → _shared/data}/useTableSearch.ts +1 -1
  82. package/src/components/{Table/_internal → _shared/data}/useTableSelection.ts +1 -1
  83. package/src/components/{Table/_internal → _shared/data}/useTableSort.test.ts +1 -1
  84. package/src/components/{Table/_internal → _shared/data}/useTableSort.ts +1 -1
  85. package/src/components/_shared/data/useVirtualizer.test.ts +158 -0
  86. package/src/components/_shared/data/useVirtualizer.ts +199 -0
  87. package/src/components/_shared/themeIdentity.test.ts +113 -0
  88. package/src/index.ts +20 -2
  89. package/dist/src/components/Table/_internal/getNestedValue.d.ts.map +0 -1
  90. package/dist/src/components/Table/_internal/useColumnAutoDetect.d.ts.map +0 -1
  91. package/dist/src/components/Table/_internal/useControllableState.d.ts.map +0 -1
  92. package/dist/src/components/Table/_internal/useDebouncedValue.d.ts.map +0 -1
  93. package/dist/src/components/Table/_internal/useTableFilter.d.ts +0 -13
  94. package/dist/src/components/Table/_internal/useTableFilter.d.ts.map +0 -1
  95. package/dist/src/components/Table/_internal/useTableSearch.d.ts.map +0 -1
  96. package/dist/src/components/Table/_internal/useTableSelection.d.ts.map +0 -1
  97. package/dist/src/components/Table/_internal/useTableSort.d.ts.map +0 -1
  98. package/src/components/Table/_internal/themeIdentity.test.ts +0 -84
  99. package/src/components/Table/_internal/useTableFilter.ts +0 -37
  100. /package/dist/src/components/{Table/_internal → _shared/data}/getNestedValue.d.ts +0 -0
  101. /package/dist/src/components/{Table/_internal → _shared/data}/useControllableState.d.ts +0 -0
  102. /package/dist/src/components/{Table/_internal → _shared/data}/useDebouncedValue.d.ts +0 -0
  103. /package/src/components/{Table/_internal → _shared/data}/getNestedValue.test.ts +0 -0
  104. /package/src/components/{Table/_internal → _shared/data}/getNestedValue.ts +0 -0
  105. /package/src/components/{Table/_internal → _shared/data}/useControllableState.ts +0 -0
  106. /package/src/components/{Table/_internal → _shared/data}/useDebouncedValue.ts +0 -0
  107. /package/src/components/{Table/_internal → _shared/data}/useTableSelection.test.ts +0 -0
@@ -0,0 +1,432 @@
1
+ import { useState, type ReactNode } from 'react';
2
+ import { Popover } from '../../Popover/Popover.js';
3
+ import { cn } from '../../../utils/cn.js';
4
+ import type {
5
+ TableFilterItem,
6
+ TableFilterModel,
7
+ TableFilterType,
8
+ } from '../../Table/table.types.js';
9
+
10
+ /**
11
+ * Per-column filter UI for DataGrid.
12
+ *
13
+ * `<ColumnFilterTrigger>` renders the filter icon button in the
14
+ * column header — when clicked, opens a Popover with the
15
+ * appropriate filter UI for the column type:
16
+ *
17
+ * - `text` → `<TextFilter>` with single input (op: `contains`)
18
+ * - `number` → `<NumberRangeFilter>` with min/max inputs (op: `between`)
19
+ * - `boolean` → `<BooleanFilter>` with true/false/all radio (op: `equals`)
20
+ * - `date` → `<DateRangeFilter>` with min/max date inputs (op: `between`)
21
+ *
22
+ * Each filter UI has Apply + Clear actions; Apply commits the
23
+ * filter to the model, Clear removes any existing filter for that
24
+ * column.
25
+ */
26
+
27
+ // ───── Helper: read/write filter model for a single column ─────
28
+
29
+ function findFilter(model: TableFilterModel, field: string): TableFilterItem | undefined {
30
+ return model.find((f) => f.field === field);
31
+ }
32
+
33
+ function setFilter(
34
+ model: TableFilterModel,
35
+ field: string,
36
+ next: TableFilterItem | null,
37
+ ): TableFilterModel {
38
+ const rest = model.filter((f) => f.field !== field);
39
+ if (next == null) return rest;
40
+ return [...rest, next];
41
+ }
42
+
43
+ // ───── Filter labels (i18n) ─────
44
+
45
+ export interface ColumnFilterLabels {
46
+ filterColumn?: string;
47
+ filterApply?: string;
48
+ filterClear?: string;
49
+ filterMin?: string;
50
+ filterMax?: string;
51
+ filterFrom?: string;
52
+ filterTo?: string;
53
+ filterAll?: string;
54
+ filterTrue?: string;
55
+ filterFalse?: string;
56
+ }
57
+
58
+ const DEFAULT_FILTER_LABELS: Required<ColumnFilterLabels> = {
59
+ filterColumn: 'Filter',
60
+ filterApply: 'Apply',
61
+ filterClear: 'Clear',
62
+ filterMin: 'Min',
63
+ filterMax: 'Max',
64
+ filterFrom: 'From',
65
+ filterTo: 'To',
66
+ filterAll: 'All',
67
+ filterTrue: 'True',
68
+ filterFalse: 'False',
69
+ };
70
+
71
+ // ───── Trigger (icon button in column header) ─────
72
+
73
+ export interface ColumnFilterTriggerProps {
74
+ field: string;
75
+ filterType: TableFilterType;
76
+ filterModel: TableFilterModel;
77
+ onFilterChange: (model: TableFilterModel) => void;
78
+ disabled?: boolean;
79
+ labels?: ColumnFilterLabels;
80
+ }
81
+
82
+ export function ColumnFilterTrigger(props: ColumnFilterTriggerProps) {
83
+ const {
84
+ field,
85
+ filterType,
86
+ filterModel,
87
+ onFilterChange,
88
+ disabled,
89
+ labels: labelsProp,
90
+ } = props;
91
+ const labels = { ...DEFAULT_FILTER_LABELS, ...labelsProp };
92
+ const [open, setOpen] = useState(false);
93
+ const active = findFilter(filterModel, field) != null;
94
+
95
+ return (
96
+ <Popover
97
+ open={open}
98
+ onOpenChange={setOpen}
99
+ side="bottom"
100
+ align="end"
101
+ content={
102
+ <FilterPanel
103
+ field={field}
104
+ filterType={filterType}
105
+ filterModel={filterModel}
106
+ onFilterChange={(next) => {
107
+ onFilterChange(next);
108
+ setOpen(false);
109
+ }}
110
+ labels={labels}
111
+ />
112
+ }
113
+ >
114
+ <button
115
+ type="button"
116
+ aria-label={labels.filterColumn}
117
+ aria-pressed={active}
118
+ disabled={disabled}
119
+ className={cn(
120
+ 'inline-flex items-center justify-center h-6 w-6 rounded',
121
+ 'text-neutral-500 hover:text-neutral-900',
122
+ active && 'text-primary-700',
123
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
124
+ 'transition-colors motion-reduce:transition-none',
125
+ 'disabled:opacity-50 disabled:cursor-not-allowed',
126
+ )}
127
+ >
128
+ <FilterIcon />
129
+ </button>
130
+ </Popover>
131
+ );
132
+ }
133
+
134
+ // ───── Panel — dispatches to the right filter UI ─────
135
+
136
+ function FilterPanel(props: {
137
+ field: string;
138
+ filterType: TableFilterType;
139
+ filterModel: TableFilterModel;
140
+ onFilterChange: (model: TableFilterModel) => void;
141
+ labels: Required<ColumnFilterLabels>;
142
+ }) {
143
+ const { field, filterType, filterModel, onFilterChange, labels } = props;
144
+ const current = findFilter(filterModel, field);
145
+
146
+ const commit = (next: TableFilterItem | null) => {
147
+ onFilterChange(setFilter(filterModel, field, next));
148
+ };
149
+
150
+ switch (filterType) {
151
+ case 'number':
152
+ return <NumberRangeFilter field={field} current={current} onCommit={commit} labels={labels} />;
153
+ case 'boolean':
154
+ return <BooleanFilter field={field} current={current} onCommit={commit} labels={labels} />;
155
+ case 'date':
156
+ return <DateRangeFilter field={field} current={current} onCommit={commit} labels={labels} />;
157
+ case 'text':
158
+ default:
159
+ return <TextFilter field={field} current={current} onCommit={commit} labels={labels} />;
160
+ }
161
+ }
162
+
163
+ // ───── Text filter ─────
164
+
165
+ function TextFilter(props: {
166
+ field: string;
167
+ current: TableFilterItem | undefined;
168
+ onCommit: (next: TableFilterItem | null) => void;
169
+ labels: Required<ColumnFilterLabels>;
170
+ }) {
171
+ const { field, current, onCommit, labels } = props;
172
+ const [value, setValue] = useState<string>(
173
+ typeof current?.value === 'string' ? current.value : '',
174
+ );
175
+
176
+ return (
177
+ <div className="flex flex-col gap-2 min-w-[200px]">
178
+ <input
179
+ type="text"
180
+ value={value}
181
+ onChange={(e) => setValue(e.target.value)}
182
+ placeholder="…"
183
+ autoFocus
184
+ 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
+ />
186
+ <FilterActions
187
+ onApply={() => onCommit(value.trim() ? { field, op: 'contains', value } : null)}
188
+ onClear={() => {
189
+ setValue('');
190
+ onCommit(null);
191
+ }}
192
+ labels={labels}
193
+ />
194
+ </div>
195
+ );
196
+ }
197
+
198
+ // ───── Number range filter ─────
199
+
200
+ function NumberRangeFilter(props: {
201
+ field: string;
202
+ current: TableFilterItem | undefined;
203
+ onCommit: (next: TableFilterItem | null) => void;
204
+ labels: Required<ColumnFilterLabels>;
205
+ }) {
206
+ const { field, current, onCommit, labels } = props;
207
+ const currentValue = current?.value;
208
+ const currentTuple: [unknown, unknown] = Array.isArray(currentValue)
209
+ ? (currentValue as [unknown, unknown])
210
+ : [null, null];
211
+ const [min, setMin] = useState<string>(
212
+ currentTuple[0] != null ? String(currentTuple[0]) : '',
213
+ );
214
+ const [max, setMax] = useState<string>(
215
+ currentTuple[1] != null ? String(currentTuple[1]) : '',
216
+ );
217
+
218
+ const apply = () => {
219
+ const minN = min.trim() ? Number(min) : null;
220
+ const maxN = max.trim() ? Number(max) : null;
221
+ if ((minN == null || Number.isFinite(minN)) && (maxN == null || Number.isFinite(maxN))) {
222
+ if (minN == null && maxN == null) {
223
+ onCommit(null);
224
+ } else {
225
+ onCommit({ field, op: 'between', value: [minN, maxN] });
226
+ }
227
+ }
228
+ };
229
+
230
+ return (
231
+ <div className="flex flex-col gap-2 min-w-[220px]">
232
+ <div className="grid grid-cols-2 gap-2">
233
+ <label className="flex flex-col gap-1 text-xs text-neutral-700">
234
+ <span>{labels.filterMin}</span>
235
+ <input
236
+ type="number"
237
+ value={min}
238
+ onChange={(e) => setMin(e.target.value)}
239
+ autoFocus
240
+ 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
+ />
242
+ </label>
243
+ <label className="flex flex-col gap-1 text-xs text-neutral-700">
244
+ <span>{labels.filterMax}</span>
245
+ <input
246
+ type="number"
247
+ value={max}
248
+ onChange={(e) => setMax(e.target.value)}
249
+ 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
+ />
251
+ </label>
252
+ </div>
253
+ <FilterActions
254
+ onApply={apply}
255
+ onClear={() => {
256
+ setMin('');
257
+ setMax('');
258
+ onCommit(null);
259
+ }}
260
+ labels={labels}
261
+ />
262
+ </div>
263
+ );
264
+ }
265
+
266
+ // ───── Boolean filter ─────
267
+
268
+ function BooleanFilter(props: {
269
+ field: string;
270
+ current: TableFilterItem | undefined;
271
+ onCommit: (next: TableFilterItem | null) => void;
272
+ labels: Required<ColumnFilterLabels>;
273
+ }) {
274
+ const { field, current, onCommit, labels } = props;
275
+ const initial: 'all' | 'true' | 'false' =
276
+ current?.value === true ? 'true' : current?.value === false ? 'false' : 'all';
277
+ const [value, setValue] = useState(initial);
278
+
279
+ const apply = () => {
280
+ if (value === 'all') onCommit(null);
281
+ else onCommit({ field, op: 'equals', value: value === 'true' });
282
+ };
283
+
284
+ return (
285
+ <div className="flex flex-col gap-2 min-w-[160px]">
286
+ <fieldset className="flex flex-col gap-1">
287
+ {(['all', 'true', 'false'] as const).map((opt) => (
288
+ <label key={opt} className="flex items-center gap-2 text-sm cursor-pointer">
289
+ <input
290
+ type="radio"
291
+ name={`filter-${field}`}
292
+ value={opt}
293
+ checked={value === opt}
294
+ onChange={() => setValue(opt)}
295
+ className="cursor-pointer"
296
+ />
297
+ <span>
298
+ {opt === 'all'
299
+ ? labels.filterAll
300
+ : opt === 'true'
301
+ ? labels.filterTrue
302
+ : labels.filterFalse}
303
+ </span>
304
+ </label>
305
+ ))}
306
+ </fieldset>
307
+ <FilterActions
308
+ onApply={apply}
309
+ onClear={() => {
310
+ setValue('all');
311
+ onCommit(null);
312
+ }}
313
+ labels={labels}
314
+ />
315
+ </div>
316
+ );
317
+ }
318
+
319
+ // ───── Date range filter ─────
320
+
321
+ function DateRangeFilter(props: {
322
+ field: string;
323
+ current: TableFilterItem | undefined;
324
+ onCommit: (next: TableFilterItem | null) => void;
325
+ labels: Required<ColumnFilterLabels>;
326
+ }) {
327
+ const { field, current, onCommit, labels } = props;
328
+ const currentValue = current?.value;
329
+ const currentTuple: [unknown, unknown] = Array.isArray(currentValue)
330
+ ? (currentValue as [unknown, unknown])
331
+ : [null, null];
332
+ const [from, setFrom] = useState<string>(
333
+ typeof currentTuple[0] === 'string' ? currentTuple[0] : '',
334
+ );
335
+ const [to, setTo] = useState<string>(
336
+ typeof currentTuple[1] === 'string' ? currentTuple[1] : '',
337
+ );
338
+
339
+ const apply = () => {
340
+ if (!from && !to) {
341
+ onCommit(null);
342
+ } else {
343
+ onCommit({
344
+ field,
345
+ op: 'between',
346
+ value: [from || null, to || null],
347
+ });
348
+ }
349
+ };
350
+
351
+ return (
352
+ <div className="flex flex-col gap-2 min-w-[220px]">
353
+ <div className="grid grid-cols-2 gap-2">
354
+ <label className="flex flex-col gap-1 text-xs text-neutral-700">
355
+ <span>{labels.filterFrom}</span>
356
+ <input
357
+ type="date"
358
+ value={from}
359
+ onChange={(e) => setFrom(e.target.value)}
360
+ autoFocus
361
+ 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
+ />
363
+ </label>
364
+ <label className="flex flex-col gap-1 text-xs text-neutral-700">
365
+ <span>{labels.filterTo}</span>
366
+ <input
367
+ type="date"
368
+ value={to}
369
+ onChange={(e) => setTo(e.target.value)}
370
+ 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
+ />
372
+ </label>
373
+ </div>
374
+ <FilterActions
375
+ onApply={apply}
376
+ onClear={() => {
377
+ setFrom('');
378
+ setTo('');
379
+ onCommit(null);
380
+ }}
381
+ labels={labels}
382
+ />
383
+ </div>
384
+ );
385
+ }
386
+
387
+ // ───── Apply + Clear actions ─────
388
+
389
+ function FilterActions(props: {
390
+ onApply: () => void;
391
+ onClear: () => void;
392
+ labels: Required<ColumnFilterLabels>;
393
+ }) {
394
+ return (
395
+ <div className="flex justify-end gap-2 pt-1 border-t border-neutral-200">
396
+ <button
397
+ type="button"
398
+ onClick={props.onClear}
399
+ className="text-xs text-neutral-600 hover:text-neutral-900 underline-offset-2 hover:underline focus:outline-none focus-visible:underline px-2 py-1"
400
+ >
401
+ {props.labels.filterClear}
402
+ </button>
403
+ <button
404
+ type="button"
405
+ onClick={props.onApply}
406
+ className="text-xs bg-primary-500 text-white rounded-md hover:bg-primary-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 px-3 py-1"
407
+ >
408
+ {props.labels.filterApply}
409
+ </button>
410
+ </div>
411
+ );
412
+ }
413
+
414
+ // ───── Icon ─────
415
+
416
+ function FilterIcon(): ReactNode {
417
+ return (
418
+ <svg
419
+ width="0.875em"
420
+ height="0.875em"
421
+ viewBox="0 0 16 16"
422
+ fill="none"
423
+ stroke="currentColor"
424
+ strokeWidth="1.5"
425
+ strokeLinecap="round"
426
+ strokeLinejoin="round"
427
+ aria-hidden="true"
428
+ >
429
+ <path d="M2 3h12M4 8h8M6 13h4" />
430
+ </svg>
431
+ );
432
+ }
@@ -0,0 +1,38 @@
1
+ // @vitest-environment node
2
+ import { describe, it, expect } from 'vitest';
3
+ import { moveColumn } from './useColumnReorder.js';
4
+
5
+ describe('moveColumn', () => {
6
+ const order = ['a', 'b', 'c', 'd'];
7
+
8
+ it('moves source BEFORE target on left drop', () => {
9
+ expect(moveColumn(order, 'd', 'b', 'left')).toEqual(['a', 'd', 'b', 'c']);
10
+ });
11
+
12
+ it('moves source AFTER target on right drop', () => {
13
+ expect(moveColumn(order, 'a', 'c', 'right')).toEqual(['b', 'c', 'a', 'd']);
14
+ });
15
+
16
+ it('handles dragging right by one', () => {
17
+ expect(moveColumn(order, 'a', 'b', 'right')).toEqual(['b', 'a', 'c', 'd']);
18
+ });
19
+
20
+ it('handles dragging left by one', () => {
21
+ expect(moveColumn(order, 'd', 'c', 'left')).toEqual(['a', 'b', 'd', 'c']);
22
+ });
23
+
24
+ it('no-ops when source == target', () => {
25
+ // moveColumn assumes the caller has already filtered source==target,
26
+ // but if not, the source ends up next to itself (still valid order).
27
+ expect(moveColumn(order, 'b', 'b', 'left')).toEqual(['a', 'b', 'c', 'd']);
28
+ });
29
+
30
+ it('returns the original list when target is not in the order', () => {
31
+ expect(moveColumn(order, 'a', 'zzz', 'left')).toEqual(order);
32
+ });
33
+
34
+ it('preserves all fields after the move', () => {
35
+ const result = moveColumn(order, 'a', 'd', 'right');
36
+ expect(result.sort()).toEqual(['a', 'b', 'c', 'd']);
37
+ });
38
+ });
@@ -0,0 +1,144 @@
1
+ import { useCallback, useRef, useState, type DragEvent } from 'react';
2
+
3
+ /**
4
+ * Native HTML5 drag-and-drop reorder for DataGrid column headers.
5
+ *
6
+ * Zero new runtime deps — uses the browser's built-in drag events.
7
+ * The hook returns handler factories you wire onto each `<th>` plus
8
+ * a `dropTarget` state used to render the visual indicator (a left
9
+ * or right border on the hovered column).
10
+ *
11
+ * - `dragStart(field)` → onDragStart; stashes source field in a ref
12
+ * + sets `dataTransfer.effectAllowed = 'move'`.
13
+ * - `dragOver(field)` → onDragOver; preventDefault + computes
14
+ * whether the cursor is over the LEFT or RIGHT half of the
15
+ * target → updates dropTarget so the indicator renders.
16
+ * - `drop(field)` → onDrop; computes the new order array via
17
+ * `moveColumn(source, target, side)` and commits it.
18
+ * - `dragEnd()` → onDragEnd; clears state.
19
+ *
20
+ * Reorder math: when dropping `source` onto the LEFT half of
21
+ * `target`, the source is inserted BEFORE the target's position
22
+ * in the order list. RIGHT half → AFTER.
23
+ */
24
+
25
+ export interface UseColumnReorderArgs {
26
+ /** The current order. */
27
+ order: string[];
28
+ /** Commit a new order. */
29
+ onChange: (next: string[]) => void;
30
+ }
31
+
32
+ export interface DropTarget {
33
+ field: string;
34
+ side: 'left' | 'right';
35
+ }
36
+
37
+ export interface ColumnReorderHandlers {
38
+ /** Current drop target (used by the renderer to show the indicator). */
39
+ dropTarget: DropTarget | null;
40
+ /** Field currently being dragged (used to dim the source header). */
41
+ draggingField: string | null;
42
+ dragStart: (field: string) => (e: DragEvent<HTMLElement>) => void;
43
+ dragOver: (field: string) => (e: DragEvent<HTMLElement>) => void;
44
+ drop: (field: string) => (e: DragEvent<HTMLElement>) => void;
45
+ dragEnd: () => void;
46
+ }
47
+
48
+ export function useColumnReorder(
49
+ args: UseColumnReorderArgs,
50
+ ): ColumnReorderHandlers {
51
+ const { order, onChange } = args;
52
+ const sourceRef = useRef<string | null>(null);
53
+ const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
54
+ const [draggingField, setDraggingField] = useState<string | null>(null);
55
+
56
+ const dragStart = useCallback(
57
+ (field: string) => (e: DragEvent<HTMLElement>) => {
58
+ sourceRef.current = field;
59
+ setDraggingField(field);
60
+ try {
61
+ e.dataTransfer.effectAllowed = 'move';
62
+ // Some browsers refuse to start a drag if no data is set.
63
+ e.dataTransfer.setData('text/plain', field);
64
+ } catch {
65
+ /* noop — jsdom doesn't implement dataTransfer fully. */
66
+ }
67
+ },
68
+ [],
69
+ );
70
+
71
+ const dragOver = useCallback(
72
+ (field: string) => (e: DragEvent<HTMLElement>) => {
73
+ if (sourceRef.current == null) return;
74
+ if (field === sourceRef.current) return;
75
+ e.preventDefault();
76
+ e.dataTransfer.dropEffect = 'move';
77
+ // Determine LEFT vs RIGHT half of the target.
78
+ const rect = e.currentTarget.getBoundingClientRect();
79
+ const midpoint = rect.left + rect.width / 2;
80
+ const side: 'left' | 'right' = e.clientX < midpoint ? 'left' : 'right';
81
+ setDropTarget((prev) =>
82
+ prev && prev.field === field && prev.side === side
83
+ ? prev
84
+ : { field, side },
85
+ );
86
+ },
87
+ [],
88
+ );
89
+
90
+ const drop = useCallback(
91
+ (field: string) => (e: DragEvent<HTMLElement>) => {
92
+ e.preventDefault();
93
+ const source = sourceRef.current;
94
+ sourceRef.current = null;
95
+ setDraggingField(null);
96
+ const target = dropTarget;
97
+ setDropTarget(null);
98
+ if (!source || source === field) return;
99
+ const side = target?.field === field ? target.side : 'right';
100
+ const next = moveColumn(order, source, field, side);
101
+ if (!arraysEqual(next, order)) onChange(next);
102
+ },
103
+ [order, onChange, dropTarget],
104
+ );
105
+
106
+ const dragEnd = useCallback(() => {
107
+ sourceRef.current = null;
108
+ setDraggingField(null);
109
+ setDropTarget(null);
110
+ }, []);
111
+
112
+ return { dropTarget, draggingField, dragStart, dragOver, drop, dragEnd };
113
+ }
114
+
115
+ /**
116
+ * Move `source` next to `target` in the `order` list.
117
+ * `side='left'` inserts before; `side='right'` inserts after.
118
+ * Exposed for unit testing.
119
+ */
120
+ export function moveColumn(
121
+ order: string[],
122
+ source: string,
123
+ target: string,
124
+ side: 'left' | 'right',
125
+ ): string[] {
126
+ const withoutSource = order.filter((f) => f !== source);
127
+ const targetIndex = withoutSource.indexOf(target);
128
+ if (targetIndex === -1) return order;
129
+ const insertAt = side === 'left' ? targetIndex : targetIndex + 1;
130
+ return [
131
+ ...withoutSource.slice(0, insertAt),
132
+ source,
133
+ ...withoutSource.slice(insertAt),
134
+ ];
135
+ }
136
+
137
+ function arraysEqual(a: string[], b: string[]): boolean {
138
+ if (a === b) return true;
139
+ if (a.length !== b.length) return false;
140
+ for (let i = 0; i < a.length; i++) {
141
+ if (a[i] !== b[i]) return false;
142
+ }
143
+ return true;
144
+ }