@optilogic/core 1.0.0-beta.0
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/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/index.cjs +6003 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2310 -0
- package/dist/index.d.ts +2310 -0
- package/dist/index.js +5828 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +96 -0
- package/dist/tailwind-preset.cjs +106 -0
- package/dist/tailwind-preset.cjs.map +1 -0
- package/dist/tailwind-preset.d.cts +23 -0
- package/dist/tailwind-preset.d.ts +23 -0
- package/dist/tailwind-preset.js +101 -0
- package/dist/tailwind-preset.js.map +1 -0
- package/package.json +154 -0
- package/src/components/accordion.tsx +187 -0
- package/src/components/alert-dialog.tsx +143 -0
- package/src/components/autocomplete.tsx +271 -0
- package/src/components/badge.tsx +62 -0
- package/src/components/button.tsx +85 -0
- package/src/components/calendar.tsx +235 -0
- package/src/components/card.tsx +94 -0
- package/src/components/checkbox.tsx +77 -0
- package/src/components/chip.tsx +77 -0
- package/src/components/confirmation-modal.tsx +195 -0
- package/src/components/context-menu.tsx +406 -0
- package/src/components/copy-button.tsx +84 -0
- package/src/components/data-grid/DataGrid.tsx +1027 -0
- package/src/components/data-grid/components/CellEditor.tsx +346 -0
- package/src/components/data-grid/components/FilterPopover.tsx +459 -0
- package/src/components/data-grid/components/HeaderCell.tsx +207 -0
- package/src/components/data-grid/components/index.ts +14 -0
- package/src/components/data-grid/hooks/index.ts +28 -0
- package/src/components/data-grid/hooks/useColumnResize.ts +378 -0
- package/src/components/data-grid/hooks/useDataGridState.ts +346 -0
- package/src/components/data-grid/hooks/useKeyboardNavigation.ts +361 -0
- package/src/components/data-grid/index.ts +71 -0
- package/src/components/data-grid/types.ts +478 -0
- package/src/components/data-grid/utils/dataProcessing.ts +277 -0
- package/src/components/data-grid/utils/index.ts +12 -0
- package/src/components/date-picker.tsx +366 -0
- package/src/components/dropdown-menu.tsx +230 -0
- package/src/components/icon-button.tsx +157 -0
- package/src/components/input.tsx +40 -0
- package/src/components/label.tsx +37 -0
- package/src/components/loading-spinner.tsx +113 -0
- package/src/components/modal.tsx +207 -0
- package/src/components/popover.tsx +62 -0
- package/src/components/progress.tsx +41 -0
- package/src/components/resizable-panel.tsx +434 -0
- package/src/components/resize-handle.tsx +187 -0
- package/src/components/select.tsx +160 -0
- package/src/components/separator.tsx +50 -0
- package/src/components/skeleton.tsx +37 -0
- package/src/components/switch.tsx +59 -0
- package/src/components/table.tsx +136 -0
- package/src/components/tabs.tsx +102 -0
- package/src/components/textarea.tsx +36 -0
- package/src/components/theme-picker.tsx +245 -0
- package/src/components/toaster.tsx +84 -0
- package/src/components/tooltip.tsx +199 -0
- package/src/index.ts +318 -0
- package/src/styles.css +96 -0
- package/src/tailwind-preset.ts +129 -0
- package/src/theme/index.ts +41 -0
- package/src/theme/presets.ts +502 -0
- package/src/theme/types.ts +164 -0
- package/src/theme/utils.ts +309 -0
- package/src/utils/cn.ts +14 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FilterPopover Component
|
|
3
|
+
*
|
|
4
|
+
* Renders filter UI based on column filter type:
|
|
5
|
+
* - Text: Input with operator selection
|
|
6
|
+
* - Number: Input with comparison operators
|
|
7
|
+
* - Date: DatePicker with calendar and range support
|
|
8
|
+
* - Select: Multi-select from predefined options
|
|
9
|
+
* - Boolean: Checkbox toggle
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as React from "react";
|
|
13
|
+
import { format, parseISO, isValid } from "date-fns";
|
|
14
|
+
import { cn } from "../../../utils/cn";
|
|
15
|
+
import { Button } from "../../button";
|
|
16
|
+
import { Input } from "../../input";
|
|
17
|
+
import { Label } from "../../label";
|
|
18
|
+
import { Checkbox } from "../../checkbox";
|
|
19
|
+
import { Switch } from "../../switch";
|
|
20
|
+
import { DatePicker } from "../../date-picker";
|
|
21
|
+
import {
|
|
22
|
+
Select,
|
|
23
|
+
SelectContent,
|
|
24
|
+
SelectItem,
|
|
25
|
+
SelectTrigger,
|
|
26
|
+
SelectValue,
|
|
27
|
+
} from "../../select";
|
|
28
|
+
import type {
|
|
29
|
+
ColumnDef,
|
|
30
|
+
FilterConfig,
|
|
31
|
+
FilterType,
|
|
32
|
+
TextFilterOperator,
|
|
33
|
+
NumberFilterOperator,
|
|
34
|
+
FilterOperator,
|
|
35
|
+
CellValue,
|
|
36
|
+
FilterValue,
|
|
37
|
+
} from "../types";
|
|
38
|
+
|
|
39
|
+
export interface FilterPopoverProps<T = Record<string, CellValue>> {
|
|
40
|
+
/** Column definition */
|
|
41
|
+
column: ColumnDef<T>;
|
|
42
|
+
/** Current filter value */
|
|
43
|
+
filter?: FilterConfig;
|
|
44
|
+
/** Callback when filter changes */
|
|
45
|
+
onFilterChange: (filter: FilterConfig | null) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Text filter operators */
|
|
49
|
+
const TEXT_OPERATORS: { value: TextFilterOperator; label: string }[] = [
|
|
50
|
+
{ value: "contains", label: "Contains" },
|
|
51
|
+
{ value: "notContains", label: "Does not contain" },
|
|
52
|
+
{ value: "equals", label: "Equals" },
|
|
53
|
+
{ value: "notEquals", label: "Does not equal" },
|
|
54
|
+
{ value: "startsWith", label: "Starts with" },
|
|
55
|
+
{ value: "endsWith", label: "Ends with" },
|
|
56
|
+
{ value: "isEmpty", label: "Is empty" },
|
|
57
|
+
{ value: "isNotEmpty", label: "Is not empty" },
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/** Number filter operators */
|
|
61
|
+
const NUMBER_OPERATORS: { value: NumberFilterOperator; label: string }[] = [
|
|
62
|
+
{ value: "equals", label: "Equals" },
|
|
63
|
+
{ value: "notEquals", label: "Does not equal" },
|
|
64
|
+
{ value: "gt", label: "Greater than" },
|
|
65
|
+
{ value: "gte", label: "Greater than or equal" },
|
|
66
|
+
{ value: "lt", label: "Less than" },
|
|
67
|
+
{ value: "lte", label: "Less than or equal" },
|
|
68
|
+
{ value: "between", label: "Between" },
|
|
69
|
+
{ value: "isEmpty", label: "Is empty" },
|
|
70
|
+
{ value: "isNotEmpty", label: "Is not empty" },
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/** Date filter operators */
|
|
74
|
+
const DATE_OPERATORS: { value: string; label: string }[] = [
|
|
75
|
+
{ value: "equals", label: "Equals" },
|
|
76
|
+
{ value: "notEquals", label: "Does not equal" },
|
|
77
|
+
{ value: "before", label: "Before" },
|
|
78
|
+
{ value: "after", label: "After" },
|
|
79
|
+
{ value: "between", label: "Between" },
|
|
80
|
+
{ value: "isEmpty", label: "Is empty" },
|
|
81
|
+
{ value: "isNotEmpty", label: "Is not empty" },
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check if operator requires no value input
|
|
86
|
+
*/
|
|
87
|
+
function isNoValueOperator(operator: string): boolean {
|
|
88
|
+
return operator === "isEmpty" || operator === "isNotEmpty";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check if operator requires two values (between)
|
|
93
|
+
*/
|
|
94
|
+
function isBetweenOperator(operator: string): boolean {
|
|
95
|
+
return operator === "between";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* FilterPopover Component
|
|
100
|
+
*/
|
|
101
|
+
export function FilterPopover<T = Record<string, CellValue>>({
|
|
102
|
+
column,
|
|
103
|
+
filter,
|
|
104
|
+
onFilterChange,
|
|
105
|
+
}: FilterPopoverProps<T>) {
|
|
106
|
+
const filterType = column.filterType || "text";
|
|
107
|
+
|
|
108
|
+
const [operator, setOperator] = React.useState<FilterOperator>(
|
|
109
|
+
filter?.operator || getDefaultOperator(filterType)
|
|
110
|
+
);
|
|
111
|
+
const [value, setValue] = React.useState<FilterValue>(filter?.value ?? "");
|
|
112
|
+
const [valueTo, setValueTo] = React.useState<FilterValue>(filter?.valueTo ?? "");
|
|
113
|
+
|
|
114
|
+
function getDefaultOperator(type: FilterType): FilterOperator {
|
|
115
|
+
switch (type) {
|
|
116
|
+
case "text":
|
|
117
|
+
return "contains";
|
|
118
|
+
case "number":
|
|
119
|
+
return "equals";
|
|
120
|
+
case "date":
|
|
121
|
+
return "equals";
|
|
122
|
+
case "select":
|
|
123
|
+
return "equals";
|
|
124
|
+
case "boolean":
|
|
125
|
+
return "equals";
|
|
126
|
+
default:
|
|
127
|
+
return "contains";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const handleApply = () => {
|
|
132
|
+
if (isNoValueOperator(operator)) {
|
|
133
|
+
onFilterChange({
|
|
134
|
+
columnKey: column.key,
|
|
135
|
+
operator,
|
|
136
|
+
value: null,
|
|
137
|
+
});
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (isBetweenOperator(operator)) {
|
|
142
|
+
if (value !== "" && valueTo !== "") {
|
|
143
|
+
onFilterChange({
|
|
144
|
+
columnKey: column.key,
|
|
145
|
+
operator,
|
|
146
|
+
value,
|
|
147
|
+
valueTo,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (value !== "" && value !== null && value !== undefined) {
|
|
154
|
+
onFilterChange({
|
|
155
|
+
columnKey: column.key,
|
|
156
|
+
operator,
|
|
157
|
+
value,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const handleClear = () => {
|
|
163
|
+
setOperator(getDefaultOperator(filterType));
|
|
164
|
+
setValue("");
|
|
165
|
+
setValueTo("");
|
|
166
|
+
onFilterChange(null);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Get operators based on filter type
|
|
171
|
+
*/
|
|
172
|
+
const getOperators = () => {
|
|
173
|
+
switch (filterType) {
|
|
174
|
+
case "text":
|
|
175
|
+
return TEXT_OPERATORS;
|
|
176
|
+
case "number":
|
|
177
|
+
return NUMBER_OPERATORS;
|
|
178
|
+
case "date":
|
|
179
|
+
return DATE_OPERATORS;
|
|
180
|
+
default:
|
|
181
|
+
return TEXT_OPERATORS;
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Render text filter
|
|
187
|
+
*/
|
|
188
|
+
const renderTextFilter = () => (
|
|
189
|
+
<div className="space-y-3">
|
|
190
|
+
<div className="space-y-1.5">
|
|
191
|
+
<Label className="text-xs">Condition</Label>
|
|
192
|
+
<Select
|
|
193
|
+
value={operator}
|
|
194
|
+
onValueChange={(val) => setOperator(val as TextFilterOperator)}
|
|
195
|
+
>
|
|
196
|
+
<SelectTrigger className="h-8 text-sm">
|
|
197
|
+
<SelectValue />
|
|
198
|
+
</SelectTrigger>
|
|
199
|
+
<SelectContent>
|
|
200
|
+
{TEXT_OPERATORS.map((op) => (
|
|
201
|
+
<SelectItem key={op.value} value={op.value}>
|
|
202
|
+
{op.label}
|
|
203
|
+
</SelectItem>
|
|
204
|
+
))}
|
|
205
|
+
</SelectContent>
|
|
206
|
+
</Select>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
{!isNoValueOperator(operator) && (
|
|
210
|
+
<div className="space-y-1.5">
|
|
211
|
+
<Label className="text-xs">Value</Label>
|
|
212
|
+
<Input
|
|
213
|
+
type="text"
|
|
214
|
+
value={value != null && !Array.isArray(value) ? String(value) : ""}
|
|
215
|
+
onChange={(e) => setValue(e.target.value)}
|
|
216
|
+
placeholder="Enter value..."
|
|
217
|
+
className="h-8 text-sm"
|
|
218
|
+
onKeyDown={(e) => {
|
|
219
|
+
if (e.key === "Enter") {
|
|
220
|
+
e.preventDefault();
|
|
221
|
+
handleApply();
|
|
222
|
+
}
|
|
223
|
+
}}
|
|
224
|
+
/>
|
|
225
|
+
</div>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Render number filter
|
|
232
|
+
*/
|
|
233
|
+
const renderNumberFilter = () => (
|
|
234
|
+
<div className="space-y-3">
|
|
235
|
+
<div className="space-y-1.5">
|
|
236
|
+
<Label className="text-xs">Condition</Label>
|
|
237
|
+
<Select
|
|
238
|
+
value={operator}
|
|
239
|
+
onValueChange={(val) => setOperator(val as NumberFilterOperator)}
|
|
240
|
+
>
|
|
241
|
+
<SelectTrigger className="h-8 text-sm">
|
|
242
|
+
<SelectValue />
|
|
243
|
+
</SelectTrigger>
|
|
244
|
+
<SelectContent>
|
|
245
|
+
{NUMBER_OPERATORS.map((op) => (
|
|
246
|
+
<SelectItem key={op.value} value={op.value}>
|
|
247
|
+
{op.label}
|
|
248
|
+
</SelectItem>
|
|
249
|
+
))}
|
|
250
|
+
</SelectContent>
|
|
251
|
+
</Select>
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
{!isNoValueOperator(operator) && (
|
|
255
|
+
<>
|
|
256
|
+
<div className="space-y-1.5">
|
|
257
|
+
<Label className="text-xs">
|
|
258
|
+
{isBetweenOperator(operator) ? "From" : "Value"}
|
|
259
|
+
</Label>
|
|
260
|
+
<Input
|
|
261
|
+
type="number"
|
|
262
|
+
value={value != null && !Array.isArray(value) ? String(value) : ""}
|
|
263
|
+
onChange={(e) => setValue(e.target.value)}
|
|
264
|
+
placeholder="Enter number..."
|
|
265
|
+
className="h-8 text-sm"
|
|
266
|
+
onKeyDown={(e) => {
|
|
267
|
+
if (e.key === "Enter" && !isBetweenOperator(operator)) {
|
|
268
|
+
e.preventDefault();
|
|
269
|
+
handleApply();
|
|
270
|
+
}
|
|
271
|
+
}}
|
|
272
|
+
/>
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
{isBetweenOperator(operator) && (
|
|
276
|
+
<div className="space-y-1.5">
|
|
277
|
+
<Label className="text-xs">To</Label>
|
|
278
|
+
<Input
|
|
279
|
+
type="number"
|
|
280
|
+
value={valueTo != null && !Array.isArray(valueTo) ? String(valueTo) : ""}
|
|
281
|
+
onChange={(e) => setValueTo(e.target.value)}
|
|
282
|
+
placeholder="Enter number..."
|
|
283
|
+
className="h-8 text-sm"
|
|
284
|
+
onKeyDown={(e) => {
|
|
285
|
+
if (e.key === "Enter") {
|
|
286
|
+
e.preventDefault();
|
|
287
|
+
handleApply();
|
|
288
|
+
}
|
|
289
|
+
}}
|
|
290
|
+
/>
|
|
291
|
+
</div>
|
|
292
|
+
)}
|
|
293
|
+
</>
|
|
294
|
+
)}
|
|
295
|
+
</div>
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
const renderDateFilter = () => {
|
|
299
|
+
const dateValue = React.useMemo(() => {
|
|
300
|
+
if (!value) return undefined;
|
|
301
|
+
if (value instanceof Date) return value;
|
|
302
|
+
if (typeof value === "string") {
|
|
303
|
+
const parsed = parseISO(value);
|
|
304
|
+
return isValid(parsed) ? parsed : undefined;
|
|
305
|
+
}
|
|
306
|
+
return undefined;
|
|
307
|
+
}, [value]);
|
|
308
|
+
|
|
309
|
+
const dateToValue = React.useMemo(() => {
|
|
310
|
+
if (!valueTo) return undefined;
|
|
311
|
+
if (valueTo instanceof Date) return valueTo;
|
|
312
|
+
if (typeof valueTo === "string") {
|
|
313
|
+
const parsed = parseISO(valueTo);
|
|
314
|
+
return isValid(parsed) ? parsed : undefined;
|
|
315
|
+
}
|
|
316
|
+
return undefined;
|
|
317
|
+
}, [valueTo]);
|
|
318
|
+
|
|
319
|
+
const handleDateChange = (date: Date | undefined) => {
|
|
320
|
+
setValue(date ? format(date, "yyyy-MM-dd") : "");
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const handleDateToChange = (date: Date | undefined) => {
|
|
324
|
+
setValueTo(date ? format(date, "yyyy-MM-dd") : "");
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<div className="space-y-3">
|
|
329
|
+
<div className="space-y-1.5">
|
|
330
|
+
<Label className="text-xs">Condition</Label>
|
|
331
|
+
<Select
|
|
332
|
+
value={operator}
|
|
333
|
+
onValueChange={(val) => setOperator(val as FilterOperator)}
|
|
334
|
+
>
|
|
335
|
+
<SelectTrigger className="h-8 text-sm">
|
|
336
|
+
<SelectValue />
|
|
337
|
+
</SelectTrigger>
|
|
338
|
+
<SelectContent>
|
|
339
|
+
{DATE_OPERATORS.map((op) => (
|
|
340
|
+
<SelectItem key={op.value} value={op.value}>
|
|
341
|
+
{op.label}
|
|
342
|
+
</SelectItem>
|
|
343
|
+
))}
|
|
344
|
+
</SelectContent>
|
|
345
|
+
</Select>
|
|
346
|
+
</div>
|
|
347
|
+
|
|
348
|
+
{!isNoValueOperator(operator) && (
|
|
349
|
+
<>
|
|
350
|
+
<div className="space-y-1.5">
|
|
351
|
+
<Label className="text-xs">
|
|
352
|
+
{isBetweenOperator(operator) ? "From" : "Date"}
|
|
353
|
+
</Label>
|
|
354
|
+
<DatePicker
|
|
355
|
+
value={dateValue}
|
|
356
|
+
onChange={handleDateChange}
|
|
357
|
+
placeholder="Select date..."
|
|
358
|
+
className="h-8 text-sm"
|
|
359
|
+
/>
|
|
360
|
+
</div>
|
|
361
|
+
|
|
362
|
+
{isBetweenOperator(operator) && (
|
|
363
|
+
<div className="space-y-1.5">
|
|
364
|
+
<Label className="text-xs">To</Label>
|
|
365
|
+
<DatePicker
|
|
366
|
+
value={dateToValue}
|
|
367
|
+
onChange={handleDateToChange}
|
|
368
|
+
placeholder="Select date..."
|
|
369
|
+
className="h-8 text-sm"
|
|
370
|
+
minDate={dateValue}
|
|
371
|
+
/>
|
|
372
|
+
</div>
|
|
373
|
+
)}
|
|
374
|
+
</>
|
|
375
|
+
)}
|
|
376
|
+
</div>
|
|
377
|
+
);
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const renderSelectFilter = () => {
|
|
381
|
+
const options = column.filterOptions || [];
|
|
382
|
+
|
|
383
|
+
return (
|
|
384
|
+
<div className="space-y-3">
|
|
385
|
+
<div className="space-y-1.5">
|
|
386
|
+
<Label className="text-xs">Select value</Label>
|
|
387
|
+
<Select value={value != null && !Array.isArray(value) ? String(value) : undefined} onValueChange={setValue}>
|
|
388
|
+
<SelectTrigger className="h-8 text-sm">
|
|
389
|
+
<SelectValue placeholder="Select..." />
|
|
390
|
+
</SelectTrigger>
|
|
391
|
+
<SelectContent>
|
|
392
|
+
{options.map((option) => (
|
|
393
|
+
<SelectItem key={option.value} value={option.value}>
|
|
394
|
+
{option.label}
|
|
395
|
+
</SelectItem>
|
|
396
|
+
))}
|
|
397
|
+
</SelectContent>
|
|
398
|
+
</Select>
|
|
399
|
+
</div>
|
|
400
|
+
</div>
|
|
401
|
+
);
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const renderBooleanFilter = () => (
|
|
405
|
+
<div className="space-y-3">
|
|
406
|
+
<div className="flex items-center justify-between">
|
|
407
|
+
<Label className="text-sm">{column.header}</Label>
|
|
408
|
+
<Switch
|
|
409
|
+
checked={value === true}
|
|
410
|
+
onCheckedChange={(checked) => setValue(checked)}
|
|
411
|
+
/>
|
|
412
|
+
</div>
|
|
413
|
+
<p className="text-xs text-muted-foreground">
|
|
414
|
+
Filter rows where this value is {value ? "true" : "false"}
|
|
415
|
+
</p>
|
|
416
|
+
</div>
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
const renderFilter = () => {
|
|
420
|
+
switch (filterType) {
|
|
421
|
+
case "text":
|
|
422
|
+
return renderTextFilter();
|
|
423
|
+
case "number":
|
|
424
|
+
return renderNumberFilter();
|
|
425
|
+
case "date":
|
|
426
|
+
return renderDateFilter();
|
|
427
|
+
case "select":
|
|
428
|
+
return renderSelectFilter();
|
|
429
|
+
case "boolean":
|
|
430
|
+
return renderBooleanFilter();
|
|
431
|
+
default:
|
|
432
|
+
return renderTextFilter();
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
return (
|
|
437
|
+
<div className="p-3 space-y-4">
|
|
438
|
+
<div className="font-medium text-sm">
|
|
439
|
+
Filter: {typeof column.header === "string" ? column.header : "Column"}
|
|
440
|
+
</div>
|
|
441
|
+
|
|
442
|
+
{renderFilter()}
|
|
443
|
+
|
|
444
|
+
<div className="flex gap-2 pt-2 border-t border-border">
|
|
445
|
+
<Button
|
|
446
|
+
variant="outline"
|
|
447
|
+
size="sm"
|
|
448
|
+
onClick={handleClear}
|
|
449
|
+
className="flex-1"
|
|
450
|
+
>
|
|
451
|
+
Clear
|
|
452
|
+
</Button>
|
|
453
|
+
<Button size="sm" onClick={handleApply} className="flex-1">
|
|
454
|
+
Apply
|
|
455
|
+
</Button>
|
|
456
|
+
</div>
|
|
457
|
+
</div>
|
|
458
|
+
);
|
|
459
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HeaderCell Component
|
|
3
|
+
*
|
|
4
|
+
* Renders a single header cell with:
|
|
5
|
+
* - Sort indicator and click-to-sort
|
|
6
|
+
* - Filter trigger (popover)
|
|
7
|
+
* - Resize handle (drag to resize)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as React from "react";
|
|
11
|
+
import { ChevronUp, ChevronDown, Filter, X, GripVertical } from "lucide-react";
|
|
12
|
+
import { cn } from "../../../utils/cn";
|
|
13
|
+
import { Button } from "../../button";
|
|
14
|
+
import { Popover, PopoverTrigger, PopoverContent } from "../../popover";
|
|
15
|
+
import type { ColumnDef, SortConfig, FilterConfig } from "../types";
|
|
16
|
+
import { FilterPopover } from "./FilterPopover";
|
|
17
|
+
|
|
18
|
+
export interface HeaderCellProps<T = any> {
|
|
19
|
+
/** Column definition */
|
|
20
|
+
column: ColumnDef<T>;
|
|
21
|
+
/** Column index */
|
|
22
|
+
columnIndex: number;
|
|
23
|
+
/** Current width of the column */
|
|
24
|
+
width: number;
|
|
25
|
+
/** Current sort config for this column (if sorted) */
|
|
26
|
+
sorting?: SortConfig;
|
|
27
|
+
/** Current filter config for this column (if filtered) */
|
|
28
|
+
filter?: FilterConfig;
|
|
29
|
+
/** Whether this column is resizable */
|
|
30
|
+
isResizable: boolean;
|
|
31
|
+
/** Callback when sort is toggled */
|
|
32
|
+
onSort?: () => void;
|
|
33
|
+
/** Callback when filter changes */
|
|
34
|
+
onFilterChange?: (filter: FilterConfig | null) => void;
|
|
35
|
+
/** Resize handle mouse down handler */
|
|
36
|
+
onResizeMouseDown?: (event: React.MouseEvent) => void;
|
|
37
|
+
/** Resize handle double click handler */
|
|
38
|
+
onResizeDoubleClick?: (event: React.MouseEvent) => void;
|
|
39
|
+
/** Whether currently resizing this column */
|
|
40
|
+
isResizing?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* HeaderCell Component
|
|
45
|
+
*/
|
|
46
|
+
export function HeaderCell<T = any>({
|
|
47
|
+
column,
|
|
48
|
+
columnIndex,
|
|
49
|
+
width,
|
|
50
|
+
sorting,
|
|
51
|
+
filter,
|
|
52
|
+
isResizable,
|
|
53
|
+
onSort,
|
|
54
|
+
onFilterChange,
|
|
55
|
+
onResizeMouseDown,
|
|
56
|
+
onResizeDoubleClick,
|
|
57
|
+
isResizing,
|
|
58
|
+
}: HeaderCellProps<T>) {
|
|
59
|
+
const [filterOpen, setFilterOpen] = React.useState(false);
|
|
60
|
+
|
|
61
|
+
const isSorted = sorting?.field === column.key;
|
|
62
|
+
const isFiltered = !!filter;
|
|
63
|
+
const isSortable = column.sortable && onSort;
|
|
64
|
+
const isFilterable = column.filterable && onFilterChange;
|
|
65
|
+
|
|
66
|
+
const handleHeaderClick = () => {
|
|
67
|
+
if (isSortable) {
|
|
68
|
+
onSort();
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const handleFilterChange = (newFilter: FilterConfig | null) => {
|
|
73
|
+
onFilterChange?.(newFilter);
|
|
74
|
+
if (!newFilter) {
|
|
75
|
+
setFilterOpen(false);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const handleClearFilter = (e: React.MouseEvent) => {
|
|
80
|
+
e.stopPropagation();
|
|
81
|
+
onFilterChange?.(null);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const renderSortIndicator = () => {
|
|
85
|
+
if (!column.sortable) return null;
|
|
86
|
+
|
|
87
|
+
const Icon = isSorted && sorting?.direction === "desc" ? ChevronDown : ChevronUp;
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<Icon
|
|
91
|
+
className={cn(
|
|
92
|
+
"w-4 h-4 flex-shrink-0 transition-opacity",
|
|
93
|
+
isSorted
|
|
94
|
+
? "opacity-100 text-foreground"
|
|
95
|
+
: "opacity-30 hover:opacity-60 text-muted-foreground"
|
|
96
|
+
)}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div
|
|
103
|
+
className={cn(
|
|
104
|
+
"relative flex-shrink-0 border-r border-border last:border-r-0",
|
|
105
|
+
"bg-muted select-none",
|
|
106
|
+
isResizing && "bg-accent/20"
|
|
107
|
+
)}
|
|
108
|
+
style={{ width }}
|
|
109
|
+
>
|
|
110
|
+
<div
|
|
111
|
+
className={cn(
|
|
112
|
+
"flex items-center gap-1 px-3 py-2 h-full",
|
|
113
|
+
isResizable && "pr-6", // Extra padding to create space for resize handle
|
|
114
|
+
isSortable && "cursor-pointer hover:bg-accent/10",
|
|
115
|
+
column.align === "center" && "justify-center",
|
|
116
|
+
column.align === "right" && "justify-end"
|
|
117
|
+
)}
|
|
118
|
+
onClick={handleHeaderClick}
|
|
119
|
+
role={isSortable ? "button" : undefined}
|
|
120
|
+
tabIndex={isSortable ? 0 : undefined}
|
|
121
|
+
onKeyDown={(e) => {
|
|
122
|
+
if (isSortable && (e.key === "Enter" || e.key === " ")) {
|
|
123
|
+
e.preventDefault();
|
|
124
|
+
handleHeaderClick();
|
|
125
|
+
}
|
|
126
|
+
}}
|
|
127
|
+
>
|
|
128
|
+
<span className="text-sm font-medium truncate flex-1">
|
|
129
|
+
{column.header}
|
|
130
|
+
</span>
|
|
131
|
+
|
|
132
|
+
{renderSortIndicator()}
|
|
133
|
+
|
|
134
|
+
{isFilterable && (
|
|
135
|
+
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
|
|
136
|
+
<PopoverTrigger asChild>
|
|
137
|
+
<Button
|
|
138
|
+
variant="ghost"
|
|
139
|
+
size="icon"
|
|
140
|
+
className={cn(
|
|
141
|
+
"h-6 w-6 p-0",
|
|
142
|
+
isFiltered && "text-primary"
|
|
143
|
+
)}
|
|
144
|
+
onClick={(e) => {
|
|
145
|
+
e.stopPropagation();
|
|
146
|
+
setFilterOpen(!filterOpen);
|
|
147
|
+
}}
|
|
148
|
+
aria-label={`Filter ${column.header}`}
|
|
149
|
+
>
|
|
150
|
+
<Filter className="w-3.5 h-3.5" />
|
|
151
|
+
</Button>
|
|
152
|
+
</PopoverTrigger>
|
|
153
|
+
<PopoverContent
|
|
154
|
+
align="start"
|
|
155
|
+
className="w-72 p-0"
|
|
156
|
+
onClick={(e) => e.stopPropagation()}
|
|
157
|
+
>
|
|
158
|
+
<FilterPopover
|
|
159
|
+
column={column}
|
|
160
|
+
filter={filter}
|
|
161
|
+
onFilterChange={handleFilterChange}
|
|
162
|
+
/>
|
|
163
|
+
</PopoverContent>
|
|
164
|
+
</Popover>
|
|
165
|
+
)}
|
|
166
|
+
|
|
167
|
+
{isFiltered && !filterOpen && (
|
|
168
|
+
<Button
|
|
169
|
+
variant="ghost"
|
|
170
|
+
size="icon"
|
|
171
|
+
className="h-5 w-5 p-0 text-primary hover:text-destructive"
|
|
172
|
+
onClick={handleClearFilter}
|
|
173
|
+
aria-label="Clear filter"
|
|
174
|
+
>
|
|
175
|
+
<X className="w-3 h-3" />
|
|
176
|
+
</Button>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{isResizable && (
|
|
181
|
+
<div
|
|
182
|
+
className={cn(
|
|
183
|
+
"absolute top-0 right-0 w-4 h-full cursor-col-resize z-10",
|
|
184
|
+
"flex items-center justify-center",
|
|
185
|
+
"hover:bg-accent/50 transition-colors",
|
|
186
|
+
"group",
|
|
187
|
+
isResizing && "bg-accent"
|
|
188
|
+
)}
|
|
189
|
+
onMouseDown={onResizeMouseDown}
|
|
190
|
+
onDoubleClick={onResizeDoubleClick}
|
|
191
|
+
role="separator"
|
|
192
|
+
aria-orientation="vertical"
|
|
193
|
+
aria-label={`Resize column ${column.header}`}
|
|
194
|
+
>
|
|
195
|
+
<GripVertical
|
|
196
|
+
className={cn(
|
|
197
|
+
"w-3 h-4 transition-colors",
|
|
198
|
+
"text-muted-foreground/40",
|
|
199
|
+
"group-hover:text-primary",
|
|
200
|
+
isResizing && "text-primary"
|
|
201
|
+
)}
|
|
202
|
+
/>
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataGrid Components
|
|
3
|
+
*
|
|
4
|
+
* Export all sub-components used by the DataGrid
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { HeaderCell } from "./HeaderCell";
|
|
8
|
+
export type { HeaderCellProps } from "./HeaderCell";
|
|
9
|
+
|
|
10
|
+
export { FilterPopover } from "./FilterPopover";
|
|
11
|
+
export type { FilterPopoverProps } from "./FilterPopover";
|
|
12
|
+
|
|
13
|
+
export { CellEditor } from "./CellEditor";
|
|
14
|
+
export type { CellEditorProps } from "./CellEditor";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataGrid Hooks
|
|
3
|
+
*
|
|
4
|
+
* Export all hooks used by the DataGrid component
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { useDataGridState } from "./useDataGridState";
|
|
8
|
+
export type {
|
|
9
|
+
UseDataGridStateOptions,
|
|
10
|
+
UseDataGridStateReturn,
|
|
11
|
+
} from "./useDataGridState";
|
|
12
|
+
|
|
13
|
+
export { useKeyboardNavigation } from "./useKeyboardNavigation";
|
|
14
|
+
export type {
|
|
15
|
+
UseKeyboardNavigationOptions,
|
|
16
|
+
UseKeyboardNavigationReturn,
|
|
17
|
+
} from "./useKeyboardNavigation";
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
useColumnResize,
|
|
21
|
+
useColumnResizeManager,
|
|
22
|
+
} from "./useColumnResize";
|
|
23
|
+
export type {
|
|
24
|
+
UseColumnResizeOptions,
|
|
25
|
+
UseColumnResizeReturn,
|
|
26
|
+
UseColumnResizeManagerOptions,
|
|
27
|
+
UseColumnResizeManagerReturn,
|
|
28
|
+
} from "./useColumnResize";
|