mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 10: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 { 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" />,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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