infoalloggi-monorepo/apps/infoalloggi/src/components/ui/calendar.tsx

527 lines
13 KiB
TypeScript
Raw Normal View History

2025-08-04 17:45:44 +02:00
"use client";
import { differenceInCalendarDays } from "date-fns";
import { ChevronLeft, ChevronRight } from "lucide-react";
import {
2025-08-28 18:27:07 +02:00
type Dispatch,
type HTMLAttributes,
type ReactNode,
type SetStateAction,
type TableHTMLAttributes,
useCallback,
useMemo,
useState,
2025-08-04 17:45:44 +02:00
} from "react";
2025-08-28 18:27:07 +02:00
import {
DayPicker,
type DayPickerProps,
labelNext,
labelPrevious,
useDayPicker,
} from "react-day-picker";
import { Button, buttonVariants } from "~/components/ui/button";
import { cn } from "~/lib/utils";
2025-08-04 17:45:44 +02:00
type CalendarProps = DayPickerProps & {
2025-08-28 18:27:07 +02:00
/**
* In the year view, the number of years to display at once.
* @default 12
*/
yearRange?: number;
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
/**
* Wether to show the year switcher in the caption.
* @default true
*/
showYearSwitcher?: boolean;
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
monthsClassName?: string;
monthCaptionClassName?: string;
weekdaysClassName?: string;
weekdayClassName?: string;
monthClassName?: string;
captionClassName?: string;
captionLabelClassName?: string;
buttonNextClassName?: string;
buttonPreviousClassName?: string;
navClassName?: string;
monthGridClassName?: string;
weekClassName?: string;
dayClassName?: string;
dayButtonClassName?: string;
rangeStartClassName?: string;
rangeEndClassName?: string;
selectedClassName?: string;
todayClassName?: string;
outsideClassName?: string;
disabledClassName?: string;
rangeMiddleClassName?: string;
hiddenClassName?: string;
2025-08-04 17:45:44 +02:00
};
type NavView = "days" | "years";
/**
* A custom calendar component built on top of react-day-picker.
* @param props The props for the calendar.
* @default yearRange 12
* @returns
*/
function Calendar({
2025-08-28 18:27:07 +02:00
className,
showOutsideDays = true,
showYearSwitcher = true,
yearRange = 12,
numberOfMonths,
...props
2025-08-04 17:45:44 +02:00
}: CalendarProps) {
2025-08-28 18:27:07 +02:00
const [navView, setNavView] = useState<NavView>("days");
const [displayYears, setDisplayYears] = useState<{
from: number;
to: number;
}>(
useMemo(() => {
const currentYear = new Date().getFullYear();
return {
from: currentYear - Math.floor(yearRange / 2 - 1),
to: currentYear + Math.ceil(yearRange / 2),
};
}, [yearRange]),
);
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
const { onPrevClick, startMonth, endMonth } = props;
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
const columnsDisplayed = navView === "years" ? 1 : numberOfMonths;
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
const _monthsClassName = cn("relative flex", props.monthsClassName);
const _monthCaptionClassName = cn(
"relative mx-10 flex h-7 items-center justify-center",
props.monthCaptionClassName,
);
const _weekdaysClassName = cn("flex flex-row", props.weekdaysClassName);
const _weekdayClassName = cn(
"w-8 text-sm font-normal text-muted-foreground",
props.weekdayClassName,
);
const _monthClassName = cn("w-full", props.monthClassName);
const _captionClassName = cn(
"relative flex items-center justify-center pt-1",
props.captionClassName,
);
const _captionLabelClassName = cn(
"truncate text-sm font-medium",
props.captionLabelClassName,
props.captionLayout === "dropdown" &&
"flex items-center text-sm font-medium border border-neutral-200 rounded-md px-2 py-1",
);
const buttonNavClassName = buttonVariants({
className:
"absolute size-7 bg-transparent p-0 opacity-50 hover:opacity-100",
2025-08-29 16:18:32 +02:00
variant: "outline",
2025-08-28 18:27:07 +02:00
});
const _buttonNextClassName = cn(
buttonNavClassName,
"right-0",
props.buttonNextClassName,
);
const _buttonPreviousClassName = cn(
buttonNavClassName,
"left-0",
props.buttonPreviousClassName,
);
const _navClassName = cn("flex items-start", props.navClassName);
const _monthGridClassName = cn("mx-auto mt-4", props.monthGridClassName);
const _weekClassName = cn("mt-2 flex w-max items-start", props.weekClassName);
const _dayClassName = cn(
"flex size-8 flex-1 items-center justify-center p-0 text-sm",
props.dayClassName,
);
const _dayButtonClassName = cn(
buttonVariants({ variant: "ghost" }),
"size-8 rounded-md p-0 font-normal transition-none aria-selected:opacity-100",
props.dayButtonClassName,
);
const buttonRangeClassName =
"bg-accent [&>button]:bg-primary [&>button]:text-primary-foreground [&>button]:hover:bg-primary [&>button]:hover:text-primary-foreground";
const _rangeStartClassName = cn(
buttonRangeClassName,
"rounded-s-md",
props.rangeStartClassName,
);
const _rangeEndClassName = cn(
buttonRangeClassName,
"rounded-e-md",
props.rangeEndClassName,
);
const _rangeMiddleClassName = cn(
"bg-accent !text-foreground [&>button]:bg-transparent [&>button]:!text-foreground [&>button]:hover:bg-transparent [&>button]:hover:!text-foreground",
props.rangeMiddleClassName,
);
const _selectedClassName = cn(
"[&>button]:bg-primary [&>button]:text-primary-foreground [&>button]:hover:bg-primary [&>button]:hover:text-primary-foreground",
props.selectedClassName,
);
const _todayClassName = cn(
"[&>button]:bg-accent [&>button]:text-accent-foreground",
props.todayClassName,
);
const _outsideClassName = cn(
"text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
props.outsideClassName,
);
const _disabledClassName = cn(
"text-muted-foreground opacity-50",
props.disabledClassName,
);
const _hiddenClassName = cn("invisible flex-1", props.hiddenClassName);
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
return (
<DayPicker
className={cn("p-3", className)}
classNames={{
button_next: _buttonNextClassName,
button_previous: _buttonPreviousClassName,
2025-08-29 16:18:32 +02:00
caption: _captionClassName,
caption_label: _captionLabelClassName,
2025-08-28 18:27:07 +02:00
day: _dayClassName,
day_button: _dayButtonClassName,
disabled: _disabledClassName,
hidden: _hiddenClassName,
2025-08-29 16:18:32 +02:00
month: _monthClassName,
month_caption: _monthCaptionClassName,
month_grid: _monthGridClassName,
months: _monthsClassName,
2025-08-28 18:27:07 +02:00
months_dropdown: "dropdown-override",
2025-08-29 16:18:32 +02:00
nav: _navClassName,
outside: _outsideClassName,
range_end: _rangeEndClassName,
range_middle: _rangeMiddleClassName,
range_start: _rangeStartClassName,
selected: _selectedClassName,
today: _todayClassName,
week: _weekClassName,
weekday: _weekdayClassName,
weekdays: _weekdaysClassName,
2025-08-28 18:27:07 +02:00
years_dropdown: "dropdown-override",
}}
components={{
CaptionLabel: (props) => (
<CaptionLabel
2025-08-29 16:18:32 +02:00
displayYears={displayYears}
2025-08-28 18:27:07 +02:00
navView={navView}
setNavView={setNavView}
2025-08-29 16:18:32 +02:00
showYearSwitcher={showYearSwitcher}
2025-08-28 18:27:07 +02:00
{...props}
/>
),
2025-08-29 16:18:32 +02:00
Chevron: ({ orientation }) => {
const Icon = orientation === "left" ? ChevronLeft : ChevronRight;
return <Icon className="size-4" />;
},
2025-08-28 18:27:07 +02:00
MonthGrid: ({ className, children, ...props }) => (
<MonthGrid
className={className}
displayYears={displayYears}
endMonth={endMonth}
navView={navView}
setNavView={setNavView}
2025-08-29 16:18:32 +02:00
startMonth={startMonth}
2025-08-28 18:27:07 +02:00
{...props}
>
{children}
</MonthGrid>
),
2025-08-29 16:18:32 +02:00
Nav: ({ className }) => (
<Nav
className={className}
displayYears={displayYears}
endMonth={endMonth}
navView={navView}
onPrevClick={onPrevClick}
setDisplayYears={setDisplayYears}
startMonth={startMonth}
/>
),
2025-08-28 18:27:07 +02:00
}}
numberOfMonths={columnsDisplayed}
2025-08-29 16:18:32 +02:00
showOutsideDays={showOutsideDays}
style={{
width: `${248.8 * (columnsDisplayed ?? 1)}px`,
}}
2025-08-28 18:27:07 +02:00
{...props}
/>
);
2025-08-04 17:45:44 +02:00
}
Calendar.displayName = "Calendar";
function Nav({
2025-08-28 18:27:07 +02:00
className,
navView,
startMonth,
endMonth,
displayYears,
setDisplayYears,
onPrevClick,
onNextClick,
2025-08-04 17:45:44 +02:00
}: {
2025-08-28 18:27:07 +02:00
className?: string;
navView: NavView;
startMonth?: Date;
endMonth?: Date;
displayYears: { from: number; to: number };
setDisplayYears: Dispatch<SetStateAction<{ from: number; to: number }>>;
onPrevClick?: (date: Date) => void;
onNextClick?: (date: Date) => void;
2025-08-04 17:45:44 +02:00
}) {
2025-08-28 18:27:07 +02:00
const { nextMonth, previousMonth, goToMonth } = useDayPicker();
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
const isPreviousDisabled = (() => {
if (navView === "years") {
return (
(startMonth &&
differenceInCalendarDays(
new Date(displayYears.from - 1, 0, 1),
startMonth,
) < 0) ||
(endMonth &&
differenceInCalendarDays(
new Date(displayYears.from - 1, 0, 1),
endMonth,
) > 0)
);
}
return !previousMonth;
})();
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
const isNextDisabled = (() => {
if (navView === "years") {
return (
(startMonth &&
differenceInCalendarDays(
new Date(displayYears.to + 1, 0, 1),
startMonth,
) < 0) ||
(endMonth &&
differenceInCalendarDays(
new Date(displayYears.to + 1, 0, 1),
endMonth,
) > 0)
);
}
return !nextMonth;
})();
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
const handlePreviousClick = useCallback(() => {
if (!previousMonth) return;
if (navView === "years") {
setDisplayYears((prev) => ({
from: prev.from - (prev.to - prev.from + 1),
to: prev.to - (prev.to - prev.from + 1),
}));
onPrevClick?.(
new Date(
displayYears.from - (displayYears.to - displayYears.from),
0,
1,
),
);
return;
}
goToMonth(previousMonth);
onPrevClick?.(previousMonth);
}, [previousMonth, goToMonth]);
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
const handleNextClick = useCallback(() => {
if (!nextMonth) return;
if (navView === "years") {
setDisplayYears((prev) => ({
from: prev.from + (prev.to - prev.from + 1),
to: prev.to + (prev.to - prev.from + 1),
}));
onNextClick?.(
new Date(
displayYears.from + (displayYears.to - displayYears.from),
0,
1,
),
);
return;
}
goToMonth(nextMonth);
onNextClick?.(nextMonth);
}, [goToMonth, nextMonth]);
return (
<nav className={cn("flex items-center", className)}>
<Button
aria-label={
navView === "years"
? `Go to the previous ${
displayYears.to - displayYears.from + 1
} years`
: labelPrevious(previousMonth)
}
2025-08-29 16:18:32 +02:00
className="absolute left-0 size-7 bg-transparent p-0 opacity-80 hover:opacity-100"
disabled={isPreviousDisabled}
2025-08-28 18:27:07 +02:00
onClick={handlePreviousClick}
2025-08-29 16:18:32 +02:00
tabIndex={isPreviousDisabled ? undefined : -1}
type="button"
variant="outline"
2025-08-28 18:27:07 +02:00
>
<ChevronLeft className="size-4" />
</Button>
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
<Button
aria-label={
navView === "years"
? `Go to the next ${displayYears.to - displayYears.from + 1} years`
: labelNext(nextMonth)
}
2025-08-29 16:18:32 +02:00
className="absolute right-0 size-7 bg-transparent p-0 opacity-80 hover:opacity-100"
disabled={isNextDisabled}
2025-08-28 18:27:07 +02:00
onClick={handleNextClick}
2025-08-29 16:18:32 +02:00
tabIndex={isNextDisabled ? undefined : -1}
type="button"
variant="outline"
2025-08-28 18:27:07 +02:00
>
<ChevronRight className="size-4" />
</Button>
</nav>
);
2025-08-04 17:45:44 +02:00
}
function CaptionLabel({
2025-08-28 18:27:07 +02:00
children,
showYearSwitcher,
navView,
setNavView,
displayYears,
...props
2025-08-04 17:45:44 +02:00
}: {
2025-08-28 18:27:07 +02:00
showYearSwitcher?: boolean;
navView: NavView;
setNavView: Dispatch<SetStateAction<NavView>>;
displayYears: { from: number; to: number };
2025-08-04 17:45:44 +02:00
} & HTMLAttributes<HTMLSpanElement>) {
2025-08-28 18:27:07 +02:00
if (!showYearSwitcher) return <span {...props}>{children}</span>;
return (
<Button
2025-10-10 16:18:43 +02:00
className="h-7 w-full truncate font-medium text-sm"
2025-08-28 18:27:07 +02:00
onClick={() => setNavView((prev) => (prev === "days" ? "years" : "days"))}
2025-08-29 16:18:32 +02:00
size="sm"
variant="ghost"
2025-08-28 18:27:07 +02:00
>
{navView === "days"
? children
: `${displayYears.from} - ${displayYears.to}`}
</Button>
);
2025-08-04 17:45:44 +02:00
}
function MonthGrid({
2025-08-28 18:27:07 +02:00
className,
children,
displayYears,
startMonth,
endMonth,
navView,
setNavView,
...props
2025-08-04 17:45:44 +02:00
}: {
2025-08-28 18:27:07 +02:00
className?: string;
children: ReactNode;
displayYears: { from: number; to: number };
startMonth?: Date;
endMonth?: Date;
navView: NavView;
setNavView: Dispatch<SetStateAction<NavView>>;
2025-08-04 17:45:44 +02:00
} & TableHTMLAttributes<HTMLTableElement>) {
2025-08-28 18:27:07 +02:00
if (navView === "years") {
return (
<YearGrid
2025-08-29 16:18:32 +02:00
className={className}
2025-08-28 18:27:07 +02:00
displayYears={displayYears}
endMonth={endMonth}
navView={navView}
2025-08-29 16:18:32 +02:00
setNavView={setNavView}
startMonth={startMonth}
2025-08-28 18:27:07 +02:00
{...props}
/>
);
}
return (
<table className={className} {...props}>
{children}
</table>
);
2025-08-04 17:45:44 +02:00
}
function YearGrid({
2025-08-28 18:27:07 +02:00
className,
displayYears,
startMonth,
endMonth,
setNavView,
navView,
...props
2025-08-04 17:45:44 +02:00
}: {
2025-08-28 18:27:07 +02:00
className?: string;
displayYears: { from: number; to: number };
startMonth?: Date;
endMonth?: Date;
setNavView: Dispatch<SetStateAction<NavView>>;
navView: NavView;
2025-08-04 17:45:44 +02:00
} & HTMLAttributes<HTMLDivElement>) {
2025-08-28 18:27:07 +02:00
const { goToMonth, selected } = useDayPicker();
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
return (
<div className={cn("grid grid-cols-4 gap-y-2", className)} {...props}>
{Array.from(
{ length: displayYears.to - displayYears.from + 1 },
(_, i) => {
const isBefore =
differenceInCalendarDays(
new Date(displayYears.from + i, 11, 31),
// biome-ignore lint/style/noNonNullAssertion: <exists>
startMonth!,
) < 0;
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
const isAfter =
differenceInCalendarDays(
new Date(displayYears.from + i, 0, 0),
// biome-ignore lint/style/noNonNullAssertion: <exists>
endMonth!,
) > 0;
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
const isDisabled = isBefore || isAfter;
return (
<Button
className={cn(
2025-10-10 16:18:43 +02:00
"h-7 w-full font-normal text-foreground text-sm",
2025-08-28 18:27:07 +02:00
displayYears.from + i === new Date().getFullYear() &&
2025-10-10 16:18:43 +02:00
"bg-accent font-medium text-accent-foreground",
2025-08-28 18:27:07 +02:00
)}
2025-08-29 16:18:32 +02:00
disabled={navView === "years" ? isDisabled : undefined}
// biome-ignore lint/suspicious/noArrayIndexKey: <index is acceptable here>
2025-08-29 16:18:32 +02:00
key={i}
2025-08-28 18:27:07 +02:00
onClick={() => {
setNavView("days");
goToMonth(
new Date(
displayYears.from + i,
(selected as Date | undefined)?.getMonth() ?? 0,
),
);
}}
2025-08-29 16:18:32 +02:00
variant="ghost"
2025-08-28 18:27:07 +02:00
>
{displayYears.from + i}
</Button>
);
},
)}
</div>
);
2025-08-04 17:45:44 +02:00
}
export { Calendar };