feat(ui): use grid calendar picker for modern ui look
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from '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 { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -21,6 +21,15 @@ export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) {
|
||||
const [assignNote, setAssignNote] = useState('');
|
||||
|
||||
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 (
|
||||
<TicketActionCard icon={Users} title="Assign to Contractor">
|
||||
@@ -56,13 +65,20 @@ export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) {
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<Label>Due Date *</Label>
|
||||
<DatePicker
|
||||
<Label htmlFor="assign-due-date">
|
||||
Due Date <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<DatePickerSimple
|
||||
id="assign-due-date"
|
||||
value={dueDate}
|
||||
onChange={setDueDate}
|
||||
showTimeZone={false}
|
||||
showLabel={false}
|
||||
placeholder="Select due date"
|
||||
disabled={assignMutation.isPending}
|
||||
className="w-full"
|
||||
startMonth={today}
|
||||
endMonth={maxDueMonth}
|
||||
disabledDates={{ before: today }}
|
||||
/>
|
||||
</div>
|
||||
<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 type { SelectPopoverOption } from './SelectPopover';
|
||||
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