@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,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data Processing Utilities
|
|
3
|
+
*
|
|
4
|
+
* Functions for sorting and filtering data internally in uncontrolled mode.
|
|
5
|
+
* Also exports helper functions for use in controlled mode.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
ColumnDef,
|
|
10
|
+
SortConfig,
|
|
11
|
+
FilterConfig,
|
|
12
|
+
FilterOperator,
|
|
13
|
+
FilterType,
|
|
14
|
+
CellValue,
|
|
15
|
+
FilterValue,
|
|
16
|
+
} from "../types";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get a value from a row using column accessor or key
|
|
20
|
+
*/
|
|
21
|
+
export function getCellValue<T>(row: T, column: ColumnDef<T>): CellValue {
|
|
22
|
+
if (column.accessor) {
|
|
23
|
+
return column.accessor(row);
|
|
24
|
+
}
|
|
25
|
+
// Dynamic property access - row is expected to be an object with string keys
|
|
26
|
+
return (row as Record<string, CellValue>)[column.key];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Apply sorting to data
|
|
31
|
+
*/
|
|
32
|
+
export function applySorting<T>(
|
|
33
|
+
data: T[],
|
|
34
|
+
sorting: SortConfig[],
|
|
35
|
+
columns: ColumnDef<T>[]
|
|
36
|
+
): T[] {
|
|
37
|
+
if (sorting.length === 0) return data;
|
|
38
|
+
|
|
39
|
+
const result = [...data];
|
|
40
|
+
|
|
41
|
+
// Apply sorts in order (primary, secondary, etc.)
|
|
42
|
+
for (const sort of sorting) {
|
|
43
|
+
const column = columns.find((c) => c.key === sort.field);
|
|
44
|
+
if (!column) continue;
|
|
45
|
+
|
|
46
|
+
result.sort((a, b) => {
|
|
47
|
+
// Use custom comparator if provided
|
|
48
|
+
if (column.sortComparator) {
|
|
49
|
+
const cmp = column.sortComparator(a, b);
|
|
50
|
+
return sort.direction === "asc" ? cmp : -cmp;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Default comparison
|
|
54
|
+
const aVal = getCellValue(a, column);
|
|
55
|
+
const bVal = getCellValue(b, column);
|
|
56
|
+
|
|
57
|
+
// Handle null/undefined
|
|
58
|
+
if (aVal == null && bVal == null) return 0;
|
|
59
|
+
if (aVal == null) return sort.direction === "asc" ? 1 : -1;
|
|
60
|
+
if (bVal == null) return sort.direction === "asc" ? -1 : 1;
|
|
61
|
+
|
|
62
|
+
// Compare based on type
|
|
63
|
+
let comparison = 0;
|
|
64
|
+
|
|
65
|
+
if (typeof aVal === "string" && typeof bVal === "string") {
|
|
66
|
+
comparison = aVal.localeCompare(bVal);
|
|
67
|
+
} else if (typeof aVal === "number" && typeof bVal === "number") {
|
|
68
|
+
comparison = aVal - bVal;
|
|
69
|
+
} else if (aVal instanceof Date && bVal instanceof Date) {
|
|
70
|
+
comparison = aVal.getTime() - bVal.getTime();
|
|
71
|
+
} else if (typeof aVal === "boolean" && typeof bVal === "boolean") {
|
|
72
|
+
comparison = aVal === bVal ? 0 : aVal ? 1 : -1;
|
|
73
|
+
} else {
|
|
74
|
+
// Fallback: convert to string
|
|
75
|
+
comparison = String(aVal).localeCompare(String(bVal));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return sort.direction === "asc" ? comparison : -comparison;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Extract a single CellValue from a FilterValue (handles arrays for single-value operations)
|
|
87
|
+
*/
|
|
88
|
+
function extractSingleValue(filterValue: FilterValue): CellValue {
|
|
89
|
+
if (Array.isArray(filterValue)) {
|
|
90
|
+
return filterValue[0];
|
|
91
|
+
}
|
|
92
|
+
return filterValue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse a value as a Date, handling various input formats
|
|
97
|
+
*/
|
|
98
|
+
function parseDate(value: CellValue): Date | null {
|
|
99
|
+
if (value == null || value === "") return null;
|
|
100
|
+
if (value instanceof Date) return value;
|
|
101
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
102
|
+
const date = new Date(value);
|
|
103
|
+
return isNaN(date.getTime()) ? null : date;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Compare two dates (ignoring time component for date-only comparisons)
|
|
110
|
+
*/
|
|
111
|
+
function compareDates(date1: Date, date2: Date): number {
|
|
112
|
+
// Normalize to start of day for comparison
|
|
113
|
+
const d1 = new Date(date1.getFullYear(), date1.getMonth(), date1.getDate());
|
|
114
|
+
const d2 = new Date(date2.getFullYear(), date2.getMonth(), date2.getDate());
|
|
115
|
+
return d1.getTime() - d2.getTime();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Apply a single filter operator to a value.
|
|
120
|
+
* Can be used in controlled mode to filter data with the same logic as internal filtering.
|
|
121
|
+
*
|
|
122
|
+
* @param value - The cell value to test
|
|
123
|
+
* @param filterValue - The filter value to compare against
|
|
124
|
+
* @param filterValueTo - Secondary value for 'between' operator
|
|
125
|
+
* @param operator - The filter operator to apply
|
|
126
|
+
* @param filterType - Optional filter type hint (text, number, date, boolean)
|
|
127
|
+
* @returns true if the value passes the filter, false otherwise
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* // Using in controlled mode
|
|
131
|
+
* const filteredData = data.filter(row =>
|
|
132
|
+
* applyFilterOperator(row.name, "john", undefined, "contains", "text")
|
|
133
|
+
* );
|
|
134
|
+
*/
|
|
135
|
+
export function applyFilterOperator(
|
|
136
|
+
value: CellValue,
|
|
137
|
+
filterValue: FilterValue,
|
|
138
|
+
filterValueTo: FilterValue,
|
|
139
|
+
operator: FilterOperator,
|
|
140
|
+
filterType?: string
|
|
141
|
+
): boolean {
|
|
142
|
+
// Handle isEmpty/isNotEmpty first (works for any type)
|
|
143
|
+
if (operator === "isEmpty") {
|
|
144
|
+
return value === "" || value == null;
|
|
145
|
+
}
|
|
146
|
+
if (operator === "isNotEmpty") {
|
|
147
|
+
return value !== "" && value != null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Handle null/undefined values for other operators
|
|
151
|
+
if (value == null) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Date-specific operators
|
|
156
|
+
if (filterType === "date" || operator === "before" || operator === "after") {
|
|
157
|
+
const dateValue = parseDate(value);
|
|
158
|
+
const dateFilterValue = parseDate(extractSingleValue(filterValue));
|
|
159
|
+
const dateFilterValueTo = parseDate(extractSingleValue(filterValueTo));
|
|
160
|
+
|
|
161
|
+
if (!dateValue) return false;
|
|
162
|
+
|
|
163
|
+
switch (operator) {
|
|
164
|
+
case "equals":
|
|
165
|
+
return dateFilterValue ? compareDates(dateValue, dateFilterValue) === 0 : false;
|
|
166
|
+
case "notEquals":
|
|
167
|
+
return dateFilterValue ? compareDates(dateValue, dateFilterValue) !== 0 : false;
|
|
168
|
+
case "before":
|
|
169
|
+
return dateFilterValue ? compareDates(dateValue, dateFilterValue) < 0 : false;
|
|
170
|
+
case "after":
|
|
171
|
+
return dateFilterValue ? compareDates(dateValue, dateFilterValue) > 0 : false;
|
|
172
|
+
case "between":
|
|
173
|
+
if (!dateFilterValue || !dateFilterValueTo) return false;
|
|
174
|
+
return (
|
|
175
|
+
compareDates(dateValue, dateFilterValue) >= 0 &&
|
|
176
|
+
compareDates(dateValue, dateFilterValueTo) <= 0
|
|
177
|
+
);
|
|
178
|
+
default:
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Number-specific operators
|
|
184
|
+
if (filterType === "number" || ["gt", "gte", "lt", "lte", "between"].includes(operator)) {
|
|
185
|
+
const numValue = Number(value);
|
|
186
|
+
const singleFilterValue = extractSingleValue(filterValue);
|
|
187
|
+
const numFilterValue = Number(singleFilterValue);
|
|
188
|
+
|
|
189
|
+
if (isNaN(numValue)) return false;
|
|
190
|
+
|
|
191
|
+
switch (operator) {
|
|
192
|
+
case "equals":
|
|
193
|
+
return numValue === numFilterValue;
|
|
194
|
+
case "notEquals":
|
|
195
|
+
return numValue !== numFilterValue;
|
|
196
|
+
case "gt":
|
|
197
|
+
return numValue > numFilterValue;
|
|
198
|
+
case "gte":
|
|
199
|
+
return numValue >= numFilterValue;
|
|
200
|
+
case "lt":
|
|
201
|
+
return numValue < numFilterValue;
|
|
202
|
+
case "lte":
|
|
203
|
+
return numValue <= numFilterValue;
|
|
204
|
+
case "between":
|
|
205
|
+
const singleFilterValueTo = extractSingleValue(filterValueTo);
|
|
206
|
+
const numFilterValueTo = Number(singleFilterValueTo);
|
|
207
|
+
return numValue >= numFilterValue && numValue <= numFilterValueTo;
|
|
208
|
+
default:
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Boolean filter
|
|
214
|
+
if (filterType === "boolean") {
|
|
215
|
+
const boolValue = value === true || value === "true" || value === 1;
|
|
216
|
+
const singleBoolFilterValue = extractSingleValue(filterValue);
|
|
217
|
+
const boolFilterValue = singleBoolFilterValue === true || singleBoolFilterValue === "true" || singleBoolFilterValue === 1;
|
|
218
|
+
return boolValue === boolFilterValue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Text/string operators (default)
|
|
222
|
+
const strValue = String(value).toLowerCase();
|
|
223
|
+
const singleStrFilterValue = extractSingleValue(filterValue);
|
|
224
|
+
const strFilterValue = singleStrFilterValue != null ? String(singleStrFilterValue).toLowerCase() : "";
|
|
225
|
+
|
|
226
|
+
switch (operator) {
|
|
227
|
+
case "contains":
|
|
228
|
+
return strValue.includes(strFilterValue);
|
|
229
|
+
case "notContains":
|
|
230
|
+
return !strValue.includes(strFilterValue);
|
|
231
|
+
case "equals":
|
|
232
|
+
return strValue === strFilterValue;
|
|
233
|
+
case "notEquals":
|
|
234
|
+
return strValue !== strFilterValue;
|
|
235
|
+
case "startsWith":
|
|
236
|
+
return strValue.startsWith(strFilterValue);
|
|
237
|
+
case "endsWith":
|
|
238
|
+
return strValue.endsWith(strFilterValue);
|
|
239
|
+
default:
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Apply filters to data
|
|
246
|
+
*/
|
|
247
|
+
export function applyFilters<T>(
|
|
248
|
+
data: T[],
|
|
249
|
+
filters: FilterConfig[],
|
|
250
|
+
columns: ColumnDef<T>[]
|
|
251
|
+
): T[] {
|
|
252
|
+
if (filters.length === 0) return data;
|
|
253
|
+
|
|
254
|
+
return data.filter((row) => {
|
|
255
|
+
// All filters must pass (AND logic)
|
|
256
|
+
return filters.every((filter) => {
|
|
257
|
+
const column = columns.find((c) => c.key === filter.columnKey);
|
|
258
|
+
if (!column) return true;
|
|
259
|
+
|
|
260
|
+
const value = getCellValue(row, column);
|
|
261
|
+
|
|
262
|
+
// Use custom filter function if provided
|
|
263
|
+
if (column.filterFn) {
|
|
264
|
+
return column.filterFn(row, filter.value, filter.operator);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Use default filter logic with filter type for better handling
|
|
268
|
+
return applyFilterOperator(
|
|
269
|
+
value,
|
|
270
|
+
filter.value,
|
|
271
|
+
filter.valueTo,
|
|
272
|
+
filter.operator,
|
|
273
|
+
column.filterType
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
}
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DatePicker Component
|
|
3
|
+
*
|
|
4
|
+
* A complete date picker combining an input field with a calendar popover.
|
|
5
|
+
* Built using library primitives for consistent styling.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Input field with formatted date display
|
|
9
|
+
* - Calendar popover for date selection
|
|
10
|
+
* - Controlled and uncontrolled modes
|
|
11
|
+
* - Clearable option
|
|
12
|
+
* - Min/max date constraints
|
|
13
|
+
* - Custom date formatting
|
|
14
|
+
* - Keyboard accessible
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as React from "react";
|
|
18
|
+
import { format, parse, isValid } from "date-fns";
|
|
19
|
+
import { Calendar as CalendarIcon, X } from "lucide-react";
|
|
20
|
+
import { cn } from "../utils/cn";
|
|
21
|
+
import { Button } from "./button";
|
|
22
|
+
import { Calendar } from "./calendar";
|
|
23
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
|
24
|
+
|
|
25
|
+
export interface DatePickerProps {
|
|
26
|
+
/** Selected date value */
|
|
27
|
+
value?: Date;
|
|
28
|
+
/** Default value for uncontrolled mode */
|
|
29
|
+
defaultValue?: Date;
|
|
30
|
+
/** Callback when date changes */
|
|
31
|
+
onChange?: (date: Date | undefined) => void;
|
|
32
|
+
/** Placeholder text when no date selected */
|
|
33
|
+
placeholder?: string;
|
|
34
|
+
/** Whether the picker is disabled */
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
/** Minimum selectable date */
|
|
37
|
+
minDate?: Date;
|
|
38
|
+
/** Maximum selectable date */
|
|
39
|
+
maxDate?: Date;
|
|
40
|
+
/** Date format string (date-fns format) */
|
|
41
|
+
dateFormat?: string;
|
|
42
|
+
/** Whether to show a clear button */
|
|
43
|
+
clearable?: boolean;
|
|
44
|
+
/** Additional class name */
|
|
45
|
+
className?: string;
|
|
46
|
+
/** Input ID for label association */
|
|
47
|
+
id?: string;
|
|
48
|
+
/** Input name for form submission */
|
|
49
|
+
name?: string;
|
|
50
|
+
/** Alignment of the popover */
|
|
51
|
+
align?: "start" | "center" | "end";
|
|
52
|
+
/** Side of the popover */
|
|
53
|
+
side?: "top" | "right" | "bottom" | "left";
|
|
54
|
+
/** Whether the popover should match the trigger width */
|
|
55
|
+
matchTriggerWidth?: boolean;
|
|
56
|
+
/** Callback when the popover opens/closes */
|
|
57
|
+
onOpenChange?: (open: boolean) => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* DatePicker Component
|
|
62
|
+
*
|
|
63
|
+
* A date input with calendar popover for easy date selection.
|
|
64
|
+
*
|
|
65
|
+
* @example Basic usage
|
|
66
|
+
* ```tsx
|
|
67
|
+
* const [date, setDate] = useState<Date>();
|
|
68
|
+
* <DatePicker value={date} onChange={setDate} />
|
|
69
|
+
* ```
|
|
70
|
+
*
|
|
71
|
+
* @example With constraints
|
|
72
|
+
* ```tsx
|
|
73
|
+
* <DatePicker
|
|
74
|
+
* value={date}
|
|
75
|
+
* onChange={setDate}
|
|
76
|
+
* minDate={new Date()}
|
|
77
|
+
* maxDate={addMonths(new Date(), 3)}
|
|
78
|
+
* placeholder="Select a date"
|
|
79
|
+
* />
|
|
80
|
+
* ```
|
|
81
|
+
*
|
|
82
|
+
* @example Custom format
|
|
83
|
+
* ```tsx
|
|
84
|
+
* <DatePicker
|
|
85
|
+
* value={date}
|
|
86
|
+
* onChange={setDate}
|
|
87
|
+
* dateFormat="dd/MM/yyyy"
|
|
88
|
+
* />
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
function DatePicker({
|
|
92
|
+
value,
|
|
93
|
+
defaultValue,
|
|
94
|
+
onChange,
|
|
95
|
+
placeholder = "Pick a date",
|
|
96
|
+
disabled = false,
|
|
97
|
+
minDate,
|
|
98
|
+
maxDate,
|
|
99
|
+
dateFormat = "PPP",
|
|
100
|
+
clearable = false,
|
|
101
|
+
className,
|
|
102
|
+
id,
|
|
103
|
+
name,
|
|
104
|
+
align = "start",
|
|
105
|
+
side = "bottom",
|
|
106
|
+
matchTriggerWidth = false,
|
|
107
|
+
onOpenChange,
|
|
108
|
+
}: DatePickerProps) {
|
|
109
|
+
const [open, setOpen] = React.useState(false);
|
|
110
|
+
const [internalValue, setInternalValue] = React.useState<Date | undefined>(
|
|
111
|
+
defaultValue
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// Controlled vs uncontrolled
|
|
115
|
+
const isControlled = value !== undefined;
|
|
116
|
+
const selectedDate = isControlled ? value : internalValue;
|
|
117
|
+
|
|
118
|
+
const handleSelect = (date: Date | undefined) => {
|
|
119
|
+
if (!isControlled) {
|
|
120
|
+
setInternalValue(date);
|
|
121
|
+
}
|
|
122
|
+
onChange?.(date);
|
|
123
|
+
setOpen(false);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const handleClear = (e: React.MouseEvent) => {
|
|
127
|
+
e.stopPropagation();
|
|
128
|
+
handleSelect(undefined);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const handleOpenChange = (newOpen: boolean) => {
|
|
132
|
+
setOpen(newOpen);
|
|
133
|
+
onOpenChange?.(newOpen);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Format the selected date for display
|
|
137
|
+
const formattedDate = selectedDate
|
|
138
|
+
? format(selectedDate, dateFormat)
|
|
139
|
+
: undefined;
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<Popover open={open} onOpenChange={handleOpenChange}>
|
|
143
|
+
<PopoverTrigger asChild>
|
|
144
|
+
<Button
|
|
145
|
+
id={id}
|
|
146
|
+
variant="outline"
|
|
147
|
+
disabled={disabled}
|
|
148
|
+
className={cn(
|
|
149
|
+
"w-full justify-start text-left font-normal",
|
|
150
|
+
!selectedDate && "text-muted-foreground",
|
|
151
|
+
className
|
|
152
|
+
)}
|
|
153
|
+
aria-expanded={open}
|
|
154
|
+
aria-haspopup="dialog"
|
|
155
|
+
>
|
|
156
|
+
<CalendarIcon className="mr-2 h-4 w-4 shrink-0" />
|
|
157
|
+
<span className="flex-1 truncate">
|
|
158
|
+
{formattedDate ?? placeholder}
|
|
159
|
+
</span>
|
|
160
|
+
{clearable && selectedDate && (
|
|
161
|
+
<X
|
|
162
|
+
className="ml-2 h-4 w-4 shrink-0 opacity-50 hover:opacity-100"
|
|
163
|
+
onClick={handleClear}
|
|
164
|
+
aria-label="Clear date"
|
|
165
|
+
/>
|
|
166
|
+
)}
|
|
167
|
+
</Button>
|
|
168
|
+
</PopoverTrigger>
|
|
169
|
+
<PopoverContent
|
|
170
|
+
className={cn("w-auto p-0", matchTriggerWidth && "w-[var(--radix-popover-trigger-width)]")}
|
|
171
|
+
align={align}
|
|
172
|
+
side={side}
|
|
173
|
+
>
|
|
174
|
+
<Calendar
|
|
175
|
+
mode="single"
|
|
176
|
+
selected={selectedDate}
|
|
177
|
+
onSelect={handleSelect}
|
|
178
|
+
disabled={(date) => {
|
|
179
|
+
if (minDate && date < minDate) return true;
|
|
180
|
+
if (maxDate && date > maxDate) return true;
|
|
181
|
+
return false;
|
|
182
|
+
}}
|
|
183
|
+
initialFocus
|
|
184
|
+
/>
|
|
185
|
+
{name && selectedDate && (
|
|
186
|
+
<input
|
|
187
|
+
type="hidden"
|
|
188
|
+
name={name}
|
|
189
|
+
value={format(selectedDate, "yyyy-MM-dd")}
|
|
190
|
+
/>
|
|
191
|
+
)}
|
|
192
|
+
</PopoverContent>
|
|
193
|
+
</Popover>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
DatePicker.displayName = "DatePicker";
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* DatePickerInput Component
|
|
201
|
+
*
|
|
202
|
+
* A variant that looks more like a traditional input field.
|
|
203
|
+
* Useful for inline editing or compact layouts.
|
|
204
|
+
*/
|
|
205
|
+
export interface DatePickerInputProps extends Omit<DatePickerProps, "className"> {
|
|
206
|
+
/** Additional class name for the input wrapper */
|
|
207
|
+
className?: string;
|
|
208
|
+
/** Size variant */
|
|
209
|
+
size?: "sm" | "default" | "lg";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function DatePickerInput({
|
|
213
|
+
value,
|
|
214
|
+
defaultValue,
|
|
215
|
+
onChange,
|
|
216
|
+
placeholder = "Pick a date",
|
|
217
|
+
disabled = false,
|
|
218
|
+
minDate,
|
|
219
|
+
maxDate,
|
|
220
|
+
dateFormat = "yyyy-MM-dd",
|
|
221
|
+
clearable = false,
|
|
222
|
+
className,
|
|
223
|
+
id,
|
|
224
|
+
name,
|
|
225
|
+
align = "start",
|
|
226
|
+
side = "bottom",
|
|
227
|
+
onOpenChange,
|
|
228
|
+
size = "default",
|
|
229
|
+
}: DatePickerInputProps) {
|
|
230
|
+
const [open, setOpen] = React.useState(false);
|
|
231
|
+
const [internalValue, setInternalValue] = React.useState<Date | undefined>(
|
|
232
|
+
defaultValue
|
|
233
|
+
);
|
|
234
|
+
const [inputValue, setInputValue] = React.useState("");
|
|
235
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
236
|
+
|
|
237
|
+
// Controlled vs uncontrolled
|
|
238
|
+
const isControlled = value !== undefined;
|
|
239
|
+
const selectedDate = isControlled ? value : internalValue;
|
|
240
|
+
|
|
241
|
+
// Sync input value with selected date
|
|
242
|
+
React.useEffect(() => {
|
|
243
|
+
if (selectedDate) {
|
|
244
|
+
setInputValue(format(selectedDate, dateFormat));
|
|
245
|
+
} else {
|
|
246
|
+
setInputValue("");
|
|
247
|
+
}
|
|
248
|
+
}, [selectedDate, dateFormat]);
|
|
249
|
+
|
|
250
|
+
const handleSelect = (date: Date | undefined) => {
|
|
251
|
+
if (!isControlled) {
|
|
252
|
+
setInternalValue(date);
|
|
253
|
+
}
|
|
254
|
+
onChange?.(date);
|
|
255
|
+
setOpen(false);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
259
|
+
const newValue = e.target.value;
|
|
260
|
+
setInputValue(newValue);
|
|
261
|
+
|
|
262
|
+
// Try to parse the date
|
|
263
|
+
if (newValue) {
|
|
264
|
+
const parsed = parse(newValue, dateFormat, new Date());
|
|
265
|
+
if (isValid(parsed)) {
|
|
266
|
+
if (!isControlled) {
|
|
267
|
+
setInternalValue(parsed);
|
|
268
|
+
}
|
|
269
|
+
onChange?.(parsed);
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
handleSelect(undefined);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const handleInputBlur = () => {
|
|
277
|
+
// Reset to the valid date on blur if input is invalid
|
|
278
|
+
if (selectedDate) {
|
|
279
|
+
setInputValue(format(selectedDate, dateFormat));
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const handleClear = (e: React.MouseEvent) => {
|
|
284
|
+
e.stopPropagation();
|
|
285
|
+
handleSelect(undefined);
|
|
286
|
+
setInputValue("");
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const handleOpenChange = (newOpen: boolean) => {
|
|
290
|
+
setOpen(newOpen);
|
|
291
|
+
onOpenChange?.(newOpen);
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const sizeClasses = {
|
|
295
|
+
sm: "h-8 text-xs px-2",
|
|
296
|
+
default: "h-9 text-sm px-3",
|
|
297
|
+
lg: "h-10 text-base px-4",
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
return (
|
|
301
|
+
<Popover open={open} onOpenChange={handleOpenChange}>
|
|
302
|
+
<div className={cn("relative", className)}>
|
|
303
|
+
<PopoverTrigger asChild>
|
|
304
|
+
<div
|
|
305
|
+
className={cn(
|
|
306
|
+
"flex items-center rounded-md border border-input bg-background ring-offset-background",
|
|
307
|
+
"focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
|
|
308
|
+
disabled && "opacity-50 cursor-not-allowed",
|
|
309
|
+
sizeClasses[size]
|
|
310
|
+
)}
|
|
311
|
+
>
|
|
312
|
+
<input
|
|
313
|
+
ref={inputRef}
|
|
314
|
+
id={id}
|
|
315
|
+
type="text"
|
|
316
|
+
value={inputValue}
|
|
317
|
+
onChange={handleInputChange}
|
|
318
|
+
onBlur={handleInputBlur}
|
|
319
|
+
placeholder={placeholder}
|
|
320
|
+
disabled={disabled}
|
|
321
|
+
className={cn(
|
|
322
|
+
"flex-1 bg-transparent outline-none placeholder:text-muted-foreground",
|
|
323
|
+
"disabled:cursor-not-allowed"
|
|
324
|
+
)}
|
|
325
|
+
/>
|
|
326
|
+
{clearable && selectedDate && (
|
|
327
|
+
<X
|
|
328
|
+
className="h-4 w-4 mx-1 cursor-pointer opacity-50 hover:opacity-100"
|
|
329
|
+
onClick={handleClear}
|
|
330
|
+
aria-label="Clear date"
|
|
331
|
+
/>
|
|
332
|
+
)}
|
|
333
|
+
<CalendarIcon
|
|
334
|
+
className="h-4 w-4 opacity-50 cursor-pointer hover:opacity-100"
|
|
335
|
+
onClick={() => !disabled && setOpen(true)}
|
|
336
|
+
/>
|
|
337
|
+
</div>
|
|
338
|
+
</PopoverTrigger>
|
|
339
|
+
<PopoverContent className="w-auto p-0" align={align} side={side}>
|
|
340
|
+
<Calendar
|
|
341
|
+
mode="single"
|
|
342
|
+
selected={selectedDate}
|
|
343
|
+
onSelect={handleSelect}
|
|
344
|
+
disabled={(date) => {
|
|
345
|
+
if (minDate && date < minDate) return true;
|
|
346
|
+
if (maxDate && date > maxDate) return true;
|
|
347
|
+
return false;
|
|
348
|
+
}}
|
|
349
|
+
initialFocus
|
|
350
|
+
/>
|
|
351
|
+
</PopoverContent>
|
|
352
|
+
{name && selectedDate && (
|
|
353
|
+
<input
|
|
354
|
+
type="hidden"
|
|
355
|
+
name={name}
|
|
356
|
+
value={format(selectedDate, "yyyy-MM-dd")}
|
|
357
|
+
/>
|
|
358
|
+
)}
|
|
359
|
+
</div>
|
|
360
|
+
</Popover>
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
DatePickerInput.displayName = "DatePickerInput";
|
|
365
|
+
|
|
366
|
+
export { DatePicker, DatePickerInput };
|