@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,311 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Clock } from 'lucide-react';
|
|
5
|
+
import { cn } from '../../../shadcn/lib/utils';
|
|
6
|
+
import { Button } from '../../../shadcn/ui/button';
|
|
7
|
+
import {
|
|
8
|
+
Popover,
|
|
9
|
+
PopoverContent,
|
|
10
|
+
PopoverTrigger,
|
|
11
|
+
} from '../../../shadcn/ui/popover';
|
|
12
|
+
import {
|
|
13
|
+
Select,
|
|
14
|
+
SelectContent,
|
|
15
|
+
SelectItem,
|
|
16
|
+
SelectTrigger,
|
|
17
|
+
SelectValue,
|
|
18
|
+
} from '../../../shadcn/ui/select';
|
|
19
|
+
|
|
20
|
+
export type TimePrecision = 'hour' | 'minute' | 'second';
|
|
21
|
+
|
|
22
|
+
export interface TimePickerProps
|
|
23
|
+
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
|
24
|
+
value?: Date | null;
|
|
25
|
+
onChange?: (date: Date | null) => void;
|
|
26
|
+
placeholder?: string;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
precision?: TimePrecision; // default 'minute'
|
|
29
|
+
hourCycle?: 12 | 24; // default 24
|
|
30
|
+
minuteStep?: number; // default 5
|
|
31
|
+
secondStep?: number; // default 5
|
|
32
|
+
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
|
|
33
|
+
open?: boolean;
|
|
34
|
+
onOpenChange?: (open: boolean) => void;
|
|
35
|
+
showFooter?: boolean; // default true
|
|
36
|
+
cancelLabel?: string; // default 'Cancel'
|
|
37
|
+
applyLabel?: string; // default 'Apply'
|
|
38
|
+
clearLabel?: string; // default 'Clear'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const clamp = (n: number, min: number, max: number) =>
|
|
42
|
+
Math.max(min, Math.min(max, n));
|
|
43
|
+
const pad2 = (n: number) => String(n).padStart(2, '0');
|
|
44
|
+
|
|
45
|
+
export function TimePicker({
|
|
46
|
+
value,
|
|
47
|
+
onChange,
|
|
48
|
+
placeholder = 'Pick a time',
|
|
49
|
+
disabled,
|
|
50
|
+
precision = 'minute',
|
|
51
|
+
hourCycle = 24,
|
|
52
|
+
minuteStep = 5,
|
|
53
|
+
secondStep = 5,
|
|
54
|
+
className,
|
|
55
|
+
buttonVariant = 'outline',
|
|
56
|
+
...props
|
|
57
|
+
}: TimePickerProps) {
|
|
58
|
+
const [internalOpen, setInternalOpen] = React.useState(false);
|
|
59
|
+
const isOpen = typeof props.open === 'boolean' ? props.open : internalOpen;
|
|
60
|
+
const setOpen = (o: boolean) =>
|
|
61
|
+
props.onOpenChange ? props.onOpenChange(o) : setInternalOpen(o);
|
|
62
|
+
|
|
63
|
+
const [draft, setDraft] = React.useState<Date | null>(value ?? null);
|
|
64
|
+
|
|
65
|
+
React.useEffect(() => {
|
|
66
|
+
if (isOpen) setDraft(value ?? null);
|
|
67
|
+
}, [isOpen, value]);
|
|
68
|
+
|
|
69
|
+
const fmtLabel = (d: Date | null): string => {
|
|
70
|
+
if (!d) return placeholder;
|
|
71
|
+
const h = d.getHours();
|
|
72
|
+
const m = d.getMinutes();
|
|
73
|
+
const s = d.getSeconds();
|
|
74
|
+
if (hourCycle === 12) {
|
|
75
|
+
const period = h >= 12 ? 'PM' : 'AM';
|
|
76
|
+
const hour12 = h % 12 === 0 ? 12 : h % 12;
|
|
77
|
+
if (precision === 'hour') return `${hour12} ${period}`;
|
|
78
|
+
if (precision === 'minute') return `${hour12}:${pad2(m)} ${period}`;
|
|
79
|
+
return `${hour12}:${pad2(m)}:${pad2(s)} ${period}`;
|
|
80
|
+
}
|
|
81
|
+
if (precision === 'hour') return `${pad2(h)}`;
|
|
82
|
+
if (precision === 'minute') return `${pad2(h)}:${pad2(m)}`;
|
|
83
|
+
return `${pad2(h)}:${pad2(m)}:${pad2(s)}`;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const setDraftPart = (
|
|
87
|
+
part: 'hour' | 'minute' | 'second' | 'period',
|
|
88
|
+
val: number | 'AM' | 'PM'
|
|
89
|
+
) => {
|
|
90
|
+
setDraft((prev) => {
|
|
91
|
+
const base = prev
|
|
92
|
+
? new Date(prev)
|
|
93
|
+
: (() => {
|
|
94
|
+
const n = new Date();
|
|
95
|
+
return new Date(
|
|
96
|
+
n.getFullYear(),
|
|
97
|
+
n.getMonth(),
|
|
98
|
+
n.getDate(),
|
|
99
|
+
0,
|
|
100
|
+
0,
|
|
101
|
+
0,
|
|
102
|
+
0
|
|
103
|
+
);
|
|
104
|
+
})();
|
|
105
|
+
if (part === 'hour') {
|
|
106
|
+
let h = Number(val);
|
|
107
|
+
if (hourCycle === 12) {
|
|
108
|
+
const isPM = base.getHours() >= 12;
|
|
109
|
+
h = h % 12;
|
|
110
|
+
base.setHours(isPM ? (h === 12 ? 12 : h + 12) : h === 12 ? 0 : h);
|
|
111
|
+
} else {
|
|
112
|
+
base.setHours(clamp(h, 0, 23));
|
|
113
|
+
}
|
|
114
|
+
} else if (part === 'minute') {
|
|
115
|
+
base.setMinutes(clamp(Number(val), 0, 59));
|
|
116
|
+
} else if (part === 'second') {
|
|
117
|
+
base.setSeconds(clamp(Number(val), 0, 59));
|
|
118
|
+
} else if (part === 'period' && (val === 'AM' || val === 'PM')) {
|
|
119
|
+
const curH = base.getHours();
|
|
120
|
+
const isAMNow = curH < 12;
|
|
121
|
+
if (val === 'AM' && !isAMNow) base.setHours(curH - 12);
|
|
122
|
+
if (val === 'PM' && isAMNow) base.setHours(curH + 12);
|
|
123
|
+
}
|
|
124
|
+
base.setMilliseconds(0);
|
|
125
|
+
return new Date(base);
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const hours = React.useMemo(() => {
|
|
130
|
+
return hourCycle === 12
|
|
131
|
+
? Array.from({ length: 12 }, (_, i) => i + 1)
|
|
132
|
+
: Array.from({ length: 24 }, (_, i) => i);
|
|
133
|
+
}, [hourCycle]);
|
|
134
|
+
const minutes = React.useMemo(
|
|
135
|
+
() =>
|
|
136
|
+
Array.from(
|
|
137
|
+
{ length: Math.ceil(60 / minuteStep) },
|
|
138
|
+
(_, i) => i * minuteStep
|
|
139
|
+
),
|
|
140
|
+
[minuteStep]
|
|
141
|
+
);
|
|
142
|
+
const seconds = React.useMemo(
|
|
143
|
+
() =>
|
|
144
|
+
Array.from(
|
|
145
|
+
{ length: Math.ceil(60 / secondStep) },
|
|
146
|
+
(_, i) => i * secondStep
|
|
147
|
+
),
|
|
148
|
+
[secondStep]
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const selectedHour = React.useMemo(() => {
|
|
152
|
+
if (!draft) return hourCycle === 12 ? 12 : 0;
|
|
153
|
+
const h = draft.getHours();
|
|
154
|
+
return hourCycle === 12 ? (h % 12 === 0 ? 12 : h % 12) : h;
|
|
155
|
+
}, [draft, hourCycle]);
|
|
156
|
+
const selectedMinute = draft?.getMinutes() ?? 0;
|
|
157
|
+
const selectedSecond = draft?.getSeconds() ?? 0;
|
|
158
|
+
const selectedPeriod: 'AM' | 'PM' =
|
|
159
|
+
draft && draft.getHours() >= 12 ? 'PM' : 'AM';
|
|
160
|
+
|
|
161
|
+
const label = fmtLabel(value ?? null);
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<div className={cn('w-fit', className)} {...props}>
|
|
165
|
+
<Popover open={isOpen} onOpenChange={setOpen}>
|
|
166
|
+
<PopoverTrigger asChild>
|
|
167
|
+
<Button
|
|
168
|
+
type="button"
|
|
169
|
+
disabled={disabled}
|
|
170
|
+
variant={buttonVariant}
|
|
171
|
+
className={cn(
|
|
172
|
+
'w-[240px] justify-start text-left font-normal',
|
|
173
|
+
!value && 'text-muted-foreground'
|
|
174
|
+
)}
|
|
175
|
+
>
|
|
176
|
+
<Clock className="mr-2 h-4 w-4" />
|
|
177
|
+
{label}
|
|
178
|
+
</Button>
|
|
179
|
+
</PopoverTrigger>
|
|
180
|
+
<PopoverContent className="p-3 w-auto" align="start">
|
|
181
|
+
<div className="flex items-end gap-2">
|
|
182
|
+
<div className="w-24">
|
|
183
|
+
<div className="mb-1 block text-xs text-muted-foreground">Hour</div>
|
|
184
|
+
<Select
|
|
185
|
+
disabled={disabled}
|
|
186
|
+
value={String(selectedHour)}
|
|
187
|
+
onValueChange={(v) => setDraftPart('hour', Number(v))}
|
|
188
|
+
>
|
|
189
|
+
<SelectTrigger aria-label="Hour">
|
|
190
|
+
<SelectValue />
|
|
191
|
+
</SelectTrigger>
|
|
192
|
+
<SelectContent>
|
|
193
|
+
{hours.map((h) => (
|
|
194
|
+
<SelectItem key={h} value={String(h)}>
|
|
195
|
+
{hourCycle === 12 ? h : pad2(h)}
|
|
196
|
+
</SelectItem>
|
|
197
|
+
))}
|
|
198
|
+
</SelectContent>
|
|
199
|
+
</Select>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
{(precision === 'minute' || precision === 'second') && (
|
|
203
|
+
<div className="w-24">
|
|
204
|
+
<div className="mb-1 block text-xs text-muted-foreground">Minute</div>
|
|
205
|
+
<Select
|
|
206
|
+
disabled={disabled}
|
|
207
|
+
value={String(selectedMinute - (selectedMinute % minuteStep))}
|
|
208
|
+
onValueChange={(v) => setDraftPart('minute', Number(v))}
|
|
209
|
+
>
|
|
210
|
+
<SelectTrigger aria-label="Minute">
|
|
211
|
+
<SelectValue />
|
|
212
|
+
</SelectTrigger>
|
|
213
|
+
<SelectContent>
|
|
214
|
+
{minutes.map((m) => (
|
|
215
|
+
<SelectItem key={m} value={String(m)}>
|
|
216
|
+
{pad2(m)}
|
|
217
|
+
</SelectItem>
|
|
218
|
+
))}
|
|
219
|
+
</SelectContent>
|
|
220
|
+
</Select>
|
|
221
|
+
</div>
|
|
222
|
+
)}
|
|
223
|
+
|
|
224
|
+
{precision === 'second' && (
|
|
225
|
+
<div className="w-24">
|
|
226
|
+
<div className="mb-1 block text-xs text-muted-foreground">Second</div>
|
|
227
|
+
<Select
|
|
228
|
+
disabled={disabled}
|
|
229
|
+
value={String(selectedSecond - (selectedSecond % secondStep))}
|
|
230
|
+
onValueChange={(v) => setDraftPart('second', Number(v))}
|
|
231
|
+
>
|
|
232
|
+
<SelectTrigger aria-label="Second">
|
|
233
|
+
<SelectValue />
|
|
234
|
+
</SelectTrigger>
|
|
235
|
+
<SelectContent>
|
|
236
|
+
{seconds.map((s) => (
|
|
237
|
+
<SelectItem key={s} value={String(s)}>
|
|
238
|
+
{pad2(s)}
|
|
239
|
+
</SelectItem>
|
|
240
|
+
))}
|
|
241
|
+
</SelectContent>
|
|
242
|
+
</Select>
|
|
243
|
+
</div>
|
|
244
|
+
)}
|
|
245
|
+
|
|
246
|
+
{hourCycle === 12 && (
|
|
247
|
+
<div className="w-24">
|
|
248
|
+
<div className="mb-1 block text-xs text-muted-foreground">Period</div>
|
|
249
|
+
<Select
|
|
250
|
+
disabled={disabled}
|
|
251
|
+
value={selectedPeriod}
|
|
252
|
+
onValueChange={(v) =>
|
|
253
|
+
setDraftPart('period', v as 'AM' | 'PM')
|
|
254
|
+
}
|
|
255
|
+
>
|
|
256
|
+
<SelectTrigger aria-label="Period">
|
|
257
|
+
<SelectValue />
|
|
258
|
+
</SelectTrigger>
|
|
259
|
+
<SelectContent>
|
|
260
|
+
<SelectItem value="AM">AM</SelectItem>
|
|
261
|
+
<SelectItem value="PM">PM</SelectItem>
|
|
262
|
+
</SelectContent>
|
|
263
|
+
</Select>
|
|
264
|
+
</div>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
{(props.showFooter ?? true) && (
|
|
269
|
+
<div className="flex items-center justify-between gap-2 pt-3 mt-3 border-t">
|
|
270
|
+
<Button
|
|
271
|
+
type="button"
|
|
272
|
+
variant="outline"
|
|
273
|
+
size="sm"
|
|
274
|
+
onClick={() => setOpen(false)}
|
|
275
|
+
disabled={disabled}
|
|
276
|
+
>
|
|
277
|
+
{props.cancelLabel ?? 'Cancel'}
|
|
278
|
+
</Button>
|
|
279
|
+
<div className="flex gap-2">
|
|
280
|
+
<Button
|
|
281
|
+
type="button"
|
|
282
|
+
variant="ghost"
|
|
283
|
+
size="sm"
|
|
284
|
+
onClick={() => onChange?.(null)}
|
|
285
|
+
disabled={disabled}
|
|
286
|
+
>
|
|
287
|
+
{props.clearLabel ?? 'Clear'}
|
|
288
|
+
</Button>
|
|
289
|
+
<Button
|
|
290
|
+
type="button"
|
|
291
|
+
variant="default"
|
|
292
|
+
size="sm"
|
|
293
|
+
onClick={() => {
|
|
294
|
+
onChange?.(draft ?? null);
|
|
295
|
+
setOpen(false);
|
|
296
|
+
}}
|
|
297
|
+
>
|
|
298
|
+
{props.applyLabel ?? 'Apply'}
|
|
299
|
+
</Button>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
)}
|
|
303
|
+
</PopoverContent>
|
|
304
|
+
</Popover>
|
|
305
|
+
</div>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
TimePicker.displayName = 'TimePicker';
|
|
310
|
+
|
|
311
|
+
export default TimePicker;
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Clock } 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 {
|
|
9
|
+
Select,
|
|
10
|
+
SelectContent,
|
|
11
|
+
SelectItem,
|
|
12
|
+
SelectTrigger,
|
|
13
|
+
SelectValue,
|
|
14
|
+
} from '../../../shadcn/ui/select';
|
|
15
|
+
|
|
16
|
+
export type TimePrecision = 'hour' | 'minute' | 'second';
|
|
17
|
+
|
|
18
|
+
export interface TimeRangePickerValue {
|
|
19
|
+
from?: Date | null;
|
|
20
|
+
to?: Date | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TimeRangePickerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
|
24
|
+
value?: TimeRangePickerValue | null;
|
|
25
|
+
onChange?: (range: TimeRangePickerValue | null) => void;
|
|
26
|
+
placeholder?: string;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
precision?: TimePrecision; // default 'minute'
|
|
29
|
+
hourCycle?: 12 | 24; // default 24
|
|
30
|
+
minuteStep?: number; // default 5
|
|
31
|
+
secondStep?: number; // default 5
|
|
32
|
+
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
|
|
33
|
+
open?: boolean;
|
|
34
|
+
onOpenChange?: (open: boolean) => void;
|
|
35
|
+
showFooter?: boolean; // default true
|
|
36
|
+
cancelLabel?: string; // default 'Cancel'
|
|
37
|
+
applyLabel?: string; // default 'Apply'
|
|
38
|
+
clearLabel?: string; // default 'Clear'
|
|
39
|
+
format?: (from?: Date | null, to?: Date | null) => string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const pad2 = (n: number) => String(n).padStart(2, '0');
|
|
43
|
+
|
|
44
|
+
function TimeUnitSelector({
|
|
45
|
+
label,
|
|
46
|
+
hourCycle,
|
|
47
|
+
precision,
|
|
48
|
+
minuteStep,
|
|
49
|
+
secondStep,
|
|
50
|
+
disabled,
|
|
51
|
+
value,
|
|
52
|
+
onChange,
|
|
53
|
+
}: {
|
|
54
|
+
label: string;
|
|
55
|
+
hourCycle: 12 | 24;
|
|
56
|
+
precision: TimePrecision;
|
|
57
|
+
minuteStep: number;
|
|
58
|
+
secondStep: number;
|
|
59
|
+
disabled?: boolean;
|
|
60
|
+
value: Date | null | undefined;
|
|
61
|
+
onChange: (next: Date | null) => void;
|
|
62
|
+
}) {
|
|
63
|
+
const hours = React.useMemo(() => (hourCycle === 12 ? Array.from({ length: 12 }, (_, i) => i + 1) : Array.from({ length: 24 }, (_, i) => i)), [hourCycle]);
|
|
64
|
+
const minutes = React.useMemo(() => Array.from({ length: Math.ceil(60 / minuteStep) }, (_, i) => i * minuteStep), [minuteStep]);
|
|
65
|
+
const seconds = React.useMemo(() => Array.from({ length: Math.ceil(60 / secondStep) }, (_, i) => i * secondStep), [secondStep]);
|
|
66
|
+
const selectedHour = React.useMemo(() => {
|
|
67
|
+
if (!value) return hourCycle === 12 ? 12 : 0;
|
|
68
|
+
const h = value.getHours();
|
|
69
|
+
return hourCycle === 12 ? (h % 12 === 0 ? 12 : h % 12) : h;
|
|
70
|
+
}, [value, hourCycle]);
|
|
71
|
+
const selectedMinute = value?.getMinutes() ?? 0;
|
|
72
|
+
const selectedSecond = value?.getSeconds() ?? 0;
|
|
73
|
+
const selectedPeriod: 'AM' | 'PM' = value && value.getHours() >= 12 ? 'PM' : 'AM';
|
|
74
|
+
|
|
75
|
+
const setPart = (part: 'hour' | 'minute' | 'second' | 'period', v: number | 'AM' | 'PM') => {
|
|
76
|
+
const base = value
|
|
77
|
+
? new Date(value)
|
|
78
|
+
: (() => {
|
|
79
|
+
const n = new Date();
|
|
80
|
+
return new Date(n.getFullYear(), n.getMonth(), n.getDate(), 0, 0, 0, 0);
|
|
81
|
+
})();
|
|
82
|
+
if (part === 'hour') {
|
|
83
|
+
let h = Number(v);
|
|
84
|
+
if (hourCycle === 12) {
|
|
85
|
+
const isPM = base.getHours() >= 12;
|
|
86
|
+
h = h % 12;
|
|
87
|
+
base.setHours(isPM ? (h === 12 ? 12 : h + 12) : (h === 12 ? 0 : h));
|
|
88
|
+
} else {
|
|
89
|
+
base.setHours(h);
|
|
90
|
+
}
|
|
91
|
+
} else if (part === 'minute') {
|
|
92
|
+
base.setMinutes(Number(v));
|
|
93
|
+
} else if (part === 'second') {
|
|
94
|
+
base.setSeconds(Number(v));
|
|
95
|
+
} else if (part === 'period' && (v === 'AM' || v === 'PM')) {
|
|
96
|
+
const curH = base.getHours();
|
|
97
|
+
const isAMNow = curH < 12;
|
|
98
|
+
if (v === 'AM' && !isAMNow) base.setHours(curH - 12);
|
|
99
|
+
if (v === 'PM' && isAMNow) base.setHours(curH + 12);
|
|
100
|
+
}
|
|
101
|
+
base.setMilliseconds(0);
|
|
102
|
+
onChange(base);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div className="space-y-1">
|
|
107
|
+
<div className="text-xs text-muted-foreground">{label}</div>
|
|
108
|
+
<div className="flex items-end gap-2">
|
|
109
|
+
<div className="w-24">
|
|
110
|
+
<div className="mb-1 block text-xs text-muted-foreground">Hour</div>
|
|
111
|
+
<Select disabled={disabled} value={String(selectedHour)} onValueChange={(v) => setPart('hour', Number(v))}>
|
|
112
|
+
<SelectTrigger aria-label={`${label} hour`}>
|
|
113
|
+
<SelectValue />
|
|
114
|
+
</SelectTrigger>
|
|
115
|
+
<SelectContent>
|
|
116
|
+
{hours.map((h) => (
|
|
117
|
+
<SelectItem key={h} value={String(h)}>{hourCycle === 12 ? h : pad2(h)}</SelectItem>
|
|
118
|
+
))}
|
|
119
|
+
</SelectContent>
|
|
120
|
+
</Select>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{(precision === 'minute' || precision === 'second') && (
|
|
124
|
+
<div className="w-24">
|
|
125
|
+
<div className="mb-1 block text-xs text-muted-foreground">Minute</div>
|
|
126
|
+
<Select disabled={disabled} value={String(selectedMinute - (selectedMinute % minuteStep))} onValueChange={(v) => setPart('minute', Number(v))}>
|
|
127
|
+
<SelectTrigger aria-label={`${label} minute`}>
|
|
128
|
+
<SelectValue />
|
|
129
|
+
</SelectTrigger>
|
|
130
|
+
<SelectContent>
|
|
131
|
+
{minutes.map((m) => (
|
|
132
|
+
<SelectItem key={m} value={String(m)}>{pad2(m)}</SelectItem>
|
|
133
|
+
))}
|
|
134
|
+
</SelectContent>
|
|
135
|
+
</Select>
|
|
136
|
+
</div>
|
|
137
|
+
)}
|
|
138
|
+
|
|
139
|
+
{precision === 'second' && (
|
|
140
|
+
<div className="w-24">
|
|
141
|
+
<div className="mb-1 block text-xs text-muted-foreground">Second</div>
|
|
142
|
+
<Select disabled={disabled} value={String(selectedSecond - (selectedSecond % secondStep))} onValueChange={(v) => setPart('second', Number(v))}>
|
|
143
|
+
<SelectTrigger aria-label={`${label} second`}>
|
|
144
|
+
<SelectValue />
|
|
145
|
+
</SelectTrigger>
|
|
146
|
+
<SelectContent>
|
|
147
|
+
{seconds.map((s) => (
|
|
148
|
+
<SelectItem key={s} value={String(s)}>{pad2(s)}</SelectItem>
|
|
149
|
+
))}
|
|
150
|
+
</SelectContent>
|
|
151
|
+
</Select>
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
|
|
155
|
+
{hourCycle === 12 && (
|
|
156
|
+
<div className="w-24">
|
|
157
|
+
<div className="mb-1 block text-xs text-muted-foreground">Period</div>
|
|
158
|
+
<Select disabled={disabled} value={selectedPeriod} onValueChange={(v) => setPart('period', v as 'AM' | 'PM')}>
|
|
159
|
+
<SelectTrigger aria-label={`${label} period`}>
|
|
160
|
+
<SelectValue />
|
|
161
|
+
</SelectTrigger>
|
|
162
|
+
<SelectContent>
|
|
163
|
+
<SelectItem value="AM">AM</SelectItem>
|
|
164
|
+
<SelectItem value="PM">PM</SelectItem>
|
|
165
|
+
</SelectContent>
|
|
166
|
+
</Select>
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function TimeRangePicker({
|
|
175
|
+
value,
|
|
176
|
+
onChange,
|
|
177
|
+
placeholder = 'Pick a time range',
|
|
178
|
+
disabled,
|
|
179
|
+
precision = 'minute',
|
|
180
|
+
hourCycle = 24,
|
|
181
|
+
minuteStep = 5,
|
|
182
|
+
secondStep = 5,
|
|
183
|
+
className,
|
|
184
|
+
buttonVariant = 'outline',
|
|
185
|
+
format,
|
|
186
|
+
...props
|
|
187
|
+
}: TimeRangePickerProps) {
|
|
188
|
+
const [internalOpen, setInternalOpen] = React.useState(false);
|
|
189
|
+
const isOpen = typeof props.open === 'boolean' ? props.open : internalOpen;
|
|
190
|
+
const setOpen = (o: boolean) => (props.onOpenChange ? props.onOpenChange(o) : setInternalOpen(o));
|
|
191
|
+
const [draft, setDraft] = React.useState<TimeRangePickerValue | null>(value ?? null);
|
|
192
|
+
|
|
193
|
+
React.useEffect(() => {
|
|
194
|
+
if (isOpen) setDraft(value ?? null);
|
|
195
|
+
}, [isOpen, value]);
|
|
196
|
+
|
|
197
|
+
const label = React.useMemo(() => {
|
|
198
|
+
const f = draft?.from ?? value?.from ?? null;
|
|
199
|
+
const t = draft?.to ?? value?.to ?? null;
|
|
200
|
+
if (format) return format(f ?? null, t ?? null);
|
|
201
|
+
const fs = f ? (hourCycle === 12
|
|
202
|
+
? `${((f.getHours() % 12) || 12)}:${pad2(f.getMinutes())}${precision === 'second' ? `:${pad2(f.getSeconds())}` : ''} ${f.getHours() >= 12 ? 'PM' : 'AM'}`
|
|
203
|
+
: `${pad2(f.getHours())}:${pad2(f.getMinutes())}${precision === 'second' ? `:${pad2(f.getSeconds())}` : ''}`) : null;
|
|
204
|
+
const ts = t ? (hourCycle === 12
|
|
205
|
+
? `${((t.getHours() % 12) || 12)}:${pad2(t.getMinutes())}${precision === 'second' ? `:${pad2(t.getSeconds())}` : ''} ${t.getHours() >= 12 ? 'PM' : 'AM'}`
|
|
206
|
+
: `${pad2(t.getHours())}:${pad2(t.getMinutes())}${precision === 'second' ? `:${pad2(t.getSeconds())}` : ''}`) : null;
|
|
207
|
+
return fs && ts ? `${fs} – ${ts}` : placeholder;
|
|
208
|
+
}, [draft, value, format, hourCycle, precision, placeholder]);
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<div className={cn('w-fit', className)} {...props}>
|
|
212
|
+
<Popover open={isOpen} onOpenChange={setOpen}>
|
|
213
|
+
<PopoverTrigger asChild>
|
|
214
|
+
<Button
|
|
215
|
+
type="button"
|
|
216
|
+
disabled={disabled}
|
|
217
|
+
variant={buttonVariant}
|
|
218
|
+
className={cn('w-[280px] justify-start text-left font-normal', !value && 'text-muted-foreground')}
|
|
219
|
+
>
|
|
220
|
+
<Clock className="mr-2 h-4 w-4" />
|
|
221
|
+
{label}
|
|
222
|
+
</Button>
|
|
223
|
+
</PopoverTrigger>
|
|
224
|
+
<PopoverContent className="p-3 w-auto" align="start">
|
|
225
|
+
<div className="space-y-4">
|
|
226
|
+
<TimeUnitSelector
|
|
227
|
+
label="From"
|
|
228
|
+
value={draft?.from ?? null}
|
|
229
|
+
onChange={(d) => setDraft((prev) => ({ ...(prev ?? {}), from: d }))}
|
|
230
|
+
hourCycle={hourCycle}
|
|
231
|
+
precision={precision}
|
|
232
|
+
minuteStep={minuteStep}
|
|
233
|
+
secondStep={secondStep}
|
|
234
|
+
disabled={disabled}
|
|
235
|
+
/>
|
|
236
|
+
<TimeUnitSelector
|
|
237
|
+
label="To"
|
|
238
|
+
value={draft?.to ?? null}
|
|
239
|
+
onChange={(d) => setDraft((prev) => ({ ...(prev ?? {}), to: d }))}
|
|
240
|
+
hourCycle={hourCycle}
|
|
241
|
+
precision={precision}
|
|
242
|
+
minuteStep={minuteStep}
|
|
243
|
+
secondStep={secondStep}
|
|
244
|
+
disabled={disabled}
|
|
245
|
+
/>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
{(props.showFooter ?? true) && (
|
|
249
|
+
<div className="flex items-center justify-between gap-2 pt-3 mt-3 border-t">
|
|
250
|
+
<Button
|
|
251
|
+
type="button"
|
|
252
|
+
variant="outline"
|
|
253
|
+
size="sm"
|
|
254
|
+
onClick={() => setOpen(false)}
|
|
255
|
+
disabled={disabled}
|
|
256
|
+
>
|
|
257
|
+
{props.cancelLabel ?? 'Cancel'}
|
|
258
|
+
</Button>
|
|
259
|
+
<div className="flex gap-2">
|
|
260
|
+
<Button
|
|
261
|
+
type="button"
|
|
262
|
+
variant="ghost"
|
|
263
|
+
size="sm"
|
|
264
|
+
onClick={() => onChange?.(null)}
|
|
265
|
+
disabled={disabled}
|
|
266
|
+
>
|
|
267
|
+
{props.clearLabel ?? 'Clear'}
|
|
268
|
+
</Button>
|
|
269
|
+
<Button
|
|
270
|
+
type="button"
|
|
271
|
+
variant="default"
|
|
272
|
+
size="sm"
|
|
273
|
+
onClick={() => {
|
|
274
|
+
onChange?.(draft ?? null);
|
|
275
|
+
setOpen(false);
|
|
276
|
+
}}
|
|
277
|
+
>
|
|
278
|
+
{props.applyLabel ?? 'Apply'}
|
|
279
|
+
</Button>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
)}
|
|
283
|
+
</PopoverContent>
|
|
284
|
+
</Popover>
|
|
285
|
+
</div>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
TimeRangePicker.displayName = 'TimeRangePicker';
|
|
290
|
+
|
|
291
|
+
export default TimeRangePicker;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react'
|
|
2
|
+
import { FormBuilder, type FormBuilderProps } from '../../../kit/builder/form/components/FormBuilder'
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof FormBuilder> = {
|
|
5
|
+
title: 'Kit/Builder/Form (DateTime)',
|
|
6
|
+
component: FormBuilder,
|
|
7
|
+
parameters: {
|
|
8
|
+
controls: { expanded: true },
|
|
9
|
+
backgrounds: { disable: true },
|
|
10
|
+
},
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default meta
|
|
14
|
+
|
|
15
|
+
type Story = StoryObj<typeof FormBuilder>
|
|
16
|
+
|
|
17
|
+
export const DateTimeFields: Story = {
|
|
18
|
+
name: 'Date/Time fields',
|
|
19
|
+
args: {
|
|
20
|
+
sections: [
|
|
21
|
+
{
|
|
22
|
+
title: 'Date & Time',
|
|
23
|
+
description: 'Examples of DateTimePicker, DateTimeRangePicker and TimeRangePicker fields',
|
|
24
|
+
variant: 'card',
|
|
25
|
+
layout: 'grid',
|
|
26
|
+
grid: { cols: 1, mdCols: 2, gap: 'gap-4' },
|
|
27
|
+
fields: [
|
|
28
|
+
{
|
|
29
|
+
name: 'dt',
|
|
30
|
+
label: 'Date & Time',
|
|
31
|
+
type: 'date_time',
|
|
32
|
+
timePrecision: 'minute',
|
|
33
|
+
hourCycle: 24,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'dtRange',
|
|
37
|
+
label: 'Date & Time Range',
|
|
38
|
+
type: 'date_time_range',
|
|
39
|
+
numberOfMonths: 2,
|
|
40
|
+
timePrecision: 'minute',
|
|
41
|
+
hourCycle: 24,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'timeRange',
|
|
45
|
+
label: 'Time Range',
|
|
46
|
+
type: 'time_range',
|
|
47
|
+
timePrecision: 'minute',
|
|
48
|
+
hourCycle: 24,
|
|
49
|
+
minuteStep: 5,
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
onSubmit: (data: unknown) => {
|
|
55
|
+
// Showing output in console for demo
|
|
56
|
+
// eslint-disable-next-line no-console
|
|
57
|
+
console.log('Submit (date/time):', data)
|
|
58
|
+
},
|
|
59
|
+
showActions: true,
|
|
60
|
+
} satisfies Partial<FormBuilderProps>,
|
|
61
|
+
render: (args) => (
|
|
62
|
+
<div className="max-w-5xl mx-auto p-6">
|
|
63
|
+
<FormBuilder {...(args as FormBuilderProps)} />
|
|
64
|
+
</div>
|
|
65
|
+
),
|
|
66
|
+
}
|