feat(ui): use grid calendar picker for modern ui look
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Users, Mail, Phone, Send, ShieldCheck } from 'lucide-react';
|
import { Users, Mail, Phone, Send, ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
import { DatePicker } from '@/components/form/DatePicker';
|
import { DatePickerSimple } from '@/components/form/DatePickerSimple';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
@@ -21,6 +21,15 @@ export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) {
|
|||||||
const [assignNote, setAssignNote] = useState('');
|
const [assignNote, setAssignNote] = useState('');
|
||||||
|
|
||||||
const assignMutation = useAssignTicketMutation(ticket.id);
|
const assignMutation = useAssignTicketMutation(ticket.id);
|
||||||
|
const today = useMemo(() => {
|
||||||
|
const date = new Date();
|
||||||
|
date.setHours(0, 0, 0, 0);
|
||||||
|
return date;
|
||||||
|
}, []);
|
||||||
|
const maxDueMonth = useMemo(
|
||||||
|
() => new Date(today.getFullYear() + 5, 11),
|
||||||
|
[today],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TicketActionCard icon={Users} title="Assign to Contractor">
|
<TicketActionCard icon={Users} title="Assign to Contractor">
|
||||||
@@ -56,13 +65,20 @@ export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Due Date *</Label>
|
<Label htmlFor="assign-due-date">
|
||||||
<DatePicker
|
Due Date <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<DatePickerSimple
|
||||||
|
id="assign-due-date"
|
||||||
value={dueDate}
|
value={dueDate}
|
||||||
onChange={setDueDate}
|
onChange={setDueDate}
|
||||||
showTimeZone={false}
|
showLabel={false}
|
||||||
|
placeholder="Select due date"
|
||||||
disabled={assignMutation.isPending}
|
disabled={assignMutation.isPending}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
startMonth={today}
|
||||||
|
endMonth={maxDueMonth}
|
||||||
|
disabledDates={{ before: today }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
129
src/components/form/DatePickerSimple.tsx
Normal file
129
src/components/form/DatePickerSimple.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import type { Matcher } from 'react-day-picker';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { CalendarGridPicker } from '@/components/ui/calendar-grid-picker';
|
||||||
|
import { FormField } from '@/components/form/FormField';
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface DatePickerSimpleProps {
|
||||||
|
value?: Date;
|
||||||
|
defaultValue?: Date;
|
||||||
|
onChange?: (date: Date | undefined) => void;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
id?: string;
|
||||||
|
showLabel?: boolean;
|
||||||
|
startMonth?: Date;
|
||||||
|
endMonth?: Date;
|
||||||
|
disabledDates?: Matcher | Matcher[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfDay(date: Date) {
|
||||||
|
const next = new Date(date);
|
||||||
|
next.setHours(0, 0, 0, 0);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampDateToRange(date: Date, min: Date, max: Date) {
|
||||||
|
if (date < min) return min;
|
||||||
|
if (date > max) return max;
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DatePickerSimple(props: DatePickerSimpleProps) {
|
||||||
|
const {
|
||||||
|
value,
|
||||||
|
defaultValue,
|
||||||
|
onChange,
|
||||||
|
label = 'Date of birth',
|
||||||
|
placeholder = 'Select date',
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
id = 'date',
|
||||||
|
showLabel = true,
|
||||||
|
startMonth,
|
||||||
|
endMonth,
|
||||||
|
disabledDates,
|
||||||
|
} = props;
|
||||||
|
const isControlled = Object.hasOwn(props, 'value');
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [internalDate, setInternalDate] = React.useState<Date | undefined>(
|
||||||
|
defaultValue,
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedDate = isControlled ? value : internalDate;
|
||||||
|
const today = React.useMemo(() => startOfDay(new Date()), []);
|
||||||
|
const resolvedStartMonth = React.useMemo(
|
||||||
|
() => startMonth ?? new Date(today.getFullYear() - 100, 0),
|
||||||
|
[startMonth, today],
|
||||||
|
);
|
||||||
|
const resolvedEndMonth = React.useMemo(
|
||||||
|
() => endMonth ?? today,
|
||||||
|
[endMonth, today],
|
||||||
|
);
|
||||||
|
const initialMonth = selectedDate
|
||||||
|
? startOfDay(selectedDate)
|
||||||
|
: clampDateToRange(today, resolvedStartMonth, resolvedEndMonth);
|
||||||
|
|
||||||
|
const handleSelect = (date: Date | undefined) => {
|
||||||
|
if (!isControlled) setInternalDate(date);
|
||||||
|
onChange?.(date);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const picker = (
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
setOpen(nextOpen);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
id={id}
|
||||||
|
disabled={disabled}
|
||||||
|
data-empty={!selectedDate}
|
||||||
|
className="w-full justify-start font-normal data-[empty=true]:text-muted-foreground"
|
||||||
|
>
|
||||||
|
{selectedDate
|
||||||
|
? selectedDate.toLocaleDateString(undefined, { dateStyle: 'long' })
|
||||||
|
: placeholder}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
|
||||||
|
<CalendarGridPicker
|
||||||
|
key={open ? 'open' : 'closed'}
|
||||||
|
mode="single"
|
||||||
|
selected={selectedDate}
|
||||||
|
defaultMonth={initialMonth}
|
||||||
|
startMonth={resolvedStartMonth}
|
||||||
|
endMonth={resolvedEndMonth}
|
||||||
|
disabled={disabledDates}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!showLabel) {
|
||||||
|
return <div className={cn('w-full', className)}>{picker}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField id={id} label={label} className={className ?? 'mx-auto w-44'}>
|
||||||
|
{picker}
|
||||||
|
</FormField>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,3 +5,5 @@ export { PhoneInput } from './phone-input/PhoneInput';
|
|||||||
export { SelectPopover } from './SelectPopover';
|
export { SelectPopover } from './SelectPopover';
|
||||||
export type { SelectPopoverOption } from './SelectPopover';
|
export type { SelectPopoverOption } from './SelectPopover';
|
||||||
export { PasswordField } from './PasswordField';
|
export { PasswordField } from './PasswordField';
|
||||||
|
export { DatePickerSimple } from './DatePickerSimple';
|
||||||
|
export type { DatePickerSimpleProps } from './DatePickerSimple';
|
||||||
|
|||||||
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