test
This commit is contained in:
parent
6e70e58a61
commit
4bf1370543
537
frontend/src/components/ResponsivePickers.jsx
Normal file
537
frontend/src/components/ResponsivePickers.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
@ -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,
|
||||
|
||||
)
|
||||
|
||||
|
||||
@ -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
|
||||
========================================================= */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user