MonthPicker & MonthRangePicker for shadcn-ui

官网只支持了Calendar以及DatePicker

components/ui/month-picker.tsx

'use client';
import * as React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { buttonVariants } from './button';
import { cn } from '@/lib/utils';

type Month = {
  number: number;
  name: string;
};

const MONTHS: Month[][] = [
  [
    { number: 0, name: 'Jan' },
    { number: 1, name: 'Feb' },
    { number: 2, name: 'Mar' },
    { number: 3, name: 'Apr' },
  ],
  [
    { number: 4, name: 'May' },
    { number: 5, name: 'Jun' },
    { number: 6, name: 'Jul' },
    { number: 7, name: 'Aug' },
  ],
  [
    { number: 8, name: 'Sep' },
    { number: 9, name: 'Oct' },
    { number: 10, name: 'Nov' },
    { number: 11, name: 'Dec' },
  ],
];

type MonthCalendarProps = {
  selectedMonth?: Date;
  onMonthSelect?: (date: Date) => void;
  onYearForward?: () => void;
  onYearBackward?: () => void;
  callbacks?: {
    yearLabel?: (year: number) => string;
    monthLabel?: (month: Month) => string;
  };
  variant?: {
    calendar?: {
      main?: ButtonVariant;
      selected?: ButtonVariant;
    };
    chevrons?: ButtonVariant;
  };
  minDate?: Date;
  maxDate?: Date;
  disabledDates?: Date[];
};

type ButtonVariant =
  | 'default'
  | 'outline'
  | 'ghost'
  | 'link'
  | 'destructive'
  | 'secondary'
  | null
  | undefined;

function MonthPicker({
  onMonthSelect,
  selectedMonth,
  minDate,
  maxDate,
  disabledDates,
  callbacks,
  onYearBackward,
  onYearForward,
  variant,
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement> & MonthCalendarProps) {
  return (
    <div className={cn('min-w-[200px] w-[280px] p-3', className)} {...props}>
      <div className='flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0'>
        <div className='space-y-4 w-full'>
          <MonthCalendar
            onMonthSelect={onMonthSelect}
            callbacks={callbacks}
            selectedMonth={selectedMonth}
            onYearBackward={onYearBackward}
            onYearForward={onYearForward}
            variant={variant}
            minDate={minDate}
            maxDate={maxDate}
            disabledDates={disabledDates}
          ></MonthCalendar>
        </div>
      </div>
    </div>
  );
}

function MonthCalendar({
  selectedMonth,
  onMonthSelect,
  callbacks,
  variant,
  minDate,
  maxDate,
  disabledDates,
  onYearBackward,
  onYearForward,
}: MonthCalendarProps) {
  const [year, setYear] = React.useState<number>(
    selectedMonth?.getFullYear() ?? new Date().getFullYear()
  );
  const [month, setMonth] = React.useState<number>(
    selectedMonth?.getMonth() ?? new Date().getMonth()
  );
  const [menuYear, setMenuYear] = React.useState<number>(year);

  if (minDate && maxDate && minDate > maxDate) minDate = maxDate;

  const disabledDatesMapped = disabledDates?.map((d) => {
    return { year: d.getFullYear(), month: d.getMonth() };
  });

  return (
    <>
      <div className='flex justify-center pt-1 relative items-center'>
        <div className='text-sm font-medium'>
          {callbacks?.yearLabel ? callbacks?.yearLabel(menuYear) : menuYear}
        </div>
        <div className='space-x-1 flex items-center'>
          <button
            onClick={() => {
              setMenuYear(menuYear - 1);
              if (onYearBackward) onYearBackward();
            }}
            className={cn(
              buttonVariants({ variant: variant?.chevrons ?? 'outline' }),
              'inline-flex items-center justify-center h-7 w-7 p-0 absolute left-1'
            )}
          >
            <ChevronLeft className='opacity-50 h-4 w-4' />
          </button>
          <button
            onClick={() => {
              setMenuYear(menuYear + 1);
              if (onYearForward) onYearForward();
            }}
            className={cn(
              buttonVariants({ variant: variant?.chevrons ?? 'outline' }),
              'inline-flex items-center justify-center h-7 w-7 p-0 absolute right-1'
            )}
          >
            <ChevronRight className='opacity-50 h-4 w-4' />
          </button>
        </div>
      </div>
      <table className='w-full border-collapse space-y-1'>
        <tbody>
          {MONTHS.map((monthRow, a) => {
            return (
              <tr key={'row-' + a} className='flex w-full mt-2'>
                {monthRow.map((m) => {
                  return (
                    <td
                      key={m.number}
                      className='h-10 w-1/4 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'
                    >
                      <button
                        onClick={() => {
                          setMonth(m.number);
                          setYear(menuYear);
                          if (onMonthSelect)
                            onMonthSelect(new Date(menuYear, m.number));
                        }}
                        disabled={
                          (maxDate
                            ? menuYear > maxDate?.getFullYear() ||
                              (menuYear == maxDate?.getFullYear() &&
                                m.number > maxDate.getMonth())
                            : false) ||
                          (minDate
                            ? menuYear < minDate?.getFullYear() ||
                              (menuYear == minDate?.getFullYear() &&
                                m.number < minDate.getMonth())
                            : false) ||
                          (disabledDatesMapped
                            ? disabledDatesMapped?.some(
                                (d) => d.year == menuYear && d.month == m.number
                              )
                            : false)
                        }
                        className={cn(
                          buttonVariants({
                            variant:
                              month == m.number && menuYear == year
                                ? (variant?.calendar?.selected ?? 'default')
                                : (variant?.calendar?.main ?? 'ghost'),
                          }),
                          'h-full w-full p-0 font-normal aria-selected:opacity-100'
                        )}
                      >
                        {callbacks?.monthLabel
                          ? callbacks.monthLabel(m)
                          : m.name}
                      </button>
                    </td>
                  );
                })}
              </tr>
            );
          })}
        </tbody>
      </table>
    </>
  );
}

MonthPicker.displayName = 'MonthPicker';

export { MonthPicker };

components/ui/month-range-picker.tsx

'use client';
import * as React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button, buttonVariants } from './button';
import { cn } from '@/lib/utils';

