Files
Q-A_test/packages/ufb-react/src/components/timepicker.tsx
T
SDI 12e4f17b62
CI / typecheck (push) Successful in 1m8s
CI / format (push) Failing after 1m6s
CI / lint (push) Failing after 49s
CI / test (push) Failing after 1m8s
first commit
2026-07-15 18:05:12 +09:00

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 };