veza/apps/web/src/components/ui/date-picker.tsx

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>
);
}