feat: Implement year grid navigation

This commit is contained in:
gpt-engineer-app[bot]
2025-10-11 17:50:46 +00:00
parent bd0b0a81a1
commit c59ab9523f
4 changed files with 269 additions and 5 deletions

View File

@@ -0,0 +1,73 @@
import * as React from "react";
import { CaptionProps, useNavigation } from "react-day-picker";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { YearGridSelector } from "@/components/ui/year-grid-selector";
import { cn } from "@/lib/utils";
interface CustomCaptionProps extends CaptionProps {
fromYear?: number;
toYear?: number;
}
export function CustomCalendarCaption({
displayMonth,
fromYear = 1800,
toYear = new Date().getFullYear() + 10,
}: CustomCaptionProps) {
const { goToMonth, nextMonth, previousMonth, currentMonth } = useNavigation();
const handleYearSelect = (year: number) => {
const newDate = new Date(displayMonth);
newDate.setFullYear(year);
goToMonth(newDate);
};
const monthName = displayMonth.toLocaleString("en-US", { month: "long" });
const selectedYear = displayMonth.getFullYear();
return (
<div className="flex justify-between items-center px-2 py-2">
<Button
variant="outline"
className="h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
onClick={() => previousMonth && goToMonth(previousMonth)}
disabled={!previousMonth}
type="button"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{monthName}</span>
<YearGridSelector
selectedYear={selectedYear}
onYearSelect={handleYearSelect}
fromYear={fromYear}
toYear={toYear}
>
<Button
variant="ghost"
className={cn(
"h-7 px-2 text-sm font-medium hover:bg-accent",
"focus:bg-accent focus:text-accent-foreground"
)}
type="button"
>
{selectedYear}
</Button>
</YearGridSelector>
</div>
<Button
variant="outline"
className="h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
onClick={() => nextMonth && goToMonth(nextMonth)}
disabled={!nextMonth}
type="button"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
);
}

View File

@@ -4,14 +4,18 @@ import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { CustomCalendarCaption } from "@/components/ui/calendar-custom-caption";
export type CalendarProps = React.ComponentProps<typeof DayPicker>; export type CalendarProps = React.ComponentProps<typeof DayPicker> & {
enableYearGrid?: boolean;
};
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) { function Calendar({ className, classNames, showOutsideDays = true, enableYearGrid = false, ...props }: CalendarProps) {
const captionLayout = enableYearGrid ? undefined : (props.captionLayout || "dropdown-buttons");
return ( return (
<DayPicker <DayPicker
showOutsideDays={showOutsideDays} showOutsideDays={showOutsideDays}
captionLayout="dropdown-buttons" captionLayout={captionLayout}
fromYear={props.fromYear || 1800} fromYear={props.fromYear || 1800}
toYear={props.toYear || new Date().getFullYear() + 10} toYear={props.toYear || new Date().getFullYear() + 10}
className={cn("p-3", className)} className={cn("p-3", className)}
@@ -45,6 +49,15 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
...classNames, ...classNames,
}} }}
components={{ components={{
Caption: enableYearGrid
? (captionProps) => (
<CustomCalendarCaption
{...captionProps}
fromYear={props.fromYear || 1800}
toYear={props.toYear || new Date().getFullYear() + 10}
/>
)
: undefined,
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />, IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />, IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />,
}} }}

View File

