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;