@oneuptime/common 8.0.5289 → 8.0.5300

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,476 @@
1
+ import React, {
2
+ FunctionComponent,
3
+ ReactElement,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ } from "react";
9
+ import OneUptimeDate from "../../../Types/Date";
10
+ import Icon from "../Icon/Icon";
11
+ import IconProp from "../../../Types/Icon/IconProp";
12
+ import Modal, { ModalWidth } from "../Modal/Modal";
13
+
14
+ export interface ComponentProps {
15
+ value?: string | Date | undefined; // ISO string or Date
16
+ onChange?: undefined | ((value: string) => void); // emits ISO string
17
+ placeholder?: string | undefined;
18
+ className?: string | undefined;
19
+ readOnly?: boolean | undefined;
20
+ disabled?: boolean | undefined;
21
+ onFocus?: (() => void) | undefined;
22
+ onBlur?: (() => void) | undefined;
23
+ dataTestId?: string | undefined;
24
+ tabIndex?: number | undefined;
25
+ autoFocus?: boolean | undefined;
26
+ error?: string | undefined;
27
+ }
28
+
29
+ const pad2: (n: number) => string = (n: number): string => {
30
+ return n < 10 ? `0${n}` : `${n}`;
31
+ };
32
+
33
+ const clamp: (n: number, min: number, max: number) => number = (
34
+ n: number,
35
+ min: number,
36
+ max: number,
37
+ ): number => {
38
+ return Math.min(Math.max(n, min), max);
39
+ };
40
+
41
+ const toDate: (v?: string | Date) => Date | undefined = (
42
+ v?: string | Date,
43
+ ): Date | undefined => {
44
+ if (!v) {
45
+ return undefined;
46
+ }
47
+ try {
48
+ return OneUptimeDate.fromString(v);
49
+ } catch {
50
+ return undefined;
51
+ }
52
+ };
53
+
54
+ const TimePicker: FunctionComponent<ComponentProps> = (
55
+ props: ComponentProps,
56
+ ): ReactElement => {
57
+ // Start with project-level preference (works on server), then update to browser preference on mount
58
+ const [userPrefers12h, setUserPrefers12h] = useState<boolean>(
59
+ OneUptimeDate.getUserPrefers12HourFormat(),
60
+ );
61
+
62
+ useEffect((): void => {
63
+ // Resolve to actual browser preference once mounted using project utility
64
+ setUserPrefers12h(OneUptimeDate.getUserPrefers12HourFormat());
65
+ }, []);
66
+
67
+ // Timezone label derived from OneUptimeDate utilities (e.g., "PDT (America/Los_Angeles)" or "GMT+5:30 (Asia/Kolkata)")
68
+ const [timezoneLabel, setTimezoneLabel] = useState<string>(
69
+ "your local time zone",
70
+ );
71
+
72
+ useEffect((): void => {
73
+ const abbr: string = OneUptimeDate.getCurrentTimezoneString();
74
+ const iana: string =
75
+ OneUptimeDate.getCurrentTimezone() as unknown as string;
76
+ setTimezoneLabel(`${abbr}${iana ? ` (${iana})` : ""}`);
77
+ }, []);
78
+
79
+ const initialDate: Date = useMemo(() => {
80
+ return toDate(props.value) || OneUptimeDate.getCurrentDate();
81
+ }, [props.value]);
82
+
83
+ const [hours24, setHours24] = useState<number>(initialDate.getHours());
84
+ const [minutes, setMinutes] = useState<number>(initialDate.getMinutes());
85
+
86
+ const hoursInputRef: React.MutableRefObject<HTMLInputElement | null> =
87
+ useRef<HTMLInputElement | null>(null);
88
+ const minutesInputRef: React.MutableRefObject<HTMLInputElement | null> =
89
+ useRef<HTMLInputElement | null>(null);
90
+
91
+ // Modal state
92
+ const [showModal, setShowModal] = useState<boolean>(false);
93
+ const [tempHours24, setTempHours24] = useState<number>(hours24);
94
+ const [tempMinutes, setTempMinutes] = useState<number>(minutes);
95
+
96
+ useEffect(() => {
97
+ const d: Date | undefined = toDate(props.value);
98
+ if (!d) {
99
+ return;
100
+ }
101
+ setHours24(d.getHours());
102
+ setMinutes(d.getMinutes());
103
+ }, [props.value]);
104
+
105
+ const emitChange: (h24: number, m: number) => void = (
106
+ h24: number,
107
+ m: number,
108
+ ): void => {
109
+ const date: Date = OneUptimeDate.getDateWithCustomTime({
110
+ hours: clamp(h24, 0, 23),
111
+ minutes: clamp(m, 0, 59),
112
+ seconds: 0,
113
+ });
114
+ props.onChange?.(OneUptimeDate.toString(date));
115
+ };
116
+
117
+ const display: { hours: string; minutes: string; isPM: boolean } =
118
+ useMemo(() => {
119
+ if (userPrefers12h) {
120
+ const isPM: boolean = hours24 >= 12;
121
+ const hr12: number = ((hours24 + 11) % 12) + 1; // 0->12
122
+ return { hours: pad2(hr12), minutes: pad2(minutes), isPM };
123
+ }
124
+ return { hours: pad2(hours24), minutes: pad2(minutes), isPM: false };
125
+ }, [hours24, minutes, userPrefers12h]);
126
+
127
+ // Inline editing disabled; all updates happen inside modal
128
+
129
+ const clickable: boolean = !(props.readOnly || props.disabled);
130
+
131
+ const baseClass: string =
132
+ props.className ||
133
+ "flex items-center w-full rounded-md border border-gray-300 bg-white text-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500 shadow-sm";
134
+
135
+ const inputClass: string =
136
+ "w-14 text-center py-2 outline-none bg-transparent leading-snug focus:outline-none" +
137
+ (props.readOnly || props.disabled ? " text-gray-500" : " text-gray-900");
138
+
139
+ const openModal: () => void = (): void => {
140
+ if (!clickable) {
141
+ return;
142
+ }
143
+ setTempHours24(hours24);
144
+ setTempMinutes(minutes);
145
+ setShowModal(true);
146
+ };
147
+
148
+ return (
149
+ <>
150
+ <div
151
+ className={
152
+ (props.error
153
+ ? baseClass +
154
+ " border-red-300 focus-within:border-red-500 focus-within:ring-red-500"
155
+ : baseClass) +
156
+ (props.disabled ? " bg-gray-100" : "") +
157
+ (clickable
158
+ ? " cursor-pointer hover:bg-gray-50"
159
+ : " cursor-not-allowed") +
160
+ " mt-2"
161
+ }
162
+ role="group"
163
+ aria-label="Time input"
164
+ aria-disabled={props.disabled ? true : undefined}
165
+ aria-describedby={props.error ? "timepicker-error" : undefined}
166
+ onClick={openModal}
167
+ aria-haspopup="dialog"
168
+ aria-expanded={showModal || undefined}
169
+ >
170
+ {/* Left time icon */}
171
+ <div className="pl-2 pr-2 text-gray-400" aria-hidden="true">
172
+ <Icon icon={IconProp.Time} className="h-5 w-5" />
173
+ </div>
174
+
175
+ {/* Hours */}
176
+ <input
177
+ ref={hoursInputRef}
178
+ data-testid={props.dataTestId}
179
+ type="text"
180
+ inputMode="numeric"
181
+ pattern="[0-9]*"
182
+ autoFocus={props.autoFocus}
183
+ tabIndex={props.tabIndex}
184
+ spellCheck={false}
185
+ placeholder={props.placeholder || (userPrefers12h ? "hh" : "HH")}
186
+ className={
187
+ inputClass +
188
+ " rounded-l-md pl-1 focus:ring-0 focus-visible:outline-none"
189
+ }
190
+ readOnly={true}
191
+ aria-label="Hours"
192
+ aria-invalid={props.error ? true : undefined}
193
+ value={display.hours}
194
+ onFocus={props.onFocus}
195
+ onBlur={() => {
196
+ return props.onBlur?.();
197
+ }}
198
+ />
199
+
200
+ <span className="px-2 text-gray-500">:</span>
201
+
202
+ {/* Minutes */}
203
+ <input
204
+ ref={minutesInputRef}
205
+ type="text"
206
+ inputMode="numeric"
207
+ pattern="[0-9]*"
208
+ spellCheck={false}
209
+ placeholder="mm"
210
+ className={
211
+ inputClass +
212
+ " rounded-r-md pr-2 focus:ring-0 focus-visible:outline-none"
213
+ }
214
+ readOnly={true}
215
+ aria-label="Minutes"
216
+ aria-invalid={props.error ? true : undefined}
217
+ value={display.minutes}
218
+ onBlur={() => {
219
+ return props.onBlur?.();
220
+ }}
221
+ />
222
+
223
+ {/* spacer to maintain layout without right icon */}
224
+ <div className="ml-auto mr-1" />
225
+
226
+ {userPrefers12h && (
227
+ <div className="border-l border-gray-200 pl-2 pr-2 ml-1 mr-1">
228
+ <div
229
+ className="flex items-center gap-1"
230
+ role="group"
231
+ aria-label="AM or PM"
232
+ >
233
+ <button
234
+ type="button"
235
+ className={
236
+ "px-2 py-1 rounded text-xs " +
237
+ (display.isPM
238
+ ? "bg-white text-gray-700 border border-gray-300"
239
+ : "bg-indigo-50 text-indigo-700 border border-indigo-200")
240
+ }
241
+ disabled={!clickable}
242
+ aria-pressed={!display.isPM}
243
+ aria-label="Open time selector for AM/PM"
244
+ onClick={openModal}
245
+ >
246
+ AM
247
+ </button>
248
+ <button
249
+ type="button"
250
+ className={
251
+ "px-2 py-1 rounded text-xs " +
252
+ (display.isPM
253
+ ? "bg-indigo-50 text-indigo-700 border border-indigo-200"
254
+ : "bg-white text-gray-700 border border-gray-300")
255
+ }
256
+ disabled={!clickable}
257
+ aria-pressed={display.isPM}
258
+ aria-label="Open time selector for AM/PM"
259
+ onClick={openModal}
260
+ >
261
+ PM
262
+ </button>
263
+ </div>
264
+ </div>
265
+ )}
266
+
267
+ {props.error && (
268
+ <div className="pointer-events-none flex items-center pr-3">
269
+ <Icon icon={IconProp.ErrorSolid} className="h-5 w-5 text-red-500" />
270
+ </div>
271
+ )}
272
+ </div>
273
+
274
+ {/* Time picker modal */}
275
+ {showModal && (
276
+ <Modal
277
+ title="Select time"
278
+ description={
279
+ userPrefers12h
280
+ ? "Choose hours, minutes, and AM/PM"
281
+ : "Choose hours and minutes"
282
+ }
283
+ modalWidth={ModalWidth.Medium}
284
+ onClose={() => {
285
+ return setShowModal(false);
286
+ }}
287
+ onSubmit={() => {
288
+ setHours24(tempHours24);
289
+ setMinutes(tempMinutes);
290
+ emitChange(tempHours24, tempMinutes);
291
+ setShowModal(false);
292
+ }}
293
+ submitButtonText="Apply"
294
+ >
295
+ <div className="p-2">
296
+ <div className="flex items-center justify-center gap-6">
297
+ {/* Hours selector */}
298
+ <div className="flex flex-col items-center">
299
+ <button
300
+ type="button"
301
+ aria-label="Increase hours"
302
+ className="p-2 rounded hover:bg-gray-50"
303
+ onClick={() => {
304
+ return setTempHours24((h: number): number => {
305
+ return (h + 1) % 24;
306
+ });
307
+ }}
308
+ >
309
+ <Icon icon={IconProp.ChevronUp} className="h-6 w-6" />
310
+ </button>
311
+ <input
312
+ type="text"
313
+ inputMode="numeric"
314
+ pattern="[0-9]*"
315
+ aria-label="Hours"
316
+ className="w-20 text-center text-3xl font-semibold py-2 rounded border border-gray-200 focus:ring-2 focus:ring-indigo-500"
317
+ value={
318
+ userPrefers12h
319
+ ? pad2(((tempHours24 + 11) % 12) + 1)
320
+ : pad2(tempHours24)
321
+ }
322
+ onChange={(e: React.ChangeEvent<HTMLInputElement>): void => {
323
+ const raw: string = e.target.value.replace(/\D/g, "");
324
+ let h: number = parseInt(raw || "0", 10);
325
+ if (userPrefers12h) {
326
+ h = clamp(h, 1, 12);
327
+ // map back to 24h preserving period
328
+ const isPM: boolean = tempHours24 >= 12;
329
+ const newH: number =
330
+ h === 12 ? (isPM ? 12 : 0) : isPM ? h + 12 : h;
331
+ setTempHours24(newH);
332
+ } else {
333
+ setTempHours24(clamp(h, 0, 23));
334
+ }
335
+ }}
336
+ />
337
+ <button
338
+ type="button"
339
+ aria-label="Decrease hours"
340
+ className="p-2 rounded hover:bg-gray-50"
341
+ onClick={(): void => {
342
+ return setTempHours24((h: number): number => {
343
+ return (h + 23) % 24;
344
+ });
345
+ }}
346
+ >
347
+ <Icon icon={IconProp.ChevronDown} className="h-6 w-6" />
348
+ </button>
349
+ </div>
350
+
351
+ <div className="text-3xl font-semibold text-gray-500">:</div>
352
+
353
+ {/* Minutes selector */}
354
+ <div className="flex flex-col items-center">
355
+ <button
356
+ type="button"
357
+ aria-label="Increase minutes"
358
+ className="p-2 rounded hover:bg-gray-50"
359
+ onClick={(): void => {
360
+ let m: number = tempMinutes + 1;
361
+ let h: number = tempHours24;
362
+ if (m >= 60) {
363
+ m = 0;
364
+ h = (h + 1) % 24;
365
+ }
366
+ setTempMinutes(m);
367
+ setTempHours24(h);
368
+ }}
369
+ >
370
+ <Icon icon={IconProp.ChevronUp} className="h-6 w-6" />
371
+ </button>
372
+ <input
373
+ type="text"
374
+ inputMode="numeric"
375
+ pattern="[0-9]*"
376
+ aria-label="Minutes"
377
+ className="w-20 text-center text-3xl font-semibold py-2 rounded border border-gray-200 focus:ring-2 focus:ring-indigo-500"
378
+ value={pad2(tempMinutes)}
379
+ onChange={(e: React.ChangeEvent<HTMLInputElement>): void => {
380
+ const raw: string = e.target.value.replace(/\D/g, "");
381
+ const m: number = clamp(parseInt(raw || "0", 10), 0, 59);
382
+ setTempMinutes(m);
383
+ }}
384
+ />
385
+ <button
386
+ type="button"
387
+ aria-label="Decrease minutes"
388
+ className="p-2 rounded hover:bg-gray-50"
389
+ onClick={(): void => {
390
+ let m: number = tempMinutes - 1;
391
+ let h: number = tempHours24;
392
+ if (m < 0) {
393
+ m = 59;
394
+ h = (h + 23) % 24;
395
+ }
396
+ setTempMinutes(m);
397
+ setTempHours24(h);
398
+ }}
399
+ >
400
+ <Icon icon={IconProp.ChevronDown} className="h-6 w-6" />
401
+ </button>
402
+ </div>
403
+
404
+ {/* AM/PM */}
405
+ {userPrefers12h && (
406
+ <div className="ml-2 flex flex-col items-stretch gap-2">
407
+ <button
408
+ type="button"
409
+ className={`px-3 py-2 rounded text-sm border ${tempHours24 < 12 ? "bg-indigo-50 text-indigo-700 border-indigo-200" : "bg-white text-gray-700 border-gray-300"}`}
410
+ aria-pressed={tempHours24 < 12}
411
+ onClick={() => {
412
+ if (tempHours24 >= 12) {
413
+ setTempHours24(tempHours24 - 12);
414
+ }
415
+ }}
416
+ >
417
+ AM
418
+ </button>
419
+ <button
420
+ type="button"
421
+ className={`px-3 py-2 rounded text-sm border ${tempHours24 >= 12 ? "bg-indigo-50 text-indigo-700 border-indigo-200" : "bg-white text-gray-700 border-gray-300"}`}
422
+ aria-pressed={tempHours24 >= 12}
423
+ onClick={() => {
424
+ if (tempHours24 < 12) {
425
+ setTempHours24(tempHours24 + 12);
426
+ }
427
+ }}
428
+ >
429
+ PM
430
+ </button>
431
+ </div>
432
+ )}
433
+ </div>
434
+
435
+ {/* Quick minutes */}
436
+ <div className="mt-6">
437
+ <div className="text-sm text-gray-500 mb-2">Quick minutes</div>
438
+ <div className="grid grid-cols-6 gap-2">
439
+ {[0, 5, 10, 15, 30, 45].map((m: number) => {
440
+ return (
441
+ <button
442
+ key={m}
443
+ type="button"
444
+ className={`px-2 py-1 rounded border text-sm ${tempMinutes === m ? "bg-indigo-50 text-indigo-700 border-indigo-200" : "bg-white text-gray-700 border-gray-300"}`}
445
+ onClick={(): void => {
446
+ return setTempMinutes(m);
447
+ }}
448
+ >
449
+ {pad2(m)}
450
+ </button>
451
+ );
452
+ })}
453
+ </div>
454
+ </div>
455
+ <div className="mt-8 text-sm text-gray-500">
456
+ This time is in your {timezoneLabel} timezone.
457
+ </div>
458
+ </div>
459
+ </Modal>
460
+ )}
461
+
462
+ {props.error && (
463
+ <p
464
+ id="timepicker-error"
465
+ data-testid="error-message"
466
+ className="mt-1 text-sm text-red-400"
467
+ aria-live="polite"
468
+ >
469
+ {props.error}
470
+ </p>
471
+ )}
472
+ </>
473
+ );
474
+ };
475
+
476
+ export default TimePicker;