445 lines
12 KiB
TypeScript
445 lines
12 KiB
TypeScript
import { useState, useMemo } from 'react';
|
|
import { Button } from './button';
|
|
import { Dropdown } from './dropdown';
|
|
import { cn } from '@/lib/utils';
|
|
import {
|
|
Calendar as CalendarIcon,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
X,
|
|
} from 'lucide-react';
|
|
|
|
/**
|
|
* DatePickerProps - Propriétés du composant DatePicker
|
|
*
|
|
* @interface DatePickerProps
|
|
*/
|
|
export interface DatePickerProps {
|
|
/**
|
|
* Date(s) sélectionnée(s)
|
|
* Date pour mode single, objet { start, end } pour mode range
|
|
*/
|
|
value?: Date | { start: Date; end: Date };
|
|
|
|
/**
|
|
* Fonction appelée lorsque la sélection change
|
|
*
|
|
* @param {Date | { start: Date; end: Date }} date - Nouvelle(s) date(s) sélectionnée(s)
|
|
*/
|
|
onChange: (date: Date | { start: Date; end: Date }) => void;
|
|
|
|
/**
|
|
* Mode de sélection
|
|
*
|
|
* - `single`: Sélection d'une date unique
|
|
* - `range`: Sélection d'une plage de dates
|
|
*
|
|
* @default 'single'
|
|
*/
|
|
mode?: 'single' | 'range';
|
|
|
|
/**
|
|
* Date minimale sélectionnable
|
|
*/
|
|
minDate?: Date;
|
|
|
|
/**
|
|
* Date maximale sélectionnable
|
|
*/
|
|
maxDate?: Date;
|
|
|
|
/**
|
|
* Texte du placeholder
|
|
*/
|
|
placeholder?: string;
|
|
|
|
/**
|
|
* Si `true`, désactive le date picker
|
|
*
|
|
* @default false
|
|
*/
|
|
disabled?: boolean;
|
|
|
|
/**
|
|
* Classes CSS personnalisées
|
|
*/
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* DatePicker - Composant de sélection de date avec calendrier
|
|
*
|
|
* Composant de date picker avec support pour :
|
|
* - Sélection de date unique
|
|
* - Sélection de plage de dates
|
|
* - Navigation par mois
|
|
* - Validation avec dates min/max
|
|
* - Affichage calendrier avec dropdown
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* // Sélection de date unique
|
|
* <DatePicker
|
|
* value={selectedDate}
|
|
* onChange={setSelectedDate}
|
|
* placeholder="Sélectionner une date"
|
|
* />
|
|
* ```
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* // Sélection de plage
|
|
* <DatePicker
|
|
* mode="range"
|
|
* value={{ start: startDate, end: endDate }}
|
|
* onChange={(range) => {
|
|
* setStartDate(range.start);
|
|
* setEndDate(range.end);
|
|
* }}
|
|
* minDate={new Date()}
|
|
* />
|
|
* ```
|
|
*
|
|
* @component
|
|
* @param {DatePickerProps} props - Propriétés du composant
|
|
* @returns {JSX.Element} Input avec calendrier dropdown
|
|
*/
|
|
|
|
const DAYS_OF_WEEK = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
|
const MONTHS = [
|
|
'January',
|
|
'February',
|
|
'March',
|
|
'April',
|
|
'May',
|
|
'June',
|
|
'July',
|
|
'August',
|
|
'September',
|
|
'October',
|
|
'November',
|
|
'December',
|
|
];
|
|
|
|
/**
|
|
* Composant DatePicker avec calendrier, sélection de date unique ou range.
|
|
*/
|
|
export function DatePicker({
|
|
value,
|
|
onChange,
|
|
mode = 'single',
|
|
minDate,
|
|
maxDate,
|
|
placeholder,
|
|
disabled = false,
|
|
className,
|
|
}: DatePickerProps) {
|
|
const [_open, setOpen] = useState(false);
|
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
|
|
|
// Normaliser les dates pour la comparaison (sans heures)
|
|
const normalizeDate = (date: Date): Date => {
|
|
const normalized = new Date(date);
|
|
normalized.setHours(0, 0, 0, 0);
|
|
return normalized;
|
|
};
|
|
|
|
const isDateDisabled = (date: Date): boolean => {
|
|
const normalized = normalizeDate(date);
|
|
if (minDate && normalized < normalizeDate(minDate)) return true;
|
|
if (maxDate && normalized > normalizeDate(maxDate)) return true;
|
|
return false;
|
|
};
|
|
|
|
const isDateInRange = (date: Date): boolean => {
|
|
if (
|
|
mode !== 'range' ||
|
|
!value ||
|
|
(typeof value === 'object' && !('start' in value))
|
|
) {
|
|
return false;
|
|
}
|
|
const range = value as { start: Date; end: Date };
|
|
if (!range.start || !range.end) return false;
|
|
const normalized = normalizeDate(date);
|
|
const start = normalizeDate(range.start);
|
|
const end = normalizeDate(range.end);
|
|
return normalized >= start && normalized <= end;
|
|
};
|
|
|
|
const isDateSelected = (date: Date): boolean => {
|
|
const normalized = normalizeDate(date);
|
|
if (mode === 'single') {
|
|
if (!value || value instanceof Date === false) return false;
|
|
return normalized.getTime() === normalizeDate(value as Date).getTime();
|
|
} else {
|
|
if (!value || typeof value !== 'object' || !('start' in value))
|
|
return false;
|
|
const range = value as { start: Date; end: Date };
|
|
if (!range.start && !range.end) return false;
|
|
if (
|
|
range.start &&
|
|
normalized.getTime() === normalizeDate(range.start).getTime()
|
|
)
|
|
return true;
|
|
if (
|
|
range.end &&
|
|
normalized.getTime() === normalizeDate(range.end).getTime()
|
|
)
|
|
return true;
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const isDateStart = (date: Date): boolean => {
|
|
if (
|
|
mode !== 'range' ||
|
|
!value ||
|
|
typeof value !== 'object' ||
|
|
!('start' in value)
|
|
) {
|
|
return false;
|
|
}
|
|
const range = value as { start: Date; end: Date };
|
|
if (!range.start) return false;
|
|
return (
|
|
normalizeDate(date).getTime() === normalizeDate(range.start).getTime()
|
|
);
|
|
};
|
|
|
|
const isDateEnd = (date: Date): boolean => {
|
|
if (
|
|
mode !== 'range' ||
|
|
!value ||
|
|
typeof value !== 'object' ||
|
|
!('end' in value)
|
|
) {
|
|
return false;
|
|
}
|
|
const range = value as { start: Date; end: Date };
|
|
if (!range.end) return false;
|
|
return normalizeDate(date).getTime() === normalizeDate(range.end).getTime();
|
|
};
|
|
|
|
const handleDateSelect = (date: Date) => {
|
|
if (isDateDisabled(date)) return;
|
|
|
|
if (mode === 'single') {
|
|
onChange(date);
|
|
setOpen(false);
|
|
} else {
|
|
// Mode range
|
|
if (!value || typeof value !== 'object' || !('start' in value)) {
|
|
onChange({ start: date, end: date });
|
|
return;
|
|
}
|
|
const range = value as { start: Date; end: Date };
|
|
if (!range.start || (range.start && range.end)) {
|
|
// Nouvelle sélection
|
|
onChange({ start: date, end: date });
|
|
} else {
|
|
// Compléter la sélection
|
|
if (date < range.start) {
|
|
onChange({ start: date, end: range.start });
|
|
} else {
|
|
onChange({ start: range.start, end: date });
|
|
}
|
|
setOpen(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleClear = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
if (mode === 'single') {
|
|
onChange(undefined as any);
|
|
} else {
|
|
onChange({ start: undefined as any, end: undefined as any });
|
|
}
|
|
};
|
|
|
|
const handlePreviousMonth = () => {
|
|
setCurrentMonth(
|
|
new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1),
|
|
);
|
|
};
|
|
|
|
const handleNextMonth = () => {
|
|
setCurrentMonth(
|
|
new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1),
|
|
);
|
|
};
|
|
|
|
const handleToday = () => {
|
|
const today = new Date();
|
|
if (!isDateDisabled(today)) {
|
|
handleDateSelect(today);
|
|
}
|
|
};
|
|
|
|
// Générer les jours du calendrier
|
|
const calendarDays = useMemo(() => {
|
|
const year = currentMonth.getFullYear();
|
|
const month = currentMonth.getMonth();
|
|
const firstDay = new Date(year, month, 1);
|
|
const lastDay = new Date(year, month + 1, 0);
|
|
const daysInMonth = lastDay.getDate();
|
|
const startingDayOfWeek = (firstDay.getDay() + 6) % 7; // Lundi = 0
|
|
|
|
const days: (Date | null)[] = [];
|
|
|
|
// Jours du mois précédent
|
|
for (let i = 0; i < startingDayOfWeek; i++) {
|
|
days.push(null);
|
|
}
|
|
|
|
// Jours du mois courant
|
|
for (let day = 1; day <= daysInMonth; day++) {
|
|
days.push(new Date(year, month, day));
|
|
}
|
|
|
|
return days;
|
|
}, [currentMonth]);
|
|
|
|
// Format de l'affichage
|
|
const displayValue = useMemo(() => {
|
|
if (!value) return placeholder || 'Select date...';
|
|
|
|
if (mode === 'single') {
|
|
if (value instanceof Date) {
|
|
return value.toLocaleDateString();
|
|
}
|
|
return placeholder || 'Select date...';
|
|
} else {
|
|
const range = value as { start: Date; end: Date };
|
|
if (range.start && range.end) {
|
|
return `${range.start.toLocaleDateString()} - ${range.end.toLocaleDateString()}`;
|
|
} else if (range.start) {
|
|
return `${range.start.toLocaleDateString()} - ...`;
|
|
}
|
|
return placeholder || 'Select date range...';
|
|
}
|
|
}, [value, mode, placeholder]);
|
|
|
|
const trigger = (
|
|
<Button
|
|
variant="outline"
|
|
disabled={disabled}
|
|
className={cn(
|
|
'w-full justify-start text-left font-normal',
|
|
!value && 'text-muted-foreground',
|
|
className,
|
|
)}
|
|
>
|
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
|
<span className="flex-1 truncate">{displayValue}</span>
|
|
{value && (
|
|
<X
|
|
className="ml-2 h-4 w-4 shrink-0 opacity-50 hover:opacity-100"
|
|
onClick={handleClear}
|
|
/>
|
|
)}
|
|
</Button>
|
|
);
|
|
|
|
return (
|
|
<Dropdown
|
|
trigger={trigger}
|
|
align="left"
|
|
onOpenChange={setOpen}
|
|
className="w-full"
|
|
>
|
|
<div className="w-auto p-0">
|
|
<div className="p-3">
|
|
{/* Header avec navigation */}
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handlePreviousMonth}
|
|
className="h-7 w-7"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<div className="text-sm font-semibold min-w-[120px] text-center">
|
|
{MONTHS[currentMonth.getMonth()]} {currentMonth.getFullYear()}
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handleNextMonth}
|
|
className="h-7 w-7"
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleToday}
|
|
className="text-xs"
|
|
>
|
|
Today
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Jours de la semaine */}
|
|
<div className="grid grid-cols-7 gap-1 mb-2">
|
|
{DAYS_OF_WEEK.map((day) => (
|
|
<div
|
|
key={day}
|
|
className="text-xs font-medium text-muted-foreground text-center py-1"
|
|
>
|
|
{day}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Grille du calendrier */}
|
|
<div className="grid grid-cols-7 gap-1">
|
|
{calendarDays.map((day, index) => {
|
|
if (!day) {
|
|
return <div key={`empty-${index}`} className="h-9" />;
|
|
}
|
|
|
|
const isSelected = isDateSelected(day);
|
|
const isInRange = isDateInRange(day);
|
|
const isStart = isDateStart(day);
|
|
const isEnd = isDateEnd(day);
|
|
const isDisabled = isDateDisabled(day);
|
|
const isToday =
|
|
normalizeDate(day).getTime() ===
|
|
normalizeDate(new Date()).getTime();
|
|
|
|
return (
|
|
<button
|
|
key={day.toISOString()}
|
|
type="button"
|
|
onClick={() => handleDateSelect(day)}
|
|
disabled={isDisabled}
|
|
className={cn(
|
|
'h-9 w-9 text-sm rounded-md transition-colors',
|
|
'hover:bg-accent hover:text-accent-foreground',
|
|
'focus:bg-accent focus:text-accent-foreground',
|
|
isSelected &&
|
|
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground',
|
|
isInRange && !isSelected && 'bg-accent',
|
|
isStart && 'rounded-l-md',
|
|
isEnd && 'rounded-r-md',
|
|
isDisabled &&
|
|
'opacity-50 cursor-not-allowed pointer-events-none',
|
|
isToday && !isSelected && 'border border-primary',
|
|
)}
|
|
>
|
|
{day.getDate()}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Dropdown>
|
|
);
|
|
}
|