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,435 @@
1
+ import React, { useState, useMemo, useRef, useEffect } from "react";
2
+ import { cn } from "@/utils/cn";
3
+ import { Clock, ChevronDown } from "lucide-react";
4
+ const DateUtils = {};
5
+ import {
6
+ useFloating,
7
+ autoUpdate,
8
+ offset,
9
+ flip,
10
+ shift,
11
+ useClick,
12
+ useDismiss,
13
+ useRole,
14
+ useInteractions,
15
+ FloatingPortal,
16
+ FloatingFocusManager,
17
+ } from "@floating-ui/react";
18
+
19
+ interface TimePickerProps {
20
+ label?: string;
21
+ description?: string;
22
+ error?: string;
23
+ value?: Date | null;
24
+ onChange?: (date: Date) => void;
25
+ timeFormat?: "12hr" | "24hr";
26
+ showSeconds?: boolean;
27
+ isTypeable?: boolean;
28
+ placeholder?: string;
29
+ variant?: "rounded" | "curved" | "square";
30
+ labelPlacement?: "top" | "left" | "right";
31
+ labelWidth?: string;
32
+ "labelAlign-X"?: "left" | "center" | "right";
33
+ "labelAlign-Y"?: "top" | "middle" | "bottom";
34
+ className?: string;
35
+ }
36
+
37
+ export const TimePicker = ({
38
+ label,
39
+ description,
40
+ error: errorProp,
41
+ value,
42
+ onChange,
43
+ timeFormat = "12hr",
44
+ showSeconds = false,
45
+ isTypeable = false,
46
+ placeholder,
47
+ variant = "curved",
48
+ labelPlacement = "top",
49
+ labelWidth = "w-32",
50
+ "labelAlign-X": labelAlignX,
51
+ "labelAlign-Y": labelAlignY = "middle",
52
+ className,
53
+ }: TimePickerProps) => {
54
+ const [isOpen, setIsOpen] = useState(false);
55
+ const [inputValue, setInputValue] = useState("");
56
+ const [isFocused, setIsFocused] = useState(false);
57
+ const [internalError, setInternalError] = useState(false);
58
+ const inputRef = useRef<HTMLInputElement>(null);
59
+ const [cursorPos, setCursorPos] = useState<number | null>(null);
60
+
61
+ // Core Utilities from root.config
62
+ const {
63
+ format = (d: Date) => d.toLocaleTimeString(),
64
+ } = DateUtils as any;
65
+
66
+ // Local helper for validation
67
+ const isValidDate = (d: any): d is Date => d instanceof Date && !isNaN(d.getTime());
68
+
69
+ const { refs, floatingStyles, context } = useFloating({
70
+ open: !isTypeable && isOpen,
71
+ onOpenChange: setIsOpen,
72
+ middleware: [offset(10), flip(), shift()],
73
+ whileElementsMounted: autoUpdate,
74
+ });
75
+
76
+ const click = useClick(context, { enabled: !isTypeable });
77
+ const dismiss = useDismiss(context, { enabled: !isTypeable });
78
+ const role = useRole(context);
79
+ const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role]);
80
+
81
+ const is12Hour = timeFormat === "12hr";
82
+
83
+ const displayFormat = useMemo(() => {
84
+ let f = is12Hour ? "hh:mm" : "HH:mm";
85
+ if (showSeconds) f += ":ss";
86
+ if (is12Hour) f += " aa";
87
+ return f;
88
+ }, [is12Hour, showSeconds]);
89
+
90
+ useEffect(() => {
91
+ if (value && isTypeable && !isFocused) {
92
+ setInputValue(format(value, displayFormat));
93
+ }
94
+ }, [value, isTypeable, displayFormat, isFocused, format]);
95
+
96
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
97
+ let rawVal = e.target.value.toUpperCase();
98
+ let cPos = e.target.selectionStart || 0;
99
+ let nativeEvent = e.nativeEvent as InputEvent;
100
+ let isDeleting = nativeEvent.inputType === "deleteContentBackward";
101
+
102
+ const rawBeforeCursor = rawVal.slice(0, cPos);
103
+ const valueCharsBefore = (rawBeforeCursor.match(/[0-9AP]/g) || []).length;
104
+
105
+ let digits = rawVal.replace(/\D/g, "");
106
+ let oldDigits = inputValue.replace(/\D/g, "");
107
+
108
+ const digitLimit = showSeconds ? 6 : 4;
109
+
110
+ if (isDeleting && oldDigits.length === digits.length && digits.length > 0) {
111
+ digits = digits.slice(0, -1);
112
+ cPos--;
113
+ }
114
+
115
+ digits = digits.slice(0, digitLimit);
116
+
117
+ let masked = "";
118
+ let hasError = false;
119
+
120
+ // Boundary checks
121
+ const hh = digits.substring(0, 2);
122
+ const mm = digits.substring(2, 4);
123
+ const ss = digits.substring(4, 6);
124
+
125
+ if (hh && (parseInt(hh) > (is12Hour ? 12 : 23) || (is12Hour && parseInt(hh) === 0))) hasError = true;
126
+ if (mm && parseInt(mm) > 59) hasError = true;
127
+ if (ss && parseInt(ss) > 59) hasError = true;
128
+
129
+ if (digits.length > 0) masked += hh;
130
+ if (digits.length >= 3) masked += ":" + mm;
131
+ if (showSeconds && digits.length >= 5) masked += ":" + ss;
132
+
133
+ if (is12Hour) {
134
+ let ampm = "";
135
+ const lastA = rawVal.lastIndexOf("A");
136
+ const lastP = rawVal.lastIndexOf("P");
137
+
138
+ if (lastP > lastA) ampm = "PM";
139
+ else if (lastA > lastP) ampm = "AM";
140
+
141
+ if (isDeleting) {
142
+ if (rawVal.endsWith("A") || rawVal.endsWith("P")) {
143
+ ampm = "";
144
+ }
145
+ }
146
+
147
+ if (ampm) masked += " " + ampm;
148
+ }
149
+
150
+ const finalVal = masked.substring(0, displayFormat.length);
151
+
152
+ let template = is12Hour ? "__:__ __" : "__:__";
153
+ if (showSeconds && !is12Hour) template = "__:__:__";
154
+ if (showSeconds && is12Hour) template = "__:__:__ __";
155
+
156
+ let fullMask = "";
157
+ for (let i = 0; i < template.length; i++) {
158
+ if (finalVal[i]) fullMask += finalVal[i];
159
+ else fullMask += template[i];
160
+ }
161
+
162
+ let newCPos = 0;
163
+ let count = 0;
164
+ for (let i = 0; i < fullMask.length; i++) {
165
+ if (/[0-9AP]/.test(fullMask[i])) count++;
166
+ if (count === valueCharsBefore) {
167
+ newCPos = i + 1;
168
+ break;
169
+ }
170
+ }
171
+ if (valueCharsBefore === 0) newCPos = 0;
172
+ else if (count < valueCharsBefore) newCPos = fullMask.length;
173
+
174
+ if (!isDeleting) {
175
+ while (fullMask[newCPos] && /[^0-9A-P_]/.test(fullMask[newCPos])) {
176
+ if (fullMask[newCPos] === " " && fullMask[newCPos + 1] === "_") break;
177
+ newCPos++;
178
+ }
179
+ } else {
180
+ while (newCPos > 0 && fullMask[newCPos - 1] && /[^0-9A-P]/.test(fullMask[newCPos - 1])) {
181
+ newCPos--;
182
+ }
183
+ }
184
+
185
+ setCursorPos(newCPos);
186
+ setInputValue(finalVal);
187
+ setInternalError(hasError);
188
+
189
+ // Parse logic
190
+ const isComplete = !is12Hour
191
+ ? finalVal.length === displayFormat.length
192
+ : (finalVal.endsWith("AM") || finalVal.endsWith("PM"));
193
+
194
+ if (!hasError && isComplete) {
195
+ try {
196
+ const today = new Date();
197
+ const parts = finalVal.split(" ");
198
+ const timeParts = parts[0].split(":");
199
+ let hour = parseInt(timeParts[0]);
200
+ const minute = parseInt(timeParts[1]);
201
+ const second = showSeconds ? parseInt(timeParts[2]) : 0;
202
+
203
+ if (is12Hour) {
204
+ if (parts[1] === "PM" && hour < 12) hour += 12;
205
+ if (parts[1] === "AM" && hour === 12) hour = 0;
206
+ }
207
+ const newDate = new Date(today.setHours(hour, minute, second, 0));
208
+ if (isValidDate(newDate)) onChange?.(newDate);
209
+ } catch (e) {}
210
+ }
211
+ };
212
+
213
+ const getFullMaskedValue = () => {
214
+ if (!isFocused && !inputValue) return "";
215
+ if (!isFocused && inputValue) return inputValue;
216
+
217
+ let current = inputValue;
218
+
219
+ let template = is12Hour ? "__:__ __" : "__:__";
220
+ if (showSeconds && !is12Hour) template = "__:__:__";
221
+ if (showSeconds && is12Hour) template = "__:__:__ __";
222
+
223
+ let res = "";
224
+ for (let i = 0; i < template.length; i++) {
225
+ if (current[i]) res += current[i];
226
+ else res += template[i];
227
+ }
228
+ return res;
229
+ };
230
+
231
+ const handleFocus = () => {
232
+ setIsFocused(true);
233
+ const pos = inputValue.length;
234
+ setTimeout(() => inputRef.current?.setSelectionRange(pos, pos), 0);
235
+ };
236
+
237
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
238
+ if (e.ctrlKey || e.metaKey || e.altKey || e.key.length > 1) return;
239
+ if (!/[0-9A-P: ]/.test(e.key.toUpperCase())) {
240
+ e.preventDefault();
241
+ }
242
+ };
243
+
244
+ React.useLayoutEffect(() => {
245
+ if (isFocused && inputRef.current && cursorPos !== null) {
246
+ inputRef.current.setSelectionRange(cursorPos, cursorPos);
247
+ }
248
+ }, [inputValue, isFocused, cursorPos]);
249
+
250
+ const hours = Array.from({ length: is12Hour ? 12 : 24 }, (_, i) => is12Hour ? i + 1 : i);
251
+ const minutes = Array.from({ length: 60 }, (_, i) => i);
252
+ const secs = Array.from({ length: 60 }, (_, i) => i);
253
+
254
+ const handleTimeSelect = (h: number, m: number, s: number, p?: string) => {
255
+ const d = new Date(value || new Date());
256
+ let hour = h;
257
+ if (is12Hour) {
258
+ const isPM = p === "PM";
259
+ if (isPM && hour < 12) hour += 12;
260
+ if (!isPM && hour === 12) hour = 0;
261
+ }
262
+ d.setHours(hour, m, s, 0);
263
+ onChange?.(d);
264
+ };
265
+
266
+ const currentHour = value ? (is12Hour ? (value.getHours() % 12 || 12) : value.getHours()) : null;
267
+ const currentMinute = value ? value.getMinutes() : null;
268
+ const currentSecond = value ? value.getSeconds() : null;
269
+ const currentPeriod = value ? (value.getHours() >= 12 ? "PM" : "AM") : "AM";
270
+
271
+ const isSideLabel = labelPlacement === "left" || labelPlacement === "right";
272
+ const xAlignment = labelAlignX || (
273
+ labelPlacement === "left" ? "left" :
274
+ labelPlacement === "right" ? "right" : "left"
275
+ );
276
+ const yAlignmentClass =
277
+ labelAlignY === "top" ? "items-start" :
278
+ labelAlignY === "bottom" ? "items-end" : "items-center";
279
+
280
+ const borderRadius = variant === "square" ? "rounded-none" : variant === "curved" ? "rounded-lg" : "rounded-full";
281
+
282
+ const hasError = internalError || !!errorProp;
283
+
284
+ return (
285
+ <div className={cn("flex w-full", labelPlacement === "top" && "flex-col gap-1.5", labelPlacement === "left" && cn("flex-row gap-4", yAlignmentClass), labelPlacement === "right" && cn("flex-row-reverse gap-4", yAlignmentClass), className)}>
286
+ {label && (
287
+ <div className={cn("flex flex-col", isSideLabel ? "shrink-0" : "w-full", labelAlignY === "top" && isSideLabel && "mt-2.5")}>
288
+ <div className={cn(
289
+ isSideLabel ? labelWidth : "w-full",
290
+ "flex flex-col",
291
+ xAlignment === "left" && "items-start text-left",
292
+ xAlignment === "right" && "items-end text-right",
293
+ xAlignment === "center" && "items-center text-center"
294
+ )}>
295
+ <span className="text-sm font-medium text-black">{label}</span>
296
+ {description && <span className="text-[11px] text-black font-medium mt-0.5">{description}</span>}
297
+ </div>
298
+ </div>
299
+ )}
300
+
301
+ <div className="flex-1 relative group">
302
+ {isTypeable ? (
303
+ <div className="relative flex items-center">
304
+ <input
305
+ ref={inputRef}
306
+ type="text"
307
+ value={getFullMaskedValue()}
308
+ onChange={handleInputChange}
309
+ onKeyDown={handleKeyDown}
310
+ onFocus={handleFocus}
311
+ onBlur={() => setIsFocused(false)}
312
+ placeholder={displayFormat.toLowerCase()}
313
+ className={cn(
314
+ "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",
315
+ borderRadius,
316
+ isFocused ? "border-sky-500 ring-4 ring-sky-500/10 shadow-lg" : "hover:border-gray-800",
317
+ hasError && "border-red-500 ring-4 ring-red-500/10 text-red-500"
318
+ )}
319
+ />
320
+ <Clock size={16} className={cn("absolute left-3 transition-colors", hasError ? "text-red-500" : "text-black")} />
321
+ </div>
322
+ ) : (
323
+ <button
324
+ type="button"
325
+ ref={refs.setReference}
326
+ {...getReferenceProps()}
327
+ onFocus={() => setIsFocused(true)}
328
+ onBlur={() => setIsFocused(false)}
329
+ className={cn(
330
+ "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",
331
+ borderRadius,
332
+ isOpen ? "border-sky-500 ring-4 ring-sky-500/10" : "hover:border-gray-800",
333
+ errorProp && "border-red-500 ring-4 ring-red-500/10"
334
+ )}
335
+ >
336
+ <div className="flex items-center pl-2.25 pr-2 shrink-0">
337
+ <Clock size={16} className="text-black" />
338
+ </div>
339
+ <span className={cn("text-md flex-1 text-left truncate text-black", !value && "text-gray-400")}>
340
+ {value ? format(value, displayFormat) : (placeholder || "Select Time")}
341
+ </span>
342
+ <ChevronDown size={14} className={cn("text-black transition-transform duration-200", isOpen && "rotate-180")} />
343
+ </button>
344
+ )}
345
+
346
+ {isOpen && !isTypeable && (
347
+ <FloatingPortal>
348
+ <FloatingFocusManager context={context} modal={false}>
349
+ <div
350
+ ref={refs.setFloating}
351
+ style={floatingStyles}
352
+ {...getFloatingProps()}
353
+ className="z-[9999] bg-white border-[1.5px] border-black rounded-xl shadow-2xl animate-in fade-in duration-200 p-2 flex gap-1 h-[280px]"
354
+ >
355
+ {/* Hour Column */}
356
+ <div className="flex flex-col overflow-y-auto custom-scrollbar px-1">
357
+ <div className="text-[10px] font-semibold tracking-widest text-black uppercase p-2 sticky top-0 bg-white">Hour</div>
358
+ {hours.map(h => (
359
+ <button
360
+ key={h}
361
+ onClick={() => handleTimeSelect(h, currentMinute || 0, currentSecond || 0, currentPeriod)}
362
+ className={cn(
363
+ "w-10 h-10 shrink-0 flex items-center justify-center rounded-lg text-sm font-bold transition-all cursor-pointer",
364
+ currentHour === h ? "bg-black text-white shadow-lg" : "hover:bg-black/5 text-black"
365
+ )}
366
+ >
367
+ {h.toString().padStart(2, "0")}
368
+ </button>
369
+ ))}
370
+ </div>
371
+
372
+ {/* Minute Column */}
373
+ <div className="flex flex-col overflow-y-auto custom-scrollbar px-1 border-l border-black">
374
+ <div className="text-[10px] font-semibold tracking-widest text-black uppercase p-2 sticky top-0 bg-white">Min</div>
375
+ {minutes.map(m => (
376
+ <button
377
+ key={m}
378
+ onClick={() => handleTimeSelect(currentHour || (is12Hour ? 12 : 0), m, currentSecond || 0, currentPeriod)}
379
+ className={cn(
380
+ "w-10 h-10 shrink-0 flex items-center justify-center rounded-lg text-sm font-bold transition-all cursor-pointer",
381
+ currentMinute === m ? "bg-black text-white shadow-lg" : "hover:bg-black/5 text-black"
382
+ )}
383
+ >
384
+ {m.toString().padStart(2, "0")}
385
+ </button>
386
+ ))}
387
+ </div>
388
+
389
+ {/* Seconds Column */}
390
+ {showSeconds && (
391
+ <div className="flex flex-col overflow-y-auto custom-scrollbar px-1 border-l border-black">
392
+ <div className="text-[10px] font-semibold tracking-widest text-black uppercase p-2 sticky top-0 bg-white">Sec</div>
393
+ {secs.map(s => (
394
+ <button
395
+ key={s}
396
+ onClick={() => handleTimeSelect(currentHour || (is12Hour ? 12 : 0), currentMinute || 0, s, currentPeriod)}
397
+ className={cn(
398
+ "w-10 h-10 shrink-0 flex items-center justify-center rounded-lg text-sm font-bold transition-all cursor-pointer",
399
+ currentSecond === s ? "bg-black text-white shadow-lg" : "hover:bg-black/5 text-black"
400
+ )}
401
+ >
402
+ {s.toString().padStart(2, "0")}
403
+ </button>
404
+ ))}
405
+ </div>
406
+ )}
407
+
408
+ {/* AM/PM Column */}
409
+ {is12Hour && (
410
+ <div className="flex flex-col px-1 border-l border-black">
411
+ <div className="text-[10px] font-semibold tracking-widest text-black uppercase p-2">Per</div>
412
+ {["AM", "PM"].map(p => (
413
+ <button
414
+ key={p}
415
+ onClick={() => handleTimeSelect(currentHour || 12, currentMinute || 0, currentSecond || 0, p)}
416
+ className={cn(
417
+ "w-12 h-10 shrink-0 flex items-center justify-center rounded-lg text-[11px] font-bold tracking-widest transition-all cursor-pointer",
418
+ currentPeriod === p ? "bg-black text-white shadow-lg" : "hover:bg-black/10 text-black"
419
+ )}
420
+ >
421
+ {p}
422
+ </button>
423
+ ))}
424
+ </div>
425
+ )}
426
+ </div>
427
+ </FloatingFocusManager>
428
+ </FloatingPortal>
429
+ )}
430
+ </div>
431
+ </div>
432
+ );
433
+ };
434
+
435
+ TimePicker.displayName = "TimePicker";