Add professional Arabic date and time picker to booking form
Introduce a custom DateTimePicker component for improved date and time selection in the protocol request form, replacing native inputs with an Arabic-localized, RTL-compatible interface.
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
import * as React from "react";
|
||||
import { CalendarClock } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
const AR_LATN = "ar-u-nu-latn";
|
||||
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
// Value contract: local wall-clock string "YYYY-MM-DDTHH:mm" (same shape a
|
||||
// native datetime-local input produces) or "" when nothing is selected.
|
||||
function parseValue(value: string): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
function toValue(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(
|
||||
d.getHours(),
|
||||
)}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function to24(hour12: number, period: "am" | "pm"): number {
|
||||
if (period === "am") return hour12 === 12 ? 0 : hour12;
|
||||
return hour12 === 12 ? 12 : hour12 + 12;
|
||||
}
|
||||
|
||||
const HOURS_12 = Array.from({ length: 12 }, (_, i) => i + 1);
|
||||
const MINUTES = Array.from({ length: 12 }, (_, i) => i * 5);
|
||||
|
||||
function TimeColumn({
|
||||
values,
|
||||
active,
|
||||
onSelect,
|
||||
format,
|
||||
ariaLabel,
|
||||
}: {
|
||||
values: number[];
|
||||
active: number;
|
||||
onSelect: (v: number) => void;
|
||||
format: (v: number) => string;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
const activeRef = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
activeRef.current?.scrollIntoView({ block: "center" });
|
||||
}, [active]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={ariaLabel}
|
||||
className="h-40 w-14 overflow-y-auto rounded-md border p-1"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{values.map((v) => {
|
||||
const isActive = v === active;
|
||||
return (
|
||||
<Button
|
||||
key={v}
|
||||
ref={isActive ? activeRef : undefined}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={isActive ? "default" : "ghost"}
|
||||
className="h-8 w-full shrink-0"
|
||||
onClick={() => onSelect(v)}
|
||||
>
|
||||
{format(v)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DateTimePicker({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "اختر التاريخ والوقت",
|
||||
disabled,
|
||||
minDate,
|
||||
}: {
|
||||
id?: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
minDate?: Date;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const selected = parseValue(value);
|
||||
|
||||
const hour12 = selected ? selected.getHours() % 12 || 12 : 9;
|
||||
const minute = selected ? selected.getMinutes() : 0;
|
||||
const period: "am" | "pm" = selected
|
||||
? selected.getHours() < 12
|
||||
? "am"
|
||||
: "pm"
|
||||
: "am";
|
||||
|
||||
const baseDate = (): Date => {
|
||||
if (selected) return new Date(selected);
|
||||
const d = minDate ? new Date(minDate) : new Date();
|
||||
d.setHours(9, 0, 0, 0);
|
||||
return d;
|
||||
};
|
||||
|
||||
const commit = (next: Date) => onChange(toValue(next));
|
||||
|
||||
const handleDay = (day: Date | undefined) => {
|
||||
if (!day) return;
|
||||
const base = baseDate();
|
||||
const next = new Date(day);
|
||||
next.setHours(base.getHours(), base.getMinutes(), 0, 0);
|
||||
commit(next);
|
||||
};
|
||||
|
||||
const handleHour = (h: number) => {
|
||||
const next = baseDate();
|
||||
next.setHours(to24(h, period), next.getMinutes(), 0, 0);
|
||||
commit(next);
|
||||
};
|
||||
|
||||
const handleMinute = (m: number) => {
|
||||
const next = baseDate();
|
||||
next.setMinutes(m, 0, 0);
|
||||
commit(next);
|
||||
};
|
||||
|
||||
const handlePeriod = (p: "am" | "pm") => {
|
||||
const next = baseDate();
|
||||
next.setHours(to24(hour12, p), next.getMinutes(), 0, 0);
|
||||
commit(next);
|
||||
};
|
||||
|
||||
const label = selected
|
||||
? new Intl.DateTimeFormat(AR_LATN, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(selected)
|
||||
: placeholder;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full justify-between font-normal",
|
||||
!selected && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{label}</span>
|
||||
<CalendarClock className="size-4 shrink-0 opacity-60" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={handleDay}
|
||||
defaultMonth={selected ?? minDate ?? new Date()}
|
||||
disabled={minDate ? { before: minDate } : undefined}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="border-t p-3">
|
||||
<div className="mb-2 text-center text-xs font-medium text-muted-foreground">
|
||||
الوقت
|
||||
</div>
|
||||
<div className="flex items-stretch justify-center gap-2">
|
||||
<TimeColumn
|
||||
values={HOURS_12}
|
||||
active={hour12}
|
||||
onSelect={handleHour}
|
||||
format={pad}
|
||||
ariaLabel="الساعة"
|
||||
/>
|
||||
<div className="flex items-center text-muted-foreground">:</div>
|
||||
<TimeColumn
|
||||
values={MINUTES}
|
||||
active={minute}
|
||||
onSelect={handleMinute}
|
||||
format={pad}
|
||||
ariaLabel="الدقيقة"
|
||||
/>
|
||||
<div className="flex flex-col justify-center gap-1">
|
||||
{(["am", "pm"] as const).map((p) => (
|
||||
<Button
|
||||
key={p}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={period === p ? "default" : "outline"}
|
||||
className="h-8 w-12"
|
||||
onClick={() => handlePeriod(p)}
|
||||
>
|
||||
{p === "am" ? "ص" : "م"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end border-t p-2">
|
||||
<Button type="button" size="sm" onClick={() => setOpen(false)}>
|
||||
تم
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { DateTimePicker } from "@/components/date-time-picker";
|
||||
|
||||
const API = `${import.meta.env.BASE_URL}api`;
|
||||
|
||||
@@ -303,24 +304,23 @@ export default function ProtocolRequestPage() {
|
||||
<Label htmlFor="startsAt">
|
||||
من <span className="text-rose-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
<DateTimePicker
|
||||
id="startsAt"
|
||||
type="datetime-local"
|
||||
value={form.startsAt}
|
||||
onChange={(e) => set("startsAt", e.target.value)}
|
||||
required
|
||||
onChange={(v) => set("startsAt", v)}
|
||||
placeholder="اختر تاريخ ووقت البداية"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endsAt">
|
||||
إلى <span className="text-rose-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
<DateTimePicker
|
||||
id="endsAt"
|
||||
type="datetime-local"
|
||||
value={form.endsAt}
|
||||
onChange={(e) => set("endsAt", e.target.value)}
|
||||
required
|
||||
onChange={(v) => set("endsAt", v)}
|
||||
placeholder="اختر تاريخ ووقت النهاية"
|
||||
minDate={form.startsAt ? new Date(form.startsAt) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user