mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 08:11:13 -05:00
feat: Implement year grid navigation
This commit is contained in:
73
src/components/ui/calendar-custom-caption.tsx
Normal file
73
src/components/ui/calendar-custom-caption.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -4,14 +4,18 @@ import { DayPicker } from "react-day-picker";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
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 (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
captionLayout="dropdown-buttons"
|
||||
captionLayout={captionLayout}
|
||||
fromYear={props.fromYear || 1800}
|
||||
toYear={props.toYear || new Date().getFullYear() + 10}
|
||||
className={cn("p-3", className)}
|
||||
@@ -45,6 +49,15 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
|
||||
...classNames,
|
||||
}}
|
||||
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" />,
|
||||
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />,
|
||||
}}
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface DatePickerProps {
|
||||
toYear?: number;
|
||||
allowTextEntry?: boolean;
|
||||
dateFormat?: string;
|
||||
enableYearGrid?: boolean;
|
||||
}
|
||||
|
||||
export function DatePicker({
|
||||
@@ -38,6 +39,7 @@ export function DatePicker({
|
||||
toYear,
|
||||
allowTextEntry = false,
|
||||
dateFormat = "yyyy-MM-dd",
|
||||
enableYearGrid = false,
|
||||
}: DatePickerProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [textInput, setTextInput] = React.useState("");
|
||||
@@ -139,7 +141,7 @@ export function DatePicker({
|
||||
className="p-3 pointer-events-auto"
|
||||
fromYear={fromYear}
|
||||
toYear={toYear}
|
||||
captionLayout="dropdown-buttons"
|
||||
enableYearGrid={enableYearGrid}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@@ -176,7 +178,7 @@ export function DatePicker({
|
||||
className="p-3 pointer-events-auto"
|
||||
fromYear={fromYear}
|
||||
toYear={toYear}
|
||||
captionLayout="dropdown-buttons"
|
||||
enableYearGrid={enableYearGrid}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
176
src/components/ui/year-grid-selector.tsx
Normal file
176
src/components/ui/year-grid-selector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user