@dashforge/tw 0.7.0-beta → 0.8.1-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +255 -0
- package/dist/index.esm.js +1057 -74
- package/dist/src/components/DataGrid/DataGrid.d.ts.map +1 -1
- package/dist/src/components/DataGrid/dataGrid.types.d.ts +43 -0
- package/dist/src/components/DataGrid/dataGrid.types.d.ts.map +1 -1
- package/dist/src/components/DataGrid/dataGrid.variants.d.ts +6 -0
- package/dist/src/components/DataGrid/dataGrid.variants.d.ts.map +1 -1
- package/dist/src/components/DataGrid/filters/ColumnFilters.d.ts +23 -0
- package/dist/src/components/DataGrid/filters/ColumnFilters.d.ts.map +1 -0
- package/dist/src/components/DataGrid/reorder/useColumnReorder.d.ts +50 -0
- package/dist/src/components/DataGrid/reorder/useColumnReorder.d.ts.map +1 -0
- package/dist/src/components/DataGrid/resize/useColumnResize.d.ts +32 -0
- package/dist/src/components/DataGrid/resize/useColumnResize.d.ts.map +1 -0
- package/dist/src/components/DataGrid/visibility/ColumnVisibilityMenu.d.ts +40 -0
- package/dist/src/components/DataGrid/visibility/ColumnVisibilityMenu.d.ts.map +1 -0
- package/dist/src/components/Table/Table.d.ts.map +1 -1
- package/dist/src/components/Table/table.types.d.ts +67 -5
- package/dist/src/components/Table/table.types.d.ts.map +1 -1
- package/dist/src/components/_shared/data/useTableFilter.d.ts +30 -7
- package/dist/src/components/_shared/data/useTableFilter.d.ts.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/components/DataGrid/DataGrid.test.tsx +418 -1
- package/src/components/DataGrid/DataGrid.tsx +416 -53
- package/src/components/DataGrid/dataGrid.types.ts +50 -1
- package/src/components/DataGrid/dataGrid.variants.ts +9 -1
- package/src/components/DataGrid/filters/ColumnFilters.tsx +432 -0
- package/src/components/DataGrid/reorder/useColumnReorder.test.ts +38 -0
- package/src/components/DataGrid/reorder/useColumnReorder.ts +144 -0
- package/src/components/DataGrid/resize/useColumnResize.ts +106 -0
- package/src/components/DataGrid/visibility/ColumnVisibilityMenu.tsx +169 -0
- package/src/components/Table/Table.tsx +11 -0
- package/src/components/Table/table.types.ts +72 -5
- package/src/components/_shared/data/useTableFilter.test.ts +166 -0
- package/src/components/_shared/data/useTableFilter.ts +97 -16
- package/src/index.ts +2 -0
|
@@ -78,7 +78,7 @@ export const dataGridVariants = tv({
|
|
|
78
78
|
'border-t border-neutral-200',
|
|
79
79
|
'bg-neutral-50 shadow-md',
|
|
80
80
|
],
|
|
81
|
-
// Sticky
|
|
81
|
+
// Sticky LEFT column — uses `position: sticky; left: 0` + z-index
|
|
82
82
|
// so the column stays pinned during horizontal scroll. The
|
|
83
83
|
// `bg-neutral-50` ensures the column doesn't bleed when content
|
|
84
84
|
// scrolls underneath.
|
|
@@ -86,6 +86,14 @@ export const dataGridVariants = tv({
|
|
|
86
86
|
// Sticky left × header intersection (top-left corner): higher
|
|
87
87
|
// z-index than both header (`z-10`) and sticky col (`z-[1]`).
|
|
88
88
|
stickyLeftHeaderCell: 'sticky left-0 z-20 bg-neutral-50',
|
|
89
|
+
// Sticky RIGHT column — mirror of stickyLeftCell. The first
|
|
90
|
+
// right-sticky column gets a `border-l` to separate it visually
|
|
91
|
+
// from the scrollable area (the consumer can override via
|
|
92
|
+
// slotProps if they don't want it).
|
|
93
|
+
stickyRightCell:
|
|
94
|
+
'sticky right-0 z-[1] bg-neutral-50 border-l border-neutral-200',
|
|
95
|
+
stickyRightHeaderCell:
|
|
96
|
+
'sticky right-0 z-20 bg-neutral-50 border-l border-neutral-200',
|
|
89
97
|
paginationFooter: 'flex justify-end mt-3',
|
|
90
98
|
},
|
|
91
99
|
variants: {
|
|
@@ -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
|
+
}
|