Last active
March 4, 2025 08:14
-
-
Save Mr-Vipi/39a11119b8c37696650c2dd22021309d to your computer and use it in GitHub Desktop.
My custom shadcn calendar to include selection of month and year with react-day-picker-v.9
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 { ChevronLeft, ChevronRight } from "lucide-react"; | |
import * as React from "react"; | |
import { | |
Chevron as ChevronDayPicker, | |
DayPicker, | |
Dropdown as DropDownDayPicker, | |
} from "react-day-picker"; | |
import { buttonVariants } from "@/components/ui/button"; | |
import { ScrollArea } from "@/components/ui/scroll-area"; | |
import { | |
Select, | |
SelectContent, | |
SelectItem, | |
SelectTrigger, | |
SelectValue, | |
} from "@/components/ui/select"; | |
import { cn } from "@/lib/utils"; | |
export type CalendarProps = React.ComponentProps<typeof DayPicker> & { | |
captionLabelClassName?: string; | |
dayClassName?: string; | |
dayButtonClassName?: string; | |
dropdownsClassName?: string; | |
footerClassName?: string; | |
monthClassName?: string; | |
monthCaptionClassName?: string; | |
monthGridClassName?: string; | |
monthsClassName?: string; | |
navClassName?: string; | |
buttonNextClassName?: string; | |
buttonPreviousClassName?: string; | |
weekClassName?: string; | |
weekdayClassName?: string; | |
weekdaysClassName?: string; | |
rangeEndClassName?: string; | |
rangeMiddleClassName?: string; | |
rangeStartClassName?: string; | |
selectedClassName?: string; | |
disabledClassName?: string; | |
hiddenClassName?: string; | |
outsideClassName?: string; | |
todayClassName?: string; | |
selectTriggerClassName?: string; | |
}; | |
function Calendar({ | |
className, | |
classNames, | |
hideNavigation, | |
showOutsideDays = true, | |
components, | |
...props | |
}: CalendarProps) { | |
const _monthsClassName = cn( | |
"relative flex flex-col gap-4 sm:flex-row", | |
props.monthsClassName | |
); | |
const _monthCaptionClassName = cn( | |
"relative flex h-7 items-center justify-center", | |
props.monthCaptionClassName | |
); | |
const _dropdownsClassName = cn( | |
"flex items-center justify-center gap-2", | |
hideNavigation ? "w-full" : "", | |
props.dropdownsClassName | |
); | |
const _footerClassName = cn("pt-3 text-sm", props.footerClassName); | |
const _weekdaysClassName = cn("flex", props.weekdaysClassName); | |
const _weekdayClassName = cn( | |
"w-9 text-sm font-normal text-muted-foreground", | |
props.weekdayClassName | |
); | |
const _captionLabelClassName = cn( | |
"truncate text-sm font-medium", | |
props.captionLabelClassName | |
); | |
const buttonNavClassName = buttonVariants({ | |
variant: "outline", | |
className: "z-10 size-7 bg-transparent p-0 opacity-50 hover:opacity-100", | |
}); | |
const _buttonNextClassName = cn( | |
buttonNavClassName, | |
props.buttonNextClassName | |
); | |
const _buttonPreviousClassName = cn( | |
buttonNavClassName, | |
props.buttonPreviousClassName | |
); | |
const _navClassName = cn( | |
"absolute flex w-full items-center justify-between", | |
props.navClassName | |
); | |
const _monthGridClassName = cn("mx-auto mt-4", props.monthGridClassName); | |
const _weekClassName = cn("mt-2 flex w-max items-start", props.weekClassName); | |
const _dayClassName = cn( | |
"flex size-9 flex-1 items-center justify-center p-0 text-sm", | |
props.dayClassName | |
); | |
const _dayButtonClassName = cn( | |
buttonVariants({ variant: "ghost" }), | |
"size-9 rounded-md p-0 font-normal transition-none aria-selected:opacity-100", | |
props.dayButtonClassName | |
); | |
const buttonRangeClassName = | |
"bg-accent [&>button]:bg-primary [&>button]:text-primary-foreground [&>button]:hover:bg-primary [&>button]:hover:text-primary-foreground"; | |
const _rangeStartClassName = cn( | |
buttonRangeClassName, | |
"rounded-s-md", | |
props.rangeStartClassName | |
); | |
const _rangeEndClassName = cn( | |
buttonRangeClassName, | |
"rounded-e-md", | |
props.rangeEndClassName | |
); | |
const _rangeMiddleClassName = cn( | |
"bg-accent !text-foreground [&>button]:bg-transparent [&>button]:!text-foreground [&>button]:hover:bg-transparent [&>button]:hover:!text-foreground", | |
props.rangeMiddleClassName | |
); | |
const _selectedClassName = cn( | |
"[&>button]:bg-primary [&>button]:text-primary-foreground [&>button]:hover:bg-primary [&>button]:hover:text-primary-foreground", | |
props.selectedClassName | |
); | |
const _todayClassName = cn( | |
"[&>button]:bg-accent [&>button]:text-accent-foreground", | |
props.todayClassName | |
); | |
const _outsideClassName = cn( | |
"text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30", | |
props.outsideClassName | |
); | |
const _disabledClassName = cn( | |
"text-muted-foreground opacity-50", | |
props.disabledClassName | |
); | |
const _hiddenClassName = cn("invisible flex-1", props.hiddenClassName); | |
const Chevron = React.useCallback( | |
({ | |
orientation, | |
...props | |
}: React.ComponentProps<typeof ChevronDayPicker>) => { | |
const Icon = orientation === "left" ? ChevronLeft : ChevronRight; | |
return <Icon className="size-4" {...props} />; | |
}, | |
[] | |
); | |
const Dropdown = React.useCallback( | |
({ | |
value, | |
onChange, | |
options, | |
}: React.ComponentProps<typeof DropDownDayPicker>) => { | |
const selected = options?.find((option) => option.value === value); | |
const handleChange = (value: string) => { | |
const changeEvent = { | |
target: { value }, | |
} as React.ChangeEvent<HTMLSelectElement>; | |
onChange?.(changeEvent); | |
}; | |
return ( | |
<Select | |
value={value?.toString()} | |
onValueChange={(value) => { | |
handleChange(value); | |
}} | |
> | |
<SelectTrigger | |
className={cn( | |
hideNavigation ? "" : "h-7", | |
props.selectTriggerClassName | |
)} | |
> | |
<SelectValue>{selected?.label}</SelectValue> | |
</SelectTrigger> | |
<SelectContent position="popper" align="center"> | |
<ScrollArea className="h-80"> | |
{options?.map(({ value, label, disabled }, id) => ( | |
<SelectItem | |
key={`${value}-${id}`} | |
value={value?.toString()} | |
disabled={disabled} | |
> | |
{label} | |
</SelectItem> | |
))} | |
</ScrollArea> | |
</SelectContent> | |
</Select> | |
); | |
}, | |
[hideNavigation, props.selectTriggerClassName] | |
); | |
return ( | |
<DayPicker | |
showOutsideDays={showOutsideDays} | |
hideNavigation={hideNavigation} | |
className={cn("p-3", className)} | |
classNames={{ | |
caption_label: _captionLabelClassName, | |
day: _dayClassName, | |
day_button: _dayButtonClassName, | |
dropdowns: _dropdownsClassName, | |
footer: _footerClassName, | |
month: props.monthClassName, | |
month_caption: _monthCaptionClassName, | |
month_grid: _monthGridClassName, | |
months: _monthsClassName, | |
nav: _navClassName, | |
button_next: _buttonNextClassName, | |
button_previous: _buttonPreviousClassName, | |
week: _weekClassName, | |
weekday: _weekdayClassName, | |
weekdays: _weekdaysClassName, | |
range_end: _rangeEndClassName, | |
range_middle: _rangeMiddleClassName, | |
range_start: _rangeStartClassName, | |
selected: _selectedClassName, | |
disabled: _disabledClassName, | |
hidden: _hiddenClassName, | |
outside: _outsideClassName, | |
today: _todayClassName, | |
...classNames, | |
}} | |
components={{ | |
Chevron, | |
Dropdown, | |
...components, | |
}} | |
{...props} | |
/> | |
); | |
} | |
Calendar.displayName = "Calendar"; | |
export { Calendar }; |
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 { | |
addDays, | |
addMonths, | |
format, | |
isBefore, | |
isEqual, | |
isWithinInterval, | |
} from "date-fns"; | |
import { Calendar as CalendarIcon } from "lucide-react"; | |
import { | |
Dispatch, | |
memo, | |
SetStateAction, | |
useCallback, | |
useEffect, | |
useMemo, | |
useState, | |
} from "react"; | |
import { DateRange } from "react-day-picker"; | |
import { Button } from "@/components/ui/button"; | |
import { Calendar } from "@/components/ui/calendar"; | |
import { | |
Popover, | |
PopoverContent, | |
PopoverTrigger, | |
} from "@/components/ui/popover"; | |
import { cn } from "@/lib/utils"; | |
// import { FieldType } from "./booking-form"; | |
type DateRangePickerProps = Readonly<{ | |
date?: DateRange; | |
setDate?: Dispatch<SetStateAction<DateRange | undefined>>; | |
// field?: FieldType; | |
focusedField?: string; | |
setFocusedField?: Dispatch<SetStateAction<string>>; | |
className?: string; | |
bookedDates?: DateRange[]; | |
}>; | |
const fromMonth = new Date(); | |
const toMonth = addMonths(new Date(), 6); | |
const DateRangePicker = ({ | |
// date, | |
// setDate, | |
// field, | |
className, | |
bookedDates, | |
}: DateRangePickerProps) => { | |
const [isPopoverOpen, setIsPopoverOpen] = useState(false); | |
const [minimumDays, setMinimumDays] = useState<DateRange>({ | |
from: undefined, | |
to: undefined, | |
}); | |
const [date, setDate] = useState<DateRange | undefined>({ | |
from: undefined, | |
to: undefined, | |
}); | |
useEffect(() => { | |
if (!date) { | |
return; | |
} | |
if (date.from && date.to) { | |
setIsPopoverOpen(false); | |
setMinimumDays({ | |
from: undefined, | |
to: undefined, | |
}); | |
return; | |
} | |
if (date.from && !date.to) { | |
setMinimumDays({ | |
from: addDays(date.from, 1), | |
to: addDays(date.from, 1), | |
}); | |
} else { | |
setMinimumDays({ | |
from: undefined, | |
to: undefined, | |
}); | |
} | |
}, [date]); | |
function formatDateRange(from: Date, to: Date | undefined) { | |
const formattedFrom = format(from, "LLL dd, y"); | |
return to ? `${formattedFrom} - ${format(to, "LLL dd, y")}` : formattedFrom; | |
} | |
const getDateText = useCallback( | |
(/*field: FieldType | undefined,*/ date: DateRange | undefined) => { | |
// if (field?.value) { | |
// const { from, to } = field.value; | |
// return from ? formatDateRange(from, to) : "Departure / Return"; | |
// } | |
if (date) { | |
const { from, to } = date; | |
return from ? formatDateRange(from, to) : "Departure / Return"; | |
} | |
return "Departure / Return"; | |
}, | |
[] | |
); | |
const checkIfBookingAvailable = useCallback( | |
({ from, to }: DateRange): boolean => { | |
let isAvailable = true; | |
if (bookedDates) { | |
bookedDates.forEach((bookedDate) => { | |
if ( | |
!( | |
bookedDate.from && | |
bookedDate.to && | |
from && | |
to && | |
isWithinInterval(bookedDate.from, { | |
start: from, | |
end: to, | |
}) && | |
isWithinInterval(bookedDate.to, { start: from, end: to }) | |
) | |
) { | |
return; | |
} | |
isAvailable = false; | |
}); | |
} | |
return isAvailable; | |
}, | |
[bookedDates] | |
); | |
const updateDate = useCallback( | |
(day: Date): void => { | |
if (setDate) { | |
setDate((prev) => { | |
if (prev?.from && isEqual(prev.from, day)) { | |
return { from: undefined, to: undefined }; | |
} | |
if (prev?.to) { | |
return { from: day, to: undefined }; | |
} else if (prev?.from && isBefore(prev?.from, day)) { | |
return checkIfBookingAvailable({ from: prev?.from, to: day }) | |
? { from: prev?.from, to: day } | |
: { from: day, to: undefined }; | |
} else { | |
return { from: day, to: undefined }; | |
} | |
}); | |
} | |
// if (!field) { | |
// return; | |
// } | |
// const updateMinimumDaysAndResetToDate = () => { | |
// setMinimumDays({ from: addDays(day, 1), to: addDays(day, 1) }); | |
// field.onChange({ from: day, to: undefined }); | |
// }; | |
// if (field.value.from && isEqual(field.value.from, day)) { | |
// field.onChange({ from: undefined, to: undefined }); | |
// setMinimumDays({ | |
// from: undefined, | |
// to: undefined, | |
// }); | |
// return; | |
// } | |
// if (field.value.to) { | |
// updateMinimumDaysAndResetToDate(); | |
// } else if (field.value.from && isBefore(field.value.from, day)) { | |
// if (checkIfBookingAvailable({ from: field.value.from, to: day })) { | |
// setIsPopoverOpen(false); | |
// field.onChange({ from: field.value.from, to: day }); | |
// setMinimumDays({ from: undefined, to: undefined }); | |
// } else { | |
// updateMinimumDaysAndResetToDate(); | |
// } | |
// } else { | |
// updateMinimumDaysAndResetToDate(); | |
// } | |
}, | |
[setDate, /*field,*/ checkIfBookingAvailable] | |
); | |
const handleOpenChange = useCallback( | |
(value: boolean) => { | |
setIsPopoverOpen(value); | |
}, | |
[setIsPopoverOpen] | |
); | |
const disabled = useMemo( | |
() => [ | |
{ | |
before: new Date(), | |
}, | |
minimumDays, | |
...(bookedDates ?? []), | |
], | |
[minimumDays, bookedDates] | |
); | |
return ( | |
<Popover open={isPopoverOpen} onOpenChange={handleOpenChange}> | |
<PopoverTrigger asChild> | |
<Button | |
variant="outline" | |
className={cn( | |
"w-full pl-3 text-left", | |
date?.from /*|| field?.value.from*/ ? "" : "text-muted-foreground", | |
className | |
)} | |
> | |
<span className="pt-1">{getDateText(/*field,*/ date)}</span> | |
<CalendarIcon className="ml-auto size-4 opacity-50" /> | |
</Button> | |
</PopoverTrigger> | |
<PopoverContent className="w-auto p-0" align="center"> | |
<Calendar | |
autoFocus | |
mode="range" | |
defaultMonth={date?.from /*|| field?.value?.from*/} | |
selected={ | |
date /*|| { | |
from: field?.value?.from, | |
to: field?.value?.to, | |
}*/ | |
} | |
numberOfMonths={2} | |
showOutsideDays={false} | |
startMonth={fromMonth} | |
endMonth={toMonth} | |
onSelect={(_date, day) => updateDate(day)} | |
disabled={disabled} | |
/> | |
</PopoverContent> | |
</Popover> | |
); | |
}; | |
const areEqual = ( | |
prevProps: DateRangePickerProps, | |
nextProps: DateRangePickerProps | |
) => { | |
return ( | |
prevProps?.date === nextProps.date && | |
// prevProps?.field === nextProps.field && | |
prevProps?.focusedField === nextProps.focusedField && | |
prevProps?.bookedDates === nextProps.bookedDates && | |
prevProps?.className === nextProps.className | |
); | |
}; | |
export default memo(DateRangePicker, areEqual); |
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 { format } from "date-fns"; | |
import * as React from "react"; | |
import { Button } from "@/components/ui/button"; | |
import { Calendar } from "@/components/ui/calendar"; | |
import { | |
Popover, | |
PopoverContent, | |
PopoverTrigger, | |
} from "@/components/ui/popover"; | |
import { cn } from "@/lib/utils"; | |
import { CalendarIcon } from "lucide-react"; | |
// Interval for the date picker | |
// const startDate = subYears(new Date(), 75); | |
// const endDate = subYears(new Date(), 23); | |
export default function DatePicker() { | |
const [date, setDate] = React.useState<Date>(); | |
return ( | |
<Popover> | |
<PopoverTrigger asChild> | |
<Button | |
variant="outline" | |
className={cn( | |
"w-full pl-3 text-left", | |
date ? "md:text-sm" : "text-muted-foreground" | |
)} | |
> | |
{date ? format(date, "PPP") : <span>Pick a date</span>} | |
<CalendarIcon className="ml-auto size-4 opacity-50" /> | |
</Button> | |
</PopoverTrigger> | |
<PopoverContent className="w-auto p-0" align="center"> | |
<Calendar | |
mode="single" | |
selected={date} | |
onSelect={setDate} | |
autoFocus | |
defaultMonth={date} | |
showOutsideDays={false} | |
captionLayout="dropdown" | |
// hideNavigation | |
// selectTriggerClassName="transition-colors duration-200 ease-in-out hover:border-primary focus:border-primary focus:shadow-around-primary focus:ring-0 focus:ring-offset-0" | |
// startMonth={startDate} | |
// endMonth={endDate} | |
// disabled={[ | |
// { before: startDate }, | |
// { after: endDate }, | |
// ]} | |
// footer={DatePickerFooter} | |
/> | |
</PopoverContent> | |
</Popover> | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment