pejay-ui 1.0.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.
@@ -0,0 +1,647 @@
1
+ import React, {
2
+ useState,
3
+ useMemo,
4
+ useRef,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ } from "react";
8
+ import { cn } from "@/utils/cn";
9
+ import {
10
+ Calendar as CalendarIcon,
11
+ ChevronLeft,
12
+ ChevronRight,
13
+ ChevronDown,
14
+ } from "lucide-react";
15
+ import { SelectInput } from "../select-dropdown/select-input";
16
+ const DateUtils = {};
17
+ import {
18
+ useFloating,
19
+ autoUpdate,
20
+ offset,
21
+ flip,
22
+ shift,
23
+ useClick,
24
+ useDismiss,
25
+ useRole,
26
+ useInteractions,
27
+ FloatingPortal,
28
+ FloatingFocusManager,
29
+ } from "@floating-ui/react";
30
+
31
+ interface DatePickerProps {
32
+ label?: string;
33
+ description?: string;
34
+ error?: string;
35
+ value?: Date;
36
+ onChange?: (date: Date) => void;
37
+ minYear?: number;
38
+ maxYear?: number;
39
+ placeholder?: string;
40
+ defaultToToday?: boolean;
41
+ formatStr?: string;
42
+ isTypeable?: boolean;
43
+ typeableFormat?: "dd/mm/yyyy" | "mm/dd/yyyy" | "yyyy-mm-dd";
44
+ disableBefore?: Date;
45
+ disableAfter?: Date;
46
+ variant?: "rounded" | "curved" | "square";
47
+ labelPlacement?: "top" | "left" | "right";
48
+ labelWidth?: string;
49
+ "labelAlign-X"?: "left" | "center" | "right";
50
+ "labelAlign-Y"?: "top" | "middle" | "bottom";
51
+ className?: string;
52
+ }
53
+
54
+ export const DatePicker = ({
55
+ label,
56
+ description,
57
+ error: errorProp,
58
+ value,
59
+ onChange,
60
+ minYear = 1900,
61
+ maxYear = 2100,
62
+ placeholder,
63
+ defaultToToday = false,
64
+ formatStr = "dd/mm/yyyy",
65
+ isTypeable = false,
66
+ typeableFormat = "dd/mm/yyyy",
67
+ disableBefore,
68
+ disableAfter,
69
+ variant = "curved",
70
+ labelPlacement = "top",
71
+ labelWidth = "w-32",
72
+ "labelAlign-X": labelAlignX,
73
+ "labelAlign-Y": labelAlignY = "middle",
74
+ className,
75
+ }: DatePickerProps) => {
76
+ const [isOpen, setIsOpen] = useState(false);
77
+ const [viewDate, setViewDate] = useState<Date>(value || new Date());
78
+ const [inputValue, setInputValue] = useState("");
79
+ const [isFocused, setIsFocused] = useState(false);
80
+ const [internalError, setInternalError] = useState(false);
81
+ const inputRef = useRef<HTMLInputElement>(null);
82
+ const [cursorPos, setCursorPos] = useState<number | null>(null);
83
+
84
+ /* Core Utilities from root.config */
85
+ const {
86
+ format: baseFormat = (d: Date) => d.toDateString(),
87
+ addMonths = (d: Date, n: number) =>
88
+ new Date(d.getFullYear(), d.getMonth() + n, 1),
89
+ subMonths = (d: Date, n: number) =>
90
+ new Date(d.getFullYear(), d.getMonth() - n, 1),
91
+ startOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1),
92
+ endOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth() + 1, 0),
93
+ startOfWeek = (d: Date) => {
94
+ const date = new Date(d);
95
+ const day = date.getDay();
96
+ const diff = date.getDate() - day;
97
+ return new Date(date.setDate(diff));
98
+ },
99
+ endOfWeek = (d: Date) => {
100
+ const date = new Date(d);
101
+ const day = date.getDay();
102
+ const diff = date.getDate() + (6 - day);
103
+ return new Date(date.setDate(diff));
104
+ },
105
+ eachDayOfInterval = ({ start, end }: { start: Date; end: Date }) => {
106
+ const days = [];
107
+ let current = new Date(start);
108
+ while (current <= end) {
109
+ days.push(new Date(current));
110
+ current.setDate(current.getDate() + 1);
111
+ }
112
+ return days;
113
+ },
114
+ isSameDay = (d1: Date, d2: Date) => d1.toDateString() === d2.toDateString(),
115
+ isToday = (d: Date) => d.toDateString() === new Date().toDateString(),
116
+ } = DateUtils as any;
117
+
118
+ const format = (d: Date, fmtStr: string) => {
119
+ const normalized = fmtStr.replace(/mm/g, "MM");
120
+ return baseFormat(d, normalized);
121
+ };
122
+
123
+ /* Local helper functions to satisfy TS and missing exports */
124
+ const isValidDate = (d: any): d is Date =>
125
+ d instanceof Date && !isNaN(d.getTime());
126
+
127
+ const parseDate = (str: string, fmt: string) => {
128
+ try {
129
+ const parts = str.split(fmt.includes("/") ? "/" : "-");
130
+ if (parts.length !== 3) return new Date("invalid");
131
+
132
+ let day, month, year;
133
+ if (fmt.startsWith("dd")) {
134
+ day = parts[0];
135
+ month = parts[1];
136
+ year = parts[2];
137
+ } else if (fmt.startsWith("MM")) {
138
+ month = parts[0];
139
+ day = parts[1];
140
+ year = parts[2];
141
+ } else {
142
+ year = parts[0];
143
+ month = parts[1];
144
+ day = parts[2];
145
+ }
146
+
147
+ return new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
148
+ } catch (e) {
149
+ return new Date("invalid");
150
+ }
151
+ };
152
+
153
+ const isBeforeDate = (d1: Date, d2: Date) => d1.getTime() < d2.getTime();
154
+ const isAfterDate = (d1: Date, d2: Date) => d1.getTime() > d2.getTime();
155
+
156
+ const { refs, floatingStyles, context } = useFloating({
157
+ open: !isTypeable && isOpen,
158
+ onOpenChange: setIsOpen,
159
+ middleware: [offset(10), flip(), shift()],
160
+ whileElementsMounted: autoUpdate,
161
+ });
162
+
163
+ const click = useClick(context, { enabled: !isTypeable });
164
+ const dismiss = useDismiss(context, { enabled: !isTypeable });
165
+ const role = useRole(context);
166
+ const { getReferenceProps, getFloatingProps } = useInteractions([
167
+ click,
168
+ dismiss,
169
+ role,
170
+ ]);
171
+
172
+ useEffect(() => {
173
+ if (value && isTypeable && !isFocused) {
174
+ setInputValue(format(value, typeableFormat));
175
+ setInternalError(false);
176
+ }
177
+ }, [value, isTypeable, typeableFormat, isFocused, format]);
178
+
179
+ const validatePart = (val: string, type: "day" | "month" | "year") => {
180
+ const num = parseInt(val);
181
+ if (isNaN(num)) return true;
182
+ if (type === "month" && (num < 1 || num > 12)) return false;
183
+ if (type === "day" && (num < 1 || num > 31)) return false;
184
+ if (type === "year" && val.length === 4 && (num < minYear || num > maxYear))
185
+ return false;
186
+ return true;
187
+ };
188
+
189
+ const isDateDisabled = (date: Date) => {
190
+ if (
191
+ disableBefore &&
192
+ isBeforeDate(date, disableBefore) &&
193
+ !isSameDay(date, disableBefore)
194
+ )
195
+ return true;
196
+ if (
197
+ disableAfter &&
198
+ isAfterDate(date, disableAfter) &&
199
+ !isSameDay(date, disableAfter)
200
+ )
201
+ return true;
202
+ return false;
203
+ };
204
+
205
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
206
+ let cPos = e.target.selectionStart || 0;
207
+ let nativeEvent = e.nativeEvent as InputEvent;
208
+ let isDeleting = nativeEvent.inputType === "deleteContentBackward";
209
+
210
+ let rawVal = e.target.value;
211
+ const rawBeforeCursor = rawVal.slice(0, cPos);
212
+ const valueCharsBefore = (rawBeforeCursor.match(/[0-9]/g) || []).length;
213
+
214
+ let digits = rawVal.replace(/\D/g, "");
215
+ let oldDigits = inputValue.replace(/\D/g, "");
216
+
217
+ if (isDeleting && oldDigits.length === digits.length && digits.length > 0) {
218
+ digits = digits.slice(0, -1);
219
+ cPos--;
220
+ }
221
+
222
+ const delimiter = typeableFormat.includes("/") ? "/" : "-";
223
+ const isISO = typeableFormat.startsWith("yyyy");
224
+
225
+ let masked = "";
226
+ let hasError = false;
227
+
228
+ if (isISO) {
229
+ const year = digits.substring(0, 4);
230
+ const month = digits.substring(4, 6);
231
+ const day = digits.substring(6, 8);
232
+ if (
233
+ !validatePart(year, "year") ||
234
+ !validatePart(month, "month") ||
235
+ !validatePart(day, "day")
236
+ )
237
+ hasError = true;
238
+ if (digits.length > 0) masked += year;
239
+ if (digits.length >= 5) masked += delimiter + month;
240
+ if (digits.length >= 7) masked += delimiter + day;
241
+ } else {
242
+ const first = digits.substring(0, 2);
243
+ const second = digits.substring(2, 4);
244
+ const year = digits.substring(4, 8);
245
+ const isMDY = typeableFormat.startsWith("MM");
246
+ if (!validatePart(first, isMDY ? "month" : "day")) hasError = true;
247
+ if (!validatePart(second, isMDY ? "day" : "month")) hasError = true;
248
+ if (!validatePart(year, "year")) hasError = true;
249
+ if (digits.length > 0) masked += first;
250
+ if (digits.length >= 3) masked += delimiter + second;
251
+ if (digits.length >= 5) masked += delimiter + year;
252
+ }
253
+
254
+ masked = masked.substring(0, 10);
255
+
256
+ let template = typeableFormat.replace(/[a-zA-Z]/g, "_");
257
+ let fullMask = "";
258
+ for (let i = 0; i < template.length; i++) {
259
+ if (masked[i]) fullMask += masked[i];
260
+ else fullMask += template[i];
261
+ }
262
+
263
+ let newCPos = 0;
264
+ let count = 0;
265
+ for (let i = 0; i < fullMask.length; i++) {
266
+ if (/[0-9]/.test(fullMask[i])) count++;
267
+ if (count === valueCharsBefore) {
268
+ newCPos = i + 1;
269
+ break;
270
+ }
271
+ }
272
+ if (valueCharsBefore === 0) newCPos = 0;
273
+ else if (count < valueCharsBefore) newCPos = fullMask.length;
274
+
275
+ if (!isDeleting) {
276
+ while (fullMask[newCPos] && /[^0-9_]/.test(fullMask[newCPos])) {
277
+ newCPos++;
278
+ }
279
+ } else {
280
+ while (
281
+ newCPos > 0 &&
282
+ fullMask[newCPos - 1] &&
283
+ /[^0-9_]/.test(fullMask[newCPos - 1])
284
+ ) {
285
+ newCPos--;
286
+ }
287
+ }
288
+
289
+ setCursorPos(newCPos);
290
+ setInputValue(masked);
291
+
292
+ if (masked.length === 10) {
293
+ try {
294
+ const parsed = parseDate(masked, typeableFormat);
295
+ if (isValidDate(parsed)) {
296
+ if (isDateDisabled(parsed)) hasError = true;
297
+ else onChange?.(parsed);
298
+ } else {
299
+ hasError = true;
300
+ }
301
+ } catch (e) {
302
+ hasError = true;
303
+ }
304
+ }
305
+
306
+ setInternalError(hasError);
307
+ };
308
+
309
+ const getFullMaskedValue = () => {
310
+ if (!isFocused && !inputValue) return "";
311
+ let current = inputValue;
312
+ const template = typeableFormat.replace(/[a-zA-Z]/g, "_");
313
+ let res = "";
314
+ for (let i = 0; i < template.length; i++) {
315
+ if (current[i]) res += current[i];
316
+ else res += template[i];
317
+ }
318
+ return res;
319
+ };
320
+
321
+ const handleFocus = () => {
322
+ setIsFocused(true);
323
+ const pos = inputValue.length;
324
+ setTimeout(() => {
325
+ inputRef.current?.setSelectionRange(pos, pos);
326
+ }, 0);
327
+ };
328
+
329
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
330
+ if (e.ctrlKey || e.metaKey || e.altKey || e.key.length > 1) return;
331
+ if (!/[0-9 \-:/]/.test(e.key.toUpperCase())) {
332
+ e.preventDefault();
333
+ }
334
+ };
335
+
336
+ React.useLayoutEffect(() => {
337
+ if (isFocused && inputRef.current && cursorPos !== null) {
338
+ inputRef.current.setSelectionRange(cursorPos, cursorPos);
339
+ }
340
+ }, [inputValue, isFocused, cursorPos]);
341
+
342
+ const calendarDays = useMemo(() => {
343
+ try {
344
+ const start = startOfWeek(startOfMonth(viewDate));
345
+ const end = endOfWeek(endOfMonth(viewDate));
346
+ return eachDayOfInterval({ start, end }) as Date[];
347
+ } catch (e) {
348
+ return [] as Date[];
349
+ }
350
+ }, [
351
+ viewDate,
352
+ startOfWeek,
353
+ startOfMonth,
354
+ endOfWeek,
355
+ endOfMonth,
356
+ eachDayOfInterval,
357
+ ]);
358
+
359
+ const months = [
360
+ "Jan",
361
+ "Feb",
362
+ "Mar",
363
+ "Apr",
364
+ "May",
365
+ "Jun",
366
+ "Jul",
367
+ "Aug",
368
+ "Sep",
369
+ "Oct",
370
+ "Nov",
371
+ "Dec",
372
+ ];
373
+ const years = Array.from(
374
+ { length: maxYear - minYear + 1 },
375
+ (_, i) => minYear + i,
376
+ );
377
+
378
+ const monthOptions = useMemo(
379
+ () =>
380
+ months.map((m, i) => ({
381
+ id: m,
382
+ label: m,
383
+ key: i.toString(),
384
+ })),
385
+ [months],
386
+ );
387
+
388
+ const yearOptions = useMemo(
389
+ () =>
390
+ years.map(y => ({
391
+ id: y.toString(),
392
+ label: y.toString(),
393
+ key: y.toString(),
394
+ })),
395
+ [years],
396
+ );
397
+
398
+ const isSideLabel = labelPlacement === "left" || labelPlacement === "right";
399
+ const xAlignment =
400
+ labelAlignX ||
401
+ (labelPlacement === "left"
402
+ ? "left"
403
+ : labelPlacement === "right"
404
+ ? "right"
405
+ : "left");
406
+ const yAlignmentClass =
407
+ labelAlignY === "top"
408
+ ? "items-start"
409
+ : labelAlignY === "bottom"
410
+ ? "items-end"
411
+ : "items-center";
412
+
413
+ const selectionRadius =
414
+ variant === "square"
415
+ ? "rounded-none"
416
+ : variant === "curved"
417
+ ? "rounded-lg"
418
+ : "rounded-full";
419
+ const borderRadius =
420
+ variant === "square"
421
+ ? "rounded-none"
422
+ : variant === "curved"
423
+ ? "rounded-lg"
424
+ : "rounded-full";
425
+
426
+ const getDisplayText = () => {
427
+ if (value) return format(value, formatStr);
428
+ if (placeholder) return placeholder;
429
+ if (defaultToToday) return format(new Date(), formatStr);
430
+ return "";
431
+ };
432
+
433
+ const hasError = internalError || !!errorProp;
434
+
435
+ return (
436
+ <div
437
+ className={cn(
438
+ "flex w-full",
439
+ labelPlacement === "top" && "flex-col gap-1.5",
440
+ labelPlacement === "left" && cn("flex-row gap-4", yAlignmentClass),
441
+ labelPlacement === "right" &&
442
+ cn("flex-row-reverse gap-4", yAlignmentClass),
443
+ className,
444
+ )}
445
+ >
446
+ {label && (
447
+ <div
448
+ className={cn(
449
+ "flex flex-col",
450
+ isSideLabel ? "shrink-0" : "w-full",
451
+ labelAlignY === "top" && isSideLabel && "mt-2.5",
452
+ )}
453
+ >
454
+ <div
455
+ className={cn(
456
+ isSideLabel ? labelWidth : "w-full",
457
+ "flex flex-col",
458
+ xAlignment === "left" && "items-start text-left",
459
+ xAlignment === "right" && "items-end text-right",
460
+ xAlignment === "center" && "items-center text-center",
461
+ )}
462
+ >
463
+ <span className="text-sm font-medium text-black">
464
+ {label}
465
+ </span>
466
+ {description && (
467
+ <span className="text-[11px] text-black font-medium mt-0.5">
468
+ {description}
469
+ </span>
470
+ )}
471
+ </div>
472
+ </div>
473
+ )}
474
+
475
+ <div className="flex-1 relative group">
476
+ {isTypeable ? (
477
+ <div className="relative flex items-center">
478
+ <input
479
+ ref={inputRef}
480
+ type="text"
481
+ value={getFullMaskedValue()}
482
+ onChange={handleInputChange}
483
+ onKeyDown={handleKeyDown}
484
+ onFocus={handleFocus}
485
+ onBlur={() => setIsFocused(false)}
486
+ placeholder={typeableFormat.toLowerCase()}
487
+ className={cn(
488
+ "flex items-center w-full pl-10 pr-2 h-9 border-[1.5px] border-black transition-all duration-200 bg-white text-md text-black outline-none placeholder:text-black/40 placeholder:text-sm placeholder:font-medium font-medium",
489
+ borderRadius,
490
+ isFocused
491
+ ? "border-sky-500 ring-4 ring-sky-500/10 shadow-lg"
492
+ : "hover:border-gray-800",
493
+ hasError &&
494
+ "border-red-500 ring-4 ring-red-500/10 text-red-500",
495
+ )}
496
+ />
497
+ <CalendarIcon
498
+ size={16}
499
+ className={cn(
500
+ "absolute left-3 transition-colors",
501
+ hasError ? "text-red-500" : "text-black",
502
+ )}
503
+ />
504
+ </div>
505
+ ) : (
506
+ <button
507
+ type="button"
508
+ ref={refs.setReference}
509
+ {...getReferenceProps()}
510
+ onFocus={() => setIsFocused(true)}
511
+ onBlur={() => setIsFocused(false)}
512
+ className={cn(
513
+ "flex items-center pr-2 w-full h-9 border-[1.5px] border-black transition-all duration-200 bg-white font-medium text-black cursor-pointer",
514
+ borderRadius,
515
+ isOpen && "border-sky-500 ring-4 ring-sky-500/10",
516
+ errorProp && "border-red-500 ring-4 ring-red-500/10",
517
+ )}
518
+ >
519
+ <div className="flex items-center pl-2.25 pr-2 shrink-0">
520
+ <CalendarIcon size={16} className="text-black" />
521
+ </div>
522
+ <span
523
+ className={cn("text-md flex-1 text-left truncate text-black")}
524
+ >
525
+ {getDisplayText()}
526
+ </span>
527
+ <ChevronDown
528
+ size={14}
529
+ className={cn(
530
+ "text-black transition-transform duration-200",
531
+ isOpen && "rotate-180",
532
+ )}
533
+ />
534
+ </button>
535
+ )}
536
+
537
+ {isOpen && !isTypeable && (
538
+ <FloatingPortal>
539
+ <FloatingFocusManager context={context} modal={false}>
540
+ <div
541
+ ref={refs.setFloating}
542
+ style={floatingStyles}
543
+ {...getFloatingProps()}
544
+ className="z-[9999] p-4 bg-white border-[1.5px] border-black rounded-xl shadow-2xl animate-in fade-in zoom-in-95 duration-200"
545
+ >
546
+ <div className="flex items-center gap-2 justify-between mb-4 px-1">
547
+ <button
548
+ onClick={e => {
549
+ e.stopPropagation();
550
+ setViewDate(subMonths(viewDate, 1));
551
+ }}
552
+ className="h-9 w-9 flex items-center justify-center rounded-lg border-[1.5px] border-black cursor-pointer text-black hover:border-gray-800 transition-all duration-150"
553
+ >
554
+ <ChevronLeft size={16} strokeWidth={2.5} />
555
+ </button>
556
+
557
+ <SelectInput
558
+ options={monthOptions}
559
+ value={viewDate.getMonth().toString()}
560
+ onChange={key => {
561
+ setViewDate(
562
+ new Date(viewDate.getFullYear(), parseInt(key), 1),
563
+ );
564
+ }}
565
+ width="w-24"
566
+ />
567
+ <SelectInput
568
+ options={yearOptions}
569
+ value={viewDate.getFullYear().toString()}
570
+ onChange={key => {
571
+ setViewDate(
572
+ new Date(parseInt(key), viewDate.getMonth(), 1),
573
+ );
574
+ }}
575
+ width="w-24"
576
+ />
577
+
578
+ <button
579
+ onClick={e => {
580
+ e.stopPropagation();
581
+ setViewDate(addMonths(viewDate, 1));
582
+ }}
583
+ className="h-9 w-9 flex items-center justify-center rounded-lg border-[1.5px] border-black cursor-pointer text-black hover:border-gray-800 transition-all duration-150"
584
+ >
585
+ <ChevronRight size={16} strokeWidth={2.5} />
586
+ </button>
587
+ </div>
588
+
589
+ <div className="grid grid-cols-7 gap-px mb-2">
590
+ {["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map(d => (
591
+ <div
592
+ key={d}
593
+ className="h-8 flex items-center justify-center text-xs font-semibold text-black uppercase tracking-widest"
594
+ >
595
+ {d}
596
+ </div>
597
+ ))}
598
+ </div>
599
+ <div className="grid grid-cols-7 gap-px">
600
+ {calendarDays.map((date: Date, i: number) => {
601
+ const isSelected = value && isSameDay(date, value);
602
+ const isOutside =
603
+ format(date, "M") !== format(viewDate, "M");
604
+ const today = isToday(date);
605
+ const disabled = isDateDisabled(date);
606
+
607
+ return (
608
+ <div
609
+ key={i}
610
+ className="h-9 flex items-center justify-center"
611
+ >
612
+ <button
613
+ disabled={disabled}
614
+ onClick={() => {
615
+ onChange?.(date);
616
+ setIsOpen(false);
617
+ }}
618
+ className={cn(
619
+ "w-7 h-7 text-xs font-semibold transition-all relative",
620
+ selectionRadius,
621
+ isSelected
622
+ ? "bg-black text-white scale-100 z-10"
623
+ : !isOutside
624
+ ? "text-black hover:bg-black/10"
625
+ : "text-gray-600",
626
+ today &&
627
+ !isSelected &&
628
+ "bg-sky-500/10 text-sky-500",
629
+ disabled
630
+ ? "opacity-20 cursor-not-allowed"
631
+ : "cursor-pointer",
632
+ )}
633
+ >
634
+ {format(date, "d")}
635
+ </button>
636
+ </div>
637
+ );
638
+ })}
639
+ </div>
640
+ </div>
641
+ </FloatingFocusManager>
642
+ </FloatingPortal>
643
+ )}
644
+ </div>
645
+ </div>
646
+ );
647
+ };