@refraktor/dates 0.0.3 → 0.0.4

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.
Files changed (30) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/build/components/date-range-picker/date-range-picker.d.ts +4 -0
  3. package/build/components/date-range-picker/date-range-picker.d.ts.map +1 -0
  4. package/build/components/date-range-picker/date-range-picker.js +379 -0
  5. package/build/components/date-range-picker/date-range-picker.types.d.ts +100 -0
  6. package/build/components/date-range-picker/date-range-picker.types.d.ts.map +1 -0
  7. package/build/components/date-range-picker/date-range-picker.types.js +1 -0
  8. package/build/components/date-range-picker/index.d.ts +3 -0
  9. package/build/components/date-range-picker/index.d.ts.map +1 -0
  10. package/build/components/date-range-picker/index.js +1 -0
  11. package/build/components/time-input/index.d.ts +1 -1
  12. package/build/components/time-input/index.d.ts.map +1 -1
  13. package/build/components/time-input/time-input.d.ts.map +1 -1
  14. package/build/components/time-input/time-input.js +7 -196
  15. package/build/components/time-input/time-input.types.d.ts +5 -83
  16. package/build/components/time-input/time-input.types.d.ts.map +1 -1
  17. package/build/components/time-picker/index.d.ts +1 -1
  18. package/build/components/time-picker/index.d.ts.map +1 -1
  19. package/build/components/time-picker/time-picker.d.ts.map +1 -1
  20. package/build/components/time-picker/time-picker.js +498 -350
  21. package/build/components/time-picker/time-picker.types.d.ts +96 -61
  22. package/build/components/time-picker/time-picker.types.d.ts.map +1 -1
  23. package/build/style.css +1 -1
  24. package/package.json +3 -3
  25. package/src/components/time-input/index.ts +6 -23
  26. package/src/components/time-input/time-input.tsx +48 -453
  27. package/src/components/time-input/time-input.types.ts +30 -163
  28. package/src/components/time-picker/index.ts +10 -19
  29. package/src/components/time-picker/time-picker.tsx +1088 -737
  30. package/src/components/time-picker/time-picker.types.ts +166 -135
