/** * Copyright 2025 LY Corporation * * LY Corporation licenses this file to you under the Apache License, * version 2.0 (the "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at: * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations * under the License. */ 'use client'; import * as React from 'react'; import { cn } from '../lib/utils'; import { Icon } from './icon'; import type { TextInputProps } from './input'; import { InputBox, InputField, TextInput } from './input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from './select'; interface PeriodSelectorProps { period: Period; setPeriod?: (m: Period) => void; date?: Date | null; onDateChange?: (date: Date | undefined) => void; onRightFocus?: () => void; onLeftFocus?: () => void; className?: string; } const TimePeriodSelect = React.forwardRef< HTMLButtonElement, PeriodSelectorProps >( ( { period, setPeriod, date, onDateChange, onLeftFocus, onRightFocus, className, }, ref, ) => { const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowRight') onRightFocus?.(); if (e.key === 'ArrowLeft') onLeftFocus?.(); }; const handleValueChange = (value: Period) => { setPeriod?.(value); /** * trigger an update whenever the user switches between AM and PM; * otherwise user must manually change the hour each time */ if (date) { const tempDate = new Date(date); const hours = display12HourValue(date.getHours()); onDateChange?.( setDateByType( tempDate, hours.toString(), '12hours', period === 'AM' ? 'PM' : 'AM', ), ); } }; return ( ); }, ); TimePeriodSelect.displayName = 'TimePeriodSelect'; interface TimePickerInputProps extends React.PropsWithoutRef { picker: TimePickerType; date?: Date | null; onDateChange?: (date: Date | undefined) => void; period?: Period; onRightFocus?: () => void; onLeftFocus?: () => void; } const TimePickerInput = React.forwardRef< HTMLInputElement, TimePickerInputProps >( ( { className, type = 'tel', value, id, name, date = new Date(new Date().setHours(0, 0, 0, 0)), onDateChange, onChange, onKeyDown, picker, period, onLeftFocus, onRightFocus, ...props }, ref, ) => { const [flag, setFlag] = React.useState(false); const [prevIntKey, setPrevIntKey] = React.useState('0'); /** * allow the user to enter the second digit within 2 seconds * otherwise start again with entering first digit */ React.useEffect(() => { if (flag) { const timer = setTimeout(() => { setFlag(false); }, 2000); return () => clearTimeout(timer); } }, [flag]); const calculatedValue = React.useMemo(() => { return getDateByType(date, picker); }, [date, picker]); const calculateNewValue = (key: string) => { /* * If picker is '12hours' and the first digit is 0, then the second digit is automatically set to 1. * The second entered digit will break the condition and the value will be set to 10-12. */ if (picker === '12hours') { if (flag && calculatedValue.slice(1, 2) === '1' && prevIntKey === '0') return `0${key}`; } return !flag ? `0${key}` : calculatedValue.slice(1, 2) + key; }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Tab') return; e.preventDefault(); if (e.key === 'ArrowRight') onRightFocus?.(); if (e.key === 'ArrowLeft') onLeftFocus?.(); if (['ArrowUp', 'ArrowDown'].includes(e.key)) { const step = e.key === 'ArrowUp' ? 1 : -1; const newValue = getArrowByType(calculatedValue, step, picker); if (flag) setFlag(false); const tempDate = date ? new Date(date) : new Date(); onDateChange?.(setDateByType(tempDate, newValue, picker, period)); } if (e.key >= '0' && e.key <= '9') { if (picker === '12hours') setPrevIntKey(e.key); const newValue = calculateNewValue(e.key); if (flag) onRightFocus?.(); setFlag((prev) => !prev); const tempDate = date ? new Date(date) : new Date(); onDateChange?.(setDateByType(tempDate, newValue, picker, period)); } }; return ( { e.preventDefault(); onChange?.(e); }} type={type} inputMode="decimal" onKeyDown={(e) => { onKeyDown?.(e); handleKeyDown(e); }} {...props} /> ); }, ); TimePickerInput.displayName = 'TimePickerInput'; interface TimePickerProps { date?: Date | null; onChange?: (date: Date | undefined) => void; hourCycle?: 12 | 24; /** * Determines the smallest unit that is displayed in the datetime picker. * Default is 'second'. * */ granularity?: Granularity; } interface TimePickerRef { minuteRef: HTMLInputElement | null; hourRef: HTMLInputElement | null; secondRef: HTMLInputElement | null; } const TimePicker = React.forwardRef( ({ date, onChange, hourCycle = 24, granularity = 'second' }, ref) => { const minuteRef = React.useRef(null); const hourRef = React.useRef(null); const secondRef = React.useRef(null); const periodRef = React.useRef(null); const [period, setPeriod] = React.useState( date && date.getHours() >= 12 ? 'PM' : 'AM', ); React.useImperativeHandle( ref, () => ({ minuteRef: minuteRef.current, hourRef: hourRef.current, secondRef: secondRef.current, periodRef: periodRef.current, }), [minuteRef, hourRef, secondRef], ); return (
minuteRef.current?.focus()} /> {(granularity === 'minute' || granularity === 'second') && ( <> : hourRef.current?.focus()} onRightFocus={() => secondRef.current?.focus()} /> )} {granularity === 'second' && ( <> : minuteRef.current?.focus()} onRightFocus={() => periodRef.current?.focus()} className="w-24" /> )} {hourCycle === 12 && ( { onChange?.(date); if (date && date.getHours() >= 12) { setPeriod('PM'); } else { setPeriod('AM'); } }} ref={periodRef} onLeftFocus={() => secondRef.current?.focus()} /> )}
); }, ); TimePicker.displayName = 'TimePicker'; type Granularity = 'hour' | 'minute' | 'second'; // ---------- utils start ---------- /** * regular expression to check for valid hour format (01-23) */ function isValidHour(value: string) { return /^(0[0-9]|1[0-9]|2[0-3])$/.test(value); } /** * regular expression to check for valid 12 hour format (01-12) */ function isValid12Hour(value: string) { return /^(0[1-9]|1[0-2])$/.test(value); } /** * regular expression to check for valid minute format (00-59) */ function isValidMinuteOrSecond(value: string) { return /^[0-5][0-9]$/.test(value); } type GetValidNumberConfig = { max: number; min?: number; loop?: boolean }; function getValidNumber( value: string, { max, min = 0, loop = false }: GetValidNumberConfig, ) { let numericValue = parseInt(value, 10); if (!Number.isNaN(numericValue)) { if (!loop) { if (numericValue > max) numericValue = max; if (numericValue < min) numericValue = min; } else { if (numericValue > max) numericValue = min; if (numericValue < min) numericValue = max; } return numericValue.toString().padStart(2, '0'); } return '00'; } function getValidHour(value: string) { if (isValidHour(value)) return value; return getValidNumber(value, { max: 23 }); } function getValid12Hour(value: string) { if (isValid12Hour(value)) return value; return getValidNumber(value, { min: 1, max: 12 }); } function getValidMinuteOrSecond(value: string) { if (isValidMinuteOrSecond(value)) return value; return getValidNumber(value, { max: 59 }); } type GetValidArrowNumberConfig = { min: number; max: number; step: number; }; function getValidArrowNumber( value: string, { min, max, step }: GetValidArrowNumberConfig, ) { let numericValue = parseInt(value, 10); if (!Number.isNaN(numericValue)) { numericValue += step; return getValidNumber(String(numericValue), { min, max, loop: true }); } return '00'; } function getValidArrowHour(value: string, step: number) { return getValidArrowNumber(value, { min: 0, max: 23, step }); } function getValidArrow12Hour(value: string, step: number) { return getValidArrowNumber(value, { min: 1, max: 12, step }); } function getValidArrowMinuteOrSecond(value: string, step: number) { return getValidArrowNumber(value, { min: 0, max: 59, step }); } function setMinutes(date: Date, value: string) { const minutes = getValidMinuteOrSecond(value); date.setMinutes(parseInt(minutes, 10)); return date; } function setSeconds(date: Date, value: string) { const seconds = getValidMinuteOrSecond(value); date.setSeconds(parseInt(seconds, 10)); return date; } function setHours(date: Date, value: string) { const hours = getValidHour(value); date.setHours(parseInt(hours, 10)); return date; } function set12Hours(date: Date, value: string, period: Period) { const hours = parseInt(getValid12Hour(value), 10); const convertedHours = convert12HourTo24Hour(hours, period); date.setHours(convertedHours); return date; } type TimePickerType = 'minutes' | 'seconds' | 'hours' | '12hours'; export type Period = 'AM' | 'PM'; function setDateByType( date: Date, value: string, type: TimePickerType, period?: Period, ) { switch (type) { case 'minutes': return setMinutes(date, value); case 'seconds': return setSeconds(date, value); case 'hours': return setHours(date, value); case '12hours': { if (!period) return date; return set12Hours(date, value, period); } default: return date; } } function getDateByType(date: Date | null, type: TimePickerType) { if (!date) return '00'; switch (type) { case 'minutes': return getValidMinuteOrSecond(String(date.getMinutes())); case 'seconds': return getValidMinuteOrSecond(String(date.getSeconds())); case 'hours': return getValidHour(String(date.getHours())); case '12hours': return getValid12Hour(String(display12HourValue(date.getHours()))); default: return '00'; } } function getArrowByType(value: string, step: number, type: TimePickerType) { switch (type) { case 'minutes': return getValidArrowMinuteOrSecond(value, step); case 'seconds': return getValidArrowMinuteOrSecond(value, step); case 'hours': return getValidArrowHour(value, step); case '12hours': return getValidArrow12Hour(value, step); default: return '00'; } } /** * handles value change of 12-hour input * 12:00 PM is 12:00 * 12:00 AM is 00:00 */ function convert12HourTo24Hour(hour: number, period: Period) { if (period === 'PM') { if (hour <= 11) { return hour + 12; } return hour; } if (hour === 12) return 0; return hour; } /** * time is stored in the 24-hour form, * but needs to be displayed to the user * in its 12-hour representation */ function display12HourValue(hours: number) { if (hours === 0 || hours === 12) return '12'; if (hours >= 22) return `${hours - 12}`; if (hours % 12 > 9) return `${hours}`; return `0${hours % 12}`; } // ---------- utils end ---------- export { TimePickerInput, TimePeriodSelect }; export type { TimePickerInputProps, PeriodSelectorProps };