Boulangerie_Website/frontend/src/components/ResponsivePickers.jsx
Louis Mazin 4db822aebe big maj
2026-10-05 17:52:28 +02:00

850 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<>
<span className="lg:hidden">
{longDate}
</span>
<span className="hidden lg:inline">
{numericDate}
</span>
</>
);
};
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 (
<Popover
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger
render={
<Button
type="button"
variant="outline"
disabled={disabled}
className={`w-full justify-start gap-2 font-normal ${className}`}
/>
}
>
<CalendarDays className="size-4 text-muted-foreground" />
<span
className={
selectedDate
? ''
: 'text-muted-foreground'
}
>
{selectedDate
? formatDateLabel(value)
: placeholder}
</span>
</PopoverTrigger>
<PopoverContent
className="w-auto p-0"
align="start"
>
<Calendar
mode="single"
locale={fr}
weekStartsOn={1}
selected={selectedDate}
defaultMonth={
selectedDate ||
minDate ||
new Date()
}
disabled={
minDate || maxDate
? (date) => (
(minDate && date < minDate) ||
(maxDate && date > maxDate)
)
: undefined
}
onSelect={(date) => {
if (!date) return;
onChange(
formatDateValue(date),
);
setOpen(false);
}}
/>
</PopoverContent>
</Popover>
);
};
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 (
<Popover
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (nextOpen) setDisplayYear(selected?.year || current.getFullYear());
}}
>
<PopoverTrigger
render={
<Button
type="button"
variant="outline"
disabled={disabled}
className={`w-full justify-start gap-2 font-normal ${className}`}
/>
}
>
<CalendarDays className="size-4 text-muted-foreground" />
<span className={selected ? 'capitalize' : 'text-muted-foreground'}>
{selected
? new Date(selected.year, selected.month, 1).toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' })
: placeholder}
</span>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-3" align="start">
<div className="mb-3 flex items-center justify-between gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
disabled={!canShowYear(displayYear - 1)}
onClick={() => setDisplayYear((year) => year - 1)}
aria-label="Année précédente"
>
<ChevronLeft className="size-4" />
</Button>
<span className="text-sm font-semibold tabular-nums">{displayYear}</span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
disabled={!canShowYear(displayYear + 1)}
onClick={() => setDisplayYear((year) => year + 1)}
aria-label="Année suivante"
>
<ChevronRight className="size-4" />
</Button>
</div>
<div className="grid grid-cols-3 gap-1.5">
{MONTH_NAMES.map((name, month) => {
const active = selected?.year === displayYear && selected?.month === month;
return (
<Button
key={name}
type="button"
size="sm"
variant={active ? 'default' : 'ghost'}
className="justify-center px-2 text-xs"
disabled={!isAllowed(displayYear, month)}
onClick={() => {
onChange(formatMonthValue(displayYear, month));
setOpen(false);
}}
>
{name.slice(0, 4)}
</Button>
);
})}
</div>
</PopoverContent>
</Popover>
);
};
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 (
<Popover
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (nextOpen) {
const year = selectedYear || Math.min(Math.max(currentYear, minYear), maxYear);
setPageStart(year - (year % 12));
}
}}
>
<PopoverTrigger
render={
<Button
type="button"
variant="outline"
disabled={disabled}
className={`w-full justify-start gap-2 font-normal ${className}`}
/>
}
>
<CalendarDays className="size-4 text-muted-foreground" />
<span className={selectedYear ? 'tabular-nums' : 'text-muted-foreground'}>
{selectedYear || placeholder}
</span>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-3" align="start">
<div className="mb-3 flex items-center justify-between gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
disabled={pageStart <= minYear}
onClick={() => setPageStart((year) => year - 12)}
aria-label="Années précédentes"
>
<ChevronLeft className="size-4" />
</Button>
<span className="text-sm font-semibold tabular-nums">
{Math.max(pageStart, minYear)} – {Math.min(pageStart + 11, maxYear)}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
disabled={pageStart + 11 >= maxYear}
onClick={() => setPageStart((year) => year + 12)}
aria-label="Années suivantes"
>
<ChevronRight className="size-4" />
</Button>
</div>
<div className="grid grid-cols-3 gap-1.5">
{years.map((year) => (
<Button
key={year}
type="button"
size="sm"
variant={selectedYear === year ? 'default' : 'ghost'}
disabled={year < minYear || year > maxYear}
onClick={() => {
onChange(String(year));
setOpen(false);
}}
>
{year}
</Button>
))}
</div>
</PopoverContent>
</Popover>
);
};
/* =========================================================
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 (
<Popover
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger
render={
<Button
type="button"
variant="outline"
disabled={disabled}
className={`w-full justify-start gap-2 font-normal ${className}`}
/>
}
>
<Clock className="size-4 text-muted-foreground" />
<span
className={
value
? 'tabular-nums'
: 'text-muted-foreground'
}
>
{value ? (
value
) : (
<>
<span className="lg:hidden">
Choisir une heure
</span>
<span className="hidden lg:inline">
Choisir
</span>
</>
)}
</span>
</PopoverTrigger>
<PopoverContent
className="w-[190px] p-3"
align="start"
>
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium">
Choisir une heure
</span>
<span className="font-mono text-sm font-semibold tabular-nums">
{value || '--:--'}
</span>
</div>
<div className="relative flex h-[144px] gap-2 overflow-hidden">
<div className="pointer-events-none absolute inset-x-0 top-1/2 z-10 h-9 -translate-y-1/2 rounded-md border bg-muted/50" />
<div
className="flex-1 cursor-grab touch-none select-none active:cursor-grabbing"
onPointerDown={handleMouseDown}
onPointerMove={(event) => handleMouseMove(event, 'hours')}
onPointerUp={handleMouseUp}
onLostPointerCapture={handleMouseUp}
onWheel={(event) =>
handleWheel(
event,
'hours',
)
}
onTouchStart={handleTouchStart}
onTouchMove={(event) =>
handleTouchMove(
event,
'hours',
)
}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
>
<div className="flex h-full flex-col items-center justify-center">
<span className="h-9 text-sm text-muted-foreground/40">
{
hourOptions[
(Number(hours || 0) +
23) %
24
]
}
</span>
<span className="flex h-9 w-full items-center justify-center rounded-md font-mono text-sm font-semibold tabular-nums">
{hours || '00'}
</span>
<span className="h-9 text-sm text-muted-foreground/40">
{
hourOptions[
(Number(hours || 0) +
1) %
24
]
}
</span>
</div>
</div>
<div className="flex items-center justify-center">
<span className="font-bold">
:
</span>
</div>
<div
className="flex-1 cursor-grab touch-none select-none active:cursor-grabbing"
onPointerDown={handleMouseDown}
onPointerMove={(event) => handleMouseMove(event, 'minutes')}
onPointerUp={handleMouseUp}
onLostPointerCapture={handleMouseUp}
onWheel={(event) =>
handleWheel(
event,
'minutes',
)
}
onTouchStart={handleTouchStart}
onTouchMove={(event) =>
handleTouchMove(
event,
'minutes',
)
}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
>
<div className="flex h-full flex-col items-center justify-center">
<span className="h-9 text-sm text-muted-foreground/40">
{
minuteOptions[
(Number(minutes || 0) +
59) %
60
]
}
</span>
<span className="flex h-9 w-full items-center justify-center rounded-md font-mono text-sm font-semibold tabular-nums">
{minutes || '00'}
</span>
<span className="h-9 text-sm text-muted-foreground/40">
{
minuteOptions[
(Number(minutes || 0) +
1) %
60
]
}
</span>
</div>
</div>
</div>
<p className="mt-2 text-center text-[10px] text-muted-foreground">
Glissez pour modifier
</p>
<div className="mt-2 border-t pt-2">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-full text-xs"
disabled={!value}
onClick={clearTime}
>
Effacer
</Button>
</div>
</PopoverContent>
</Popover>
);
};
// 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 (
<DesktopMonthPicker
value={value}
onChange={onChange}
min={min}
max={max}
placeholder={placeholder}
disabled={disabled}
className={className}
/>
);
}
if (mode === 'year') {
return (
<DesktopYearPicker
value={value}
onChange={onChange}
min={min}
max={max}
placeholder={placeholder}
disabled={disabled}
className={className}
/>
);
}
return (
<>
<Input
id={id}
aria-label={ariaLabel}
type="date"
min={min}
max={max}
value={value || ''}
required={required}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
className={`block w-full min-w-0 md:hidden ${className}`}
/>
<div className="hidden md:block">
<DesktopDatePicker
value={value}
onChange={onChange}
min={min}
max={max}
placeholder={placeholder}
disabled={disabled}
className={className}
/>
</div>
</>
);
};
export const TimePicker = ({
value,
onChange,
id,
disabled = false,
required = false,
className = '',
ariaLabel,
}) => (
<>
<Input
id={id}
aria-label={ariaLabel}
type="time"
value={value || ''}
required={required}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
className={`block w-full min-w-0 md:hidden ${className}`}
/>
<div className="hidden md:block">
<DesktopTimePicker
value={value}
onChange={onChange}
disabled={disabled}
className={className}
/>
</div>
</>
);