Files
TX/artifacts/tx-os/src/components/date-time-picker.tsx
T
Replit Agent 457eff44c4 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.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 8b01db58-6cde-464b-bd7d-46bd031e467f
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/OsTuUKE
Replit-Helium-Checkpoint-Created: true
2026-07-06 11:40:36 +00:00

225 lines
6.1 KiB
TypeScript
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 * 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>
);
}