Search Components...

Time Picker

A time-picker component that displays time.

Loading...

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

This component copied originally from time.openstatus.dev with a few tweaks.