@@ -1,737 +1,1088 @@
1
- import { useId, useUncontrolled } from "@refraktor/utils";
2
- import { KeyboardEvent, ReactNode, useMemo } from "react";
3
- import {
4
- createClassNamesConfig,
5
- createComponentConfig,
6
- factory,
7
- useClassNames,
8
- useProps,
9
- useTheme
10
- } from "@refraktor/core";
11
- import { getGridColumns, getPickerSizeStyles } from "../picker-shared";
12
- import {
13
- TimePickerClassNames,
14
- TimePickerFactoryPayload,
15
- TimePickerMode,
16
- TimePickerPeriod,
17
- TimePickerProps,
18
- TimePickerValue
19
- } from "./time-picker.types";
20
-
21
- const HOURS_IN_DAY = 24;
22
- const MINUTES_IN_HOUR = 60;
23
- const SECONDS_IN_MINUTE = 60;
24
- const SECONDS_IN_DAY = HOURS_IN_DAY * MINUTES_IN_HOUR * SECONDS_IN_MINUTE;
25
- const DEFAULT_MODE: TimePickerMode = "24h";
26
- const TIME_SEGMENT_PATTERN = /^\d{1,2}$/;
27
-
28
- const defaultProps = {
29
- mode: DEFAULT_MODE,
30
- disabled: false,
31
- size: "md",
32
- radius: "default"
33
- } satisfies Partial<TimePickerProps>;
34
-
35
- type TimeBounds = {
36
- minSeconds: number;
37
- maxSeconds: number;
38
- hasMin: boolean;
39
- hasMax: boolean;
40
- };
41
-
42
- type TimeParts = {
43
- hours24: number;
44
- hour12: number;
45
- minutes: number;
46
- seconds: number;
47
- period: TimePickerPeriod;
48
- };
49
-
50
- type PickerOptionValue = number | TimePickerPeriod;
51
-
52
- type PickerOption<TValue extends PickerOptionValue = PickerOptionValue> = {
53
- value: TValue;
54
- label: ReactNode;
55
- ariaLabel: string;
56
- selected: boolean;
57
- disabled: boolean;
58
- };
59
-
60
- const pad = (value: number) => String(value).padStart(2, "0");
61
-
62
- const to12Hour = (hours24: number) => {
63
- const normalized = hours24 % 12;
64
- return normalized === 0 ? 12 : normalized;
65
- };
66
-
67
- const to24Hour = (hour12: number, period: TimePickerPeriod) => {
68
- const normalizedHour = hour12 % 12;
69
- return period === "pm" ? normalizedHour + 12 : normalizedHour;
70
- };
71
-
72
- const toSecondsOfDay = (hours24: number, minutes: number, seconds: number) =>
73
- hours24 * MINUTES_IN_HOUR * SECONDS_IN_MINUTE + minutes * SECONDS_IN_MINUTE + seconds;
74
-
75
- const createTimeParts = (hours24: number, minutes: number, seconds: number): TimeParts => {
76
- const normalizedHours24 = hours24 % HOURS_IN_DAY;
77
-
78
- return {
79
- hours24: normalizedHours24,
80
- hour12: to12Hour(normalizedHours24),
81
- minutes,
82
- seconds,
83
- period: normalizedHours24 >= 12 ? "pm" : "am"
84
- };
85
- };
86
-
87
- const parseTimeValue = (value: unknown): TimeParts | undefined => {
88
- if (typeof value !== "string") {
89
- return undefined;
90
- }
91
-
92
- const segments = value.trim().split(":");
93
-
94
- if (
95
- segments.length !== 3 ||
96
- !segments.every((segment) => TIME_SEGMENT_PATTERN.test(segment))
97
- ) {
98
- return undefined;
99
- }
100
-
101
- const [hoursText, minutesText, secondsText] = segments;
102
- const hours24 = Number.parseInt(hoursText, 10);
103
- const minutes = Number.parseInt(minutesText, 10);
104
- const seconds = Number.parseInt(secondsText, 10);
105
-
106
- if (
107
- hours24 < 0 ||
108
- hours24 >= HOURS_IN_DAY ||
109
- minutes < 0 ||
110
- minutes >= MINUTES_IN_HOUR ||
111
- seconds < 0 ||
112
- seconds >= SECONDS_IN_MINUTE
113
- ) {
114
- return undefined;
115
- }
116
-
117
- return createTimeParts(hours24, minutes, seconds);
118
- };
119
-
120
- const formatTimeValue = (hours24: number, minutes: number, seconds: number) =>
121
- `${pad(hours24)}:${pad(minutes)}:${pad(seconds)}`;
122
-
123
- const toTimePartsFromSeconds = (seconds: number) => {
124
- const normalizedSeconds =
125
- ((seconds % SECONDS_IN_DAY) + SECONDS_IN_DAY) % SECONDS_IN_DAY;
126
- const hours24 = Math.floor(
127
- normalizedSeconds / (MINUTES_IN_HOUR * SECONDS_IN_MINUTE)
128
- );
129
- const minutes = Math.floor(
130
- (normalizedSeconds % (MINUTES_IN_HOUR * SECONDS_IN_MINUTE)) /
131
- SECONDS_IN_MINUTE
132
- );
133
- const remainingSeconds = normalizedSeconds % SECONDS_IN_MINUTE;
134
-
135
- return createTimeParts(hours24, minutes, remainingSeconds);
136
- };
137
-
138
- const clampTimeParts = (parts: TimeParts, bounds: TimeBounds) => {
139
- const seconds = toSecondsOfDay(parts.hours24, parts.minutes, parts.seconds);
140
-
141
- if (bounds.hasMin && seconds < bounds.minSeconds) {
142
- return toTimePartsFromSeconds(bounds.minSeconds);
143
- }
144
-
145
- if (bounds.hasMax && seconds > bounds.maxSeconds) {
146
- return toTimePartsFromSeconds(bounds.maxSeconds);
147
- }
148
-
149
- return parts;
150
- };
151
-
152
- const getCurrentTimeParts = () => {
153
- const current = new Date();
154
- return createTimeParts(
155
- current.getHours(),
156
- current.getMinutes(),
157
- current.getSeconds()
158
- );
159
- };
160
-
161
- const getTimeBounds = (
162
- minTime?: TimePickerValue,
163
- maxTime?: TimePickerValue
164
- ): TimeBounds => {
165
- const minParts = parseTimeValue(minTime);
166
- const maxParts = parseTimeValue(maxTime);
167
- const hasMin = minParts !== undefined;
168
- const hasMax = maxParts !== undefined;
169
-
170
- const minSeconds = hasMin
171
- ? toSecondsOfDay(minParts.hours24, minParts.minutes, minParts.seconds)
172
- : Number.NEGATIVE_INFINITY;
173
- const maxSeconds = hasMax
174
- ? toSecondsOfDay(maxParts.hours24, maxParts.minutes, maxParts.seconds)
175
- : Number.POSITIVE_INFINITY;
176
-
177
- if (hasMin && hasMax && minSeconds > maxSeconds) {
178
- return {
179
- minSeconds: maxSeconds,
180
- maxSeconds: minSeconds,
181
- hasMin: true,
182
- hasMax: true
183
- };
184
- }
185
-
186
- return {
187
- minSeconds,
188
- maxSeconds,
189
- hasMin,
190
- hasMax
191
- };
192
- };
193
-
194
- const isTimeDisabled = (seconds: number, disabled: boolean, bounds: TimeBounds) =>
195
- disabled ||
196
- (bounds.hasMin && seconds < bounds.minSeconds) ||
197
- (bounds.hasMax && seconds > bounds.maxSeconds);
198
-
199
- const getSelectablePeriodRange = (
200
- period: TimePickerPeriod,
201
- bounds: TimeBounds
202
- ) => {
203
- const periodStart = period === "am" ? 0 : SECONDS_IN_DAY / 2;
204
- const periodEnd = period === "am" ? SECONDS_IN_DAY / 2 - 1 : SECONDS_IN_DAY - 1;
205
-
206
- const start = Math.max(periodStart, bounds.minSeconds);
207
- const end = Math.min(periodEnd, bounds.maxSeconds);
208
-
209
- if (start > end) {
210
- return undefined;
211
- }
212
-
213
- return { start, end };
214
- };
215
-
216
- const getFirstEnabledIndex = <TValue extends PickerOptionValue>(
217
- options: PickerOption<TValue>[]
218
- ) => options.findIndex((option) => !option.disabled);
219
-
220
- const getLastEnabledIndex = <TValue extends PickerOptionValue>(
221
- options: PickerOption<TValue>[]
222
- ) => {
223
- for (let index = options.length - 1; index >= 0; index -= 1) {
224
- if (!options[index].disabled) {
225
- return index;
226
- }
227
- }
228
-
229
- return -1;
230
- };
231
-
232
- const findNextEnabledIndex = <TValue extends PickerOptionValue>(
233
- options: PickerOption<TValue>[],
234
- startIndex: number,
235
- direction: 1 | -1
236
- ) => {
237
- let index = startIndex + direction;
238
-
239
- while (index >= 0 && index < options.length) {
240
- if (!options[index].disabled) {
241
- return index;
242
- }
243
-
244
- index += direction;
245
- }
246
-
247
- return startIndex;
248
- };
249
-
250
- const handleListKeyDown = <TValue extends PickerOptionValue>(
251
- event: KeyboardEvent<HTMLDivElement>,
252
- options: PickerOption<TValue>[],
253
- onSelect: (value: TValue) => void
254
- ) => {
255
- const firstEnabledIndex = getFirstEnabledIndex(options);
256
- const lastEnabledIndex = getLastEnabledIndex(options);
257
-
258
- if (firstEnabledIndex === -1 || lastEnabledIndex === -1) {
259
- return;
260
- }
261
-
262
- const selectedIndex = options.findIndex((option) => option.selected);
263
-
264
- if (event.key === "Home") {
265
- event.preventDefault();
266
- onSelect(options[firstEnabledIndex].value);
267
- return;
268
- }
269
-
270
- if (event.key === "End") {
271
- event.preventDefault();
272
- onSelect(options[lastEnabledIndex].value);
273
- return;
274
- }
275
-
276
- const applyStep = (direction: 1 | -1, repeat = 1) => {
277
- event.preventDefault();
278
-
279
- let index =
280
- selectedIndex === -1
281
- ? direction === 1
282
- ? firstEnabledIndex
283
- : lastEnabledIndex
284
- : selectedIndex;
285
-
286
- for (let step = 0; step < repeat; step += 1) {
287
- const nextIndex = findNextEnabledIndex(options, index, direction);
288
-
289
- if (nextIndex === index) {
290
- break;
291
- }
292
-
293
- index = nextIndex;
294
- }
295
-
296
- onSelect(options[index].value);
297
- };
298
-
299
- if (event.key === "ArrowDown") {
300
- applyStep(1);
301
- return;
302
- }
303
-
304
- if (event.key === "ArrowUp") {
305
- applyStep(-1);
306
- return;
307
- }
308
-
309
- if (event.key === "PageDown") {
310
- applyStep(1, 5);
311
- return;
312
- }
313
-
314
- if (event.key === "PageUp") {
315
- applyStep(-1, 5);
316
- }
317
- };
318
-
319
- const TimePicker = factory<TimePickerFactoryPayload>((_props, ref) => {
320
- const { cx, getRadius } = useTheme();
321
- const {
322
- id,
323
- value,
324
- defaultValue,
325
- onChange,
326
- minTime,
327
- maxTime,
328
- mode,
329
- disabled,
330
- size,
331
- radius,
332
- getHourLabel,
333
- getMinuteLabel,
334
- getSecondLabel,
335
- getPeriodLabel,
336
- getHourAriaLabel,
337
- getMinuteAriaLabel,
338
- getSecondAriaLabel,
339
- getPeriodAriaLabel,
340
- className,
341
- classNames,
342
- ...props
343
- } = useProps("TimePicker", defaultProps, _props);
344
- const classes = useClassNames("TimePicker", classNames);
345
-
346
- const _id = useId(id);
347
- const sizeStyles = getPickerSizeStyles(size);
348
-
349
- const bounds = useMemo(() => getTimeBounds(minTime, maxTime), [minTime, maxTime]);
350
-
351
- const [selectedTimeState, setSelectedTime] = useUncontrolled<
352
- TimePickerValue | undefined
353
- >({
354
- value,
355
- defaultValue,
356
- finalValue: undefined,
357
- onChange: (nextTime) => {
358
- if (nextTime !== undefined) {
359
- onChange?.(nextTime);
360
- }
361
- }
362
- });
363
-
364
- const selectedParts = useMemo(() => {
365
- const parsed = parseTimeValue(selectedTimeState);
366
-
367
- if (!parsed) {
368
- return undefined;
369
- }
370
-
371
- return clampTimeParts(parsed, bounds);
372
- }, [bounds, selectedTimeState]);
373
- const selectedTime = useMemo(
374
- () =>
375
- selectedParts
376
- ? formatTimeValue(
377
- selectedParts.hours24,
378
- selectedParts.minutes,
379
- selectedParts.seconds
380
- )
381
- : undefined,
382
- [selectedParts]
383
- );
384
- const fallbackParts = useMemo(
385
- () => clampTimeParts(getCurrentTimeParts(), bounds),
386
- [bounds]
387
- );
388
- const activeParts = selectedParts ?? fallbackParts;
389
-
390
- const applyTime = (hours24: number, minutes: number, seconds: number) => {
391
- if (disabled) {
392
- return;
393
- }
394
-
395
- const nextSeconds = toSecondsOfDay(hours24, minutes, seconds);
396
-
397
- if (isTimeDisabled(nextSeconds, false, bounds)) {
398
- return;
399
- }
400
-
401
- const nextTime = formatTimeValue(hours24, minutes, seconds);
402
-
403
- if (selectedTime === nextTime) {
404
- return;
405
- }
406
-
407
- setSelectedTime(nextTime);
408
- };
409
-
410
- const setHour = (hour: number) => {
411
- const nextHour = mode === "12h" ? to24Hour(hour, activeParts.period) : hour;
412
- applyTime(nextHour, activeParts.minutes, activeParts.seconds);
413
- };
414
-
415
- const setMinute = (minute: number) => {
416
- applyTime(activeParts.hours24, minute, activeParts.seconds);
417
- };
418
-
419
- const setSecond = (second: number) => {
420
- applyTime(activeParts.hours24, activeParts.minutes, second);
421
- };
422
-
423
- const setPeriod = (period: TimePickerPeriod) => {
424
- const selectableRange = getSelectablePeriodRange(period, bounds);
425
-
426
- if (!selectableRange) {
427
- return;
428
- }
429
-
430
- const nextHour24 = to24Hour(activeParts.hour12, period);
431
- const candidateSeconds = toSecondsOfDay(
432
- nextHour24,
433
- activeParts.minutes,
434
- activeParts.seconds
435
- );
436
- const nextSeconds = Math.min(
437
- selectableRange.end,
438
- Math.max(selectableRange.start, candidateSeconds)
439
- );
440
- const nextParts = toTimePartsFromSeconds(nextSeconds);
441
-
442
- applyTime(nextParts.hours24, nextParts.minutes, nextParts.seconds);
443
- };
444
-
445
- const hourOptions = useMemo<PickerOption<number>[]>(
446
- () => {
447
- const totalHours = mode === "12h" ? 12 : HOURS_IN_DAY;
448
-
449
- return Array.from({ length: totalHours }, (_, index) => {
450
- const hour = mode === "12h" ? index + 1 : index;
451
- const hours24 =
452
- mode === "12h" ? to24Hour(hour, activeParts.period) : hour;
453
- const nextSeconds = toSecondsOfDay(
454
- hours24,
455
- activeParts.minutes,
456
- activeParts.seconds
457
- );
458
- const selected =
459
- selectedParts !== undefined &&
460
- (mode === "12h"
461
- ? selectedParts.hour12 === hour
462
- : selectedParts.hours24 === hour);
463
-
464
- return {
465
- value: hour,
466
- label: getHourLabel ? getHourLabel(hour, mode) : pad(hour),
467
- ariaLabel: getHourAriaLabel
468
- ? getHourAriaLabel(hour, mode, selected)
469
- : selected
470
- ? `Hour ${pad(hour)}, selected`
471
- : `Choose hour ${pad(hour)}`,
472
- selected,
473
- disabled: isTimeDisabled(nextSeconds, disabled ?? false, bounds)
474
- };
475
- });
476
- },
477
- [
478
- activeParts.minutes,
479
- activeParts.period,
480
- activeParts.seconds,
481
- bounds,
482
- disabled,
483
- getHourAriaLabel,
484
- getHourLabel,
485
- mode,
486
- selectedParts
487
- ]
488
- );
489
-
490
- const minuteOptions = useMemo<PickerOption<number>[]>(
491
- () =>
492
- Array.from({ length: MINUTES_IN_HOUR }, (_, minute) => {
493
- const nextSeconds = toSecondsOfDay(
494
- activeParts.hours24,
495
- minute,
496
- activeParts.seconds
497
- );
498
- const selected = selectedParts?.minutes === minute;
499
-
500
- return {
501
- value: minute,
502
- label: getMinuteLabel ? getMinuteLabel(minute) : pad(minute),
503
- ariaLabel: getMinuteAriaLabel
504
- ? getMinuteAriaLabel(minute, selected)
505
- : selected
506
- ? `Minute ${pad(minute)}, selected`
507
- : `Choose minute ${pad(minute)}`,
508
- selected,
509
- disabled: isTimeDisabled(nextSeconds, disabled ?? false, bounds)
510
- };
511
- }),
512
- [
513
- activeParts.hours24,
514
- activeParts.seconds,
515
- bounds,
516
- disabled,
517
- getMinuteAriaLabel,
518
- getMinuteLabel,
519
- selectedParts
520
- ]
521
- );
522
-
523
- const secondOptions = useMemo<PickerOption<number>[]>(
524
- () =>
525
- Array.from({ length: SECONDS_IN_MINUTE }, (_, second) => {
526
- const nextSeconds = toSecondsOfDay(
527
- activeParts.hours24,
528
- activeParts.minutes,
529
- second
530
- );
531
- const selected = selectedParts?.seconds === second;
532
-
533
- return {
534
- value: second,
535
- label: getSecondLabel ? getSecondLabel(second) : pad(second),
536
- ariaLabel: getSecondAriaLabel
537
- ? getSecondAriaLabel(second, selected)
538
- : selected
539
- ? `Second ${pad(second)}, selected`
540
- : `Choose second ${pad(second)}`,
541
- selected,
542
- disabled: isTimeDisabled(nextSeconds, disabled ?? false, bounds)
543
- };
544
- }),
545
- [
546
- activeParts.hours24,
547
- activeParts.minutes,
548
- bounds,
549
- disabled,
550
- getSecondAriaLabel,
551
- getSecondLabel,
552
- selectedParts
553
- ]
554
- );
555
-
556
- const periodOptions = useMemo<PickerOption<TimePickerPeriod>[]>(
557
- () =>
558
- (["am", "pm"] as const).map((period) => {
559
- const selected = selectedParts?.period === period;
560
-
561
- return {
562
- value: period,
563
- label: getPeriodLabel
564
- ? getPeriodLabel(period)
565
- : period.toUpperCase(),
566
- ariaLabel: getPeriodAriaLabel
567
- ? getPeriodAriaLabel(period, selected)
568
- : selected
569
- ? `${period.toUpperCase()}, selected`
570
- : `Choose ${period.toUpperCase()}`,
571
- selected,
572
- disabled:
573
- (disabled ?? false) ||
574
- !getSelectablePeriodRange(period, bounds)
575
- };
576
- }),
577
- [
578
- bounds,
579
- disabled,
580
- getPeriodAriaLabel,
581
- getPeriodLabel,
582
- selectedParts
583
- ]
584
- );
585
-
586
- const renderSection = <TValue extends PickerOptionValue>({
587
- label,
588
- labelId,
589
- listLabel,
590
- options,
591
- onSelect,
592
- className
593
- }: {
594
- label: ReactNode;
595
- labelId: string;
596
- listLabel: string;
597
- options: PickerOption<TValue>[];
598
- onSelect: (value: TValue) => void;
599
- className?: string;
600
- }) => {
601
- const hasVisibleSelection = options.some((option) => option.selected);
602
- const firstEnabledIndex = getFirstEnabledIndex(options);
603
-
604
- return (
605
- <div
606
- role="group"
607
- aria-labelledby={labelId}
608
- className={cx("flex flex-col min-w-0", classes.section, className)}
609
- >
610
- <div
611
- id={labelId}
612
- className={cx(
613
- "py-2 text-center font-medium text-[var(--refraktor-text-secondary)] border-b border-[var(--refraktor-border)] bg-[var(--refraktor-bg-subtle)]",
614
- sizeStyles.label,
615
- classes.sectionLabel
616
- )}
617
- >
618
- {label}
619
- </div>
620
-
621
- <div
622
- role="listbox"
623
- aria-label={listLabel}
624
- className={cx(
625
- "refraktor-scrollbar flex max-h-64 flex-col p-1 overflow-y-auto",
626
- sizeStyles.gridGap,
627
- classes.list
628
- )}
629
- onKeyDown={(event) => handleListKeyDown(event, options, onSelect)}
630
- >
631
- {options.map((option, index) => {
632
- const tabIndex =
633
- option.selected ||
634
- (!hasVisibleSelection && index === firstEnabledIndex)
635
- ? 0
636
- : -1;
637
-
638
- return (
639
- <button
640
- key={`${labelId}-${String(option.value)}`}
641
- type="button"
642
- role="option"
643
- aria-selected={option.selected}
644
- aria-label={option.ariaLabel}
645
- data-active={option.selected}
646
- data-disabled={option.disabled}
647
- disabled={option.disabled}
648
- tabIndex={tabIndex}
649
- className={cx(
650
- "inline-flex w-full items-center justify-center font-medium text-[var(--refraktor-text)] transition-colors",
651
- option.selected
652
- ? "bg-[var(--refraktor-primary)] text-[var(--refraktor-primary-text)]"
653
- : "hover:bg-[var(--refraktor-bg-hover)]",
654
- option.disabled &&
655
- "pointer-events-none cursor-not-allowed opacity-50",
656
- sizeStyles.cell,
657
- getRadius(radius),
658
- classes.option,
659
- option.selected && classes.optionActive,
660
- option.disabled && classes.optionDisabled
661
- )}
662
- onClick={() => onSelect(option.value)}
663
- >
664
- {option.label}
665
- </button>
666
- );
667
- })}
668
- </div>
669
- </div>
670
- );
671
- };
672
-
673
- return (
674
- <div
675
- ref={ref}
676
- id={_id}
677
- className={cx(
678
- "inline-flex w-full flex-col bg-[var(--refraktor-bg)] overflow-hidden border border-[var(--refraktor-border)]",
679
- getRadius(radius),
680
- classes.root,
681
- className
682
- )}
683
- {...props}
684
- >
685
- <div
686
- className={cx(
687
- "grid divide-x divide-[var(--refraktor-border)]",
688
- getGridColumns(mode === "12h" ? 4 : 3),
689
- classes.grid
690
- )}
691
- >
692
- {renderSection({
693
- label: "Hour",
694
- labelId: `${_id}-hour-label`,
695
- listLabel: "Hour options",
696
- options: hourOptions,
697
- onSelect: setHour,
698
- className: classes.hourSection
699
- })}
700
-
701
- {renderSection({
702
- label: "Minute",
703
- labelId: `${_id}-minute-label`,
704
- listLabel: "Minute options",
705
- options: minuteOptions,
706
- onSelect: setMinute,
707
- className: classes.minuteSection
708
- })}
709
-
710
- {renderSection({
711
- label: "Second",
712
- labelId: `${_id}-second-label`,
713
- listLabel: "Second options",
714
- options: secondOptions,
715
- onSelect: setSecond,
716
- className: classes.secondSection
717
- })}
718
-
719
- {mode === "12h" &&
720
- renderSection({
721
- label: "Period",
722
- labelId: `${_id}-period-label`,
723
- listLabel: "AM or PM options",
724
- options: periodOptions,
725
- onSelect: setPeriod,
726
- className: classes.periodSection
727
- })}
728
- </div>
729
- </div>
730
- );
731
- });
732
-
733
- TimePicker.displayName = "@refraktor/dates/TimePicker";
734
- TimePicker.configure = createComponentConfig<TimePickerProps>();
735
- TimePicker.classNames = createClassNamesConfig<TimePickerClassNames>();
736
-
737
- export default TimePicker;
1
+ import { useId, useUncontrolled } from "@refraktor/utils";
2
+ import {
3
+ KeyboardEvent,
4
+ ReactNode,
5
+ Ref,
6
+ useCallback,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState
11
+ } from "react";
12
+ import {
13
+ createClassNamesConfig,
14
+ createComponentConfig,
15
+ factory,
16
+ Input,
17
+ useClassNames,
18
+ useProps,
19
+ useTheme
20
+ } from "@refraktor/core";
21
+ import {
22
+ autoUpdate,
23
+ flip,
24
+ FloatingFocusManager,
25
+ FloatingPortal,
26
+ offset,
27
+ shift,
28
+ useClick,
29
+ useDismiss,
30
+ useFloating,
31
+ useInteractions,
32
+ useRole
33
+ } from "@floating-ui/react";
34
+ import { getPickerSizeStyles } from "../picker-shared";
35
+ import {
36
+ TimePickerClassNames,
37
+ TimePickerFactoryPayload,
38
+ TimePickerFormat,
39
+ TimePickerProps,
40
+ TimePickerValue
41
+ } from "./time-picker.types";
42
+
43
+ const HOURS_IN_DAY = 24;
44
+ const MINUTES_IN_HOUR = 60;
45
+ const SECONDS_IN_MINUTE = 60;
46
+ const PLACEHOLDER = "--";
47
+
48
+ const defaultProps = {
49
+ format: "24h" as TimePickerFormat,
50
+ withSeconds: false,
51
+ withDropdown: false,
52
+ clearable: false,
53
+ disabled: false,
54
+ readOnly: false,
55
+ variant: "default" as const,
56
+ size: "md" as const,
57
+ radius: "default" as const,
58
+ hoursStep: 1,
59
+ minutesStep: 1,
60
+ secondsStep: 1,
61
+ amPmLabels: { am: "AM", pm: "PM" }
62
+ } satisfies Partial<TimePickerProps>;
63
+
64
+ type TimeParts = {
65
+ hours: number | null;
66
+ minutes: number | null;
67
+ seconds: number | null;
68
+ amPm: "AM" | "PM" | null;
69
+ };
70
+
71
+ const pad = (value: number) => String(value).padStart(2, "0");
72
+
73
+ const parseValue = (
74
+ value: string | undefined,
75
+ withSeconds: boolean
76
+ ): TimeParts => {
77
+ if (!value || value === "") {
78
+ return { hours: null, minutes: null, seconds: null, amPm: null };
79
+ }
80
+
81
+ const segments = value.trim().split(":");
82
+ if (segments.length < 2) {
83
+ return { hours: null, minutes: null, seconds: null, amPm: null };
84
+ }
85
+
86
+ const hours = Number.parseInt(segments[0], 10);
87
+ const minutes = Number.parseInt(segments[1], 10);
88
+ const seconds =
89
+ segments.length >= 3 ? Number.parseInt(segments[2], 10) : 0;
90
+
91
+ if (
92
+ Number.isNaN(hours) ||
93
+ Number.isNaN(minutes) ||
94
+ Number.isNaN(seconds) ||
95
+ hours < 0 ||
96
+ hours >= HOURS_IN_DAY ||
97
+ minutes < 0 ||
98
+ minutes >= MINUTES_IN_HOUR ||
99
+ seconds < 0 ||
100
+ seconds >= SECONDS_IN_MINUTE
101
+ ) {
102
+ return { hours: null, minutes: null, seconds: null, amPm: null };
103
+ }
104
+
105
+ return {
106
+ hours,
107
+ minutes,
108
+ seconds: withSeconds ? seconds : null,
109
+ amPm: hours >= 12 ? "PM" : "AM"
110
+ };
111
+ };
112
+
113
+ const formatValue = (parts: TimeParts, withSeconds: boolean): string => {
114
+ if (parts.hours === null || parts.minutes === null) {
115
+ return "";
116
+ }
117
+
118
+ const base = `${pad(parts.hours)}:${pad(parts.minutes)}`;
119
+
120
+ if (withSeconds) {
121
+ return `${base}:${pad(parts.seconds ?? 0)}`;
122
+ }
123
+
124
+ return base;
125
+ };
126
+
127
+ const to12Hour = (hours24: number): number => {
128
+ if (hours24 === 0 || hours24 === 12) return 12;
129
+ return hours24 % 12;
130
+ };
131
+
132
+ const to24Hour = (hour12: number, amPm: "AM" | "PM"): number => {
133
+ if (amPm === "AM") {
134
+ return hour12 === 12 ? 0 : hour12;
135
+ }
136
+ return hour12 === 12 ? 12 : hour12 + 12;
137
+ };
138
+
139
+ const clampValue = (
140
+ value: number,
141
+ min: number,
142
+ max: number,
143
+ step: number
144
+ ): number => {
145
+ const clamped = Math.max(min, Math.min(max, value));
146
+ return Math.round(clamped / step) * step;
147
+ };
148
+
149
+ const parseMinMax = (
150
+ timeStr: string | undefined
151
+ ): { hours: number; minutes: number; seconds: number } | null => {
152
+ if (!timeStr) return null;
153
+ const segments = timeStr.trim().split(":");
154
+ if (segments.length < 2) return null;
155
+
156
+ const hours = Number.parseInt(segments[0], 10);
157
+ const minutes = Number.parseInt(segments[1], 10);
158
+ const seconds =
159
+ segments.length >= 3 ? Number.parseInt(segments[2], 10) : 0;
160
+
161
+ if (Number.isNaN(hours) || Number.isNaN(minutes) || Number.isNaN(seconds))
162
+ return null;
163
+
164
+ return { hours, minutes, seconds };
165
+ };
166
+
167
+ const setRef = <T,>(ref: Ref<T> | undefined, node: T | null) => {
168
+ if (typeof ref === "function") {
169
+ ref(node);
170
+ } else if (ref && "current" in ref) {
171
+ (ref as React.MutableRefObject<T | null>).current = node;
172
+ }
173
+ };
174
+
175
+ const inputSizes: Record<string, string> = {
176
+ xs: "h-5 px-2 text-[8px]",
177
+ sm: "h-6 px-2.5 text-[10px]",
178
+ md: "h-8 px-3 text-xs",
179
+ lg: "h-10 px-3.5 text-sm",
180
+ xl: "h-12 px-4 text-base"
181
+ };
182
+
183
+ const inputVariants: Record<string, string> = {
184
+ default:
185
+ "bg-[var(--refraktor-bg)] text-[var(--refraktor-text)] border border-[var(--refraktor-border)]",
186
+ filled: "bg-[var(--refraktor-bg)] text-[var(--refraktor-text)]",
187
+ outline:
188
+ "bg-transparent text-[var(--refraktor-text)] border border-[var(--refraktor-border)]"
189
+ };
190
+
191
+ const segmentWidths: Record<string, string> = {
192
+ xs: "w-[1.25rem]",
193
+ sm: "w-[1.5rem]",
194
+ md: "w-[1.75rem]",
195
+ lg: "w-[2rem]",
196
+ xl: "w-[2.5rem]"
197
+ };
198
+
199
+ const amPmWidths: Record<string, string> = {
200
+ xs: "w-[1.75rem]",
201
+ sm: "w-[2rem]",
202
+ md: "w-[2.25rem]",
203
+ lg: "w-[2.75rem]",
204
+ xl: "w-[3.25rem]"
205
+ };
206
+
207
+ const separatorSizes: Record<string, string> = {
208
+ xs: "text-[8px]",
209
+ sm: "text-[10px]",
210
+ md: "text-xs",
211
+ lg: "text-sm",
212
+ xl: "text-base"
213
+ };
214
+
215
+ const TimePicker = factory<TimePickerFactoryPayload>((_props, ref) => {
216
+ const { cx, getRadius } = useTheme();
217
+ const {
218
+ id,
219
+ value,
220
+ defaultValue,
221
+ onChange,
222
+ format,
223
+ withSeconds,
224
+ withDropdown,
225
+ clearable,
226
+ min,
227
+ max,
228
+ hoursStep,
229
+ minutesStep,
230
+ secondsStep,
231
+ amPmLabels,
232
+ disabled,
233
+ readOnly,
234
+ variant,
235
+ size,
236
+ radius,
237
+ label,
238
+ description,
239
+ error,
240
+ required,
241
+ withAsterisk,
242
+ leftSection,
243
+ rightSection,
244
+ hoursRef: hoursRefProp,
245
+ minutesRef: minutesRefProp,
246
+ secondsRef: secondsRefProp,
247
+ amPmRef: amPmRefProp,
248
+ hoursInputLabel,
249
+ minutesInputLabel,
250
+ secondsInputLabel,
251
+ amPmInputLabel,
252
+ popoverProps,
253
+ onFocus,
254
+ onBlur,
255
+ className,
256
+ classNames,
257
+ ...props
258
+ } = useProps("TimePicker", defaultProps, _props);
259
+ const classes = useClassNames("TimePicker", classNames);
260
+
261
+ const _id = useId(id);
262
+ const is12h = format === "12h";
263
+
264
+ const hoursRef = useRef<HTMLInputElement>(null);
265
+ const minutesRef = useRef<HTMLInputElement>(null);
266
+ const secondsRef = useRef<HTMLInputElement>(null);
267
+ const amPmRef = useRef<HTMLInputElement>(null);
268
+ const wrapperRef = useRef<HTMLDivElement>(null);
269
+ const blurTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
270
+
271
+ const minParsed = useMemo(() => parseMinMax(min), [min]);
272
+ const maxParsed = useMemo(() => parseMinMax(max), [max]);
273
+
274
+ const [internalValue, setInternalValue] = useUncontrolled<string>({
275
+ value,
276
+ defaultValue,
277
+ finalValue: "",
278
+ onChange
279
+ });
280
+
281
+ const [parts, setParts] = useState<TimeParts>(() =>
282
+ parseValue(internalValue, withSeconds)
283
+ );
284
+
285
+ useEffect(() => {
286
+ const parsed = parseValue(internalValue, withSeconds);
287
+ setParts(parsed);
288
+ }, [internalValue, withSeconds]);
289
+
290
+ const [dropdownOpen, setDropdownOpen] = useState(false);
291
+ const isDropdownVisible = withDropdown && dropdownOpen && !disabled;
292
+
293
+ const floating = useFloating({
294
+ placement: popoverProps?.placement ?? "bottom-start",
295
+ open: isDropdownVisible,
296
+ onOpenChange: (open) => {
297
+ if (!disabled && !readOnly) setDropdownOpen(open);
298
+ },
299
+ middleware: [
300
+ offset(popoverProps?.offset ?? 4),
301
+ flip(),
302
+ shift()
303
+ ],
304
+ whileElementsMounted: autoUpdate,
305
+ strategy: "fixed"
306
+ });
307
+
308
+ const click = useClick(floating.context, {
309
+ enabled: withDropdown && !disabled && !readOnly
310
+ });
311
+ const dismiss = useDismiss(floating.context);
312
+ const role = useRole(floating.context, { role: "dialog" });
313
+ const { getReferenceProps, getFloatingProps } = useInteractions([
314
+ click,
315
+ dismiss,
316
+ role
317
+ ]);
318
+
319
+ const emitValue = useCallback(
320
+ (nextParts: TimeParts) => {
321
+ const allFilled =
322
+ nextParts.hours !== null &&
323
+ nextParts.minutes !== null &&
324
+ (!withSeconds || nextParts.seconds !== null) &&
325
+ (!is12h || nextParts.amPm !== null);
326
+
327
+ const allEmpty =
328
+ nextParts.hours === null &&
329
+ nextParts.minutes === null &&
330
+ (nextParts.seconds === null || !withSeconds);
331
+
332
+ if (allFilled) {
333
+ let hours = nextParts.hours!;
334
+ if (is12h && nextParts.amPm !== null) {
335
+ hours = to24Hour(hours, nextParts.amPm);
336
+ }
337
+ const formatted = formatValue(
338
+ { ...nextParts, hours },
339
+ withSeconds
340
+ );
341
+ setInternalValue(formatted);
342
+ } else if (allEmpty) {
343
+ setInternalValue("");
344
+ }
345
+ },
346
+ [is12h, setInternalValue, withSeconds]
347
+ );
348
+
349
+ const updateParts = useCallback(
350
+ (updater: (prev: TimeParts) => TimeParts) => {
351
+ setParts((prev) => {
352
+ const next = updater(prev);
353
+ emitValue(next);
354
+ return next;
355
+ });
356
+ },
357
+ [emitValue]
358
+ );
359
+
360
+ const getDisplayHours = useCallback(
361
+ (hours24: number | null): string => {
362
+ if (hours24 === null) return PLACEHOLDER;
363
+ if (is12h) return pad(to12Hour(hours24));
364
+ return pad(hours24);
365
+ },
366
+ [is12h]
367
+ );
368
+
369
+ const handleClear = useCallback(() => {
370
+ if (disabled || readOnly) return;
371
+ const empty: TimeParts = {
372
+ hours: null,
373
+ minutes: null,
374
+ seconds: null,
375
+ amPm: null
376
+ };
377
+ setParts(empty);
378
+ setInternalValue("");
379
+ hoursRef.current?.focus();
380
+ }, [disabled, readOnly, setInternalValue]);
381
+
382
+ const createSegmentKeyHandler = useCallback(
383
+ (
384
+ segment: "hours" | "minutes" | "seconds",
385
+ prevRef: React.RefObject<HTMLInputElement | null>,
386
+ nextRef: React.RefObject<HTMLInputElement | null>
387
+ ) => {
388
+ return (event: KeyboardEvent<HTMLInputElement>) => {
389
+ if (disabled || readOnly) return;
390
+
391
+ const step =
392
+ segment === "hours"
393
+ ? hoursStep
394
+ : segment === "minutes"
395
+ ? minutesStep
396
+ : secondsStep;
397
+ const maxVal =
398
+ segment === "hours"
399
+ ? is12h
400
+ ? 12
401
+ : HOURS_IN_DAY - 1
402
+ : segment === "minutes"
403
+ ? MINUTES_IN_HOUR - 1
404
+ : SECONDS_IN_MINUTE - 1;
405
+ const minVal = segment === "hours" && is12h ? 1 : 0;
406
+
407
+ if (event.key === "ArrowUp") {
408
+ event.preventDefault();
409
+ updateParts((prev) => {
410
+ const current = prev[segment] ?? minVal;
411
+ let next = current + step;
412
+ if (next > maxVal) next = minVal;
413
+ return { ...prev, [segment]: next };
414
+ });
415
+ return;
416
+ }
417
+
418
+ if (event.key === "ArrowDown") {
419
+ event.preventDefault();
420
+ updateParts((prev) => {
421
+ const current = prev[segment] ?? maxVal;
422
+ let next = current - step;
423
+ if (next < minVal) next = maxVal;
424
+ return { ...prev, [segment]: next };
425
+ });
426
+ return;
427
+ }
428
+
429
+ if (event.key === "ArrowRight") {
430
+ event.preventDefault();
431
+ nextRef.current?.focus();
432
+ nextRef.current?.select();
433
+ return;
434
+ }
435
+
436
+ if (event.key === "ArrowLeft") {
437
+ event.preventDefault();
438
+ prevRef.current?.focus();
439
+ prevRef.current?.select();
440
+ return;
441
+ }
442
+
443
+ if (event.key === "Home") {
444
+ event.preventDefault();
445
+ updateParts((prev) => ({ ...prev, [segment]: minVal }));
446
+ return;
447
+ }
448
+
449
+ if (event.key === "End") {
450
+ event.preventDefault();
451
+ updateParts((prev) => ({ ...prev, [segment]: maxVal }));
452
+ return;
453
+ }
454
+
455
+ if (event.key === "Backspace") {
456
+ event.preventDefault();
457
+ updateParts((prev) => {
458
+ if (prev[segment] === null) {
459
+ prevRef.current?.focus();
460
+ prevRef.current?.select();
461
+ return prev;
462
+ }
463
+ return { ...prev, [segment]: null };
464
+ });
465
+ return;
466
+ }
467
+
468
+ if (event.key === "Tab") {
469
+ return;
470
+ }
471
+
472
+ if (/^\d$/.test(event.key)) {
473
+ event.preventDefault();
474
+ const digit = Number.parseInt(event.key, 10);
475
+ updateParts((prev) => {
476
+ const current = prev[segment];
477
+ let next: number;
478
+
479
+ if (current === null || current >= 10) {
480
+ next = digit;
481
+ } else {
482
+ next = current * 10 + digit;
483
+ }
484
+
485
+ if (next > maxVal) {
486
+ next = digit;
487
+ }
488
+
489
+ const result = { ...prev, [segment]: next };
490
+
491
+ if (next >= (maxVal + 1) / 10 || (current !== null && current < 10)) {
492
+ requestAnimationFrame(() => {
493
+ nextRef.current?.focus();
494
+ nextRef.current?.select();
495
+ });
496
+ }
497
+
498
+ return result;
499
+ });
500
+ return;
501
+ }
502
+
503
+ if (!event.ctrlKey && !event.metaKey && !event.altKey) {
504
+ event.preventDefault();
505
+ }
506
+ };
507
+ },
508
+ [disabled, hoursStep, is12h, minutesStep, readOnly, secondsStep, updateParts]
509
+ );
510
+
511
+ const handleAmPmKeyDown = useCallback(
512
+ (event: KeyboardEvent<HTMLInputElement>) => {
513
+ if (disabled || readOnly) return;
514
+
515
+ if (event.key === "ArrowUp" || event.key === "ArrowDown") {
516
+ event.preventDefault();
517
+ updateParts((prev) => ({
518
+ ...prev,
519
+ amPm: prev.amPm === "AM" ? "PM" : "AM"
520
+ }));
521
+ return;
522
+ }
523
+
524
+ if (event.key === "ArrowLeft") {
525
+ event.preventDefault();
526
+ const prev = withSeconds ? secondsRef : minutesRef;
527
+ prev.current?.focus();
528
+ prev.current?.select();
529
+ return;
530
+ }
531
+
532
+ if (
533
+ event.key.toLowerCase() === "a" ||
534
+ event.key.toLowerCase() === "p"
535
+ ) {
536
+ event.preventDefault();
537
+ const nextAmPm = event.key.toLowerCase() === "a" ? "AM" : "PM";
538
+ updateParts((prev) => ({ ...prev, amPm: nextAmPm }));
539
+ return;
540
+ }
541
+
542
+ if (event.key === "Backspace") {
543
+ event.preventDefault();
544
+ updateParts((prev) => {
545
+ if (prev.amPm === null) {
546
+ const prev2 = withSeconds ? secondsRef : minutesRef;
547
+ prev2.current?.focus();
548
+ prev2.current?.select();
549
+ return prev;
550
+ }
551
+ return { ...prev, amPm: null };
552
+ });
553
+ return;
554
+ }
555
+
556
+ if (event.key === "Tab") {
557
+ return;
558
+ }
559
+
560
+ if (!event.ctrlKey && !event.metaKey && !event.altKey) {
561
+ event.preventDefault();
562
+ }
563
+ },
564
+ [disabled, readOnly, updateParts, withSeconds]
565
+ );
566
+
567
+ const handleWrapperFocus = useCallback(
568
+ (event: React.FocusEvent<HTMLDivElement>) => {
569
+ if (blurTimeoutRef.current !== null) {
570
+ clearTimeout(blurTimeoutRef.current);
571
+ blurTimeoutRef.current = null;
572
+ }
573
+
574
+ if (!wrapperRef.current?.contains(event.relatedTarget as Node)) {
575
+ onFocus?.(event);
576
+ if (withDropdown && !disabled && !readOnly) {
577
+ setDropdownOpen(true);
578
+ }
579
+ }
580
+ },
581
+ [disabled, onFocus, readOnly, withDropdown]
582
+ );
583
+
584
+ const handleWrapperBlur = useCallback(
585
+ (event: React.FocusEvent<HTMLDivElement>) => {
586
+ blurTimeoutRef.current = setTimeout(() => {
587
+ if (
588
+ !wrapperRef.current?.contains(document.activeElement) &&
589
+ !floating.refs.floating.current?.contains(
590
+ document.activeElement
591
+ )
592
+ ) {
593
+ onBlur?.(event);
594
+ setDropdownOpen(false);
595
+ }
596
+ }, 0);
597
+ },
598
+ [floating.refs.floating, onBlur]
599
+ );
600
+
601
+ const displayHours = is12h
602
+ ? parts.hours !== null
603
+ ? pad(to12Hour(parts.hours))
604
+ : PLACEHOLDER
605
+ : parts.hours !== null
606
+ ? pad(parts.hours)
607
+ : PLACEHOLDER;
608
+ const displayMinutes =
609
+ parts.minutes !== null ? pad(parts.minutes) : PLACEHOLDER;
610
+ const displaySeconds =
611
+ parts.seconds !== null ? pad(parts.seconds) : PLACEHOLDER;
612
+ const displayAmPm = parts.amPm ?? PLACEHOLDER;
613
+
614
+ const hasValue =
615
+ parts.hours !== null ||
616
+ parts.minutes !== null ||
617
+ (withSeconds && parts.seconds !== null);
618
+
619
+ const showClearButton = clearable && hasValue && !disabled && !readOnly;
620
+
621
+ const effectiveRightSection = showClearButton ? (
622
+ <button
623
+ type="button"
624
+ tabIndex={-1}
625
+ aria-label="Clear time"
626
+ className={cx(
627
+ "inline-flex items-center justify-center text-[var(--refraktor-text-secondary)] hover:text-[var(--refraktor-text)] transition-colors cursor-pointer"
628
+ )}
629
+ onClick={handleClear}
630
+ onMouseDown={(e) => e.preventDefault()}
631
+ >
632
+ <svg
633
+ xmlns="http://www.w3.org/2000/svg"
634
+ viewBox="0 0 16 16"
635
+ fill="currentColor"
636
+ className="size-3.5"
637
+ >
638
+ <path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z" />
639
+ </svg>
640
+ </button>
641
+ ) : (
642
+ rightSection
643
+ );
644
+
645
+ const sizeClass = inputSizes[size] ?? inputSizes.md;
646
+ const variantClass = inputVariants[variant] ?? inputVariants.default;
647
+ const segmentWidth = segmentWidths[size] ?? segmentWidths.md;
648
+ const amPmWidth = amPmWidths[size] ?? amPmWidths.md;
649
+ const sepSize = separatorSizes[size] ?? separatorSizes.md;
650
+
651
+ const hoursKeyHandler = createSegmentKeyHandler(
652
+ "hours",
653
+ { current: null },
654
+ minutesRef
655
+ );
656
+ const minutesKeyHandler = createSegmentKeyHandler(
657
+ "minutes",
658
+ hoursRef,
659
+ withSeconds ? secondsRef : is12h ? amPmRef : { current: null }
660
+ );
661
+ const secondsKeyHandler = createSegmentKeyHandler(
662
+ "seconds",
663
+ minutesRef,
664
+ is12h ? amPmRef : { current: null }
665
+ );
666
+
667
+ const renderDropdown = () => {
668
+ if (!withDropdown) return null;
669
+
670
+ const sizeStyles = getPickerSizeStyles(size);
671
+ const hoursCount = is12h ? 12 : HOURS_IN_DAY;
672
+ const hoursStart = is12h ? 1 : 0;
673
+
674
+ const hourOptions: number[] = [];
675
+ for (let i = hoursStart; i < hoursStart + hoursCount; i += hoursStep) {
676
+ hourOptions.push(i);
677
+ }
678
+
679
+ const minuteOptions: number[] = [];
680
+ for (let i = 0; i < MINUTES_IN_HOUR; i += minutesStep) {
681
+ minuteOptions.push(i);
682
+ }
683
+
684
+ const secondOptions: number[] = [];
685
+ for (let i = 0; i < SECONDS_IN_MINUTE; i += secondsStep) {
686
+ secondOptions.push(i);
687
+ }
688
+
689
+ const currentHourDisplay = is12h
690
+ ? parts.hours !== null
691
+ ? to12Hour(parts.hours)
692
+ : null
693
+ : parts.hours;
694
+
695
+ const renderColumn = (
696
+ columnLabel: string,
697
+ options: (number | string)[],
698
+ selectedValue: number | string | null,
699
+ onSelect: (value: number | string) => void,
700
+ extraClassName?: string
701
+ ) => (
702
+ <div
703
+ className={cx(
704
+ "flex flex-col min-w-0",
705
+ classes.dropdownColumn,
706
+ extraClassName
707
+ )}
708
+ >
709
+ <div
710
+ className={cx(
711
+ "py-1.5 text-center font-medium text-[var(--refraktor-text-secondary)] border-b border-[var(--refraktor-border)] bg-[var(--refraktor-bg-subtle)]",
712
+ sizeStyles.label,
713
+ classes.dropdownColumnLabel
714
+ )}
715
+ >
716
+ {columnLabel}
717
+ </div>
718
+ <div
719
+ className={cx(
720
+ "refraktor-scrollbar flex max-h-52 flex-col gap-0.5 p-1 overflow-y-auto"
721
+ )}
722
+ >
723
+ {options.map((opt) => {
724
+ const isSelected = opt === selectedValue;
725
+ const displayLabel =
726
+ typeof opt === "number" ? pad(opt) : opt;
727
+
728
+ return (
729
+ <button
730
+ key={String(opt)}
731
+ type="button"
732
+ tabIndex={-1}
733
+ className={cx(
734
+ "inline-flex w-full items-center justify-center font-medium transition-colors",
735
+ isSelected
736
+ ? "bg-[var(--refraktor-primary)] text-[var(--refraktor-primary-text)]"
737
+ : "hover:bg-[var(--refraktor-bg-hover)] text-[var(--refraktor-text)]",
738
+ sizeStyles.cell,
739
+ getRadius(radius),
740
+ classes.dropdownOption,
741
+ isSelected && classes.dropdownOptionActive
742
+ )}
743
+ onMouseDown={(e) => e.preventDefault()}
744
+ onClick={() => onSelect(opt)}
745
+ >
746
+ {displayLabel}
747
+ </button>
748
+ );
749
+ })}
750
+ </div>
751
+ </div>
752
+ );
753
+
754
+ const handleDropdownHourSelect = (val: number | string) => {
755
+ const hourVal = typeof val === "number" ? val : Number(val);
756
+ updateParts((prev) => {
757
+ const next = { ...prev, hours: is12h ? to24Hour(hourVal, prev.amPm ?? "AM") : hourVal };
758
+ if (next.minutes === null) {
759
+ requestAnimationFrame(() => {
760
+ minutesRef.current?.focus();
761
+ minutesRef.current?.select();
762
+ });
763
+ }
764
+ return next;
765
+ });
766
+ };
767
+
768
+ const handleDropdownMinuteSelect = (val: number | string) => {
769
+ const minVal = typeof val === "number" ? val : Number(val);
770
+ updateParts((prev) => {
771
+ const next = { ...prev, minutes: minVal };
772
+ if (withSeconds && next.seconds === null) {
773
+ requestAnimationFrame(() => {
774
+ secondsRef.current?.focus();
775
+ secondsRef.current?.select();
776
+ });
777
+ }
778
+ return next;
779
+ });
780
+ };
781
+
782
+ const handleDropdownSecondSelect = (val: number | string) => {
783
+ const secVal = typeof val === "number" ? val : Number(val);
784
+ updateParts((prev) => ({ ...prev, seconds: secVal }));
785
+ };
786
+
787
+ const handleDropdownAmPmSelect = (val: number | string) => {
788
+ const amPmVal = val as "AM" | "PM";
789
+ updateParts((prev) => {
790
+ if (prev.hours === null) return { ...prev, amPm: amPmVal };
791
+ const hour12 = to12Hour(prev.hours);
792
+ const newHours24 = to24Hour(hour12, amPmVal);
793
+ return { ...prev, hours: newHours24, amPm: amPmVal };
794
+ });
795
+ };
796
+
797
+ const columnCount =
798
+ (withSeconds ? 4 : 3) + (is12h ? 1 : 0) - (withSeconds ? 0 : 1);
799
+ const gridColsClass =
800
+ columnCount === 2
801
+ ? "grid-cols-2"
802
+ : columnCount === 3
803
+ ? "grid-cols-3"
804
+ : "grid-cols-4";
805
+
806
+ return (
807
+ <FloatingPortal>
808
+ {isDropdownVisible && (
809
+ <FloatingFocusManager
810
+ context={floating.context}
811
+ modal={false}
812
+ initialFocus={-1}
813
+ returnFocus={false}
814
+ >
815
+ <div
816
+ ref={floating.refs.setFloating}
817
+ style={{
818
+ ...floating.floatingStyles,
819
+ zIndex: 1000
820
+ }}
821
+ className={cx(
822
+ "border border-[var(--refraktor-border)] bg-[var(--refraktor-bg)] shadow-md overflow-hidden",
823
+ getRadius(radius),
824
+ classes.dropdown
825
+ )}
826
+ {...getFloatingProps()}
827
+ >
828
+ <div
829
+ className={cx(
830
+ "grid divide-x divide-[var(--refraktor-border)]",
831
+ gridColsClass
832
+ )}
833
+ >
834
+ {renderColumn(
835
+ "Hour",
836
+ hourOptions,
837
+ currentHourDisplay,
838
+ handleDropdownHourSelect
839
+ )}
840
+ {renderColumn(
841
+ "Min",
842
+ minuteOptions,
843
+ parts.minutes,
844
+ handleDropdownMinuteSelect
845
+ )}
846
+ {withSeconds &&
847
+ renderColumn(
848
+ "Sec",
849
+ secondOptions,
850
+ parts.seconds,
851
+ handleDropdownSecondSelect
852
+ )}
853
+ {is12h &&
854
+ renderColumn(
855
+ amPmLabels.am + "/" + amPmLabels.pm,
856
+ [amPmLabels.am, amPmLabels.pm],
857
+ parts.amPm ===
858
+ "AM"
859
+ ? amPmLabels.am
860
+ : parts.amPm === "PM"
861
+ ? amPmLabels.pm
862
+ : null,
863
+ (val) => {
864
+ const normalized =
865
+ val === amPmLabels.am
866
+ ? "AM"
867
+ : "PM";
868
+ handleDropdownAmPmSelect(
869
+ normalized
870
+ );
871
+ }
872
+ )}
873
+ </div>
874
+ </div>
875
+ </FloatingFocusManager>
876
+ )}
877
+ </FloatingPortal>
878
+ );
879
+ };
880
+
881
+ const hasWrapper = label || description || error;
882
+ const inputContent = (
883
+ <div
884
+ ref={(node) => {
885
+ if (wrapperRef) (wrapperRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
886
+ floating.refs.setReference(node);
887
+ }}
888
+ className={cx(
889
+ "relative w-full inline-flex items-center transition-all",
890
+ sizeClass,
891
+ variantClass,
892
+ getRadius(radius),
893
+ "focus-within:border-[var(--refraktor-primary)]",
894
+ error && typeof error !== "boolean" && "border-[var(--refraktor-colors-red-6)]",
895
+ disabled && "opacity-50 cursor-not-allowed",
896
+ classes.fieldsWrapper,
897
+ !hasWrapper && className
898
+ )}
899
+ onFocus={handleWrapperFocus}
900
+ onBlur={handleWrapperBlur}
901
+ {...(!hasWrapper ? getReferenceProps(props) : getReferenceProps())}
902
+ >
903
+ {leftSection && (
904
+ <div className="flex h-full items-center justify-center text-[var(--refraktor-text-secondary)] shrink-0 select-none">
905
+ {leftSection}
906
+ </div>
907
+ )}
908
+
909
+ <div
910
+ className={cx(
911
+ "flex items-center flex-1 min-w-0 gap-0.5"
912
+ )}
913
+ >
914
+ <input
915
+ ref={(node) => {
916
+ hoursRef.current = node;
917
+ setRef(hoursRefProp, node);
918
+ }}
919
+ id={`${_id}-hours`}
920
+ type="text"
921
+ inputMode="numeric"
922
+ autoComplete="off"
923
+ placeholder={PLACEHOLDER}
924
+ value={
925
+ parts.hours !== null ? (is12h ? pad(to12Hour(parts.hours)) : pad(parts.hours)) : ""
926
+ }
927
+ aria-label={hoursInputLabel ?? "Hours"}
928
+ readOnly
929
+ tabIndex={disabled ? -1 : 0}
930
+ disabled={disabled}
931
+ className={cx(
932
+ "bg-transparent border-none outline-none text-center text-[var(--refraktor-text)] placeholder:text-[var(--refraktor-text-tertiary)] cursor-default select-all p-0",
933
+ segmentWidth,
934
+ classes.field
935
+ )}
936
+ onKeyDown={hoursKeyHandler}
937
+ onFocus={(e) => e.target.select()}
938
+ />
939
+
940
+ <span
941
+ className={cx(
942
+ "text-[var(--refraktor-text-secondary)] select-none leading-none",
943
+ sepSize,
944
+ classes.separator
945
+ )}
946
+ >
947
+ :
948
+ </span>
949
+
950
+ <input
951
+ ref={(node) => {
952
+ minutesRef.current = node;
953
+ setRef(minutesRefProp, node);
954
+ }}
955
+ id={`${_id}-minutes`}
956
+ type="text"
957
+ inputMode="numeric"
958
+ autoComplete="off"
959
+ placeholder={PLACEHOLDER}
960
+ value={
961
+ parts.minutes !== null ? pad(parts.minutes) : ""
962
+ }
963
+ aria-label={minutesInputLabel ?? "Minutes"}
964
+ readOnly
965
+ tabIndex={disabled ? -1 : 0}
966
+ disabled={disabled}
967
+ className={cx(
968
+ "bg-transparent border-none outline-none text-center text-[var(--refraktor-text)] placeholder:text-[var(--refraktor-text-tertiary)] cursor-default select-all p-0",
969
+ segmentWidth,
970
+ classes.field
971
+ )}
972
+ onKeyDown={minutesKeyHandler}
973
+ onFocus={(e) => e.target.select()}
974
+ />
975
+
976
+ {withSeconds && (
977
+ <>
978
+ <span
979
+ className={cx(
980
+ "text-[var(--refraktor-text-secondary)] select-none leading-none",
981
+ sepSize,
982
+ classes.separator
983
+ )}
984
+ >
985
+ :
986
+ </span>
987
+ <input
988
+ ref={(node) => {
989
+ secondsRef.current = node;
990
+ setRef(secondsRefProp, node);
991
+ }}
992
+ id={`${_id}-seconds`}
993
+ type="text"
994
+ inputMode="numeric"
995
+ autoComplete="off"
996
+ placeholder={PLACEHOLDER}
997
+ value={
998
+ parts.seconds !== null
999
+ ? pad(parts.seconds)
1000
+ : ""
1001
+ }
1002
+ aria-label={secondsInputLabel ?? "Seconds"}
1003
+ readOnly
1004
+ tabIndex={disabled ? -1 : 0}
1005
+ disabled={disabled}
1006
+ className={cx(
1007
+ "bg-transparent border-none outline-none text-center text-[var(--refraktor-text)] placeholder:text-[var(--refraktor-text-tertiary)] cursor-default select-all p-0",
1008
+ segmentWidth,
1009
+ classes.field
1010
+ )}
1011
+ onKeyDown={secondsKeyHandler}
1012
+ onFocus={(e) => e.target.select()}
1013
+ />
1014
+ </>
1015
+ )}
1016
+
1017
+ {is12h && (
1018
+ <input
1019
+ ref={(node) => {
1020
+ amPmRef.current = node;
1021
+ setRef(amPmRefProp, node);
1022
+ }}
1023
+ id={`${_id}-ampm`}
1024
+ type="text"
1025
+ autoComplete="off"
1026
+ placeholder={PLACEHOLDER}
1027
+ value={
1028
+ parts.amPm !== null
1029
+ ? parts.amPm === "AM"
1030
+ ? amPmLabels.am
1031
+ : amPmLabels.pm
1032
+ : ""
1033
+ }
1034
+ aria-label={amPmInputLabel ?? "AM/PM"}
1035
+ readOnly
1036
+ tabIndex={disabled ? -1 : 0}
1037
+ disabled={disabled}
1038
+ className={cx(
1039
+ "bg-transparent border-none outline-none text-center text-[var(--refraktor-text)] placeholder:text-[var(--refraktor-text-tertiary)] cursor-default select-all p-0 ml-1",
1040
+ amPmWidth,
1041
+ classes.amPmInput
1042
+ )}
1043
+ onKeyDown={handleAmPmKeyDown}
1044
+ onFocus={(e) => e.target.select()}
1045
+ />
1046
+ )}
1047
+ </div>
1048
+
1049
+ {effectiveRightSection && (
1050
+ <div className="flex h-full items-center justify-center text-[var(--refraktor-text-secondary)] shrink-0 select-none">
1051
+ {effectiveRightSection}
1052
+ </div>
1053
+ )}
1054
+ </div>
1055
+ );
1056
+
1057
+ const content = hasWrapper ? (
1058
+ <Input.Wrapper
1059
+ ref={ref}
1060
+ label={label}
1061
+ description={description}
1062
+ error={error}
1063
+ required={required}
1064
+ withAsterisk={withAsterisk}
1065
+ inputId={`${_id}-hours`}
1066
+ className={cx(classes.root, className)}
1067
+ >
1068
+ {inputContent}
1069
+ {renderDropdown()}
1070
+ </Input.Wrapper>
1071
+ ) : (
1072
+ <div
1073
+ ref={ref}
1074
+ className={cx(classes.root, className)}
1075
+ >
1076
+ {inputContent}
1077
+ {renderDropdown()}
1078
+ </div>
1079
+ );
1080
+
1081
+ return content;
1082
+ });
1083
+
1084
+ TimePicker.displayName = "@refraktor/dates/TimePicker";
1085
+ TimePicker.configure = createComponentConfig<TimePickerProps>();
1086
+ TimePicker.classNames = createClassNamesConfig<TimePickerClassNames>();
1087
+
1088
+ export default TimePicker;