Time Picker
A time-picker component that displays time.
Loading...
Installation
npx shadcn@latest add /registry/time-picker.json
Manual Installation
This component is dependent on floating-button component. Please add that component first before adding this one.
1. Copy and paste the following code into your project.
import * as React from "react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { IconButton } from "@/components/ui/buttons/icon-button";
import { Button } from "@/components/ui/buttons/button";
import { FloatingLabelButon } from "@/components/ui/buttons/floating-label-button";
import { Clock4Icon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Period, TimePickerType, getArrowByType, getDateByType, setDateByType, display12HourValue } from "./time-picker-utils";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
// ----------------------------------------------------------------------
interface TimePickerProps {
buttonProps?: Omit<React.ComponentPropsWithoutRef<typeof FloatingLabelButon>, "label" | "value">;
label: string;
value: string;
date?: Date | null;
setDate: (date: Date | null) => void;
picker?: TimePickerType;
period: Period;
setPeriod: React.Dispatch<React.SetStateAction<Period>>;
onClear?: () => void;
}
export default function TimePicker({
picker = "12hours",
buttonProps,
label,
value,
date,
setDate,
period,
setPeriod,
onClear,
}: TimePickerProps) {
const [open, setOpen] = React.useState(false);
const [localDate, setLocalDate] = React.useState(date ?? new Date(new Date().setHours(0, 0, 0, 0)));
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 handleLocalDateChange = (dateVal: Date | null) => {
if (!!dateVal) {
setLocalDate(dateVal);
}
};
const handleOpenChange = (open: boolean) => {
if (!open) {
setDate(localDate);
}
setOpen(open);
};
const handleClear = () => {
if (!!onClear) {
onClear();
setOpen(false);
}
};
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<FloatingLabelButon
variant="outline"
size="md"
label={label}
value={value ?? ""}
{...buttonProps}
className={cn(
"w-full justify-start text-base font-medium select-none overflow-x-clip py-1",
!value && "text-muted-foreground",
buttonProps?.className,
)}
endIcon={
<IconButton size="md" asChild>
<Clock4Icon className="fill-foreground/12 stroke-foreground/90 hover:bg-foreground/5" />
</IconButton>
}
/>
</PopoverTrigger>
<PopoverContent className={cn("min-w-64 w-[--radix-popper-anchor-width]", !!onClear && "pt-3 pb-0 px-0")}>
<div className={cn("flex items-center justify-between gap-2", !!onClear && "px-4")}>
<div className="grid gap-1 text-center">
<Label htmlFor="hours" className="text-xs">
Hours
</Label>
<TimePickerInput
picker={picker}
period={period}
date={localDate}
setDate={handleLocalDateChange}
ref={hourRef}
onRightFocus={() => minuteRef.current?.focus()}
/>
</div>
<div className="grid gap-1 text-center">
<Label htmlFor="minutes" className="text-xs">
Minutes
</Label>
<TimePickerInput
picker="minutes"
date={localDate}
setDate={handleLocalDateChange}
ref={minuteRef}
onLeftFocus={() => hourRef.current?.focus()}
onRightFocus={() => secondRef.current?.focus()}
/>
</div>
<div className="grid gap-1 text-center">
<Label htmlFor="seconds" className="text-xs">
Seconds
</Label>
<TimePickerInput
picker="seconds"
date={localDate}
setDate={handleLocalDateChange}
ref={secondRef}
onLeftFocus={() => minuteRef.current?.focus()}
onRightFocus={() => periodRef.current?.focus()}
/>
</div>
<div className="grid gap-1 text-center">
<Label htmlFor="period" className="text-xs">
Period
</Label>
<TimePeriodSelect
period={period}
setPeriod={setPeriod}
date={localDate}
setDate={handleLocalDateChange}
ref={periodRef}
onLeftFocus={() => secondRef.current?.focus()}
onRightFocus={() => periodRef.current?.focus()}
/>
</div>
</div>
{!!onClear && (
<Button
variant="link"
onClick={handleClear}
className="w-full pb-0.5 pt-0.5 mt-2 h-auto hover:no-underline hover:bg-foreground/5 rounded-t-none">
Clear
</Button>
)}
</PopoverContent>
</Popover>
);
}
export interface TimePickerInputProps extends React.ComponentPropsWithoutRef<typeof Input> {
picker: TimePickerType;
date: Date;
setDate: (date: Date | null) => void;
period?: Period;
onRightFocus?: () => void;
onLeftFocus?: () => void;
}
const TimePickerInput = React.forwardRef<HTMLInputElement, TimePickerInputProps>(
(
{ className, type = "tel", value, id, name, date, setDate, 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 = new Date(date);
setDate(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 = new Date(date);
setDate(setDateByType(tempDate, newValue, picker, period));
}
};
return (
<Input
ref={ref}
id={id || picker}
name={name || picker}
className={cn(
"w-[48px] text-center font-mono text-base tabular-nums caret-transparent focus:bg-accent focus:text-accent-foreground [&::-webkit-inner-spin-button]:appearance-none",
className,
)}
value={value || calculatedValue}
onChange={(e) => {
e.preventDefault();
onChange?.(e);
}}
type={type}
inputMode="decimal"
onKeyDown={(e) => {
onKeyDown?.(e);
handleKeyDown(e);
}}
{...props}
/>
);
},
);
TimePickerInput.displayName = "TimePickerInput";
export interface PeriodSelectorProps {
period: Period;
setPeriod: (m: Period) => void;
date: Date | null;
setDate: (date: Date | null) => void;
onRightFocus?: () => void;
onLeftFocus?: () => void;
}
export const TimePeriodSelect = React.forwardRef<HTMLButtonElement, PeriodSelectorProps>(
({ period, setPeriod, date, setDate, onLeftFocus, onRightFocus }, ref) => {
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
e.preventDefault();
if (e.key === "ArrowRight") onRightFocus?.();
if (e.key === "ArrowLeft") onLeftFocus?.();
if (["ArrowUp", "ArrowDown"].includes(e.key)) {
togglePeriod();
}
};
const togglePeriod = () => {
setPeriod(period === "AM" ? "PM" : "AM");
if (date) {
const tempDate = new Date(date);
const hours = display12HourValue(date.getHours());
setDate(setDateByType(tempDate, hours.toString(), "12hours", period === "AM" ? "PM" : "AM"));
}
};
return (
<div className="flex items-center">
<Button
variant="outline"
onClick={togglePeriod}
className="text-forground border-input hover:shadow-none focus:ring-offset-0 focus:ring-2 ring-offset-background focus:ring-forground h-9"
type="button"
ref={ref}
onKeyDown={handleKeyDown}>
{period}
</Button>
</div>
);
},
);
TimePeriodSelect.displayName = "TimePeriodSelect";
export { TimePickerInput };
2. Copy and paste the following code into your project.
/**
* regular expression to check for valid hour format (01-23)
*/
export 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)
*/
export function isValid12Hour(value: string) {
return /^(0[1-9]|1[0-2])$/.test(value);
}
/**
* regular expression to check for valid minute format (00-59)
*/
export function isValidMinuteOrSecond(value: string) {
return /^[0-5][0-9]$/.test(value);
}
type GetValidNumberConfig = { max: number; min?: number; loop?: boolean };
export function getValidNumber(value: string, { max, min = 0, loop = false }: GetValidNumberConfig) {
let numericValue = parseInt(value, 10);
if (!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";
}
export function getValidHour(value: string) {
if (isValidHour(value)) return value;
return getValidNumber(value, { max: 23 });
}
export function getValid12Hour(value: string) {
if (isValid12Hour(value)) return value;
return getValidNumber(value, { min: 1, max: 12 });
}
export function getValidMinuteOrSecond(value: string) {
if (isValidMinuteOrSecond(value)) return value;
return getValidNumber(value, { max: 59 });
}
type GetValidArrowNumberConfig = {
min: number;
max: number;
step: number;
};
export function getValidArrowNumber(value: string, { min, max, step }: GetValidArrowNumberConfig) {
let numericValue = parseInt(value, 10);
if (!isNaN(numericValue)) {
numericValue += step;
return getValidNumber(String(numericValue), { min, max, loop: true });
}
return "00";
}
export function getValidArrowHour(value: string, step: number) {
return getValidArrowNumber(value, { min: 0, max: 23, step });
}
export function getValidArrow12Hour(value: string, step: number) {
return getValidArrowNumber(value, { min: 1, max: 12, step });
}
export function getValidArrowMinuteOrSecond(value: string, step: number) {
return getValidArrowNumber(value, { min: 0, max: 59, step });
}
export function setMinutes(date: Date, value: string) {
const minutes = getValidMinuteOrSecond(value);
date.setMinutes(parseInt(minutes, 10));
return date;
}
export function setSeconds(date: Date, value: string) {
const seconds = getValidMinuteOrSecond(value);
date.setSeconds(parseInt(seconds, 10));
return date;
}
export function setHours(date: Date, value: string) {
const hours = getValidHour(value);
date.setHours(parseInt(hours, 10));
return date;
}
export function set12Hours(date: Date, value: string, period: Period) {
const hours = parseInt(getValid12Hour(value), 10);
const convertedHours = convert12HourTo24Hour(hours, period);
date.setHours(convertedHours);
return date;
}
export type TimePickerType = "minutes" | "seconds" | "hours" | "12hours";
export type Period = "AM" | "PM";
export 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;
}
}
export function getDateByType(date: Date, type: TimePickerType) {
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":
const hours = display12HourValue(date.getHours());
return getValid12Hour(String(hours));
default:
return "00";
}
}
export function getStringTime(date: Date) {
return (
getValidHour(String(date.getHours())) +
":" +
getValidMinuteOrSecond(String(date.getMinutes())) +
":" +
getValidMinuteOrSecond(String(date.getSeconds()))
);
}
export function getString12HourTime(date: Date, period: Period) {
return `${display12HourValue(date.getHours())}:${getValidMinuteOrSecond(String(date.getMinutes()))}:${getValidMinuteOrSecond(
String(date.getSeconds()),
)} ${period}`;
}
export 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
*/
export function convert12HourTo24Hour(hour: number, period: Period) {
if (period === "PM") {
if (hour <= 11) {
return hour + 12;
} else {
return hour;
}
} else if (period === "AM") {
if (hour === 12) return 0;
return hour;
}
return hour;
}
/**
* time is stored in the 24-hour form,
* but needs to be displayed to the user
* in its 12-hour representation
*/
export 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}`;
}