231 lines
6.4 KiB
TypeScript
231 lines
6.4 KiB
TypeScript
/**
|
|
* FormDatePicker Component
|
|
*
|
|
* A reusable date picker component that handles:
|
|
* - Single date selection
|
|
* - Date range selection
|
|
* - Label
|
|
* - Controller (react-hook-form)
|
|
* - Error handling
|
|
*
|
|
* This component reduces code duplication for date picker fields across forms.
|
|
*/
|
|
|
|
import React from 'react';
|
|
import { Control, Controller, FieldError, FieldPath, FieldValues } from 'react-hook-form';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Calendar } from '@/components/ui/calendar';
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from '@/components/ui/popover';
|
|
import { CalendarIcon } from 'lucide-react';
|
|
import { format } from 'date-fns';
|
|
import { DateRange } from 'react-day-picker';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
// =============================================================================
|
|
// TYPES
|
|
// =============================================================================
|
|
|
|
export interface FormDatePickerProps<
|
|
TFieldValues extends FieldValues = FieldValues,
|
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
|
> {
|
|
// Form control
|
|
control: Control<TFieldValues>;
|
|
name: TName;
|
|
|
|
// Field configuration
|
|
label?: string;
|
|
placeholder?: string;
|
|
required?: boolean;
|
|
|
|
// Date picker configuration
|
|
mode?: 'single' | 'range';
|
|
numberOfMonths?: number;
|
|
|
|
// Styling
|
|
className?: string;
|
|
labelClassName?: string;
|
|
buttonClassName?: string;
|
|
errorClassName?: string;
|
|
|
|
// Validation
|
|
validation?: {
|
|
required?: boolean | string;
|
|
};
|
|
|
|
// Additional props
|
|
disabled?: boolean;
|
|
size?: 'sm' | 'md' | 'lg';
|
|
|
|
// Date formatting
|
|
dateFormat?: string;
|
|
|
|
// Custom render functions
|
|
renderTrigger?: (props: {
|
|
field: any;
|
|
fieldState: { error?: FieldError };
|
|
selectedDate: Date | DateRange | undefined;
|
|
placeholder: string;
|
|
}) => React.ReactElement;
|
|
}
|
|
|
|
// =============================================================================
|
|
// COMPONENT
|
|
// =============================================================================
|
|
|
|
export function FormDatePicker<
|
|
TFieldValues extends FieldValues = FieldValues,
|
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
|
>({
|
|
control,
|
|
name,
|
|
label,
|
|
placeholder = 'Pick a date',
|
|
required = false,
|
|
mode = 'single',
|
|
numberOfMonths = 1,
|
|
className = '',
|
|
labelClassName = '',
|
|
buttonClassName = '',
|
|
errorClassName = '',
|
|
validation,
|
|
disabled = false,
|
|
size = 'md',
|
|
dateFormat = 'LLL dd, y',
|
|
renderTrigger,
|
|
}: FormDatePickerProps<TFieldValues, TName>) {
|
|
|
|
// =============================================================================
|
|
// HELPER FUNCTIONS
|
|
// =============================================================================
|
|
|
|
const getButtonSize = (size: 'sm' | 'md' | 'lg') => {
|
|
switch (size) {
|
|
case 'sm':
|
|
return 'h-8 text-sm';
|
|
case 'lg':
|
|
return 'h-12 text-base';
|
|
default:
|
|
return 'h-10 text-sm';
|
|
}
|
|
};
|
|
|
|
const formatDate = (date: Date | DateRange | undefined): string => {
|
|
if (!date) return '';
|
|
|
|
if (mode === 'range' && 'from' in date) {
|
|
if (date.from) {
|
|
if (date.to) {
|
|
return `${format(date.from, dateFormat)} - ${format(date.to, dateFormat)}`;
|
|
}
|
|
return format(date.from, dateFormat);
|
|
}
|
|
} else if (date instanceof Date) {
|
|
return format(date, dateFormat);
|
|
}
|
|
|
|
return '';
|
|
};
|
|
|
|
// =============================================================================
|
|
// RENDER FUNCTIONS
|
|
// =============================================================================
|
|
|
|
const renderDefaultTrigger = (
|
|
field: any,
|
|
fieldState: { error?: FieldError },
|
|
selectedDate: Date | DateRange | undefined,
|
|
placeholder: string
|
|
) => (
|
|
<Button
|
|
variant="outline"
|
|
className={cn(
|
|
'w-full justify-start text-left font-normal transition-colors',
|
|
getButtonSize(size),
|
|
fieldState.error && 'border-destructive focus:border-destructive',
|
|
!selectedDate && 'text-muted-foreground',
|
|
buttonClassName
|
|
)}
|
|
disabled={disabled}
|
|
>
|
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
|
{formatDate(selectedDate) || placeholder}
|
|
</Button>
|
|
);
|
|
|
|
// =============================================================================
|
|
// MAIN RENDER
|
|
// =============================================================================
|
|
|
|
return (
|
|
<div className={cn('space-y-2', className)}>
|
|
{/* Label */}
|
|
{label && (
|
|
<Label
|
|
htmlFor={name}
|
|
className={cn(
|
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
|
required && 'after:content-["*"] after:ml-0.5 after:text-destructive',
|
|
labelClassName
|
|
)}
|
|
>
|
|
{label}
|
|
</Label>
|
|
)}
|
|
|
|
{/* Controller */}
|
|
<Controller
|
|
control={control}
|
|
name={name}
|
|
rules={{
|
|
required: validation?.required || required,
|
|
}}
|
|
render={({ field, fieldState }) => {
|
|
const selectedDate = field.value;
|
|
|
|
return (
|
|
<div className="space-y-1">
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
{renderTrigger ?
|
|
renderTrigger({ field, fieldState, selectedDate, placeholder }) :
|
|
renderDefaultTrigger(field, fieldState, selectedDate, placeholder)
|
|
}
|
|
</PopoverTrigger>
|
|
|
|
<PopoverContent className="w-auto p-0" align="start">
|
|
<Calendar
|
|
initialFocus
|
|
mode={mode}
|
|
defaultMonth={mode === 'range' && 'from' in selectedDate ? selectedDate.from : selectedDate}
|
|
selected={selectedDate}
|
|
onSelect={field.onChange}
|
|
numberOfMonths={numberOfMonths}
|
|
disabled={disabled}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{/* Error Message */}
|
|
{fieldState.error && (
|
|
<p className={cn(
|
|
'text-sm text-destructive',
|
|
errorClassName
|
|
)}>
|
|
{fieldState.error.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default FormDatePicker;
|