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