@checkstack/ui 0.3.0 → 0.4.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.
@@ -1,18 +1,36 @@
1
- import React from "react";
2
- import { format } from "date-fns";
3
- import { Input } from "./Input";
1
+ import React, { useState, useEffect, useCallback } from "react";
2
+ import * as Popover from "@radix-ui/react-popover";
3
+ import { DayPicker } from "react-day-picker";
4
+ import { Button } from "./Button";
4
5
  import { Calendar, Clock } from "lucide-react";
6
+ import "react-day-picker/style.css";
5
7
 
6
8
  export interface DateTimePickerProps {
7
- value: Date;
8
- onChange: (date: Date) => void;
9
+ value: Date | undefined;
10
+ onChange: (date: Date | undefined) => void;
9
11
  minDate?: Date;
10
12
  maxDate?: Date;
11
13
  className?: string;
12
14
  }
13
15
 
16
+ interface FieldState {
17
+ day: string;
18
+ month: string;
19
+ year: string;
20
+ hour: string;
21
+ minute: string;
22
+ }
23
+
24
+ const padZero = (value: number, length: number = 2): string =>
25
+ String(value).padStart(length, "0");
26
+
27
+ // Only allow numeric characters
28
+ const filterNumeric = (value: string): string => {
29
+ return value.replaceAll(/[^0-9]/g, "");
30
+ };
31
+
14
32
  /**
15
- * Combined date and time picker component
33
+ * Combined date and time picker component with independent fields and calendar popup
16
34
  */
