This commit is contained in:
Louis Mazin 2026-09-25 10:47:55 +02:00
parent 6e70e58a61
commit 4bf1370543
3 changed files with 552 additions and 482 deletions

View File

@ -0,0 +1,537 @@
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, 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,
placeholder = 'Choisir une date',
}) => {
const [open, setOpen] =
useState(false);
const selectedDate =
parseDateValue(value);
const minDate =
parseDateValue(min);
return (
<Popover
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger
render={
<Button
type="button"
variant="outline"
className="w-full justify-start gap-2 font-normal"
/>
}
>
<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
? {
before: minDate,
}
: undefined
}
onSelect={(date) => {
if (!date) return;
onChange(
formatDateValue(date),
);
setOpen(false);
}}
/>
</PopoverContent>
</Popover>
);
};
/* =========================================================
TIME PICKER
========================================================= */
const DesktopTimePicker = ({
value,
onChange
}) => {
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"
className="w-full justify-start gap-2 font-normal"
/>
}
>
<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 ouvrent le sélecteur du téléphone.
// Sur ordinateur, on conserve le calendrier et la molette de TimeClock.
export const DatePicker = ({ value, onChange, min, placeholder, id }) => (
<>
<Input
id={id}
type="date"
min={min}
value={value || ''}
onChange={(event) => onChange(event.target.value)}
className="block w-full min-w-0 md:hidden"
/>
<div className="hidden md:block">
<DesktopDatePicker value={value} onChange={onChange} min={min} placeholder={placeholder} />
</div>
</>
);
export const TimePicker = ({ value, onChange, id }) => (
<>
<Input
id={id}
type="time"
value={value || ''}
onChange={(event) => onChange(event.target.value)}
className="block w-full min-w-0 md:hidden"
/>
<div className="hidden md:block">
<DesktopTimePicker value={value} onChange={onChange} />
</div>
</>
);

View File

@ -17,6 +17,7 @@ import LoadingCard from '@/components/LoadingCard';
import MainCard from '@/components/MainCard';
import HoverInfo from '@/components/HoverInfo';
import { DatePicker, TimePicker } from '@/components/ResponsivePickers';
import {
@ -2358,27 +2359,21 @@ const CalendarPage = () => {
</Label>
<Input
<DatePicker
id="event-start-date"
type="date"
value={
eventForm.start_date
}
onChange={(
event,
) =>
onChange={(value) =>
handleStartDateChange(
event.target.value,
value,
)
@ -2396,12 +2391,10 @@ const CalendarPage = () => {
</Label>
<Input
<DatePicker
id="event-end-date"
type="date"
min={
eventForm.start_date ||
@ -2416,17 +2409,13 @@ const CalendarPage = () => {
}
onChange={(
event,
) =>
onChange={(value) =>
handleFormChange(
'end_date',
event.target.value,
value,
)
@ -2456,29 +2445,23 @@ const CalendarPage = () => {
</Label>
<Input
<TimePicker
id="event-start-time"
type="time"
value={
eventForm.start_time
}
onChange={(
event,
) =>
onChange={(value) =>
handleFormChange(
'start_time',
event.target.value,
value,
)
@ -2502,29 +2485,23 @@ const CalendarPage = () => {
</Label>
<Input
<TimePicker
id="event-end-time"
type="time"
value={
eventForm.end_time
}
onChange={(
event,
) =>
onChange={(value) =>
handleFormChange(
'end_time',
event.target.value,
value,
)

View File

@ -31,6 +31,7 @@ import {
import { Badge } from '@/components/ui/badge';
import ConfirmDialog from '@/components/ConfirmDialog';
import { DatePicker, TimePicker } from '@/components/ResponsivePickers';
import {
Dialog,
@ -39,16 +40,9 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
CalendarDays,
Clock,
} from 'lucide-react';
@ -95,24 +89,6 @@ const parseDateValue = (value) => {
};
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);
@ -183,426 +159,6 @@ const formatDate = (
DATE PICKER
========================================================= */
const DatePicker = ({
value,
onChange,
min,
placeholder = 'Choisir une date',
}) => {
const [open, setOpen] =
useState(false);
const selectedDate =
parseDateValue(value);
const minDate =
parseDateValue(min);
return (
<Popover
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger
render={
<Button
type="button"
variant="outline"
className="w-full justify-start gap-2 font-normal"
/>
}
>
<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"
selected={selectedDate}
defaultMonth={
selectedDate ||
minDate ||
new Date()
}
disabled={
minDate
? {
before: minDate,
}
: undefined
}
onSelect={(date) => {
if (!date) return;
onChange(
formatDateValue(date),
);
setOpen(false);
}}
/>
</PopoverContent>
</Popover>
);
};
/* =========================================================
TIME PICKER
========================================================= */
const TimePicker = ({
value,
onChange
}) => {
const [open, setOpen] = useState(false);
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,
) => {
onChange(
`${String(nextHours).padStart(2, '0')}:${String(
nextMinutes,
).padStart(2, '0')}`,
);
};
const changeHour = (direction) => {
const currentHour = Number(hours || 0);
const currentMinute = Number(minutes || 0);
let nextHour =
currentHour + direction;
if (nextHour < 0) {
nextHour = 23;
}
if (nextHour > 23) {
nextHour = 0;
}
updateTime(
nextHour,
currentMinute,
);
};
const changeMinute = (direction) => {
const currentHour = Number(hours || 0);
const currentMinute = Number(minutes || 0);
let nextMinute =
currentMinute + direction;
let nextHour = currentHour;
if (nextMinute < 0) {
nextMinute = 59;
nextHour =
currentHour === 0
? 23
: currentHour - 1;
}
if (nextMinute > 59) {
nextMinute = 0;
nextHour =
currentHour === 23
? 0
: currentHour + 1;
}
updateTime(
nextHour,
nextMinute,
);
};
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"
className="w-full justify-start gap-2 font-normal"
/>
}
>
<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 touch-none select-none"
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 touch-none select-none"
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>
);
};
/* =========================================================
PAGE
========================================================= */