const addMonths = (input: Date, months: number) => {
  const date = new Date(input);
  date.setDate(1);
  date.setMonth(date.getMonth() + months);
  date.setDate(
    Math.min(
      input.getDate(),
      getDaysInMonth(date.getFullYear(), date.getMonth() + 1)
    )
  );
  return date;
};
const getDaysInMonth = (year: number, month: number) =>
  new Date(year, month, 0).getDate();

type Month = {
  number: number;
  name: string;
  yearOffset: number;
};

const MONTHS: Month[][] = [
  [
    { number: 0, name: 'Jan', yearOffset: 0 },
    { number: 1, name: 'Feb', yearOffset: 0 },
    { number: 2, name: 'Mar', yearOffset: 0 },
    { number: 3, name: 'Apr', yearOffset: 0 },
    { number: 0, name: 'Jan', yearOffset: 1 },
    { number: 1, name: 'Feb', yearOffset: 1 },
    { number: 2, name: 'Mar', yearOffset: 1 },
    { number: 3, name: 'Apr', yearOffset: 1 },
  ],
  [
    { number: 4, name: 'May', yearOffset: 0 },
    { number: 5, name: 'Jun', yearOffset: 0 },
    { number: 6, name: 'Jul', yearOffset: 0 },
    { number: 7, name: 'Aug', yearOffset: 0 },
    { number: 4, name: 'May', yearOffset: 1 },
    { number: 5, name: 'Jun', yearOffset: 1 },
    { number: 6, name: 'Jul', yearOffset: 1 },
    { number: 7, name: 'Aug', yearOffset: 1 },
  ],
  [
    { number: 8, name: 'Sep', yearOffset: 0 },
    { number: 9, name: 'Oct', yearOffset: 0 },
    { number: 10, name: 'Nov', yearOffset: 0 },
    { number: 11, name: 'Dec', yearOffset: 0 },
    { number: 8, name: 'Sep', yearOffset: 1 },
    { number: 9, name: 'Oct', yearOffset: 1 },
    { number: 10, name: 'Nov', yearOffset: 1 },
    { number: 11, name: 'Dec', yearOffset: 1 },
  ],
];

type QuickSelector = {
  label: string;
  startMonth: Date;
  endMonth: Date;
  variant?: ButtonVariant;
  onClick?: (selector: QuickSelector) => void;
};