@@ -24,6 +24,7 @@ export interface DatePickerProps {
toYear?: number; toYear?: number;
allowTextEntry?: boolean; allowTextEntry?: boolean;
dateFormat?: string; dateFormat?: string;
enableYearGrid?: boolean;
} }
export function DatePicker({ export function DatePicker({
@@ -38,6 +39,7 @@ export function DatePicker({
toYear, toYear,
allowTextEntry = false, allowTextEntry = false,
dateFormat = "yyyy-MM-dd", dateFormat = "yyyy-MM-dd",
enableYearGrid = false,
}: DatePickerProps) { }: DatePickerProps) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [textInput, setTextInput] = React.useState(""); const [textInput, setTextInput] = React.useState("");
@@ -139,7 +141,7 @@ export function DatePicker({
className="p-3 pointer-events-auto" className="p-3 pointer-events-auto"
fromYear={fromYear} fromYear={fromYear}
toYear={toYear} toYear={toYear}
captionLayout="dropdown-buttons" enableYearGrid={enableYearGrid}
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@@ -176,7 +178,7 @@ export function DatePicker({
className="p-3 pointer-events-auto" className="p-3 pointer-events-auto"
fromYear={fromYear} fromYear={fromYear}
toYear={toYear} toYear={toYear}
captionLayout="dropdown-buttons" enableYearGrid={enableYearGrid}
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>

View File

@@ -0,0 +1,176 @@
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
interface YearGridSelectorProps {
selectedYear: number;
onYearSelect: (year: number) => void;
fromYear?: number;
toYear?: number;
children: React.ReactNode;
}
export function YearGridSelector({
selectedYear,
onYearSelect,
fromYear = 1800,
toYear = new Date().getFullYear() + 10,
children,
}: YearGridSelectorProps) {
const [open, setOpen] = React.useState(false);
const [view, setView] = React.useState<"year" | "decade">("year");
const [startYear, setStartYear] = React.useState(() => {
// Start from the decade containing the selected year
return Math.floor(selectedYear / 10) * 10;
});
const handleYearSelect = (year: number) => {
onYearSelect(year);
setOpen(false);
};
const handleDecadeSelect = (decade: number) => {
setStartYear(decade);
setView("year");
};
const navigateYears = (direction: "prev" | "next") => {
setStartYear((prev) => prev + (direction === "next" ? 12 : -12));
};
const navigateDecades = (direction: "prev" | "next") => {
setStartYear((prev) => prev + (direction === "next" ? 120 : -120));
};
const renderYearGrid = () => {
const years: number[] = [];
for (let i = 0; i < 12; i++) {
const year = startYear + i;
if (year >= fromYear && year <= toYear) {
years.push(year);
}
}
const endYear = startYear + 11;
const currentYear = new Date().getFullYear();
return (
<div className="p-3 space-y-3">
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => navigateYears("prev")}
disabled={startYear <= fromYear}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
className="text-sm font-medium hover:bg-accent"
onClick={() => setView("decade")}
>
{startYear} - {endYear}
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => navigateYears("next")}
disabled={endYear >= toYear}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-4 gap-1">
{years.map((year) => (
<Button
key={year}
variant={year === selectedYear ? "default" : "ghost"}
className={cn(
"h-9 w-full text-sm font-normal",
year === currentYear && year !== selectedYear && "border border-primary"
)}
onClick={() => handleYearSelect(year)}
>
{year}
</Button>
))}
</div>
</div>
);
};
const renderDecadeGrid = () => {
const decades: number[] = [];
const decadeStart = Math.floor(startYear / 100) * 100;
for (let i = 0; i < 12; i++) {
const decade = decadeStart + i * 10;
if (decade >= Math.floor(fromYear / 10) * 10 && decade <= Math.ceil(toYear / 10) * 10) {
decades.push(decade);
}
}
const decadeEnd = decadeStart + 110;
return (
<div className="p-3 space-y-3">
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => navigateDecades("prev")}
disabled={decadeStart <= Math.floor(fromYear / 100) * 100}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
className="text-sm font-medium hover:bg-accent"
onClick={() => setView("year")}
>
{decadeStart}s - {decadeEnd}s
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => navigateDecades("next")}
disabled={decadeEnd >= Math.ceil(toYear / 100) * 100}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-3 gap-2">
{decades.map((decade) => (
<Button
key={decade}
variant="ghost"
className="h-10 w-full text-sm font-normal"
onClick={() => handleDecadeSelect(decade)}
>
{decade}s
</Button>
))}
</div>
</div>
);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
{view === "year" ? renderYearGrid() : renderDecadeGrid()}
</PopoverContent>
</Popover>
);
}