153 lines
4.3 KiB
TypeScript
153 lines
4.3 KiB
TypeScript
"use client";
|
|
|
|
import { format } from "date-fns";
|
|
import { CalendarDays, Clock2Icon, X } from "lucide-react";
|
|
import { useId, useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Calendar } from "@/components/ui/calendar";
|
|
import {
|
|
Field,
|
|
FieldGroup,
|
|
FieldLabel,
|
|
} from "@/components/ui/field";
|
|
import {
|
|
InputGroup,
|
|
InputGroupAddon,
|
|
InputGroupInput,
|
|
} from "@/components/ui/input-group";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import { Separator } from "@/components/ui/separator";
|
|
|
|
const TIME_PATTERN = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
|
|
|
|
function formatTimeInput(input: string) {
|
|
const digits = input.replace(/\D/g, "").slice(0, 4);
|
|
return digits.length > 2
|
|
? `${digits.slice(0, 2)}:${digits.slice(2)}`
|
|
: digits;
|
|
}
|
|
|
|
export function DateTimePicker({
|
|
id,
|
|
value,
|
|
onChange,
|
|
placeholder = "Select date and time",
|
|
}: {
|
|
id?: string;
|
|
value?: Date;
|
|
onChange: (value: Date | undefined) => void;
|
|
placeholder?: string;
|
|
}) {
|
|
const generatedId = useId();
|
|
const timeInputId = `${id ?? generatedId}-time`;
|
|
const [open, setOpen] = useState(false);
|
|
const time = value ? format(value, "HH:mm") : "23:59";
|
|
const [timeInput, setTimeInput] = useState(time);
|
|
|
|
const handleDateChange = (date: Date | undefined) => {
|
|
if (!date) {
|
|
setTimeInput("23:59");
|
|
onChange(undefined);
|
|
return;
|
|
}
|
|
|
|
const next = new Date(date);
|
|
if (value) {
|
|
next.setHours(value.getHours(), value.getMinutes(), 0, 0);
|
|
} else {
|
|
next.setHours(23, 59, 0, 0);
|
|
}
|
|
setTimeInput(format(next, "HH:mm"));
|
|
onChange(next);
|
|
};
|
|
|
|
const handleTimeChange = (nextTime: string) => {
|
|
const formattedTime = formatTimeInput(nextTime);
|
|
setTimeInput(formattedTime);
|
|
if (!value || !TIME_PATTERN.test(formattedTime)) return;
|
|
|
|
const [hours, minutes] = formattedTime.split(":").map(Number);
|
|
const next = new Date(value);
|
|
next.setHours(hours, minutes, 0, 0);
|
|
onChange(next);
|
|
};
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
id={id}
|
|
type="button"
|
|
variant="outline"
|
|
data-empty={!value}
|
|
className="w-full justify-start text-left font-normal data-[empty=true]:text-muted-foreground"
|
|
>
|
|
<CalendarDays data-icon="inline-start" />
|
|
{value ? format(value, "PPP 'at' HH:mm") : placeholder}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-auto gap-0 p-0" align="start">
|
|
<Calendar
|
|
mode="single"
|
|
selected={value}
|
|
defaultMonth={value}
|
|
onSelect={handleDateChange}
|
|
/>
|
|
<Separator />
|
|
<FieldGroup className="gap-3 p-3">
|
|
<Field data-disabled={!value || undefined}>
|
|
<FieldLabel htmlFor={timeInputId}>
|
|
Time (24-hour HH:mm)
|
|
</FieldLabel>
|
|
<InputGroup data-disabled={!value || undefined}>
|
|
<InputGroupInput
|
|
id={timeInputId}
|
|
type="text"
|
|
inputMode="numeric"
|
|
autoComplete="off"
|
|
maxLength={5}
|
|
pattern="(?:[01][0-9]|2[0-3]):[0-5][0-9]"
|
|
placeholder="23:59"
|
|
value={timeInput}
|
|
disabled={!value}
|
|
onChange={(event) => handleTimeChange(event.target.value)}
|
|
onBlur={() => setTimeInput(time)}
|
|
/>
|
|
<InputGroupAddon align="inline-end">
|
|
<Clock2Icon />
|
|
</InputGroupAddon>
|
|
</InputGroup>
|
|
</Field>
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
disabled={!value}
|
|
onClick={() => {
|
|
setTimeInput("23:59");
|
|
onChange(undefined);
|
|
}}
|
|
>
|
|
<X data-icon="inline-start" />
|
|
Clear
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
disabled={!value}
|
|
onClick={() => setOpen(false)}
|
|
>
|
|
Done
|
|
</Button>
|
|
</div>
|
|
</FieldGroup>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|