Last active
June 6, 2025 09:55
-
-
Save maxgfr/94b00cfc2a8bb4031f36e52b0923b56d to your computer and use it in GitHub Desktop.
Shadcn Calendar UI Picker with months selections
This file contains hidden or 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 * as React from "react"; | |
import { ChevronLeft, ChevronRight } from "lucide-react"; | |
import { DayPicker } from "react-day-picker"; | |
import { format } from "date-fns"; | |
import { cn } from "~/lib/utils"; | |
import { buttonVariants } from "~/components/ui/button"; | |
import { fr } from "date-fns/locale"; | |
import { | |
Select, | |
SelectContent, | |
SelectItem, | |
SelectTrigger, | |
SelectValue, | |
} from "~/components/ui/select"; | |
export type CalendarProps = React.ComponentProps<typeof DayPicker>; | |
function CalendarDropdown({ | |
displayMonth, | |
displayYear, | |
onChangeMonth, | |
onChangeYear, | |
}: { | |
displayMonth: Date; | |
displayYear: number; | |
onChangeMonth: (date: Date) => void; | |
onChangeYear: (year: number) => void; | |
}) { | |
const months = Array.from({ length: 12 }, (_, i) => { | |
const month = new Date(displayYear, i, 1); | |
return { | |
value: i.toString(), | |
label: format(month, "MMMM", { locale: fr }), | |
}; | |
}); | |
const years = Array.from( | |
{ length: 20 }, | |
(_, i) => new Date().getFullYear() - 5 + i, | |
).sort((a, b) => a - b); | |
return ( | |
<div className="flex items-center justify-center gap-1 pt-1"> | |
<Select | |
value={displayMonth.getMonth().toString()} | |
onValueChange={(value) => { | |
const newDate = new Date(displayMonth); | |
newDate.setMonth(parseInt(value)); | |
onChangeMonth(newDate); | |
}} | |
> | |
<SelectTrigger className="h-7 w-[110px]"> | |
<SelectValue> | |
{format(displayMonth, "MMMM", { locale: fr })} | |
</SelectValue> | |
</SelectTrigger> | |
<SelectContent> | |
{months.map((month) => ( | |
<SelectItem | |
key={month.value} | |
value={month.value} | |
className="cursor-pointer" | |
> | |
{month.label} | |
</SelectItem> | |
))} | |
</SelectContent> | |
</Select> | |
<Select | |
value={displayYear.toString()} | |
onValueChange={(value) => { | |
onChangeYear(parseInt(value)); | |
}} | |
> | |
<SelectTrigger className="h-7 w-[80px]"> | |
<SelectValue>{displayYear}</SelectValue> | |
</SelectTrigger> | |
<SelectContent className="max-h-[300px]"> | |
{years.map((year) => ( | |
<SelectItem | |
key={year} | |
value={year.toString()} | |
className="cursor-pointer" | |
> | |
{year} | |
</SelectItem> | |
))} | |
</SelectContent> | |
</Select> | |
</div> | |
); | |
} | |
function Calendar({ | |
className, | |
classNames, | |
showOutsideDays = true, | |
defaultMonth, | |
index = 0, // Ajout d'un index pour gérer plusieurs calendriers | |
...props | |
}: CalendarProps & { index?: number }) { | |
const [currentMonth, setCurrentMonth] = React.useState<Date>(() => { | |
if (props.selected instanceof Date) { | |
return props.selected; | |
} | |
if (Array.isArray(props.selected) && props.selected[0]) { | |
const selectedDate = props.selected[0]; | |
if (selectedDate instanceof Date) { | |
return new Date( | |
selectedDate.getFullYear(), | |
selectedDate.getMonth() + index, | |
1, | |
); | |
} | |
return new Date(); | |
} | |
if (defaultMonth) { | |
return new Date( | |
defaultMonth.getFullYear(), | |
defaultMonth.getMonth() + index, | |
1, | |
); | |
} | |
const now = new Date(); | |
return new Date(now.getFullYear(), now.getMonth() + index, 1); | |
}); | |
const [currentYear, setCurrentYear] = React.useState<number>(() => | |
currentMonth.getFullYear(), | |
); | |
React.useEffect(() => { | |
if (props.selected instanceof Date) { | |
const newDate = new Date(props.selected); | |
newDate.setMonth(newDate.getMonth() + index); | |
setCurrentMonth(newDate); | |
setCurrentYear(newDate.getFullYear()); | |
} else if ( | |
Array.isArray(props.selected) && | |
props.selected[0] instanceof Date | |
) { | |
const newDate = new Date(props.selected[0]); | |
newDate.setMonth(newDate.getMonth() + index); | |
setCurrentMonth(newDate); | |
setCurrentYear(newDate.getFullYear()); | |
} | |
}, [props.selected, index]); | |
const handleMonthChange = (newMonth: Date) => { | |
setCurrentMonth(newMonth); | |
props.onMonthChange?.(newMonth); | |
}; | |
const handleYearChange = (year: number) => { | |
const newDate = new Date(currentMonth); | |
newDate.setFullYear(year); | |
setCurrentYear(year); | |
setCurrentMonth(newDate); | |
props.onMonthChange?.(newDate); | |
}; | |
return ( | |
<DayPicker | |
showOutsideDays={showOutsideDays} | |
className={cn("p-3", className)} | |
locale={fr} | |
month={currentMonth} | |
defaultMonth={defaultMonth} | |
onMonthChange={handleMonthChange} | |
classNames={{ | |
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0", | |
month: "space-y-4", | |
caption: "flex justify-center pt-1 relative items-center", | |
caption_label: "text-sm font-medium", | |
nav: "space-x-1 flex items-center", | |
nav_button: cn( | |
buttonVariants({ variant: "outline" }), | |
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100", | |
), | |
nav_button_previous: "absolute left-1", | |
nav_button_next: "absolute right-1", | |
table: "w-full border-collapse space-y-1", | |
head_row: "flex", | |
head_cell: | |
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]", | |
row: "flex w-full mt-2", | |
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20", | |
day: cn( | |
buttonVariants({ variant: "ghost" }), | |
"h-9 w-9 p-0 font-normal aria-selected:opacity-100", | |
), | |
day_range_end: "day-range-end", | |
day_selected: | |
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", | |
day_today: "bg-accent text-accent-foreground", | |
day_outside: | |
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground", | |
day_disabled: "text-muted-foreground opacity-50", | |
day_range_middle: | |
"aria-selected:bg-accent aria-selected:text-accent-foreground", | |
day_hidden: "invisible", | |
...classNames, | |
}} | |
components={{ | |
IconLeft: ({ className, ...props }) => ( | |
<ChevronLeft className={cn("h-4 w-4", className)} {...props} /> | |
), | |
IconRight: ({ className, ...props }) => ( | |
<ChevronRight className={cn("h-4 w-4", className)} {...props} /> | |
), | |
Caption: () => ( | |
<CalendarDropdown | |
displayMonth={currentMonth} | |
displayYear={currentYear} | |
onChangeMonth={handleMonthChange} | |
onChangeYear={handleYearChange} | |
/> | |
), | |
}} | |
{...props} | |
/> | |
); | |
} | |
Calendar.displayName = "Calendar"; | |
export { Calendar }; |
I will add here my updated version of your component. I've made the following improvements:
- Make sure the
locale
you use for months is the one passed to the component, rather than using thefr
one. - Make sure you properly support
fromDate
andtoDate
to limit the date selection. - Slightly enlarge the month and year selectors to make sure they are displayed properly.
'use client';
import { format, Locale } from 'date-fns';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import * as React from 'react';
import { DayPicker } from 'react-day-picker';
import { buttonVariants } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { useMemo } from 'react';
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function CalendarDropdown({
locale,
fromDate,
toDate,
displayMonth,
displayYear,
onChangeMonth,
onChangeYear,
}: {
locale?: Locale;
fromDate?: Date;
toDate?: Date;
displayMonth: Date;
displayYear: number;
onChangeMonth: (date: Date) => void;
onChangeYear: (year: number) => void;
}) {
const startDate = useMemo(() => fromDate || new Date(1900, 0, 1), [fromDate]);
const endDate = useMemo(() => toDate || new Date(2100, 11, 31), [toDate]);
// Compute the years between startDate and endDate
const years = useMemo(() => {
return Array.from(
{ length: endDate.getFullYear() - startDate.getFullYear() + 1 },
(_, i) => startDate.getFullYear() + i,
);
}, [endDate, startDate]);
// Compute the months for the current display year.
// If the year is in the past, all monts should be available.
// If the year is the current year, only months up to the current month should be available.
const months = useMemo(() => {
if (displayYear < startDate.getFullYear() || displayYear > endDate.getFullYear()) {
return [];
}
if (displayYear === endDate.getFullYear()) {
return Array.from({ length: endDate.getMonth() + 1 }, (_, i) => {
const month = new Date(displayYear, i, 1);
return {
value: i.toString(),
label: format(month, 'MMMM', { locale: locale }),
};
});
}
return Array.from({ length: 12 }, (_, i) => {
const month = new Date(displayYear, i, 1);
return {
value: i.toString(),
label: format(month, 'MMMM', { locale: locale }),
};
});
}, [displayYear, endDate, locale, startDate]);
return (
<div className="flex flex-row items-center justify-center gap-1">
<Select
value={displayMonth.getMonth().toString()}
onValueChange={(value) => {
const newDate = new Date(displayMonth);
newDate.setMonth(parseInt(value));
onChangeMonth(newDate);
}}
>
<SelectTrigger className="h-7 w-[130px]">
<SelectValue>{format(displayMonth, 'MMMM', { locale: locale })}</SelectValue>
</SelectTrigger>
<SelectContent>
{months.map((month) => (
<SelectItem key={month.value} value={month.value} className="cursor-pointer">
{month.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={displayYear.toString()}
onValueChange={(value) => {
onChangeYear(parseInt(value));
}}
>
<SelectTrigger className="h-7 grow">
<SelectValue>{displayYear}</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-[300px]">
{years.map((year) => (
<SelectItem key={year} value={year.toString()} className="cursor-pointer">
{year}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
function Calendar({
className,
classNames,
showOutsideDays = true,
defaultMonth,
index = 0, // Ajout d'un index pour gérer plusieurs calendriers
locale,
...props
}: CalendarProps & { index?: number }) {
const [currentMonth, setCurrentMonth] = React.useState<Date>(() => {
if (props.selected instanceof Date) {
return props.selected;
}
if (Array.isArray(props.selected) && props.selected[0]) {
const selectedDate = props.selected[0];
if (selectedDate instanceof Date) {
return new Date(selectedDate.getFullYear(), selectedDate.getMonth() + index, 1);
}
return new Date();
}
if (defaultMonth) {
return new Date(defaultMonth.getFullYear(), defaultMonth.getMonth() + index, 1);
}
const now = new Date();
return new Date(now.getFullYear(), now.getMonth() + index, 1);
});
const [currentYear, setCurrentYear] = React.useState<number>(() => currentMonth.getFullYear());
React.useEffect(() => {
if (props.selected instanceof Date) {
const newDate = new Date(props.selected);
newDate.setMonth(newDate.getMonth() + index);
setCurrentMonth(newDate);
setCurrentYear(newDate.getFullYear());
} else if (Array.isArray(props.selected) && props.selected[0] instanceof Date) {
const newDate = new Date(props.selected[0]);
newDate.setMonth(newDate.getMonth() + index);
setCurrentMonth(newDate);
setCurrentYear(newDate.getFullYear());
}
}, [props.selected, index]);
const handleMonthChange = (newMonth: Date) => {
setCurrentMonth(newMonth);
props.onMonthChange?.(newMonth);
};
const handleYearChange = (year: number) => {
const newDate = new Date(currentMonth);
newDate.setFullYear(year);
setCurrentYear(year);
setCurrentMonth(newDate);
props.onMonthChange?.(newDate);
};
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
locale={locale}
month={currentMonth}
defaultMonth={defaultMonth}
onMonthChange={handleMonthChange}
classNames={{
months: 'flex flex-col sm:flex-row gap-2',
month: 'flex flex-col gap-4',
caption: 'flex justify-center pt-1 relative items-center w-full',
caption_label: 'text-sm font-medium',
nav: 'flex items-center gap-1',
nav_button: cn(
buttonVariants({ variant: 'outline' }),
'size-7 bg-transparent p-0 opacity-50 hover:opacity-100',
),
nav_button_previous: 'absolute left-1',
nav_button_next: 'absolute right-1',
table: 'w-full border-collapse space-x-1',
head_row: 'flex',
head_cell: 'text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]',
row: 'flex w-full mt-2',
cell: cn(
'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md',
props.mode === 'range'
? '[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md'
: '[&:has([aria-selected])]:rounded-md',
),
day: cn(
buttonVariants({ variant: 'ghost' }),
'size-8 p-0 font-normal aria-selected:opacity-100',
),
day_range_start:
'day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground',
day_range_end:
'day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground',
day_selected:
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
day_today: 'bg-accent text-accent-foreground',
day_outside: 'day-outside text-muted-foreground aria-selected:text-muted-foreground',
day_disabled: 'text-muted-foreground opacity-50',
day_range_middle: 'aria-selected:bg-accent aria-selected:text-accent-foreground',
day_hidden: 'invisible',
...classNames,
}}
components={{
IconLeft: ({ className, ...props }) => (
<ChevronLeft className={cn('size-4', className)} {...props} />
),
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn('h-4 w-4', className)} {...props} />
),
Caption: () => (
<CalendarDropdown
fromDate={props.fromDate}
toDate={props.toDate}
locale={locale}
displayMonth={currentMonth}
displayYear={currentYear}
onChangeMonth={handleMonthChange}
onChangeYear={handleYearChange}
/>
),
}}
{...props}
/>
);
}
Calendar.displayName = 'Calendar';
export { Calendar };
@RiccardoM Thank you for sharing 🙏
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Awesome! Thank you for this!