@k3-universe/react-kit 0.0.10 → 0.0.11
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/dist/index.js +1303 -75
- package/dist/kit/builder/form/components/FormBuilder.d.ts +5 -1
- package/dist/kit/builder/form/components/FormBuilder.d.ts.map +1 -1
- package/dist/kit/builder/form/components/FormBuilderField.d.ts.map +1 -1
- package/dist/kit/builder/form/components/fields/DateTimePickerField.d.ts +4 -0
- package/dist/kit/builder/form/components/fields/DateTimePickerField.d.ts.map +1 -0
- package/dist/kit/builder/form/components/fields/DateTimeRangePickerField.d.ts +4 -0
- package/dist/kit/builder/form/components/fields/DateTimeRangePickerField.d.ts.map +1 -0
- package/dist/kit/builder/form/components/fields/TimePickerField.d.ts +4 -0
- package/dist/kit/builder/form/components/fields/TimePickerField.d.ts.map +1 -0
- package/dist/kit/builder/form/components/fields/TimeRangePickerField.d.ts +4 -0
- package/dist/kit/builder/form/components/fields/TimeRangePickerField.d.ts.map +1 -0
- package/dist/kit/builder/form/components/fields/index.d.ts +4 -0
- package/dist/kit/builder/form/components/fields/index.d.ts.map +1 -1
- package/dist/kit/components/datetimepicker/DateTimePicker.d.ts +32 -0
- package/dist/kit/components/datetimepicker/DateTimePicker.d.ts.map +1 -0
- package/dist/kit/components/datetimepicker/DateTimeRangePicker.d.ts +39 -0
- package/dist/kit/components/datetimepicker/DateTimeRangePicker.d.ts.map +1 -0
- package/dist/kit/components/datetimepicker/index.d.ts +5 -0
- package/dist/kit/components/datetimepicker/index.d.ts.map +1 -0
- package/dist/kit/components/timepicker/TimePicker.d.ts +26 -0
- package/dist/kit/components/timepicker/TimePicker.d.ts.map +1 -0
- package/dist/kit/components/timepicker/TimeRangePicker.d.ts +31 -0
- package/dist/kit/components/timepicker/TimeRangePicker.d.ts.map +1 -0
- package/dist/kit/components/timepicker/index.d.ts +5 -0
- package/dist/kit/components/timepicker/index.d.ts.map +1 -0
- package/dist/kit/themes/clean-slate.css +16 -0
- package/dist/kit/themes/default.css +16 -0
- package/dist/kit/themes/minimal-modern.css +16 -0
- package/dist/kit/themes/spotify.css +16 -0
- package/package.json +1 -1
- package/src/kit/builder/form/components/FormBuilder.tsx +17 -0
- package/src/kit/builder/form/components/FormBuilderField.tsx +48 -0
- package/src/kit/builder/form/components/fields/DateTimePickerField.tsx +33 -0
- package/src/kit/builder/form/components/fields/DateTimeRangePickerField.tsx +42 -0
- package/src/kit/builder/form/components/fields/TimePickerField.tsx +30 -0
- package/src/kit/builder/form/components/fields/TimeRangePickerField.tsx +37 -0
- package/src/kit/builder/form/components/fields/index.ts +4 -0
- package/src/kit/components/datetimepicker/DateTimePicker.tsx +314 -0
- package/src/kit/components/datetimepicker/DateTimeRangePicker.tsx +486 -0
- package/src/kit/components/datetimepicker/index.ts +3 -0
- package/src/kit/components/timepicker/TimePicker.tsx +311 -0
- package/src/kit/components/timepicker/TimeRangePicker.tsx +291 -0
- package/src/kit/components/timepicker/index.ts +3 -0
- package/src/stories/kit/builder/Form.DateTime.stories.tsx +66 -0
- package/src/stories/kit/builder/Form.Time.stories.tsx +64 -0
- package/src/stories/kit/components/TimePicker.stories.tsx +69 -0
- package/src/stories/kit/components/TimeRangePicker.stories.tsx +37 -0
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Calendar as CalendarIcon } from 'lucide-react';
|
|
5
|
+
import { cn } from '../../../shadcn/lib/utils';
|
|
6
|
+
import { Button } from '../../../shadcn/ui/button';
|
|
7
|
+
import { Popover, PopoverContent, PopoverTrigger } from '../../../shadcn/ui/popover';
|
|
8
|
+
import { Calendar } from '../../../shadcn/ui/calendar';
|
|
9
|
+
import {
|
|
10
|
+
Select,
|
|
11
|
+
SelectContent,
|
|
12
|
+
SelectItem,
|
|
13
|
+
SelectTrigger,
|
|
14
|
+
SelectValue,
|
|
15
|
+
} from '../../../shadcn/ui/select';
|
|
16
|
+
|
|
17
|
+
export type TimePrecision = 'hour' | 'minute' | 'second';
|
|
18
|
+
|
|
19
|
+
export interface DateTimeRangeValue {
|
|
20
|
+
from?: Date | null;
|
|
21
|
+
to?: Date | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DateTimeRangePickerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
|
25
|
+
value?: DateTimeRangeValue | null;
|
|
26
|
+
onChange?: (range: DateTimeRangeValue | null) => void;
|
|
27
|
+
placeholder?: string;
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
minDate?: Date;
|
|
30
|
+
maxDate?: Date;
|
|
31
|
+
disabledDates?: Array<Date | { from: Date; to: Date }>;
|
|
32
|
+
numberOfMonths?: number;
|
|
33
|
+
popoverSide?: 'top' | 'right' | 'bottom' | 'left';
|
|
34
|
+
// time config
|
|
35
|
+
timePrecision?: TimePrecision; // default 'minute'
|
|
36
|
+
hourCycle?: 12 | 24; // default 24
|
|
37
|
+
minuteStep?: number; // default 5
|
|
38
|
+
secondStep?: number; // default 5
|
|
39
|
+
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
|
|
40
|
+
open?: boolean;
|
|
41
|
+
onOpenChange?: (open: boolean) => void;
|
|
42
|
+
showFooter?: boolean; // default true
|
|
43
|
+
cancelLabel?: string; // default 'Cancel'
|
|
44
|
+
applyLabel?: string; // default 'Apply'
|
|
45
|
+
clearLabel?: string; // default 'Clear'
|
|
46
|
+
contentClassName?: string; // custom classes for the inner content container
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const pad2 = (n: number) => String(n).padStart(2, '0');
|
|
50
|
+
function startOfDay(d: Date) { return new Date(d.getFullYear(), d.getMonth(), d.getDate()); }
|
|
51
|
+
function sameDay(a: Date, b: Date) { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); }
|
|
52
|
+
function isBefore(date: Date, min?: Date) { return !!(min && date < startOfDay(min)); }
|
|
53
|
+
function isAfter(date: Date, max?: Date) { return !!(max && date > startOfDay(max)); }
|
|
54
|
+
function inDisabled(date: Date, items?: Array<Date | { from: Date; to: Date }>) {
|
|
55
|
+
if (!items || items.length === 0) return false;
|
|
56
|
+
const d = startOfDay(date);
|
|
57
|
+
for (const it of items) {
|
|
58
|
+
if (it instanceof Date) {
|
|
59
|
+
if (sameDay(d, it)) return true;
|
|
60
|
+
} else if (it && 'from' in it && 'to' in it) {
|
|
61
|
+
const from = startOfDay(it.from);
|
|
62
|
+
const to = startOfDay(it.to);
|
|
63
|
+
if (d >= from && d <= to) return true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
function rangeContainsDisabled(from?: Date, to?: Date, items?: Array<Date | { from: Date; to: Date }>) {
|
|
69
|
+
if (!from || !to) return false;
|
|
70
|
+
if (!items || items.length === 0) return false;
|
|
71
|
+
const a = startOfDay(from); const b = startOfDay(to);
|
|
72
|
+
const start = a <= b ? a : b; const end = a <= b ? b : a;
|
|
73
|
+
let cur = start;
|
|
74
|
+
while (cur <= end) {
|
|
75
|
+
if (inDisabled(cur, items)) return true;
|
|
76
|
+
cur = new Date(cur.getFullYear(), cur.getMonth(), cur.getDate() + 1);
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function TimeSelectors({
|
|
82
|
+
label,
|
|
83
|
+
value,
|
|
84
|
+
onChange,
|
|
85
|
+
precision,
|
|
86
|
+
hourCycle,
|
|
87
|
+
minuteStep,
|
|
88
|
+
secondStep,
|
|
89
|
+
disabled,
|
|
90
|
+
compact,
|
|
91
|
+
}: {
|
|
92
|
+
label: string;
|
|
93
|
+
value: Date | null | undefined;
|
|
94
|
+
onChange: (val: Date | null) => void;
|
|
95
|
+
precision: TimePrecision;
|
|
96
|
+
hourCycle: 12 | 24;
|
|
97
|
+
minuteStep: number;
|
|
98
|
+
secondStep: number;
|
|
99
|
+
disabled?: boolean;
|
|
100
|
+
compact?: boolean;
|
|
101
|
+
}) {
|
|
102
|
+
const hours = React.useMemo(() => (hourCycle === 12 ? Array.from({ length: 12 }, (_, i) => i + 1) : Array.from({ length: 24 }, (_, i) => i)), [hourCycle]);
|
|
103
|
+
const minutes = React.useMemo(() => Array.from({ length: Math.ceil(60 / minuteStep) }, (_, i) => i * minuteStep), [minuteStep]);
|
|
104
|
+
const seconds = React.useMemo(() => Array.from({ length: Math.ceil(60 / secondStep) }, (_, i) => i * secondStep), [secondStep]);
|
|
105
|
+
const selectedHour = React.useMemo(() => {
|
|
106
|
+
if (!value) return hourCycle === 12 ? 12 : 0;
|
|
107
|
+
const h = value.getHours();
|
|
108
|
+
return hourCycle === 12 ? (h % 12 === 0 ? 12 : h % 12) : h;
|
|
109
|
+
}, [value, hourCycle]);
|
|
110
|
+
const selectedMinute = value?.getMinutes() ?? 0;
|
|
111
|
+
const selectedSecond = value?.getSeconds() ?? 0;
|
|
112
|
+
const selectedPeriod: 'AM' | 'PM' = value && value.getHours() >= 12 ? 'PM' : 'AM';
|
|
113
|
+
|
|
114
|
+
const setPart = (part: 'hour' | 'minute' | 'second' | 'period', v: number | 'AM' | 'PM') => {
|
|
115
|
+
const base = value
|
|
116
|
+
? new Date(value)
|
|
117
|
+
: (() => {
|
|
118
|
+
const n = new Date();
|
|
119
|
+
return new Date(n.getFullYear(), n.getMonth(), n.getDate(), 0, 0, 0, 0);
|
|
120
|
+
})();
|
|
121
|
+
if (part === 'hour') {
|
|
122
|
+
let h = Number(v);
|
|
123
|
+
if (hourCycle === 12) {
|
|
124
|
+
const isPM = base.getHours() >= 12;
|
|
125
|
+
h = h % 12;
|
|
126
|
+
base.setHours(isPM ? (h === 12 ? 12 : h + 12) : (h === 12 ? 0 : h));
|
|
127
|
+
} else {
|
|
128
|
+
base.setHours(h);
|
|
129
|
+
}
|
|
130
|
+
} else if (part === 'minute') {
|
|
131
|
+
base.setMinutes(Number(v));
|
|
132
|
+
} else if (part === 'second') {
|
|
133
|
+
base.setSeconds(Number(v));
|
|
134
|
+
} else if (part === 'period' && (v === 'AM' || v === 'PM')) {
|
|
135
|
+
const curH = base.getHours();
|
|
136
|
+
const isAMNow = curH < 12;
|
|
137
|
+
if (v === 'AM' && !isAMNow) base.setHours(curH - 12);
|
|
138
|
+
if (v === 'PM' && isAMNow) base.setHours(curH + 12);
|
|
139
|
+
}
|
|
140
|
+
base.setMilliseconds(0);
|
|
141
|
+
onChange(base);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const widthClass = compact ? 'w-16' : 'w-24';
|
|
145
|
+
return (
|
|
146
|
+
<div className={compact ? '' : 'space-y-1'}>
|
|
147
|
+
{!compact && <div className="text-xs text-muted-foreground">{label}</div>}
|
|
148
|
+
<div className="flex items-end gap-2">
|
|
149
|
+
<div className={widthClass}>
|
|
150
|
+
{!compact && <div className="mb-1 block text-xs text-muted-foreground">Hour</div>}
|
|
151
|
+
<Select disabled={disabled} value={String(selectedHour)} onValueChange={(v) => setPart('hour', Number(v))}>
|
|
152
|
+
<SelectTrigger aria-label={`${label} hour`}>
|
|
153
|
+
<SelectValue />
|
|
154
|
+
</SelectTrigger>
|
|
155
|
+
<SelectContent>
|
|
156
|
+
{hours.map((h) => (
|
|
157
|
+
<SelectItem key={h} value={String(h)}>{hourCycle === 12 ? h : pad2(h)}</SelectItem>
|
|
158
|
+
))}
|
|
159
|
+
</SelectContent>
|
|
160
|
+
</Select>
|
|
161
|
+
</div>
|
|
162
|
+
{(precision === 'minute' || precision === 'second') && (
|
|
163
|
+
<div className={widthClass}>
|
|
164
|
+
{!compact && <div className="mb-1 block text-xs text-muted-foreground">Minute</div>}
|
|
165
|
+
<Select disabled={disabled} value={String(selectedMinute - (selectedMinute % minuteStep))} onValueChange={(v) => setPart('minute', Number(v))}>
|
|
166
|
+
<SelectTrigger aria-label={`${label} minute`}>
|
|
167
|
+
<SelectValue />
|
|
168
|
+
</SelectTrigger>
|
|
169
|
+
<SelectContent>
|
|
170
|
+
{minutes.map((m) => (
|
|
171
|
+
<SelectItem key={m} value={String(m)}>{pad2(m)}</SelectItem>
|
|
172
|
+
))}
|
|
173
|
+
</SelectContent>
|
|
174
|
+
</Select>
|
|
175
|
+
</div>
|
|
176
|
+
)}
|
|
177
|
+
{precision === 'second' && (
|
|
178
|
+
<div className={widthClass}>
|
|
179
|
+
{!compact && <div className="mb-1 block text-xs text-muted-foreground">Second</div>}
|
|
180
|
+
<Select disabled={disabled} value={String(selectedSecond - (selectedSecond % secondStep))} onValueChange={(v) => setPart('second', Number(v))}>
|
|
181
|
+
<SelectTrigger aria-label={`${label} second`}>
|
|
182
|
+
<SelectValue />
|
|
183
|
+
</SelectTrigger>
|
|
184
|
+
<SelectContent>
|
|
185
|
+
{seconds.map((s) => (
|
|
186
|
+
<SelectItem key={s} value={String(s)}>{pad2(s)}</SelectItem>
|
|
187
|
+
))}
|
|
188
|
+
</SelectContent>
|
|
189
|
+
</Select>
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
{hourCycle === 12 && (
|
|
193
|
+
<div className={widthClass}>
|
|
194
|
+
{!compact && <div className="mb-1 block text-xs text-muted-foreground">Period</div>}
|
|
195
|
+
<Select disabled={disabled} value={selectedPeriod} onValueChange={(v) => setPart('period', v as 'AM' | 'PM')}>
|
|
196
|
+
<SelectTrigger aria-label={`${label} period`}>
|
|
197
|
+
<SelectValue />
|
|
198
|
+
</SelectTrigger>
|
|
199
|
+
<SelectContent>
|
|
200
|
+
<SelectItem value="AM">AM</SelectItem>
|
|
201
|
+
<SelectItem value="PM">PM</SelectItem>
|
|
202
|
+
</SelectContent>
|
|
203
|
+
</Select>
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function DateTimeRangePicker({
|
|
212
|
+
value,
|
|
213
|
+
onChange,
|
|
214
|
+
placeholder = 'Pick a date & time range',
|
|
215
|
+
disabled,
|
|
216
|
+
minDate,
|
|
217
|
+
maxDate,
|
|
218
|
+
disabledDates,
|
|
219
|
+
numberOfMonths = 2,
|
|
220
|
+
popoverSide,
|
|
221
|
+
timePrecision = 'minute',
|
|
222
|
+
hourCycle = 24,
|
|
223
|
+
minuteStep = 5,
|
|
224
|
+
secondStep = 5,
|
|
225
|
+
className,
|
|
226
|
+
buttonVariant = 'outline',
|
|
227
|
+
contentClassName,
|
|
228
|
+
...props
|
|
229
|
+
}: DateTimeRangePickerProps) {
|
|
230
|
+
const [internalOpen, setInternalOpen] = React.useState(false);
|
|
231
|
+
const isOpen = typeof props.open === 'boolean' ? props.open : internalOpen;
|
|
232
|
+
const setOpen = (o: boolean) => (props.onOpenChange ? props.onOpenChange(o) : setInternalOpen(o));
|
|
233
|
+
const [draft, setDraft] = React.useState<DateTimeRangeValue | null>(value ?? null);
|
|
234
|
+
|
|
235
|
+
React.useEffect(() => {
|
|
236
|
+
if (isOpen) setDraft(value ?? null);
|
|
237
|
+
}, [isOpen, value]);
|
|
238
|
+
|
|
239
|
+
const fmtTime = React.useCallback((d?: Date | null) => {
|
|
240
|
+
if (!d) return '';
|
|
241
|
+
const h = d.getHours();
|
|
242
|
+
const m = d.getMinutes();
|
|
243
|
+
const s = d.getSeconds();
|
|
244
|
+
return hourCycle === 12
|
|
245
|
+
? `${((h % 12) || 12)}:${pad2(m)}${timePrecision === 'second' ? `:${pad2(s)}` : ''} ${h >= 12 ? 'PM' : 'AM'}`
|
|
246
|
+
: `${pad2(h)}:${pad2(m)}${timePrecision === 'second' ? `:${pad2(s)}` : ''}`;
|
|
247
|
+
}, [hourCycle, timePrecision]);
|
|
248
|
+
|
|
249
|
+
const label = React.useMemo(() => {
|
|
250
|
+
const f = draft?.from ?? value?.from ?? null;
|
|
251
|
+
const t = draft?.to ?? value?.to ?? null;
|
|
252
|
+
if (f && t) {
|
|
253
|
+
const fd = f.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: '2-digit' });
|
|
254
|
+
const td = t.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: '2-digit' });
|
|
255
|
+
return `${fd} ${fmtTime(f)} – ${td} ${fmtTime(t)}`;
|
|
256
|
+
}
|
|
257
|
+
return placeholder;
|
|
258
|
+
}, [draft, value, placeholder, fmtTime]);
|
|
259
|
+
|
|
260
|
+
// Date inputs and parsing (DD / MM / YYYY)
|
|
261
|
+
const fmtDate = React.useCallback((d?: Date | null) => {
|
|
262
|
+
if (!d) return '';
|
|
263
|
+
const dd = String(d.getDate()).padStart(2, '0');
|
|
264
|
+
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
265
|
+
const yyyy = d.getFullYear();
|
|
266
|
+
return `${dd} / ${mm} / ${yyyy}`;
|
|
267
|
+
}, []);
|
|
268
|
+
const [fromInput, setFromInput] = React.useState<string>(fmtDate(draft?.from ?? value?.from ?? null));
|
|
269
|
+
const [toInput, setToInput] = React.useState<string>(fmtDate(draft?.to ?? value?.to ?? null));
|
|
270
|
+
React.useEffect(() => {
|
|
271
|
+
if (isOpen) {
|
|
272
|
+
setFromInput(fmtDate(value?.from ?? null));
|
|
273
|
+
setToInput(fmtDate(value?.to ?? null));
|
|
274
|
+
}
|
|
275
|
+
}, [isOpen, value, fmtDate]);
|
|
276
|
+
const maskDate = (raw: string) => {
|
|
277
|
+
const digits = raw.replace(/\D/g, '').slice(0, 8);
|
|
278
|
+
const parts: string[] = [];
|
|
279
|
+
const dd = digits.slice(0, Math.min(2, digits.length)); if (dd) parts.push(dd);
|
|
280
|
+
const mm = digits.length > 2 ? digits.slice(2, Math.min(4, digits.length)) : ''; if (mm) parts.push(mm);
|
|
281
|
+
const yyyy = digits.length > 4 ? digits.slice(4) : ''; if (yyyy) parts.push(yyyy);
|
|
282
|
+
return parts.join(' / ');
|
|
283
|
+
};
|
|
284
|
+
const parseMasked = (masked: string): Date | undefined => {
|
|
285
|
+
const m = masked.match(/^(\d{1,2})\s*\/\s*(\d{1,2})\s*\/\s*(\d{4})$/);
|
|
286
|
+
if (!m) return undefined;
|
|
287
|
+
const dd = Number(m[1]); const mm = Number(m[2]); const yyyy = Number(m[3]);
|
|
288
|
+
if (mm < 1 || mm > 12) return undefined;
|
|
289
|
+
const lastDay = new Date(yyyy, mm, 0).getDate();
|
|
290
|
+
if (dd < 1 || dd > lastDay) return undefined;
|
|
291
|
+
const out = new Date(yyyy, mm - 1, dd);
|
|
292
|
+
if (isBefore(out, minDate) || isAfter(out, maxDate) || inDisabled(out, disabledDates)) return undefined;
|
|
293
|
+
return out;
|
|
294
|
+
};
|
|
295
|
+
const fromParsed = parseMasked(fromInput);
|
|
296
|
+
const toParsed = parseMasked(toInput);
|
|
297
|
+
const mergedFrom = React.useMemo(() => {
|
|
298
|
+
if (fromParsed) {
|
|
299
|
+
const base = draft?.from ?? value?.from ?? null;
|
|
300
|
+
if (base) {
|
|
301
|
+
return new Date(
|
|
302
|
+
fromParsed.getFullYear(),
|
|
303
|
+
fromParsed.getMonth(),
|
|
304
|
+
fromParsed.getDate(),
|
|
305
|
+
base.getHours(),
|
|
306
|
+
base.getMinutes(),
|
|
307
|
+
base.getSeconds()
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
return fromParsed;
|
|
311
|
+
}
|
|
312
|
+
return draft?.from ?? undefined;
|
|
313
|
+
}, [fromParsed, draft, value]);
|
|
314
|
+
const mergedTo = React.useMemo(() => {
|
|
315
|
+
if (toParsed) {
|
|
316
|
+
const base = draft?.to ?? value?.to ?? null;
|
|
317
|
+
if (base) {
|
|
318
|
+
return new Date(
|
|
319
|
+
toParsed.getFullYear(),
|
|
320
|
+
toParsed.getMonth(),
|
|
321
|
+
toParsed.getDate(),
|
|
322
|
+
base.getHours(),
|
|
323
|
+
base.getMinutes(),
|
|
324
|
+
base.getSeconds()
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
return toParsed;
|
|
328
|
+
}
|
|
329
|
+
return draft?.to ?? undefined;
|
|
330
|
+
}, [toParsed, draft, value]);
|
|
331
|
+
const invalidRange = !mergedFrom || !mergedTo || isBefore(mergedFrom, minDate) || isAfter(mergedTo, maxDate) || mergedFrom > mergedTo || rangeContainsDisabled(mergedFrom, mergedTo, disabledDates);
|
|
332
|
+
|
|
333
|
+
return (
|
|
334
|
+
<div className={cn('w-fit', className)} {...props}>
|
|
335
|
+
<Popover open={isOpen} onOpenChange={setOpen}>
|
|
336
|
+
<PopoverTrigger asChild>
|
|
337
|
+
<Button type="button" disabled={disabled} variant={buttonVariant} className={cn('w-[360px] justify-start text-left font-normal', !value && 'text-muted-foreground')}>
|
|
338
|
+
<CalendarIcon className="mr-2 h-4 w-4" />
|
|
339
|
+
{label}
|
|
340
|
+
</Button>
|
|
341
|
+
</PopoverTrigger>
|
|
342
|
+
<PopoverContent className="w-auto max-w-none p-4" align="start" side={popoverSide ?? 'bottom'} sideOffset={8}>
|
|
343
|
+
<div className={cn('w-fit min-w-0', contentClassName)}>
|
|
344
|
+
{/* Header row: date inputs with inline time selectors beside each */}
|
|
345
|
+
<div className="mb-3 rounded-md border border-input bg-background/50 px-3 py-2">
|
|
346
|
+
<div className="flex flex-wrap items-end gap-3">
|
|
347
|
+
<div className="flex items-end gap-2">
|
|
348
|
+
<input
|
|
349
|
+
type="text"
|
|
350
|
+
inputMode="numeric"
|
|
351
|
+
value={fromInput}
|
|
352
|
+
onChange={(e) => setFromInput(maskDate(e.target.value))}
|
|
353
|
+
onBlur={() => {
|
|
354
|
+
const p = parseMasked(fromInput);
|
|
355
|
+
if (p) {
|
|
356
|
+
const prev = draft?.from ?? null;
|
|
357
|
+
const withTime = new Date(
|
|
358
|
+
p.getFullYear(),
|
|
359
|
+
p.getMonth(),
|
|
360
|
+
p.getDate(),
|
|
361
|
+
prev ? prev.getHours() : 0,
|
|
362
|
+
prev ? prev.getMinutes() : 0,
|
|
363
|
+
prev ? prev.getSeconds() : 0
|
|
364
|
+
);
|
|
365
|
+
setDraft((d) => ({ ...(d ?? {}), from: withTime }));
|
|
366
|
+
}
|
|
367
|
+
}}
|
|
368
|
+
placeholder="dd/mm/yyyy"
|
|
369
|
+
className="h-9 w-40 rounded-md border bg-background px-3 text-sm shadow-xs outline-hidden"
|
|
370
|
+
/>
|
|
371
|
+
<TimeSelectors
|
|
372
|
+
label="From"
|
|
373
|
+
compact
|
|
374
|
+
value={draft?.from ?? null}
|
|
375
|
+
onChange={(d) => setDraft((prev) => ({ ...(prev ?? {}), from: d }))}
|
|
376
|
+
precision={timePrecision}
|
|
377
|
+
hourCycle={hourCycle}
|
|
378
|
+
minuteStep={minuteStep}
|
|
379
|
+
secondStep={secondStep}
|
|
380
|
+
disabled={disabled}
|
|
381
|
+
/>
|
|
382
|
+
</div>
|
|
383
|
+
<span className="text-muted-foreground">–</span>
|
|
384
|
+
<div className="flex items-end gap-2">
|
|
385
|
+
<input
|
|
386
|
+
type="text"
|
|
387
|
+
inputMode="numeric"
|
|
388
|
+
value={toInput}
|
|
389
|
+
onChange={(e) => setToInput(maskDate(e.target.value))}
|
|
390
|
+
onBlur={() => {
|
|
391
|
+
const p = parseMasked(toInput);
|
|
392
|
+
if (p) {
|
|
393
|
+
const prev = draft?.to ?? null;
|
|
394
|
+
const withTime = new Date(
|
|
395
|
+
p.getFullYear(),
|
|
396
|
+
p.getMonth(),
|
|
397
|
+
p.getDate(),
|
|
398
|
+
prev ? prev.getHours() : 0,
|
|
399
|
+
prev ? prev.getMinutes() : 0,
|
|
400
|
+
prev ? prev.getSeconds() : 0
|
|
401
|
+
);
|
|
402
|
+
setDraft((d) => ({ ...(d ?? {}), to: withTime }));
|
|
403
|
+
}
|
|
404
|
+
}}
|
|
405
|
+
placeholder="dd/mm/yyyy"
|
|
406
|
+
className="h-9 w-40 rounded-md border bg-background px-3 text-sm shadow-xs outline-hidden"
|
|
407
|
+
/>
|
|
408
|
+
<TimeSelectors
|
|
409
|
+
label="To"
|
|
410
|
+
compact
|
|
411
|
+
value={draft?.to ?? null}
|
|
412
|
+
onChange={(d) => setDraft((prev) => ({ ...(prev ?? {}), to: d }))}
|
|
413
|
+
precision={timePrecision}
|
|
414
|
+
hourCycle={hourCycle}
|
|
415
|
+
minuteStep={minuteStep}
|
|
416
|
+
secondStep={secondStep}
|
|
417
|
+
disabled={disabled}
|
|
418
|
+
/>
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
421
|
+
</div>
|
|
422
|
+
|
|
423
|
+
{/* Calendar */}
|
|
424
|
+
<Calendar
|
|
425
|
+
mode="range"
|
|
426
|
+
numberOfMonths={numberOfMonths}
|
|
427
|
+
selected={draft?.from && draft?.to ? { from: draft.from, to: draft.to } : undefined}
|
|
428
|
+
onSelect={(range) => {
|
|
429
|
+
if (disabled) return;
|
|
430
|
+
if (!range) { setDraft(null); return; }
|
|
431
|
+
const { from, to } = range as { from?: Date; to?: Date };
|
|
432
|
+
if (from && (isBefore(from, minDate) || isAfter(from, maxDate))) return;
|
|
433
|
+
if (to && (isBefore(to, minDate) || isAfter(to, maxDate))) return;
|
|
434
|
+
if (from && to && rangeContainsDisabled(from, to, disabledDates)) return;
|
|
435
|
+
const prevFrom = draft?.from ?? from;
|
|
436
|
+
const prevTo = draft?.to ?? to;
|
|
437
|
+
const nextFrom = from
|
|
438
|
+
? new Date(from.getFullYear(), from.getMonth(), from.getDate(), prevFrom?.getHours?.() ?? 0, prevFrom?.getMinutes?.() ?? 0, prevFrom?.getSeconds?.() ?? 0)
|
|
439
|
+
: undefined;
|
|
440
|
+
const nextTo = to
|
|
441
|
+
? new Date(to.getFullYear(), to.getMonth(), to.getDate(), prevTo?.getHours?.() ?? 0, prevTo?.getMinutes?.() ?? 0, prevTo?.getSeconds?.() ?? 0)
|
|
442
|
+
: undefined;
|
|
443
|
+
setDraft({ from: nextFrom ?? null, to: nextTo ?? null });
|
|
444
|
+
setFromInput(fmtDate(nextFrom ?? null));
|
|
445
|
+
setToInput(fmtDate(nextTo ?? null));
|
|
446
|
+
}}
|
|
447
|
+
defaultMonth={draft?.from ?? value?.from ?? new Date()}
|
|
448
|
+
disabled={(d) => isBefore(d, minDate) || isAfter(d, maxDate) || inDisabled(d, disabledDates)}
|
|
449
|
+
buttonVariant="ghost"
|
|
450
|
+
showOutsideDays
|
|
451
|
+
/>
|
|
452
|
+
</div>
|
|
453
|
+
{(props.showFooter ?? true) && (
|
|
454
|
+
<div className="flex items-center justify-between gap-2 pt-3 mt-3 border-t">
|
|
455
|
+
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(false)} disabled={disabled}>
|
|
456
|
+
{props.cancelLabel ?? 'Cancel'}
|
|
457
|
+
</Button>
|
|
458
|
+
<div className="flex gap-2">
|
|
459
|
+
<Button type="button" variant="ghost" size="sm" onClick={() => onChange?.(null)} disabled={disabled}>
|
|
460
|
+
{props.clearLabel ?? 'Clear'}
|
|
461
|
+
</Button>
|
|
462
|
+
<Button
|
|
463
|
+
type="button"
|
|
464
|
+
variant="default"
|
|
465
|
+
size="sm"
|
|
466
|
+
onClick={() => {
|
|
467
|
+
if (invalidRange || !mergedFrom || !mergedTo) return;
|
|
468
|
+
onChange?.({ from: mergedFrom, to: mergedTo });
|
|
469
|
+
setOpen(false);
|
|
470
|
+
}}
|
|
471
|
+
disabled={disabled || invalidRange}
|
|
472
|
+
>
|
|
473
|
+
{props.applyLabel ?? 'Apply'}
|
|
474
|
+
</Button>
|
|
475
|
+
</div>
|
|
476
|
+
</div>
|
|
477
|
+
)}
|
|
478
|
+
</PopoverContent>
|
|
479
|
+
</Popover>
|
|
480
|
+
</div>
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
DateTimeRangePicker.displayName = 'DateTimeRangePicker';
|
|
485
|
+
|
|
486
|
+
export default DateTimeRangePicker;
|