Skip to content

Instantly share code, notes, and snippets.

@maxgfr
Last active June 6, 2025 09:55
Show Gist options
  • Save maxgfr/94b00cfc2a8bb4031f36e52b0923b56d to your computer and use it in GitHub Desktop.
Save maxgfr/94b00cfc2a8bb4031f36e52b0923b56d to your computer and use it in GitHub Desktop.
Shadcn Calendar UI Picker with months selections
"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 };
@i701
Copy link

i701 commented May 7, 2025

Awesome! Thank you for this!

@RiccardoM
Copy link

RiccardoM commented Jun 5, 2025

I will add here my updated version of your component. I've made the following improvements:

  1. Make sure the locale you use for months is the one passed to the component, rather than using the fr one.
  2. Make sure you properly support fromDate and toDate to limit the date selection.
  3. 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 };

@maxgfr
Copy link
Author

maxgfr commented Jun 6, 2025

@RiccardoM Thank you for sharing 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment