import { useRef, useState } from 'react'; import { Calendar } from '@/components/ui/calendar'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { CalendarDays, ChevronLeft, ChevronRight, Clock } from 'lucide-react'; import { fr } from 'date-fns/locale'; const parseDateValue = (value) => { if (!value) return undefined; const [ year, month, day, ] = value.split('-').map(Number); if (!year || !month || !day) { return undefined; } return new Date( year, month - 1, day, ); }; const formatDateValue = (date) => { if (!date) return ''; const year = date.getFullYear(); const month = String( date.getMonth() + 1, ).padStart(2, '0'); const day = String( date.getDate(), ).padStart(2, '0'); return `${year}-${month}-${day}`; }; const formatDateLabel = (value) => { const date = parseDateValue(value); if (!date) { return 'Choisir une date'; } const numericDate = date.toLocaleDateString( 'fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', }, ); const longDate = date.toLocaleDateString( 'fr-FR', { day: '2-digit', month: 'long', year: 'numeric', }, ); return ( <> {longDate} {numericDate} ); }; const DesktopDatePicker = ({ value, onChange, min, max, placeholder = 'Choisir une date', disabled = false, className = '', }) => { const [open, setOpen] = useState(false); const selectedDate = parseDateValue(value); const minDate = parseDateValue(min); const maxDate = parseDateValue(max); return ( } > {selectedDate ? formatDateLabel(value) : placeholder} ( (minDate && date < minDate) || (maxDate && date > maxDate) ) : undefined } onSelect={(date) => { if (!date) return; onChange( formatDateValue(date), ); setOpen(false); }} /> ); }; const MONTH_NAMES = [ 'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre', ]; const parseMonthValue = (value) => { if (!value) return null; const [year, month] = String(value).split('-').map(Number); if (!year || !month || month < 1 || month > 12) return null; return { year, month: month - 1 }; }; const formatMonthValue = (year, month) => `${year}-${String(month + 1).padStart(2, '0')}`; const monthIndex = (year, month) => year * 12 + month; const yearFromBound = (value, fallback) => { const year = Number(String(value || '').slice(0, 4)); return Number.isFinite(year) && year > 0 ? year : fallback; }; const DesktopMonthPicker = ({ value, onChange, min, max, placeholder = 'Choisir un mois', disabled = false, className = '', }) => { const selected = parseMonthValue(value); const current = new Date(); const [open, setOpen] = useState(false); const [displayYear, setDisplayYear] = useState(selected?.year || current.getFullYear()); const minMonth = parseMonthValue(min); const maxMonth = parseMonthValue(max); const minIndex = minMonth ? monthIndex(minMonth.year, minMonth.month) : null; const maxIndex = maxMonth ? monthIndex(maxMonth.year, maxMonth.month) : null; const isAllowed = (year, month) => { const index = monthIndex(year, month); return (minIndex === null || index >= minIndex) && (maxIndex === null || index <= maxIndex); }; const canShowYear = (year) => Array.from({ length: 12 }, (_, month) => isAllowed(year, month)).some(Boolean); return ( { setOpen(nextOpen); if (nextOpen) setDisplayYear(selected?.year || current.getFullYear()); }} > } > {selected ? new Date(selected.year, selected.month, 1).toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }) : placeholder}
{displayYear}
{MONTH_NAMES.map((name, month) => { const active = selected?.year === displayYear && selected?.month === month; return ( ); })}
); }; const DesktopYearPicker = ({ value, onChange, min, max, placeholder = 'Choisir une année', disabled = false, className = '', }) => { const currentYear = new Date().getFullYear(); const selectedYear = Number(value) || null; const minYear = yearFromBound(min, 1900); const maxYear = yearFromBound(max, 2100); const baseYear = selectedYear || Math.min(Math.max(currentYear, minYear), maxYear); const [open, setOpen] = useState(false); const [pageStart, setPageStart] = useState(baseYear - (baseYear % 12)); const years = Array.from({ length: 12 }, (_, index) => pageStart + index); return ( { setOpen(nextOpen); if (nextOpen) { const year = selectedYear || Math.min(Math.max(currentYear, minYear), maxYear); setPageStart(year - (year % 12)); } }} > } > {selectedYear || placeholder}
{Math.max(pageStart, minYear)} – {Math.min(pageStart + 11, maxYear)}
{years.map((year) => ( ))}
); }; /* ========================================================= TIME PICKER ========================================================= */ const DesktopTimePicker = ({ value, onChange, disabled = false, className = '', }) => { const [open, setOpen] = useState(false); const timeRef = useRef(value); timeRef.current = value; const mouseState = useRef({ active: false, lastY: 0, accumulated: 0 }); const touchState = useRef({ active: false, lastY: 0, accumulated: 0, }); const [hours = '', minutes = ''] = value ? value.split(':') : []; const hourOptions = Array.from( { length: 24 }, (_, i) => String(i).padStart(2, '0'), ); const minuteOptions = Array.from( { length: 60 }, (_, i) => String(i).padStart(2, '0'), ); const updateTime = ( nextHours, nextMinutes, ) => { const nextValue = `${String(nextHours).padStart(2, '0')}:${String( nextMinutes, ).padStart(2, '0')}`; timeRef.current = nextValue; onChange(nextValue); }; const changeHour = (direction) => { const [currentHour = 0, currentMinute = 0] = (timeRef.current || '00:00').split(':').map(Number); updateTime((currentHour + direction % 24 + 24) % 24, currentMinute); }; const changeMinute = (direction) => { const [currentHour = 0, currentMinute = 0] = (timeRef.current || '00:00').split(':').map(Number); const total = ((currentHour * 60 + currentMinute + direction) % 1440 + 1440) % 1440; updateTime(Math.floor(total / 60), total % 60); }; const handleMouseDown = (event) => { if (event.pointerType !== 'mouse' || event.button !== 0) return; event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); mouseState.current = { active: true, lastY: event.clientY, accumulated: 0 }; }; const handleMouseMove = (event, type) => { if (!mouseState.current.active || event.pointerType !== 'mouse') return; const state = mouseState.current; state.accumulated += state.lastY - event.clientY; state.lastY = event.clientY; const steps = Math.trunc(state.accumulated / 18); if (!steps) return; state.accumulated -= steps * 18; if (type === 'hours') changeHour(steps); else changeMinute(steps); }; const handleMouseUp = () => { mouseState.current.active = false; mouseState.current.accumulated = 0; }; const handleWheel = (event, type) => { event.stopPropagation(); const direction = event.deltaY > 0 ? 1 : -1; if (type === 'hours') { changeHour(direction); } else { changeMinute(direction); } }; const handleTouchStart = (event) => { const touch = event.touches[0]; touchState.current = { active: true, lastY: touch.clientY, accumulated: 0, }; }; const handleTouchMove = (event, type) => { if (!touchState.current.active) { return; } const touch = event.touches[0]; const currentY = touch.clientY; const deltaY = touchState.current.lastY - currentY; touchState.current.lastY = currentY; touchState.current.accumulated += deltaY; const pixelsPerUnit = 18; const steps = Math.trunc( touchState.current.accumulated / pixelsPerUnit, ); if (steps === 0) { return; } touchState.current.accumulated -= steps * pixelsPerUnit; if (type === 'hours') { changeHour(steps); } else { changeMinute(steps); } event.stopPropagation(); }; const handleTouchEnd = () => { touchState.current.active = false; touchState.current.accumulated = 0; }; const clearTime = () => { onChange(''); setOpen(false); }; return ( } > {value ? ( value ) : ( <> Choisir une heure Choisir )}
Choisir une heure {value || '--:--'}
handleMouseMove(event, 'hours')} onPointerUp={handleMouseUp} onLostPointerCapture={handleMouseUp} onWheel={(event) => handleWheel( event, 'hours', ) } onTouchStart={handleTouchStart} onTouchMove={(event) => handleTouchMove( event, 'hours', ) } onTouchEnd={handleTouchEnd} onTouchCancel={handleTouchEnd} >
{ hourOptions[ (Number(hours || 0) + 23) % 24 ] } {hours || '00'} { hourOptions[ (Number(hours || 0) + 1) % 24 ] }
:
handleMouseMove(event, 'minutes')} onPointerUp={handleMouseUp} onLostPointerCapture={handleMouseUp} onWheel={(event) => handleWheel( event, 'minutes', ) } onTouchStart={handleTouchStart} onTouchMove={(event) => handleTouchMove( event, 'minutes', ) } onTouchEnd={handleTouchEnd} onTouchCancel={handleTouchEnd} >
{ minuteOptions[ (Number(minutes || 0) + 59) % 60 ] } {minutes || '00'} { minuteOptions[ (Number(minutes || 0) + 1) % 60 ] }

Glissez pour modifier

); }; // Les champs natifs restent pratiques sur téléphone pour les dates et les heures. // Les mois et années utilisent le même DatePicker partout pour garder une UI cohérente. export const DatePicker = ({ value, onChange, min, max, placeholder, id, mode = 'date', disabled = false, required = false, className = '', ariaLabel, }) => { if (mode === 'month') { return ( ); } if (mode === 'year') { return ( ); } return ( <> onChange(event.target.value)} className={`block w-full min-w-0 md:hidden ${className}`} />
); }; export const TimePicker = ({ value, onChange, id, disabled = false, required = false, className = '', ariaLabel, }) => ( <> onChange(event.target.value)} className={`block w-full min-w-0 md:hidden ${className}`} />
);