17
35
  export const DateTimePicker: React.FC<DateTimePickerProps> = ({
18
36
  value,
@@ -21,44 +39,319 @@ export const DateTimePicker: React.FC<DateTimePickerProps> = ({
21
39
  maxDate,
22
40
  className,
23
41
  }) => {
24
- const dateString = format(value, "yyyy-MM-dd");
25
- const timeString = format(value, "HH:mm");
26
-
27
- const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
28
- const [year, month, day] = e.target.value.split("-").map(Number);
29
- const newDate = new Date(value);
30
- newDate.setFullYear(year, month - 1, day);
31
- onChange(newDate);
42
+ const isValidDate = value instanceof Date && !Number.isNaN(value.getTime());
43
+
44
+ // Track if the change came from internal editing
45
+ const isInternalChange = React.useRef(false);
46
+
47
+ // Initialize state from value
48
+ const [fields, setFields] = useState<FieldState>(() => ({
49
+ day: isValidDate ? padZero(value.getDate()) : "",
50
+ month: isValidDate ? padZero(value.getMonth() + 1) : "",
51
+ year: isValidDate ? String(value.getFullYear()) : "",
52
+ hour: isValidDate ? padZero(value.getHours()) : "",
53
+ minute: isValidDate ? padZero(value.getMinutes()) : "",
54
+ }));
55
+
56
+ const [calendarOpen, setCalendarOpen] = useState(false);
57
+
58
+ // Sync state when value prop changes from outside (not from internal editing)
59
+ useEffect(() => {
60
+ if (isInternalChange.current) {
61
+ isInternalChange.current = false;
62
+ return;
63
+ }
64
+ if (isValidDate) {
65
+ setFields({
66
+ day: padZero(value.getDate()),
67
+ month: padZero(value.getMonth() + 1),
68
+ year: String(value.getFullYear()),
69
+ hour: padZero(value.getHours()),
70
+ minute: padZero(value.getMinutes()),
71
+ });
72
+ }
73
+ }, [value, isValidDate]);
74
+
75
+ // Build date from fields or return undefined if any field is invalid
76
+ const buildDate = useCallback((f: FieldState): Date | undefined => {
77
+ const day = Number.parseInt(f.day, 10);
78
+ const month = Number.parseInt(f.month, 10);
79
+ const year = Number.parseInt(f.year, 10);
80
+ const hour = Number.parseInt(f.hour, 10);
81
+ const minute = Number.parseInt(f.minute, 10);
82
+
83
+ if (
84
+ Number.isNaN(day) ||
85
+ Number.isNaN(month) ||
86
+ Number.isNaN(year) ||
87
+ Number.isNaN(hour) ||
88
+ Number.isNaN(minute)
89
+ ) {
90
+ return undefined;
91
+ }
92
+
93
+ // Validate ranges
94
+ if (
95
+ day < 1 ||
96
+ day > 31 ||
97
+ month < 1 ||
98
+ month > 12 ||
99
+ year < 1 ||
100
+ hour < 0 ||
101
+ hour > 23 ||
102
+ minute < 0 ||
103
+ minute > 59
104
+ ) {
105
+ return undefined;
106
+ }
107
+
108
+ // Create date and check if it's valid (handles Feb 30, etc.)
109
+ const date = new Date(year, month - 1, day, hour, minute, 0, 0);
110
+ // If the date rolled over to the next month, it was invalid
111
+ if (date.getMonth() !== month - 1 || date.getDate() !== day) {
112
+ return undefined;
113
+ }
114
+
115
+ return date;
116
+ }, []);
117
+
118
+ const handleFieldChange = (field: keyof FieldState, inputValue: string) => {
119
+ const filtered = filterNumeric(inputValue);
120
+ const newFields = { ...fields, [field]: filtered };
121
+ setFields(newFields);
122
+ isInternalChange.current = true;
123
+ onChange(buildDate(newFields));
32
124
  };
33
125
 
34
- const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
35
- const [hours, minutes] = e.target.value.split(":").map(Number);
36
- const newDate = new Date(value);
37
- newDate.setHours(hours, minutes);
38
- onChange(newDate);
126
+ // Helper to get days in a month (respects leap years)
127
+ const getDaysInMonth = (month: number, year: number): number => {
128
+ // Use Date to calculate (day 0 of next month = last day of current month)
129
+ return new Date(year, month, 0).getDate();
130
+ };
131
+
132
+ // Validate and clamp value on blur
133
+ const handleFieldBlur = (field: keyof FieldState) => {
134
+ const value = Number.parseInt(fields[field], 10);
135
+ if (Number.isNaN(value)) return;
136
+
137
+ let clamped: number;
138
+ let formatted: string;
139
+
140
+ switch (field) {
141
+ case "day": {
142
+ const month = Number.parseInt(fields.month, 10);
143
+ const year = Number.parseInt(fields.year, 10);
144
+ // Use 31 as fallback if month/year aren't valid yet
145
+ const maxDay =
146
+ !Number.isNaN(month) && !Number.isNaN(year)
147
+ ? getDaysInMonth(month, year)
148
+ : 31;
149
+ clamped = Math.min(Math.max(value, 1), maxDay);
150
+ formatted = padZero(clamped);
151
+ break;
152
+ }
153
+ case "month": {
154
+ clamped = Math.min(Math.max(value, 1), 12);
155
+ formatted = padZero(clamped);
156
+ // Also re-clamp day if month changes and day is now out of range
157
+ {
158
+ const day = Number.parseInt(fields.day, 10);
159
+ const year = Number.parseInt(fields.year, 10);
160
+ if (!Number.isNaN(day) && !Number.isNaN(year)) {
161
+ const maxDay = getDaysInMonth(clamped, year);
162
+ if (day > maxDay) {
163
+ const newFields = {
164
+ ...fields,
165
+ month: padZero(clamped),
166
+ day: padZero(maxDay),
167
+ };
168
+ setFields(newFields);
169
+ isInternalChange.current = true;
170
+ onChange(buildDate(newFields));
171
+ return;
172
+ }
173
+ }
174
+ }
175
+ break;
176
+ }
177
+ case "year": {
178
+ clamped = Math.max(value, 1);
179
+ formatted = String(clamped);
180
+ // Also re-clamp day if year changes (for leap year Feb 29 -> 28)
181
+ {
182
+ const day = Number.parseInt(fields.day, 10);
183
+ const month = Number.parseInt(fields.month, 10);
184
+ if (!Number.isNaN(day) && !Number.isNaN(month)) {
185
+ const maxDay = getDaysInMonth(month, clamped);
186
+ if (day > maxDay) {
187
+ const newFields = {
188
+ ...fields,
189
+ year: String(clamped),
190
+ day: padZero(maxDay),
191
+ };
192
+ setFields(newFields);
193
+ isInternalChange.current = true;
194
+ onChange(buildDate(newFields));
195
+ return;
196
+ }
197
+ }
198
+ }
199
+ break;
200
+ }
201
+ case "hour": {
202
+ clamped = Math.min(Math.max(value, 0), 23);
203
+ formatted = padZero(clamped);
204
+ break;
205
+ }
206
+ case "minute": {
207
+ clamped = Math.min(Math.max(value, 0), 59);
208
+ formatted = padZero(clamped);
209
+ break;
210
+ }
211
+ default: {
212
+ return;
213
+ }
214
+ }
215
+
216
+ if (fields[field] !== formatted) {
217
+ const newFields = { ...fields, [field]: formatted };
218
+ setFields(newFields);
219
+ isInternalChange.current = true;
220
+ onChange(buildDate(newFields));
221
+ }
222
+ };
223
+
224
+ const handleCalendarSelect = (date: Date | undefined) => {
225
+ if (date) {
226
+ const newFields = {
227
+ ...fields,
228
+ day: padZero(date.getDate()),
229
+ month: padZero(date.getMonth() + 1),
230
+ year: String(date.getFullYear()),
231
+ };
232
+ setFields(newFields);
233
+ onChange(buildDate(newFields));
234
+ setCalendarOpen(false);
235
+ }
236
+ };
237
+
238
+ // Get selected date for calendar (only if date fields are valid)
239
+ const getSelectedDate = (): Date | undefined => {
240
+ const day = Number.parseInt(fields.day, 10);
241
+ const month = Number.parseInt(fields.month, 10);
242
+ const year = Number.parseInt(fields.year, 10);
243
+ if (Number.isNaN(day) || Number.isNaN(month) || Number.isNaN(year)) {
244
+ return undefined;
245
+ }
246
+ return new Date(year, month - 1, day);
39
247
  };
40
248
 
41
249
  return (
42
- <div className={`flex gap-2 ${className ?? ""}`}>
43
- <div className="relative flex-1">
44
- <Input
45
- type="date"
46
- value={dateString}
47
- onChange={handleDateChange}
48
- min={minDate ? format(minDate, "yyyy-MM-dd") : undefined}
49
- max={maxDate ? format(maxDate, "yyyy-MM-dd") : undefined}
50
- className="pl-9"
51
- />
52
- <Calendar className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
53
- </div>
54
- <div className="relative w-28">
55
- <Input
56
- type="time"
57
- value={timeString}
58
- onChange={handleTimeChange}
59
- className="pl-9"
60
- />
61
- <Clock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
250
+ <div className={`flex items-center ${className ?? ""}`}>
251
+ {/* Combined date and time container */}
252
+ <div className="flex items-center border rounded-lg bg-background overflow-hidden">
253
+ {/* Date section with calendar popup */}
254
+ <Popover.Root open={calendarOpen} onOpenChange={setCalendarOpen}>
255
+ <Popover.Trigger asChild>
256
+ <Button
257
+ variant="ghost"
258
+ size="icon"
259
+ className="h-9 w-9 rounded-none border-r"
260
+ type="button"
261
+ >
262
+ <Calendar className="h-4 w-4" />
263
+ </Button>
264
+ </Popover.Trigger>
265
+
266
+ <div className="flex items-center px-2">
267
+ <input
268
+ type="text"
269
+ inputMode="numeric"
270
+ value={fields.day}
271
+ onChange={(e) => handleFieldChange("day", e.target.value)}
272
+ onBlur={() => handleFieldBlur("day")}
273
+ placeholder="DD"
274
+ className="w-7 text-center bg-transparent border-none outline-none text-sm font-mono"
275
+ maxLength={2}
276
+ />
277
+ <span className="text-muted-foreground">/</span>
278
+ <input
279
+ type="text"
280
+ inputMode="numeric"
281
+ value={fields.month}
282
+ onChange={(e) => handleFieldChange("month", e.target.value)}
283
+ onBlur={() => handleFieldBlur("month")}
284
+ placeholder="MM"
285
+ className="w-7 text-center bg-transparent border-none outline-none text-sm font-mono"
286
+ maxLength={2}
287
+ />
288
+ <span className="text-muted-foreground">/</span>
289
+ <input
290
+ type="text"
291
+ inputMode="numeric"
292
+ value={fields.year}
293
+ onChange={(e) => handleFieldChange("year", e.target.value)}
294
+ onBlur={() => handleFieldBlur("year")}
295
+ placeholder="YYYY"
296
+ className="w-11 text-center bg-transparent border-none outline-none text-sm font-mono"
297
+ maxLength={4}
298
+ />
299
+ </div>
300
+
301
+ <Popover.Portal>
302
+ <Popover.Content
303
+ className="z-50 rounded-md border bg-popover p-3 shadow-md"
304
+ sideOffset={5}
305
+ align="start"
306
+ >
307
+ <DayPicker
308
+ mode="single"
309
+ selected={getSelectedDate()}
310
+ onSelect={handleCalendarSelect}
311
+ fromDate={minDate}
312
+ toDate={maxDate}
313
+ classNames={{
314
+ root: "rdp-root",
315
+ day: "rdp-day hover:bg-accent rounded-md",
316
+ selected: "bg-primary text-primary-foreground",
317
+ today: "font-bold text-primary",
318
+ chevron: "fill-foreground",
319
+ button_previous: "hover:bg-accent rounded-md p-1",
320
+ button_next: "hover:bg-accent rounded-md p-1",
321
+ }}
322
+ />
323
+ </Popover.Content>
324
+ </Popover.Portal>
325
+ </Popover.Root>
326
+
327
+ {/* Separator */}
328
+ <div className="w-px h-6 bg-border" />
329
+
330
+ {/* Time section */}
331
+ <div className="flex items-center px-2">
332
+ <Clock className="h-4 w-4 text-muted-foreground mr-2" />
333
+ <input
334
+ type="text"
335
+ inputMode="numeric"
336
+ value={fields.hour}
337
+ onChange={(e) => handleFieldChange("hour", e.target.value)}
338
+ onBlur={() => handleFieldBlur("hour")}
339
+ placeholder="HH"
340
+ className="w-6 text-center bg-transparent border-none outline-none text-sm font-mono"
341
+ maxLength={2}
342
+ />
343
+ <span className="text-muted-foreground">:</span>
344
+ <input
345
+ type="text"
346
+ inputMode="numeric"
347
+ value={fields.minute}
348
+ onChange={(e) => handleFieldChange("minute", e.target.value)}
349
+ onBlur={() => handleFieldBlur("minute")}
350
+ placeholder="MM"
351
+ className="w-6 text-center bg-transparent border-none outline-none text-sm font-mono"
352
+ maxLength={2}
353
+ />
354
+ </div>
62
355
  </div>
63
356
  </div>
64
357
  );
@@ -19,7 +19,7 @@ const DialogOverlay = React.forwardRef<
19
19
  ref={ref}
20
20
  className={cn(
21
21
  "fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
22
- className
22
+ className,
23
23
  )}
24
24
  {...props}
25
25
  />
@@ -27,7 +27,7 @@ const DialogOverlay = React.forwardRef<
27
27
  DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
28
28
 
29
29
  const dialogContentVariants = cva(
30
- "fixed left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-background text-foreground p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg max-h-[85vh] overflow-y-auto",
30
+ "fixed left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-background text-foreground p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg max-h-[85vh] overflow-y-auto overflow-x-visible",
31
31
  {
32
32
  variants: {
33
33
  size: {
@@ -41,11 +41,12 @@ const dialogContentVariants = cva(
41
41
  defaultVariants: {
42
42
  size: "default",
43
43
  },
44
- }
44
+ },
45
45
  );
46
46
 
47
47
  interface DialogContentProps
48
- extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,
48
+ extends
49
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,
49
50
  VariantProps<typeof dialogContentVariants> {}
50
51
 
51
52
  const DialogContent = React.forwardRef<
@@ -59,7 +60,8 @@ const DialogContent = React.forwardRef<
59
60
  className={cn(dialogContentVariants({ size }), className)}
60
61
  {...props}
61
62
  >
62
- {children}
63
+ {/* Wrapper with negative margin and positive padding to allow focus rings to extend */}
64
+ <div className="-mx-2 px-2">{children}</div>
63
65
  </DialogPrimitive.Content>
64
66
  </DialogPortal>
65
67
  ));
@@ -72,7 +74,7 @@ const DialogHeader = ({
72
74
  <div
73
75
  className={cn(
74
76
  "flex flex-col space-y-1.5 text-center sm:text-left",
75
- className
77
+ className,
76
78
  )}
77
79
  {...props}
78
80
  />
@@ -86,7 +88,7 @@ const DialogFooter = ({
86
88
  <div
87
89
  className={cn(
88
90
  "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
89
- className
91
+ className,
90
92
  )}
91
93
  {...props}
92
94
  />
@@ -101,7 +103,7 @@ const DialogTitle = React.forwardRef<
101
103
  ref={ref}
102
104
  className={cn(
103
105
  "text-lg font-semibold leading-none tracking-tight",
104
- className
106
+ className,
105
107
  )}
106
108
  {...props}
107
109
  />