feat(ui): use grid calendar picker for modern ui look
This commit is contained in:
537
src/components/ui/calendar-grid-picker.tsx
Normal file
537
src/components/ui/calendar-grid-picker.tsx
Normal file
@@ -0,0 +1,537 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||
import { useDayPicker, type MonthCaptionProps } from 'react-day-picker';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar, CalendarDayButton } from '@/components/ui/calendar';
|
||||
|
||||
type CalendarView = 'days' | 'months' | 'years';
|
||||
|
||||
const MONTH_LABELS = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
] as const;
|
||||
|
||||
const PICKER_WIDTH = 'w-[280px]';
|
||||
const PICKER_SHELL_CLASS =
|
||||
'group/calendar p-3 text-popover-foreground [--cell-size:--spacing(8)]';
|
||||
const PICKER_HEADER_CLASS =
|
||||
'grid min-h-8 grid-cols-[2rem_1fr_2rem] items-center gap-1 border-b border-border pb-2';
|
||||
const PICKER_NAV_BUTTON_CLASS = 'size-8 shrink-0 p-0';
|
||||
const GRID_BUTTON_CLASS =
|
||||
'h-9 w-full rounded-md font-medium text-foreground hover:bg-accent/60 hover:text-accent-foreground';
|
||||
const GRID_BUTTON_ACTIVE_CLASS =
|
||||
'bg-muted text-foreground hover:bg-muted hover:text-foreground';
|
||||
const GRID_BUTTON_DISABLED_CLASS =
|
||||
'text-muted-foreground opacity-50 hover:bg-transparent hover:text-muted-foreground';
|
||||
|
||||
function getMonthIndex(date: Date) {
|
||||
return date.getFullYear() * 12 + date.getMonth();
|
||||
}
|
||||
|
||||
function isYearOutsideRange(year: number, startMonth?: Date, endMonth?: Date) {
|
||||
if (startMonth && year < startMonth.getFullYear()) return true;
|
||||
if (endMonth && year > endMonth.getFullYear()) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isMonthOutsideRange(
|
||||
year: number,
|
||||
month: number,
|
||||
startMonth?: Date,
|
||||
endMonth?: Date,
|
||||
) {
|
||||
const monthIndex = year * 12 + month;
|
||||
|
||||
if (startMonth && monthIndex < getMonthIndex(startMonth)) return true;
|
||||
if (endMonth && monthIndex > getMonthIndex(endMonth)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function clampMonthToRange(
|
||||
year: number,
|
||||
month: number,
|
||||
startMonth?: Date,
|
||||
endMonth?: Date,
|
||||
) {
|
||||
let nextMonth = month;
|
||||
|
||||
if (startMonth && year === startMonth.getFullYear()) {
|
||||
nextMonth = Math.max(nextMonth, startMonth.getMonth());
|
||||
}
|
||||
|
||||
if (endMonth && year === endMonth.getFullYear()) {
|
||||
nextMonth = Math.min(nextMonth, endMonth.getMonth());
|
||||
}
|
||||
|
||||
return Math.min(11, Math.max(0, nextMonth));
|
||||
}
|
||||
|
||||
type CalendarGridPickerContextValue = {
|
||||
view: CalendarView;
|
||||
setView: (view: CalendarView) => void;
|
||||
pickerYear: number;
|
||||
setPickerYear: React.Dispatch<React.SetStateAction<number>>;
|
||||
decadeStart: number;
|
||||
setDecadeStart: React.Dispatch<React.SetStateAction<number>>;
|
||||
};
|
||||
|
||||
const CalendarGridPickerContext =
|
||||
React.createContext<CalendarGridPickerContextValue | null>(null);
|
||||
|
||||
function useCalendarGridPicker() {
|
||||
const context = React.useContext(CalendarGridPickerContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useCalendarGridPicker must be used within CalendarGridPicker',
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
function PickerNav({
|
||||
label,
|
||||
onPrevious,
|
||||
onNext,
|
||||
onLabelClick,
|
||||
disablePrevious = false,
|
||||
disableNext = false,
|
||||
}: {
|
||||
label: string;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
onLabelClick?: () => void;
|
||||
disablePrevious?: boolean;
|
||||
disableNext?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={PICKER_HEADER_CLASS}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(PICKER_NAV_BUTTON_CLASS, 'justify-self-start')}
|
||||
onClick={onPrevious}
|
||||
disabled={disablePrevious}
|
||||
aria-label="Previous"
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</Button>
|
||||
{onLabelClick ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-sm px-2 text-center text-sm font-semibold text-foreground hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onLabelClick}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
) : (
|
||||
<span className="px-2 text-center text-sm font-semibold text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(PICKER_NAV_BUTTON_CLASS, 'justify-self-end')}
|
||||
onClick={onNext}
|
||||
disabled={disableNext}
|
||||
aria-label="Next"
|
||||
>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MonthGrid({
|
||||
year,
|
||||
activeMonth,
|
||||
onSelect,
|
||||
startMonth,
|
||||
endMonth,
|
||||
}: {
|
||||
year: number;
|
||||
activeMonth: number;
|
||||
onSelect: (month: number) => void;
|
||||
startMonth?: Date;
|
||||
endMonth?: Date;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-x-2 gap-y-1.5 pt-2">
|
||||
{MONTH_LABELS.map((label, month) => {
|
||||
const isDisabled = isMonthOutsideRange(
|
||||
year,
|
||||
month,
|
||||
startMonth,
|
||||
endMonth,
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
GRID_BUTTON_CLASS,
|
||||
isDisabled && GRID_BUTTON_DISABLED_CLASS,
|
||||
activeMonth === month && !isDisabled && GRID_BUTTON_ACTIVE_CLASS,
|
||||
)}
|
||||
onClick={() => onSelect(month)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function YearGrid({
|
||||
decadeStart,
|
||||
activeYear,
|
||||
onSelect,
|
||||
startMonth,
|
||||
endMonth,
|
||||
}: {
|
||||
decadeStart: number;
|
||||
activeYear: number;
|
||||
onSelect: (year: number) => void;
|
||||
startMonth?: Date;
|
||||
endMonth?: Date;
|
||||
}) {
|
||||
const years = React.useMemo(
|
||||
() => Array.from({ length: 10 }, (_, index) => decadeStart + index),
|
||||
[decadeStart],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5 pt-2">
|
||||
{years.map((year) => {
|
||||
const isDisabled = isYearOutsideRange(year, startMonth, endMonth);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={year}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
GRID_BUTTON_CLASS,
|
||||
isDisabled && GRID_BUTTON_DISABLED_CLASS,
|
||||
activeYear === year && !isDisabled && GRID_BUTTON_ACTIVE_CLASS,
|
||||
)}
|
||||
onClick={() => onSelect(year)}
|
||||
>
|
||||
{year}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarMonthCaption({
|
||||
calendarMonth,
|
||||
className,
|
||||
children: _children,
|
||||
displayIndex: _displayIndex,
|
||||
...props
|
||||
}: MonthCaptionProps) {
|
||||
const { goToMonth, previousMonth, nextMonth } = useDayPicker();
|
||||
const { setView, setPickerYear, setDecadeStart } = useCalendarGridPicker();
|
||||
const date = calendarMonth.date;
|
||||
const monthName = date.toLocaleString('default', { month: 'long' });
|
||||
const year = date.getFullYear();
|
||||
|
||||
return (
|
||||
<div className={cn(PICKER_HEADER_CLASS, className)} {...props}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(PICKER_NAV_BUTTON_CLASS, 'justify-self-start')}
|
||||
disabled={!previousMonth}
|
||||
aria-label="Previous month"
|
||||
onClick={() => previousMonth && goToMonth(previousMonth)}
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-center gap-1 text-sm font-semibold">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-sm px-1 text-center text-sm font-semibold text-foreground hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => {
|
||||
setPickerYear(year);
|
||||
setView('months');
|
||||
}}
|
||||
>
|
||||
{monthName}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-sm px-1 text-center text-sm font-semibold text-foreground hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => {
|
||||
setDecadeStart(Math.floor(year / 10) * 10);
|
||||
setPickerYear(year);
|
||||
setView('years');
|
||||
}}
|
||||
>
|
||||
{year}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(PICKER_NAV_BUTTON_CLASS, 'justify-self-end')}
|
||||
disabled={!nextMonth}
|
||||
aria-label="Next month"
|
||||
onClick={() => nextMonth && goToMonth(nextMonth)}
|
||||
>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarGridPickerPanel({
|
||||
month,
|
||||
onMonthChange,
|
||||
startMonth,
|
||||
endMonth,
|
||||
}: {
|
||||
month: Date;
|
||||
onMonthChange: (month: Date) => void;
|
||||
startMonth?: Date;
|
||||
endMonth?: Date;
|
||||
}) {
|
||||
const {
|
||||
view,
|
||||
setView,
|
||||
pickerYear,
|
||||
setPickerYear,
|
||||
decadeStart,
|
||||
setDecadeStart,
|
||||
} = useCalendarGridPicker();
|
||||
|
||||
const minYear = startMonth?.getFullYear();
|
||||
const maxYear = endMonth?.getFullYear();
|
||||
const activeMonth = clampMonthToRange(
|
||||
pickerYear,
|
||||
month.getMonth(),
|
||||
startMonth,
|
||||
endMonth,
|
||||
);
|
||||
|
||||
if (view === 'months') {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<PickerNav
|
||||
label={String(pickerYear)}
|
||||
onPrevious={() => setPickerYear((year) => year - 1)}
|
||||
onNext={() => setPickerYear((year) => year + 1)}
|
||||
onLabelClick={() => {
|
||||
setDecadeStart(Math.floor(pickerYear / 10) * 10);
|
||||
setView('years');
|
||||
}}
|
||||
disablePrevious={minYear !== undefined && pickerYear <= minYear}
|
||||
disableNext={maxYear !== undefined && pickerYear >= maxYear}
|
||||
/>
|
||||
<MonthGrid
|
||||
year={pickerYear}
|
||||
activeMonth={activeMonth}
|
||||
startMonth={startMonth}
|
||||
endMonth={endMonth}
|
||||
onSelect={(nextMonth) => {
|
||||
onMonthChange(new Date(pickerYear, nextMonth, 1));
|
||||
setView('days');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (view === 'years') {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<PickerNav
|
||||
label={`${decadeStart} - ${decadeStart + 9}`}
|
||||
onPrevious={() => setDecadeStart((start) => start - 10)}
|
||||
onNext={() => setDecadeStart((start) => start + 10)}
|
||||
disablePrevious={minYear !== undefined && decadeStart <= minYear}
|
||||
disableNext={maxYear !== undefined && decadeStart + 10 > maxYear}
|
||||
/>
|
||||
<YearGrid
|
||||
decadeStart={decadeStart}
|
||||
activeYear={pickerYear}
|
||||
startMonth={startMonth}
|
||||
endMonth={endMonth}
|
||||
onSelect={(year) => {
|
||||
const nextMonth = clampMonthToRange(
|
||||
year,
|
||||
activeMonth,
|
||||
startMonth,
|
||||
endMonth,
|
||||
);
|
||||
|
||||
setPickerYear(year);
|
||||
onMonthChange(new Date(year, nextMonth, 1));
|
||||
setView('months');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export type CalendarGridPickerProps = Omit<
|
||||
Extract<React.ComponentProps<typeof Calendar>, { mode: 'single' }>,
|
||||
'captionLayout' | 'navLayout' | 'hideNavigation'
|
||||
> & {
|
||||
onViewChange?: (view: CalendarView) => void;
|
||||
};
|
||||
|
||||
function CalendarGridPicker({
|
||||
className,
|
||||
classNames,
|
||||
components,
|
||||
formatters,
|
||||
month,
|
||||
defaultMonth,
|
||||
onMonthChange,
|
||||
onViewChange,
|
||||
startMonth,
|
||||
endMonth,
|
||||
...props
|
||||
}: CalendarGridPickerProps) {
|
||||
const initialMonth = month ?? defaultMonth ?? new Date();
|
||||
const [view, setView] = React.useState<CalendarView>('days');
|
||||
const [pickerYear, setPickerYear] = React.useState(
|
||||
initialMonth.getFullYear(),
|
||||
);
|
||||
const [decadeStart, setDecadeStart] = React.useState(
|
||||
Math.floor(initialMonth.getFullYear() / 10) * 10,
|
||||
);
|
||||
const [internalMonth, setInternalMonth] = React.useState(initialMonth);
|
||||
|
||||
const currentMonth = month ?? internalMonth;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (month) {
|
||||
setPickerYear(month.getFullYear());
|
||||
setDecadeStart(Math.floor(month.getFullYear() / 10) * 10);
|
||||
}
|
||||
}, [month]);
|
||||
|
||||
const handleViewChange = React.useCallback(
|
||||
(nextView: CalendarView) => {
|
||||
setView(nextView);
|
||||
onViewChange?.(nextView);
|
||||
},
|
||||
[onViewChange],
|
||||
);
|
||||
|
||||
const handleMonthChange = React.useCallback(
|
||||
(nextMonth: Date) => {
|
||||
if (!month) setInternalMonth(nextMonth);
|
||||
setPickerYear(nextMonth.getFullYear());
|
||||
setDecadeStart(Math.floor(nextMonth.getFullYear() / 10) * 10);
|
||||
onMonthChange?.(nextMonth);
|
||||
},
|
||||
[month, onMonthChange],
|
||||
);
|
||||
|
||||
const contextValue = React.useMemo(
|
||||
() => ({
|
||||
view,
|
||||
setView: handleViewChange,
|
||||
pickerYear,
|
||||
setPickerYear,
|
||||
decadeStart,
|
||||
setDecadeStart,
|
||||
}),
|
||||
[view, handleViewChange, pickerYear, decadeStart],
|
||||
);
|
||||
|
||||
if (view !== 'days') {
|
||||
return (
|
||||
<CalendarGridPickerContext.Provider value={contextValue}>
|
||||
<div className={cn(PICKER_SHELL_CLASS, PICKER_WIDTH, className)}>
|
||||
<CalendarGridPickerPanel
|
||||
month={currentMonth}
|
||||
onMonthChange={handleMonthChange}
|
||||
startMonth={startMonth}
|
||||
endMonth={endMonth}
|
||||
/>
|
||||
</div>
|
||||
</CalendarGridPickerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CalendarGridPickerContext.Provider value={contextValue}>
|
||||
<Calendar
|
||||
hideNavigation
|
||||
className={cn(PICKER_SHELL_CLASS, PICKER_WIDTH, className)}
|
||||
month={currentMonth}
|
||||
onMonthChange={handleMonthChange}
|
||||
startMonth={startMonth}
|
||||
endMonth={endMonth}
|
||||
formatters={{
|
||||
formatWeekdayName: (date) =>
|
||||
date.toLocaleString('en-US', { weekday: 'short' }).slice(0, 2),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
month: 'flex w-full flex-col gap-2',
|
||||
month_caption: 'p-0',
|
||||
weekdays: 'flex w-full',
|
||||
weekday:
|
||||
'flex-1 text-center text-[0.8rem] font-medium text-muted-foreground select-none',
|
||||
week: 'mt-1 flex w-full',
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
MonthCaption: CalendarMonthCaption,
|
||||
DayButton: (dayButtonProps) => (
|
||||
<CalendarDayButton
|
||||
{...dayButtonProps}
|
||||
className={cn(
|
||||
dayButtonProps.className,
|
||||
'data-[selected-single=true]:rounded-full data-[selected-single=true]:bg-muted data-[selected-single=true]:text-foreground',
|
||||
)}
|
||||
/>
|
||||
),
|
||||
...components,
|
||||
}}
|
||||
{...(props as Extract<
|
||||
React.ComponentProps<typeof Calendar>,
|
||||
{ mode: 'single' }
|
||||
>)}
|
||||
/>
|
||||
</CalendarGridPickerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export { CalendarGridPicker, type CalendarView };
|
||||
Reference in New Issue
Block a user