534 lines
14 KiB
TypeScript
534 lines
14 KiB
TypeScript
/**
|
|
* 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<HTMLButtonElement>) => {
|
|
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 (
|
|
<Select
|
|
value={period}
|
|
onValueChange={(value: Period) => handleValueChange(value)}
|
|
>
|
|
<SelectTrigger
|
|
className={className}
|
|
ref={ref}
|
|
onKeyDown={handleKeyDown}
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="AM">AM</SelectItem>
|
|
<SelectItem value="PM">PM</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
},
|
|
);
|
|
|
|
TimePeriodSelect.displayName = 'TimePeriodSelect';
|
|
|
|
interface TimePickerInputProps extends React.PropsWithoutRef<TextInputProps> {
|
|
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<boolean>(false);
|
|
const [prevIntKey, setPrevIntKey] = React.useState<string>('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<HTMLInputElement>) => {
|
|
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 (
|
|
<InputField className={cn('w-24', className)}>
|
|
<InputBox>
|
|
<TextInput
|
|
ref={ref}
|
|
id={id ?? picker}
|
|
name={name ?? picker}
|
|
className="text-left font-mono tabular-nums caret-transparent [&::-webkit-inner-spin-button]:appearance-none"
|
|
value={value ?? calculatedValue}
|
|
onChange={(e) => {
|
|
e.preventDefault();
|
|
onChange?.(e);
|
|
}}
|
|
type={type}
|
|
inputMode="decimal"
|
|
onKeyDown={(e) => {
|
|
onKeyDown?.(e);
|
|
handleKeyDown(e);
|
|
}}
|
|
{...props}
|
|
/>
|
|
</InputBox>
|
|
</InputField>
|
|
);
|
|
},
|
|
);
|
|
|
|
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<TimePickerRef, TimePickerProps>(
|
|
({ date, onChange, hourCycle = 24, granularity = 'second' }, ref) => {
|
|
const minuteRef = React.useRef<HTMLInputElement>(null);
|
|
const hourRef = React.useRef<HTMLInputElement>(null);
|
|
const secondRef = React.useRef<HTMLInputElement>(null);
|
|
const periodRef = React.useRef<HTMLButtonElement>(null);
|
|
const [period, setPeriod] = React.useState<Period>(
|
|
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 (
|
|
<div className="flex items-center justify-center gap-2">
|
|
<label
|
|
htmlFor="datetime-picker-hour-input"
|
|
className="flex cursor-pointer items-center"
|
|
>
|
|
<Icon name="RiTimeLine" size={16} />
|
|
</label>
|
|
<TimePickerInput
|
|
picker={hourCycle === 24 ? 'hours' : '12hours'}
|
|
date={date}
|
|
id="datetime-picker-hour-input"
|
|
onDateChange={onChange}
|
|
ref={hourRef}
|
|
period={period}
|
|
onRightFocus={() => minuteRef.current?.focus()}
|
|
/>
|
|
{(granularity === 'minute' || granularity === 'second') && (
|
|
<>
|
|
:
|
|
<TimePickerInput
|
|
picker="minutes"
|
|
date={date}
|
|
onDateChange={onChange}
|
|
ref={minuteRef}
|
|
onLeftFocus={() => hourRef.current?.focus()}
|
|
onRightFocus={() => secondRef.current?.focus()}
|
|
/>
|
|
</>
|
|
)}
|
|
{granularity === 'second' && (
|
|
<>
|
|
:
|
|
<TimePickerInput
|
|
picker="seconds"
|
|
date={date}
|
|
onDateChange={onChange}
|
|
ref={secondRef}
|
|
onLeftFocus={() => minuteRef.current?.focus()}
|
|
onRightFocus={() => periodRef.current?.focus()}
|
|
className="w-24"
|
|
/>
|
|
</>
|
|
)}
|
|
{hourCycle === 12 && (
|
|
<TimePeriodSelect
|
|
period={period}
|
|
setPeriod={setPeriod}
|
|
date={date}
|
|
onDateChange={(date) => {
|
|
onChange?.(date);
|
|
if (date && date.getHours() >= 12) {
|
|
setPeriod('PM');
|
|
} else {
|
|
setPeriod('AM');
|
|
}
|
|
}}
|
|
ref={periodRef}
|
|
onLeftFocus={() => secondRef.current?.focus()}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
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 };
|