Datetime Picker
A datetime picker built on top of shadcn-ui and no additional library needed.
A datetime picker built on top of shadcn-ui and no additional library needed.
Thanks to Yerfa
npm install chrono-node react-day-picker@^8.9.1
1// @ts-nocheck2'use client';34import React from 'react';5import { parseDate } from 'chrono-node';6import {7Popover,8PopoverContent,9PopoverTrigger,10} from '@/components/website/ui/popover';11import { ActiveModifiers } from 'react-day-picker';12import { Calendar, CalendarProps } from '@/components/website/ui/calendar';13import { Button, buttonVariants } from '@/components/website/ui/button';14import { cn } from '@/lib/utils';15import { Calendar as CalendarIcon, LucideTextCursorInput } from 'lucide-react';16import { ScrollArea } from '@/components/website/ui/scroll-area';1718/* -------------------------------------------------------------------------- */19/* Inspired By: */20/* @steventey */21/* ------------------https://dub.co/blog/smart-datetime-picker--------------- */22/* -------------------------------------------------------------------------- */2324/**25* Utility function that parses dates.26* Parses a given date string using the `chrono-node` library.27*28* @param str - A string representation of a date and time.29* @returns A `Date` object representing the parsed date and time, or `null` if the string could not be parsed.30*/31export const parseDateTime = (str: Date | string) => {32if (str instanceof Date) return str;33return parseDate(str);34};3536/**37* Converts a given timestamp or the current date and time to a string representation in the local time zone.38* format: `HH:mm`, adjusted for the local time zone.39*40* @param timestamp {Date | string}41* @returns A string representation of the timestamp42*/43export const getDateTimeLocal = (timestamp?: Date): string => {44const d = timestamp ? new Date(timestamp) : new Date();45if (d.toString() === 'Invalid Date') return '';46return new Date(d.getTime() - d.getTimezoneOffset() * 60000)47.toISOString()48.split(':')49.slice(0, 2)50.join(':');51};5253/**54* Formats a given date and time object or string into a human-readable string representation.55* "MMM D, YYYY h:mm A" (e.g. "Jan 1, 2023 12:00 PM").56*57* @param datetime - {Date | string}58* @returns A string representation of the date and time59*/60const formatTimeOnly = (datetime: Date | string) => {61return new Date(datetime).toLocaleTimeString('en-US', {62hour: 'numeric',63minute: 'numeric',64hour12: true,65});66};6768const formatDateOnly = (datetime: Date | string) => {69return new Date(datetime).toLocaleDateString('en-US', {70month: 'short',71day: 'numeric',72year: 'numeric',73});74};7576const formatDateTime = (77datetime: Date | string,78showCalendar: boolean,79showTimePicker: boolean80) => {81if (!showCalendar && showTimePicker) {82return formatTimeOnly(datetime);83}84if (showCalendar && !showTimePicker) {85return formatDateOnly(datetime);86}87return new Date(datetime).toLocaleTimeString('en-US', {88month: 'short',89day: 'numeric',90year: 'numeric',91hour: 'numeric',92minute: 'numeric',93hour12: true,94});95};9697const inputBase =98'bg-transparent focus:outline-none focus:ring-0 focus-within:outline-none focus-within:ring-0 sm:text-sm disabled:cursor-not-allowed disabled:opacity-50';99100// @source: https://www.perplexity.ai/search/in-javascript-how-RfI7fMtITxKr5c.V9Lv5KA#1101// use this pattern to validate the transformed date string for the natural language input102const naturalInputValidationPattern =103'^[A-Z][a-z]{2}sd{1,2},sd{4},sd{1,2}:d{2}s[AP]M$';104105const DEFAULT_SIZE = 96;106107/**108* Smart time input Docs: {@link: https://shadcn-extension.vercel.app/docs/smart-time-input}109*/110111interface SmartDatetimeInputProps {112value?: Date;113onValueChange: (date: Date) => void;114showCalendar?: boolean;115showTimePicker?: boolean;116}117118interface SmartDatetimeInputContextProps extends SmartDatetimeInputProps {119Time: string;120onTimeChange: (time: string) => void;121}122123const SmartDatetimeInputContext =124React.createContext<SmartDatetimeInputContextProps | null>(null);125126const useSmartDateInput = () => {127const context = React.useContext(SmartDatetimeInputContext);128if (!context) {129throw new Error(130'useSmartDateInput must be used within SmartDateInputProvider'131);132}133return context;134};135export const SmartDatetimeInput = React.forwardRef<136HTMLInputElement,137Omit<138React.InputHTMLAttributes<HTMLInputElement>,139'type' | 'ref' | 'value' | 'defaultValue' | 'onBlur'140> &141SmartDatetimeInputProps142>(143(144{145className,146value,147onValueChange,148placeholder,149disabled,150showCalendar = true,151showTimePicker = true,152},153ref154) => {155const [Time, setTime] = React.useState<string>('');156157const onTimeChange = React.useCallback((time: string) => {158setTime(time);159}, []);160161// If neither calendar nor timepicker is specified, show both162const shouldShowBoth = showCalendar === showTimePicker;163164return (165<SmartDatetimeInputContext.Provider166value={{167value,168onValueChange,169Time,170onTimeChange,171showCalendar: shouldShowBoth ? true : showCalendar,172showTimePicker: shouldShowBoth ? true : showTimePicker,173}}174>175<div className='flex items-center justify-center bg-background'>176<div177className={cn(178'flex gap-1 w-full p-1 items-center justify-between rounded-md border transition-all',179'focus-within:outline-0 focus:outline-0 focus:ring-0',180'placeholder:text-muted-foreground focus-visible:outline-0 ',181className182)}183>184<DateTimeLocalInput />185<NaturalLanguageInput186placeholder={placeholder}187disabled={disabled}188ref={ref}189/>190</div>191</div>192</SmartDatetimeInputContext.Provider>193);194}195);196197SmartDatetimeInput.displayName = 'DatetimeInput';198199// Make it a standalone component200201const TimePicker = () => {202const { value, onValueChange, Time, onTimeChange } = useSmartDateInput();203const [activeIndex, setActiveIndex] = React.useState(-1);204const timestamp = 15;205206const formateSelectedTime = React.useCallback(207(time: string, hour: number, partStamp: number) => {208onTimeChange(time);209210let newVal = value ? new Date(value) : new Date();211212// If no value exists, use current date but only set the time213newVal.setHours(214hour,215partStamp === 0 ? parseInt('00') : timestamp * partStamp216);217218onValueChange(newVal);219},220[value, onValueChange, onTimeChange]221);222223const handleKeydown = React.useCallback(224(e: React.KeyboardEvent<HTMLDivElement>) => {225e.stopPropagation();226227if (!document) return;228229const moveNext = () => {230const nextIndex =231activeIndex + 1 > DEFAULT_SIZE - 1 ? 0 : activeIndex + 1;232233const currentElm = document.getElementById(`time-${nextIndex}`);234235currentElm?.focus();236237setActiveIndex(nextIndex);238};239240const movePrev = () => {241const prevIndex =242activeIndex - 1 < 0 ? DEFAULT_SIZE - 1 : activeIndex - 1;243244const currentElm = document.getElementById(`time-${prevIndex}`);245246currentElm?.focus();247248setActiveIndex(prevIndex);249};250251const setElement = () => {252const currentElm = document.getElementById(`time-${activeIndex}`);253254if (!currentElm) return;255256currentElm.focus();257258const timeValue = currentElm.textContent ?? '';259260// this should work now haha that hour is what does the trick261262const PM_AM = timeValue.split(' ')[1];263const PM_AM_hour = parseInt(timeValue.split(' ')[0].split(':')[0]);264const hour =265PM_AM === 'AM'266? PM_AM_hour === 12267? 0268: PM_AM_hour269: PM_AM_hour === 12270? 12271: PM_AM_hour + 12;272273const part = Math.floor(274parseInt(timeValue.split(' ')[0].split(':')[1]) / 15275);276277formateSelectedTime(timeValue, hour, part);278};279280const reset = () => {281const currentElm = document.getElementById(`time-${activeIndex}`);282currentElm?.blur();283setActiveIndex(-1);284};285286switch (e.key) {287case 'ArrowUp':288movePrev();289break;290291case 'ArrowDown':292moveNext();293break;294295case 'Escape':296reset();297break;298299case 'Enter':300setElement();301break;302}303},304[activeIndex, formateSelectedTime]305);306307const handleClick = React.useCallback(308(hour: number, part: number, PM_AM: string, currentIndex: number) => {309formateSelectedTime(310`${hour}:${part === 0 ? '00' : timestamp * part} ${PM_AM}`,311hour,312part313);314setActiveIndex(currentIndex);315},316[formateSelectedTime]317);318319const currentTime = React.useMemo(() => {320const timeVal = Time.split(' ')[0];321return {322hours: parseInt(timeVal.split(':')[0]),323minutes: parseInt(timeVal.split(':')[1]),324};325}, [Time]);326327React.useEffect(() => {328const getCurrentElementTime = () => {329const timeVal = Time.split(' ')[0];330const hours = parseInt(timeVal.split(':')[0]);331const minutes = parseInt(timeVal.split(':')[1]);332const PM_AM = Time.split(' ')[1];333334const formatIndex =335PM_AM === 'AM' ? hours : hours === 12 ? hours : hours + 12;336const formattedHours = formatIndex;337338console.log(formatIndex);339340for (let j = 0; j <= 3; j++) {341const diff = Math.abs(j * timestamp - minutes);342const selected =343PM_AM === (formattedHours >= 12 ? 'PM' : 'AM') &&344(minutes <= 53 ? diff < Math.ceil(timestamp / 2) : diff < timestamp);345346if (selected) {347const trueIndex =348activeIndex === -1 ? formattedHours * 4 + j : activeIndex;349350setActiveIndex(trueIndex);351352const currentElm = document.getElementById(`time-${trueIndex}`);353currentElm?.scrollIntoView({354block: 'center',355behavior: 'smooth',356});357}358}359};360361getCurrentElementTime();362}, [Time, activeIndex]);363364const height = React.useMemo(() => {365if (!document) return;366const calendarElm = document.getElementById('calendar');367if (!calendarElm) return;368return calendarElm.style.height;369}, []);370371return (372<div className='space-y-2 pr-3 py-3 relative '>373<h3 className='text-sm font-medium text-center'>Time</h3>374<ScrollArea375onKeyDown={handleKeydown}376className='h-[90%] w-full focus-visible:outline-0 focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-0 py-0.5'377style={{378height,379}}380>381<ul382className={cn(383'flex items-center flex-col gap-1 h-full max-h-56 w-28 px-1 py-0.5'384)}385>386{Array.from({ length: 24 }).map((_, i) => {387const PM_AM = i >= 12 ? 'PM' : 'AM';388const formatIndex = i > 12 ? i % 12 : i === 0 || i === 12 ? 12 : i;389return Array.from({ length: 4 }).map((_, part) => {390const diff = Math.abs(part * timestamp - currentTime.minutes);391392const trueIndex = i * 4 + part;393394// ? refactor : add the select of the default time on the current device (H:MM)395const isSelected =396(currentTime.hours === i ||397currentTime.hours === formatIndex) &&398Time.split(' ')[1] === PM_AM &&399(currentTime.minutes <= 53400? diff < Math.ceil(timestamp / 2)401: diff < timestamp);402403const isSuggested = !value && isSelected;404405const currentValue = `${formatIndex}:${406part === 0 ? '00' : timestamp * part407} ${PM_AM}`;408409return (410<li411tabIndex={isSelected ? 0 : -1}412id={`time-${trueIndex}`}413key={`time-${trueIndex}`}414aria-label='currentTime'415className={cn(416buttonVariants({417variant: isSuggested418? 'secondary'419: isSelected420? 'default'421: 'outline',422}),423'h-8 px-3 w-full text-sm focus-visible:outline-0 outline-0 focus-visible:border-0 cursor-default ring-0'424)}425onClick={() => handleClick(i, part, PM_AM, trueIndex)}426onFocus={() => isSuggested && setActiveIndex(trueIndex)}427>428{currentValue}429</li>430);431});432})}433</ul>434</ScrollArea>435</div>436);437};438const getDefaultPlaceholder = (439showCalendar: boolean,440showTimePicker: boolean441) => {442if (!showCalendar && showTimePicker) {443return 'e.g. "5pm" or "in 2 hours"';444}445if (showCalendar && !showTimePicker) {446return 'e.g. "tomorrow" or "next monday"';447}448return 'e.g. "tomorrow at 5pm" or "in 2 hours"';449};450const NaturalLanguageInput = React.forwardRef<451HTMLInputElement,452{453placeholder?: string;454disabled?: boolean;455}456>(({ placeholder, ...props }, ref) => {457const {458value,459onValueChange,460Time,461onTimeChange,462showCalendar,463showTimePicker,464} = useSmartDateInput();465466const _placeholder =467placeholder ?? getDefaultPlaceholder(showCalendar, showTimePicker);468469const [inputValue, setInputValue] = React.useState<string>('');470471React.useEffect(() => {472if (!value) {473setInputValue('');474return;475}476477const formattedValue = formatDateTime(value, showCalendar, showTimePicker);478setInputValue(formattedValue);479480// Only update time if time picker is shown481if (showTimePicker) {482const hour = value.getHours();483const timeVal = `${hour >= 12 ? hour % 12 || 12 : hour || 12}:${String(484value.getMinutes()485).padStart(2, '0')} ${hour >= 12 ? 'PM' : 'AM'}`;486onTimeChange(timeVal);487}488}, [value, showCalendar, showTimePicker]);489490const handleParse = React.useCallback(491(e: React.ChangeEvent<HTMLInputElement>) => {492const parsedDateTime = parseDateTime(e.currentTarget.value);493if (parsedDateTime) {494// If only showing time picker, preserve the current date495if (!showCalendar && showTimePicker && value) {496parsedDateTime.setFullYear(497value.getFullYear(),498value.getMonth(),499value.getDate()500);501}502// If only showing calendar, preserve the current time503if (showCalendar && !showTimePicker && value) {504parsedDateTime.setHours(0, 0, 0, 0);505}506// console.log(parsedDateTime);507508onValueChange(parsedDateTime);509setInputValue(510formatDateTime(parsedDateTime, showCalendar, showTimePicker)511);512513if (showTimePicker) {514const PM_AM = parsedDateTime.getHours() >= 12 ? 'PM' : 'AM';515const PM_AM_hour = parsedDateTime.getHours();516const hour =517PM_AM_hour > 12518? PM_AM_hour % 12519: PM_AM_hour === 0 || PM_AM_hour === 12520? 12521: PM_AM_hour;522onTimeChange(523`${hour}:${String(parsedDateTime.getMinutes()).padStart(5242,525'0'526)} ${PM_AM}`527);528}529}530},531[value, showCalendar, showTimePicker]532);533534const handleKeydown = React.useCallback(535(e: React.KeyboardEvent<HTMLInputElement>) => {536if (e.key === 'Enter') {537handleParse(e as any);538}539},540[handleParse]541);542543return (544<input545ref={ref}546type='text'547placeholder={_placeholder}548value={inputValue}549onChange={(e) => setInputValue(e.currentTarget.value)}550onKeyDown={handleKeydown}551onBlur={handleParse}552className={cn(553'px-2 mr-0.5 bg-background flex-1 border-none h-8 rounded',554inputBase555)}556{...props}557/>558);559});560561NaturalLanguageInput.displayName = 'NaturalLanguageInput';562563type DateTimeLocalInputProps = {} & CalendarProps;564565const DateTimeLocalInput = ({566className,567...props568}: DateTimeLocalInputProps) => {569const { value, onValueChange, Time, showCalendar, showTimePicker } =570useSmartDateInput();571572const formateSelectedDate = React.useCallback(573(574date: Date | undefined,575selectedDate: Date,576m: ActiveModifiers,577e: React.MouseEvent578) => {579const parsedDateTime = new Date(selectedDate);580581if (!showTimePicker) {582// If only calendar is shown, set time to start of day583parsedDateTime.setHours(0, 0, 0, 0);584} else if (value) {585// If time picker is shown, preserve existing time586parsedDateTime.setHours(587value.getHours(),588value.getMinutes(),589value.getSeconds(),590value.getMilliseconds()591);592}593594onValueChange(parsedDateTime);595},596[value, showTimePicker, onValueChange]597);598599return (600<Popover>601<PopoverTrigger asChild>602<Button603variant={'outline'}604size={'icon'}605className={cn(606'size-9 flex items-center justify-center font-normal',607!value && 'text-muted-foreground'608)}609>610<CalendarIcon className='size-4' />611<span className='sr-only'>calendar</span>612</Button>613</PopoverTrigger>614<PopoverContent className='w-auto p-0 bg-background' sideOffset={8}>615<div className='flex gap-1'>616{showCalendar && (617<Calendar618{...props}619id={'calendar'}620className={cn('peer flex justify-end', inputBase, className)}621mode='single'622selected={value}623onSelect={formateSelectedDate}624initialFocus625/>626)}627{showTimePicker && <TimePicker />}628</div>629</PopoverContent>630</Popover>631);632};633634DateTimeLocalInput.displayName = 'DateTimeLocalInput';
Prop | Type | Default | Description |
---|---|---|---|
value | Date | undefined | The selected date value for the input. |
onValueChange | (date: Date) => void | - | Callback function triggered when the date value changes. |
showCalendar | boolean | true | Whether to display a calendar for date selection. |
showTimePicker | boolean | true | Whether to display a time picker for time selection. |
className | string | undefined | Additional CSS class names to style the input component. |
placeholder | string | undefined | Placeholder text displayed when no value is selected. |
disabled | boolean | undefined | Whether the input is disabled and not interactive. |