const QUICK_SELECTORS: QuickSelector[] = [
  {
    label: 'This year',
    startMonth: new Date(new Date().getFullYear(), 0),
    endMonth: new Date(new Date().getFullYear(), 11),
  },
  {
    label: 'Last year',
    startMonth: new Date(new Date().getFullYear() - 1, 0),
    endMonth: new Date(new Date().getFullYear() - 1, 11),
  },
  {
    label: 'Last 6 months',
    startMonth: new Date(addMonths(new Date(), -6)),
    endMonth: new Date(),
  },
  {
    label: 'Last 12 months',
    startMonth: new Date(addMonths(new Date(), -12)),
    endMonth: new Date(),
  },
];

type MonthRangeCalendarProps = {
  selectedMonthRange?: { start: Date; end: Date };
  onStartMonthSelect?: (date: Date) => void;
  onMonthRangeSelect?: ({ start, end }: { start: Date; end: Date }) => void;
  onYearForward?: () => void;
  onYearBackward?: () => void;
  callbacks?: {
    yearLabel?: (year: number) => string;
    monthLabel?: (month: Month) => string;
  };
  variant?: {
    calendar?: {
      main?: ButtonVariant;
      selected?: ButtonVariant;
    };
    chevrons?: ButtonVariant;
  };
  minDate?: Date;
  maxDate?: Date;
  quickSelectors?: QuickSelector[];
  showQuickSelectors?: boolean;
};

type ButtonVariant =
  | 'default'
  | 'outline'
  | 'ghost'
  | 'link'
  | 'destructive'
  | 'secondary'
  | null
  | undefined;

