Last active
August 18, 2024 12:08
-
-
Save zacwellmer/316e38705b6534907c142af8ca21fa9d to your computer and use it in GitHub Desktop.
shadcn datetime picker component
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"use client" | |
import { CalendarDateTime, isToday as _isToday, } from "@internationalized/date"; | |
import { format } from "date-fns"; | |
import { CalendarIcon, ClockIcon } from "lucide-react"; | |
import { useRef, useState } from "react"; | |
import { DateValue, TimeValue, useDateSegment, useInteractOutside, useLocale, useTimeField } from "react-aria"; | |
import { DateFieldState, DatePickerStateOptions, DateSegment as IDateSegment, useDatePickerState, useTimeFieldState } from "react-stately"; | |
import { cn } from "../lib/utils"; | |
// imports from shadcn/ui | |
import { Button } from "./ui/button"; | |
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; | |
import { Toggle } from "./ui/toggle"; | |
import { Calendar } from "./ui/calendar"; | |
interface DateSegmentProps { | |
segment: IDateSegment; | |
state: DateFieldState; | |
} | |
function DateSegment({ segment, state }: DateSegmentProps) { | |
const ref = useRef(null); | |
const { | |
segmentProps: { ...segmentProps }, | |
} = useDateSegment(segment, state, ref); | |
return ( | |
<div | |
{...segmentProps} | |
ref={ref} | |
className={cn( | |
"focus:rounded-[2px] focus:bg-accent focus:text-accent-foreground focus:outline-none", | |
segment.type !== "literal" ? "px-[1px]" : "", | |
segment.isPlaceholder ? "text-muted-foreground" : "" | |
)} | |
> | |
{segment.text} | |
</div> | |
); | |
} | |
function TimeField({ hasTime, onHasTimeChange, disabled, ...props }: { | |
disabled: boolean | |
hasTime: boolean | |
onHasTimeChange: (hasTime: boolean) => void | |
value: TimeValue | null | |
onChange: (value: TimeValue) => void | |
}) { | |
const ref = useRef<HTMLDivElement | null>(null); | |
const { locale } = useLocale(); | |
const state = useTimeFieldState({ | |
...props, | |
locale, | |
}); | |
useTimeField(props, state, ref); | |
return ( | |
<div className={cn("flex items-center space-x-2 mt-1", disabled ? "cursor-not-allowed opacity-70" : "")}> | |
<Toggle | |
disabled={disabled} | |
pressed={hasTime} | |
onPressedChange={onHasTimeChange} | |
size="lg" | |
variant="outline" | |
aria-label="Toggle time" | |
> | |
<ClockIcon size='16px' /> | |
</Toggle> | |
{hasTime && ( | |
<div | |
ref={ref} | |
className="inline-flex h-10 w-full flex-1 rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" | |
> | |
{state.segments.map((segment, i) => ( | |
<DateSegment key={i} segment={segment} state={state} /> | |
))} | |
</div> | |
)} | |
</div> | |
); | |
} | |
const dateToCalendarDateTime = (date: Date): CalendarDateTime => { | |
const year = date.getFullYear(); | |
const month = date.getMonth() + 1; // JavaScript months are 0-based | |
const day = date.getDate(); | |
const hour = date.getHours(); | |
const minute = date.getMinutes(); | |
const second = date.getSeconds(); | |
const millisecond = date.getMilliseconds(); | |
return new CalendarDateTime(year, month, day, hour, minute, second, millisecond); | |
} | |
type DatePickerProps = { | |
value?: { date?: Date | null, hasTime: boolean } | |
onChange: (value: { date: Date, hasTime: boolean }) => void | |
isDisabled?: boolean | |
} | |
const DateTimePicker = (props: DatePickerProps) => { | |
const contentRef = useRef<HTMLDivElement | null>(null); | |
const [open, setOpen] = useState(false); | |
const hasTime = props.value?.hasTime || false | |
const onChangeWrapper = (value: DateValue, newHasTime?: boolean) => { | |
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone | |
props.onChange({ date: value.toDate(timeZone), hasTime: newHasTime ?? hasTime }) | |
} | |
const datePickerProps: DatePickerStateOptions<CalendarDateTime> = { | |
value: props.value?.date ? dateToCalendarDateTime(props.value.date) : undefined, | |
onChange: onChangeWrapper, | |
isDisabled: props.isDisabled, | |
granularity: 'minute', | |
} | |
const state = useDatePickerState(datePickerProps) | |
useInteractOutside({ | |
ref: contentRef, | |
onInteractOutside: (e) => { | |
setOpen(false); | |
}, | |
}); | |
const dateDisplayFormat = hasTime ? 'MM/dd/yyyy hh:mm a' : 'MM/dd/yyyy' | |
return ( | |
<Popover open={open} onOpenChange={setOpen} aria-label='Date Time Picker'> | |
<PopoverTrigger asChild> | |
<Button | |
variant={"outline"} | |
className={cn( | |
"min-w-[240px] justify-start text-left h-12", | |
!props.value && "text-muted-foreground", | |
)} | |
> | |
<CalendarIcon className="mr-2 h-4 w-4" /> | |
{props.value?.date ? format(props.value.date, dateDisplayFormat) : <span>Pick a date</span>} | |
</Button> | |
</PopoverTrigger> | |
<PopoverContent ref={contentRef} className="w-auto" align="start"> | |
<Calendar | |
mode="single" | |
selected={props.value?.date || undefined} | |
onSelect={value => onChangeWrapper(dateToCalendarDateTime(value!))} | |
initialFocus | |
footer={ | |
<TimeField | |
aria-label='Time Picker' | |
disabled={!props.value?.date} | |
hasTime={hasTime} | |
onHasTimeChange={newHasTime => | |
onChangeWrapper(dateToCalendarDateTime(props.value?.date!), newHasTime) | |
} | |
value={hasTime ? state.timeValue : null} | |
onChange={state.setTimeValue} | |
/> | |
} | |
/> | |
</PopoverContent> | |
</Popover> | |
); | |
}; | |
export { DateTimePicker }; |
This is awesome! thanks for posting! You saved me a lot of time and effort.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
thx! It's great but remains a warning tho:
app-index.js:33 Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?
Check the render method of
SlotClone
.at DateTimePicker (webpack-internal:///(app-pages-browser)/./src/app/(routes)/(protected)/panel/[[...slug]]/DateTimePicker.tsx:129:69)