Datetime Picker

A datetime picker built on top of shadcn-ui and no additional library needed.

Thanks to Yerfa

Smart DateTime Input Example

Installation

npm install chrono-node react-day-picker@^8.9.1
1
// @ts-nocheck
2
'use client';
3
4
import React from 'react';
5
import { parseDate } from 'chrono-node';
6
import {
7
Popover,
8
PopoverContent,
9
PopoverTrigger,
10
} from '@/components/website/ui/popover';
11
import { ActiveModifiers } from 'react-day-picker';
12
import { Calendar, CalendarProps } from '@/components/website/ui/calendar';
13
import { Button, buttonVariants } from '@/components/website/ui/button';
14
import { cn } from '@/lib/utils';
15
import { Calendar as CalendarIcon, LucideTextCursorInput } from 'lucide-react';
16
import { ScrollArea } from '@/components/website/ui/scroll-area';
17
18
/* -------------------------------------------------------------------------- */
19
/* Inspired By: */
20
/* @steventey */
21
/* ------------------https://dub.co/blog/smart-datetime-picker--------------- */
22
/* -------------------------------------------------------------------------- */
23
24
/**
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
*/
31
export const parseDateTime = (str: Date | string) => {
32
if (str instanceof Date) return str;
33
return parseDate(str);
34
};
35
36
/**
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 timestamp
42
*/
43
export const getDateTimeLocal = (timestamp?: Date): string => {
44
const d = timestamp ? new Date(timestamp) : new Date();
45
if (d.toString() === 'Invalid Date') return '';
46
return new Date(d.getTime() - d.getTimezoneOffset() * 60000)
47
.toISOString()
48
.split(':')
49
.slice(0, 2)
50
.join(':');
51
};
52
53
/**
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 time
59
*/
60
const formatTimeOnly = (datetime: Date | string) => {
61
return new Date(datetime).toLocaleTimeString('en-US', {
62
hour: 'numeric',
63
minute: 'numeric',
64
hour12: true,
65
});
66
};
67
68
const formatDateOnly = (datetime: Date | string) => {
69
return new Date(datetime).toLocaleDateString('en-US', {
70
month: 'short',
71
day: 'numeric',
72
year: 'numeric',
73
});
74
};
75
76
const formatDateTime = (
77
datetime: Date | string,
78
showCalendar: boolean,
79
showTimePicker: boolean
80
) => {
81
if (!showCalendar && showTimePicker) {
82
return formatTimeOnly(datetime);
83
}
84
if (showCalendar && !showTimePicker) {
85
return formatDateOnly(datetime);
86
}
87
return new Date(datetime).toLocaleTimeString('en-US', {
88
month: 'short',
89
day: 'numeric',
90
year: 'numeric',
91
hour: 'numeric',
92
minute: 'numeric',
93
hour12: true,
94
});
95
};
96
97
const 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';
99
100
// @source: https://www.perplexity.ai/search/in-javascript-how-RfI7fMtITxKr5c.V9Lv5KA#1
101
// use this pattern to validate the transformed date string for the natural language input
102
const naturalInputValidationPattern =
103
'^[A-Z][a-z]{2}sd{1,2},sd{4},sd{1,2}:d{2}s[AP]M$';
104
105
const DEFAULT_SIZE = 96;
106
107
/**
108
* Smart time input Docs: {@link: https://shadcn-extension.vercel.app/docs/smart-time-input}
109
*/
110
111
interface SmartDatetimeInputProps {
112
value?: Date;
113
onValueChange: (date: Date) => void;
114
showCalendar?: boolean;
115
showTimePicker?: boolean;
116
}
117
118
interface SmartDatetimeInputContextProps extends SmartDatetimeInputProps {
119
Time: string;
120
onTimeChange: (time: string) => void;
121
}
122
123
const SmartDatetimeInputContext =
124
React.createContext<SmartDatetimeInputContextProps | null>(null);
125
126
const useSmartDateInput = () => {
127
const context = React.useContext(SmartDatetimeInputContext);
128
if (!context) {
129
throw new Error(
130
'useSmartDateInput must be used within SmartDateInputProvider'
131
);
132
}
133
return context;
134
};
135
export const SmartDatetimeInput = React.forwardRef<
136
HTMLInputElement,
137
Omit<
138
React.InputHTMLAttributes<HTMLInputElement>,
139
'type' | 'ref' | 'value' | 'defaultValue' | 'onBlur'
140
> &
141
SmartDatetimeInputProps
142
>(
143
(
144
{
145
className,
146
value,
147
onValueChange,
148
placeholder,
149
disabled,
150
showCalendar = true,
151
showTimePicker = true,
152
},
153
ref
154
) => {
155
const [Time, setTime] = React.useState<string>('');
156
157
const onTimeChange = React.useCallback((time: string) => {
158
setTime(time);
159
}, []);
160
161
// If neither calendar nor timepicker is specified, show both
162
const shouldShowBoth = showCalendar === showTimePicker;
163
164
return (
165
<SmartDatetimeInputContext.Provider
166
value={{
167
value,
168
onValueChange,
169
Time,
170
onTimeChange,
171
showCalendar: shouldShowBoth ? true : showCalendar,
172
showTimePicker: shouldShowBoth ? true : showTimePicker,
173
}}
174
>
175
<div className='flex items-center justify-center bg-background'>
176
<div
177
className={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 ',
181
className
182
)}
183
>
184
<DateTimeLocalInput />
185
<NaturalLanguageInput
186
placeholder={placeholder}
187
disabled={disabled}
188
ref={ref}
189
/>
190
</div>
191
</div>
192
</SmartDatetimeInputContext.Provider>
193
);
194
}
195
);
196
197
SmartDatetimeInput.displayName = 'DatetimeInput';
198
199
// Make it a standalone component
200
201
const TimePicker = () => {
202
const { value, onValueChange, Time, onTimeChange } = useSmartDateInput();
203
const [activeIndex, setActiveIndex] = React.useState(-1);
204
const timestamp = 15;
205
206
const formateSelectedTime = React.useCallback(
207
(time: string, hour: number, partStamp: number) => {
208
onTimeChange(time);
209
210
let newVal = value ? new Date(value) : new Date();
211
212
// If no value exists, use current date but only set the time
213
newVal.setHours(
214
hour,
215
partStamp === 0 ? parseInt('00') : timestamp * partStamp
216
);
217
218
onValueChange(newVal);
219
},
220
[value, onValueChange, onTimeChange]
221
);
222
223
const handleKeydown = React.useCallback(
224
(e: React.KeyboardEvent<HTMLDivElement>) => {
225
e.stopPropagation();
226
227
if (!document) return;
228
229
const moveNext = () => {
230
const nextIndex =
231
activeIndex + 1 > DEFAULT_SIZE - 1 ? 0 : activeIndex + 1;
232
233
const currentElm = document.getElementById(`time-${nextIndex}`);
234
235
currentElm?.focus();
236
237
setActiveIndex(nextIndex);
238
};
239
240
const movePrev = () => {
241
const prevIndex =
242
activeIndex - 1 < 0 ? DEFAULT_SIZE - 1 : activeIndex - 1;
243
244
const currentElm = document.getElementById(`time-${prevIndex}`);
245
246
currentElm?.focus();
247
248
setActiveIndex(prevIndex);
249
};
250
251
const setElement = () => {
252
const currentElm = document.getElementById(`time-${activeIndex}`);
253
254
if (!currentElm) return;
255
256
currentElm.focus();
257
258
const timeValue = currentElm.textContent ?? '';
259
260
// this should work now haha that hour is what does the trick
261
262
const PM_AM = timeValue.split(' ')[1];
263
const PM_AM_hour = parseInt(timeValue.split(' ')[0].split(':')[0]);
264
const hour =
265
PM_AM === 'AM'
266
? PM_AM_hour === 12
267
? 0
268
: PM_AM_hour
269
: PM_AM_hour === 12
270
? 12
271
: PM_AM_hour + 12;
272
273
const part = Math.floor(
274
parseInt(timeValue.split(' ')[0].split(':')[1]) / 15
275
);
276
277
formateSelectedTime(timeValue, hour, part);
278
};
279
280
const reset = () => {
281
const currentElm = document.getElementById(`time-${activeIndex}`);
282
currentElm?.blur();
283
setActiveIndex(-1);
284
};
285
286
switch (e.key) {
287
case 'ArrowUp':
288
movePrev();
289
break;
290
291
case 'ArrowDown':
292
moveNext();
293
break;
294
295
case 'Escape':
296
reset();
297
break;
298
299
case 'Enter':
300
setElement();
301
break;
302
}
303
},
304
[activeIndex, formateSelectedTime]
305
);
306
307
const handleClick = React.useCallback(
308
(hour: number, part: number, PM_AM: string, currentIndex: number) => {
309
formateSelectedTime(
310
`${hour}:${part === 0 ? '00' : timestamp * part} ${PM_AM}`,
311
hour,
312
part
313
);
314
setActiveIndex(currentIndex);
315
},
316
[formateSelectedTime]
317
);
318
319
const currentTime = React.useMemo(() => {
320
const timeVal = Time.split(' ')[0];
321
return {
322
hours: parseInt(timeVal.split(':')[0]),
323
minutes: parseInt(timeVal.split(':')[1]),
324
};
325
}, [Time]);
326
327
React.useEffect(() => {
328
const getCurrentElementTime = () => {
329
const timeVal = Time.split(' ')[0];
330
const hours = parseInt(timeVal.split(':')[0]);
331
const minutes = parseInt(timeVal.split(':')[1]);
332
const PM_AM = Time.split(' ')[1];
333
334
const formatIndex =
335
PM_AM === 'AM' ? hours : hours === 12 ? hours : hours + 12;
336
const formattedHours = formatIndex;
337
338
console.log(formatIndex);
339
340
for (let j = 0; j <= 3; j++) {
341
const diff = Math.abs(j * timestamp - minutes);
342
const selected =
343
PM_AM === (formattedHours >= 12 ? 'PM' : 'AM') &&
344
(minutes <= 53 ? diff < Math.ceil(timestamp / 2) : diff < timestamp);
345
346
if (selected) {
347
const trueIndex =
348
activeIndex === -1 ? formattedHours * 4 + j : activeIndex;
349
350
setActiveIndex(trueIndex);
351
352
const currentElm = document.getElementById(`time-${trueIndex}`);
353
currentElm?.scrollIntoView({
354
block: 'center',
355
behavior: 'smooth',
356
});
357
}
358
}
359
};
360
361
getCurrentElementTime();
362
}, [Time, activeIndex]);
363
364
const height = React.useMemo(() => {
365
if (!document) return;
366
const calendarElm = document.getElementById('calendar');
367
if (!calendarElm) return;
368
return calendarElm.style.height;
369
}, []);
370
371
return (
372
<div className='space-y-2 pr-3 py-3 relative '>
373
<h3 className='text-sm font-medium text-center'>Time</h3>
374
<ScrollArea
375
onKeyDown={handleKeydown}
376
className='h-[90%] w-full focus-visible:outline-0 focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-0 py-0.5'
377
style={{
378
height,
379
}}
380
>
381
<ul
382
className={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) => {
387
const PM_AM = i >= 12 ? 'PM' : 'AM';
388
const formatIndex = i > 12 ? i % 12 : i === 0 || i === 12 ? 12 : i;
389
return Array.from({ length: 4 }).map((_, part) => {
390
const diff = Math.abs(part * timestamp - currentTime.minutes);
391
392
const trueIndex = i * 4 + part;
393
394
// ? refactor : add the select of the default time on the current device (H:MM)
395
const isSelected =
396
(currentTime.hours === i ||
397
currentTime.hours === formatIndex) &&
398
Time.split(' ')[1] === PM_AM &&
399
(currentTime.minutes <= 53
400
? diff < Math.ceil(timestamp / 2)
401
: diff < timestamp);
402
403
const isSuggested = !value && isSelected;
404
405
const currentValue = `${formatIndex}:${
406
part === 0 ? '00' : timestamp * part
407
} ${PM_AM}`;
408
409
return (
410
<li
411
tabIndex={isSelected ? 0 : -1}
412
id={`time-${trueIndex}`}
413
key={`time-${trueIndex}`}
414
aria-label='currentTime'
415
className={cn(
416
buttonVariants({
417
variant: isSuggested
418
? 'secondary'
419
: isSelected
420
? '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
)}
425
onClick={() => handleClick(i, part, PM_AM, trueIndex)}
426
onFocus={() => isSuggested && setActiveIndex(trueIndex)}
427
>
428
{currentValue}
429
</li>
430
);
431
});
432
})}
433
</ul>
434
</ScrollArea>
435
</div>
436
);
437
};
438
const getDefaultPlaceholder = (
439
showCalendar: boolean,
440
showTimePicker: boolean
441
) => {
442
if (!showCalendar && showTimePicker) {
443
return 'e.g. "5pm" or "in 2 hours"';
444
}
445
if (showCalendar && !showTimePicker) {
446
return 'e.g. "tomorrow" or "next monday"';
447
}
448
return 'e.g. "tomorrow at 5pm" or "in 2 hours"';
449
};
450
const NaturalLanguageInput = React.forwardRef<
451
HTMLInputElement,
452
{
453
placeholder?: string;
454
disabled?: boolean;
455
}
456
>(({ placeholder, ...props }, ref) => {
457
const {
458
value,
459
onValueChange,
460
Time,
461
onTimeChange,
462
showCalendar,
463
showTimePicker,
464
} = useSmartDateInput();
465
466
const _placeholder =
467
placeholder ?? getDefaultPlaceholder(showCalendar, showTimePicker);
468
469
const [inputValue, setInputValue] = React.useState<string>('');
470
471
React.useEffect(() => {
472
if (!value) {
473
setInputValue('');
474
return;
475
}
476
477
const formattedValue = formatDateTime(value, showCalendar, showTimePicker);
478
setInputValue(formattedValue);
479
480
// Only update time if time picker is shown
481
if (showTimePicker) {
482
const hour = value.getHours();
483
const timeVal = `${hour >= 12 ? hour % 12 || 12 : hour || 12}:${String(
484
value.getMinutes()
485
).padStart(2, '0')} ${hour >= 12 ? 'PM' : 'AM'}`;
486
onTimeChange(timeVal);
487
}
488
}, [value, showCalendar, showTimePicker]);
489
490
const handleParse = React.useCallback(
491
(e: React.ChangeEvent<HTMLInputElement>) => {
492
const parsedDateTime = parseDateTime(e.currentTarget.value);
493
if (parsedDateTime) {
494
// If only showing time picker, preserve the current date
495
if (!showCalendar && showTimePicker && value) {
496
parsedDateTime.setFullYear(
497
value.getFullYear(),
498
value.getMonth(),
499
value.getDate()
500
);
501
}
502
// If only showing calendar, preserve the current time
503
if (showCalendar && !showTimePicker && value) {
504
parsedDateTime.setHours(0, 0, 0, 0);
505
}
506
// console.log(parsedDateTime);
507
508
onValueChange(parsedDateTime);
509
setInputValue(
510
formatDateTime(parsedDateTime, showCalendar, showTimePicker)
511
);
512
513
if (showTimePicker) {
514
const PM_AM = parsedDateTime.getHours() >= 12 ? 'PM' : 'AM';
515
const PM_AM_hour = parsedDateTime.getHours();
516
const hour =
517
PM_AM_hour > 12
518
? PM_AM_hour % 12
519
: PM_AM_hour === 0 || PM_AM_hour === 12
520
? 12
521
: PM_AM_hour;
522
onTimeChange(
523
`${hour}:${String(parsedDateTime.getMinutes()).padStart(
524
2,
525
'0'
526
)} ${PM_AM}`
527
);
528
}
529
}
530
},
531
[value, showCalendar, showTimePicker]
532
);
533
534
const handleKeydown = React.useCallback(
535
(e: React.KeyboardEvent<HTMLInputElement>) => {
536
if (e.key === 'Enter') {
537
handleParse(e as any);
538
}
539
},
540
[handleParse]
541
);
542
543
return (
544
<input
545
ref={ref}
546
type='text'
547
placeholder={_placeholder}
548
value={inputValue}
549
onChange={(e) => setInputValue(e.currentTarget.value)}
550
onKeyDown={handleKeydown}
551
onBlur={handleParse}
552
className={cn(
553
'px-2 mr-0.5 bg-background flex-1 border-none h-8 rounded',
554
inputBase
555
)}
556
{...props}
557
/>
558
);
559
});
560
561
NaturalLanguageInput.displayName = 'NaturalLanguageInput';
562
563
type DateTimeLocalInputProps = {} & CalendarProps;
564
565
const DateTimeLocalInput = ({
566
className,
567
...props
568
}: DateTimeLocalInputProps) => {
569
const { value, onValueChange, Time, showCalendar, showTimePicker } =
570
useSmartDateInput();
571
572
const formateSelectedDate = React.useCallback(
573
(
574
date: Date | undefined,
575
selectedDate: Date,
576
m: ActiveModifiers,
577
e: React.MouseEvent
578
) => {
579
const parsedDateTime = new Date(selectedDate);
580
581
if (!showTimePicker) {
582
// If only calendar is shown, set time to start of day
583
parsedDateTime.setHours(0, 0, 0, 0);
584
} else if (value) {
585
// If time picker is shown, preserve existing time
586
parsedDateTime.setHours(
587
value.getHours(),
588
value.getMinutes(),
589
value.getSeconds(),
590
value.getMilliseconds()
591
);
592
}
593
594
onValueChange(parsedDateTime);
595
},
596
[value, showTimePicker, onValueChange]
597
);
598
599
return (
600
<Popover>
601
<PopoverTrigger asChild>
602
<Button
603
variant={'outline'}
604
size={'icon'}
605
className={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
<Calendar
618
{...props}
619
id={'calendar'}
620
className={cn('peer flex justify-end', inputBase, className)}
621
mode='single'
622
selected={value}
623
onSelect={formateSelectedDate}
624
initialFocus
625
/>
626
)}
627
{showTimePicker && <TimePicker />}
628
</div>
629
</PopoverContent>
630
</Popover>
631
);
632
};
633
634
DateTimeLocalInput.displayName = 'DateTimeLocalInput';

Example

Time Picker

Time Input Example

Date Picker

Date Input Example

Props

PropTypeDefaultDescription
valueDateundefinedThe selected date value for the input.
onValueChange(date: Date) => void-Callback function triggered when the date value changes.
showCalendarbooleantrueWhether to display a calendar for date selection.
showTimePickerbooleantrueWhether to display a time picker for time selection.
classNamestringundefinedAdditional CSS class names to style the input component.
placeholderstringundefinedPlaceholder text displayed when no value is selected.
disabledbooleanundefinedWhether the input is disabled and not interactive.