function MonthRangePicker({
  onMonthRangeSelect,
  onStartMonthSelect,
  callbacks,
  selectedMonthRange,
  onYearBackward,
  onYearForward,
  variant,
  minDate,
  maxDate,
  quickSelectors,
  showQuickSelectors,
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement> & MonthRangeCalendarProps) {
  return (
    <div className={cn('min-w-[400px]  p-3', className)} {...props}>
      <div className='flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0'>
        <div className='w-full'>
          <MonthRangeCalendar
            onMonthRangeSelect={onMonthRangeSelect}
            onStartMonthSelect={onStartMonthSelect}
            callbacks={callbacks}
            selectedMonthRange={selectedMonthRange}
            onYearBackward={onYearBackward}
            onYearForward={onYearForward}
            variant={variant}
            minDate={minDate}
            maxDate={maxDate}
            quickSelectors={quickSelectors}
            showQuickSelectors={showQuickSelectors}
          ></MonthRangeCalendar>
        </div>
      </div>
    </div>
  );
}

function MonthRangeCalendar({
  selectedMonthRange,
  onMonthRangeSelect,
  onStartMonthSelect,
  callbacks,
  variant,
  minDate,
  maxDate,
  quickSelectors = QUICK_SELECTORS,
  showQuickSelectors = false,
  onYearBackward,
  onYearForward,
}: MonthRangeCalendarProps) {
  const [startYear, setStartYear] = React.useState<number>(
    selectedMonthRange?.start.getFullYear() ?? new Date().getFullYear()
  );
  const [startMonth, setStartMonth] = React.useState<number>(
    selectedMonthRange?.start?.getMonth() ?? new Date().getMonth()
  );
  const [endYear, setEndYear] = React.useState<number>(
    selectedMonthRange?.end?.getFullYear() ?? new Date().getFullYear() + 1
  );
  const [endMonth, setEndMonth] = React.useState<number>(
    selectedMonthRange?.end?.getMonth() ?? new Date().getMonth()
  );
  const [rangePending, setRangePending] = React.useState<boolean>(false);
  const [endLocked, setEndLocked] = React.useState<boolean>(true);
  const [menuYear, setMenuYear] = React.useState<number>(startYear);

  if (minDate && maxDate && minDate > maxDate) minDate = maxDate;

  return (
    <div className='flex gap-4'>
      <div className='min-w-[400px] space-y-4'>
        <div className='flex justify-evenly pt-1 relative items-center'>
          <div className='text-sm font-medium'>
            {callbacks?.yearLabel ? callbacks?.yearLabel(menuYear) : menuYear}
          </div>
          <div className='space-x-1 flex items-center'>
            <button
              onClick={() => {
                setMenuYear(menuYear - 1);
                if (onYearBackward) onYearBackward();
              }}
              className={cn(
                buttonVariants({ variant: variant?.chevrons ?? 'outline' }),
                'inline-flex items-center justify-center h-7 w-7 p-0 absolute left-1'
              )}
            >
              <ChevronLeft className='opacity-50 h-4 w-4' />
            </button>
            <button
              onClick={() => {
                setMenuYear(menuYear + 1);
                if (onYearForward) onYearForward();
              }}
              className={cn(
                buttonVariants({ variant: variant?.chevrons ?? 'outline' }),
                'inline-flex items-center justify-center h-7 w-7 p-0 absolute right-1'
              )}
            >
              <ChevronRight className='opacity-50 h-4 w-4' />
            </button>
          </div>
          <div className='text-sm font-medium'>
            {callbacks?.yearLabel
              ? callbacks?.yearLabel(menuYear + 1)
              : menuYear + 1}
          </div>
        </div>
        <table className='w-full border-collapse space-y-1'>
          <tbody>
            {MONTHS.map((monthRow, a) => {
              return (
                <tr key={'row-' + a} className='flex w-full mt-2'>
                  {monthRow.map((m, i) => {
                    return (
                      <td
                        key={m.number + '-' + m.yearOffset}
                        className={cn(
                          cn(
                            cn(
                              cn(
                                'h-10 w-1/4 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',
                                (menuYear + m.yearOffset > startYear ||
                                  (menuYear + m.yearOffset == startYear &&
                                    m.number > startMonth)) &&
                                  (menuYear + m.yearOffset < endYear ||
                                    (menuYear + m.yearOffset == endYear &&
                                      m.number < endMonth)) &&
                                  (rangePending || endLocked)
                                  ? 'text-accent-foreground bg-accent'
                                  : ''
                              ),
                              menuYear + m.yearOffset == startYear &&
                                m.number == startMonth &&
                                (rangePending || endLocked)
                                ? 'text-accent-foreground bg-accent rounded-l-md'
                                : ''
                            ),
                            menuYear + m.yearOffset == endYear &&
                              m.number == endMonth &&
                              (rangePending || endLocked) &&
                              menuYear + m.yearOffset >= startYear &&
                              m.number >= startMonth
                              ? 'text-accent-foreground bg-accent rounded-r-md'
                              : ''
                          ),
                          i == 3 ? 'mr-2' : i == 4 ? 'ml-2' : ''
                        )}
                        onMouseEnter={() => {
                          if (rangePending && !endLocked) {
                            setEndYear(menuYear + m.yearOffset);
                            setEndMonth(m.number);
                          }
                        }}
                      >
                        <button
                          onClick={() => {
                            if (rangePending) {
                              if (
                                menuYear + m.yearOffset < startYear ||
                                (menuYear + m.yearOffset == startYear &&
                                  m.number < startMonth)
                              ) {
                                setRangePending(true);
                                setEndLocked(false);
                                setStartMonth(m.number);
                                setStartYear(menuYear + m.yearOffset);
                                setEndYear(menuYear + m.yearOffset);
                                setEndMonth(m.number);
                                if (onStartMonthSelect)
                                  onStartMonthSelect(
                                    new Date(menuYear + m.yearOffset, m.number)
                                  );
                              } else {
                                setRangePending(false);
                                setEndLocked(true);
                                // Event fire data selected

                                if (onMonthRangeSelect)
                                  onMonthRangeSelect({
                                    start: new Date(startYear, startMonth),
                                    end: new Date(
                                      menuYear + m.yearOffset,
                                      m.number
                                    ),
                                  });
                              }
                            } else {
                              setRangePending(true);
                              setEndLocked(false);
                              setStartMonth(m.number);
                              setStartYear(menuYear + m.yearOffset);
                              setEndYear(menuYear + m.yearOffset);
                              setEndMonth(m.number);
                              if (onStartMonthSelect)
                                onStartMonthSelect(
                                  new Date(menuYear + m.yearOffset, m.number)
                                );
                            }
                          }}
                          disabled={
                            (maxDate
                              ? menuYear + m.yearOffset >
                                  maxDate?.getFullYear() ||
                                (menuYear + m.yearOffset ==
                                  maxDate?.getFullYear() &&
                                  m.number > maxDate.getMonth())
                              : false) ||
                            (minDate
                              ? menuYear + m.yearOffset <
                                  minDate?.getFullYear() ||
                                (menuYear + m.yearOffset ==
                                  minDate?.getFullYear() &&
                                  m.number < minDate.getMonth())
                              : false)
                          }
                          className={cn(
                            buttonVariants({
                              variant:
                                (startMonth == m.number &&
                                  menuYear + m.yearOffset == startYear) ||
                                (endMonth == m.number &&
                                  menuYear + m.yearOffset == endYear &&
                                  !rangePending)
                                  ? (variant?.calendar?.selected ?? 'default')
                                  : (variant?.calendar?.main ?? 'ghost'),
                            }),
                            'h-full w-full p-0 font-normal aria-selected:opacity-100'
                          )}
                        >
                          {callbacks?.monthLabel
                            ? callbacks.monthLabel(m)
                            : m.name}
                        </button>
                      </td>
                    );
                  })}
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>

      {showQuickSelectors ? (
        <div className=' flex flex-col gap-1 justify-center'>
          {quickSelectors.map((s) => {
            return (
              <Button
                onClick={() => {
                  setStartYear(s.startMonth.getFullYear());
                  setStartMonth(s.startMonth.getMonth());
                  setEndYear(s.endMonth.getFullYear());
                  setEndMonth(s.endMonth.getMonth());
                  setRangePending(false);
                  setEndLocked(true);
                  if (onMonthRangeSelect)
                    onMonthRangeSelect({
                      start: s.startMonth,
                      end: s.endMonth,
                    });
                  if (s.onClick) s.onClick(s);
                }}
                key={s.label}
                variant={s.variant ?? 'outline'}
              >
                {s.label}
              </Button>
            );
          })}
        </div>
      ) : null}
    </div>
  );
}

MonthRangePicker.displayName = 'MonthRangePicker';

export { MonthRangePicker };

example

需要shadcn-ui的popover依赖

'use client';
import React from 'react';
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from '@/components/ui/popover';
import { CalendarIcon } from 'lucide-react';
import { format } from 'date-fns';
import { MonthPicker } from '@/components/ui/month-picker';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { MonthRangePicker } from '@/components/ui/month-range-picker';

const Page = () => {
  const [date, setDate] = React.useState<Date>();
  const [dates, setDates] = React.useState<{ start: Date; end: Date }>({
    start: new Date(),
    end: new Date(),
  });

  return (
    <div className='flex flex-col gap-4'>
      <div className='flex gap-4'>
        <Popover>
          <PopoverTrigger asChild>
            <Button
              variant={'outline'}
              className={cn(
                'w-[280px] justify-start text-left font-normal',
                !date && 'text-muted-foreground'
              )}
            >
              <CalendarIcon className='mr-2 h-4 w-4' />
              {date ? format(date, 'MMM yyyy') : <span>Pick a month</span>}
            </Button>
          </PopoverTrigger>
          <PopoverContent className='w-auto p-0'>
            <MonthPicker
              onMonthSelect={(newDate) => setDate(newDate)}
              selectedMonth={date}
              variant={{ chevrons: 'ghost' }}
            ></MonthPicker>
          </PopoverContent>
        </Popover>
        <p className='mb-4 text-sm opacity-50'>
          selected date: {date?.toDateString()}
        </p>
      </div>
      <div className='flex gap-4'>
        <Popover>
          <PopoverTrigger asChild>
            <Button
              variant={'outline'}
              className={cn(
                'w-[280px] justify-start text-left font-normal',
                !date && 'text-muted-foreground'
              )}
            >
              <CalendarIcon className='mr-2 h-4 w-4' />
              {dates ? (
                `${format(dates.start, 'MMM yyyy')} - ${format(dates.end, 'MMM yyyy')}`
              ) : (
                <span>Pick a month range</span>
              )}
            </Button>
          </PopoverTrigger>
          <PopoverContent className='w-auto p-0'>
            <MonthRangePicker
              onMonthRangeSelect={(newDates) => setDates(newDates)}
              selectedMonthRange={dates}
            ></MonthRangePicker>
          </PopoverContent>
        </Popover>
        <p className='mb-4 text-sm opacity-50'>
          selected date:{' '}
          {`${dates?.start?.toDateString()} - ${dates?.end?.toDateString()}`}
        </p>
      </div>
    </div>
  );
};

export default Page;