Files
2026-06-04 19:30:50 +05:30

304 lines
10 KiB
TypeScript

"use client";
/**
* DatePicker — a calendar popover for picking a single date, no external
* deps. Trigger button shows the formatted date (or a placeholder);
* clicking opens a month grid with prev/next navigation. Value in/out is
* an ISO "YYYY-MM-DD" string ("" = no date). `max`/`min` (also ISO) bound
* the selectable range — out-of-range days render disabled.
*
* The panel is portaled to <body> with fixed positioning and collision
* handling: it opens below the trigger, flips above when there isn't room,
* and clamps to the viewport horizontally. Position is measured in event
* handlers (open click + scroll/resize) — never synchronously inside an
* effect — so opening can't trigger React's cascading-render warning.
*/
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { CalendarDays, ChevronLeft, ChevronRight, X } from "lucide-react";
import { cn } from "@/lib/utils";
const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
const PANEL_W = 288; // matches w-72
const PANEL_H = 332; // approximate; used only for the flip decision
const GAP = 6;
const MARGIN = 8;
function pad2(n: number) {
return String(n).padStart(2, "0");
}
function toISO(y: number, m0: number, d: number) {
return `${y}-${pad2(m0 + 1)}-${pad2(d)}`;
}
/** Parse "YYYY-MM-DD" → {y, m0 (0-based), d}, or null. */
function parseISO(s: string): { y: number; m0: number; d: number } | null {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s);
if (!m) return null;
return { y: Number(m[1]), m0: Number(m[2]) - 1, d: Number(m[3]) };
}
function prettyDate(s: string): string {
const p = parseISO(s);
if (!p) return "";
return new Date(p.y, p.m0, p.d).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
});
}
export function DatePicker({
value,
onChange,
max,
min,
id,
ariaLabel,
placeholder = "Select a date",
}: {
value: string;
onChange: (value: string) => void;
max?: string;
min?: string;
id?: string;
ariaLabel?: string;
placeholder?: string;
}) {
const [open, setOpen] = useState(false);
// Either `top` (opens below, anchored to the trigger's bottom) or
// `bottom` (opens above, anchored just over the trigger's top) is set —
// anchoring the relevant edge keeps the panel flush to the trigger
// regardless of its actual height. `maxHeight` clamps it to the
// available space (scrolls if it ever can't fit).
const [pos, setPos] = useState<{
left: number;
top?: number;
bottom?: number;
maxHeight: number;
} | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const today = new Date();
const todayISO = toISO(today.getFullYear(), today.getMonth(), today.getDate());
const initial = parseISO(value) ?? {
y: today.getFullYear(),
m0: today.getMonth(),
d: today.getDate(),
};
const [view, setView] = useState<{ y: number; m0: number }>({
y: initial.y,
m0: initial.m0,
});
// Measure the trigger and place the panel flush to it — below by
// default, flipped above only when below lacks room and above has more.
// Called from event handlers only (open click + scroll/resize).
function recompute() {
const btn = triggerRef.current;
if (!btn) return;
const rect = btn.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let left = rect.left;
if (left + PANEL_W > vw - MARGIN) left = vw - MARGIN - PANEL_W;
if (left < MARGIN) left = MARGIN;
const spaceBelow = vh - rect.bottom - GAP - MARGIN;
const spaceAbove = rect.top - GAP - MARGIN;
if (spaceBelow >= PANEL_H || spaceBelow >= spaceAbove) {
// Below: top edge anchored just under the trigger.
setPos({ left, top: rect.bottom + GAP, maxHeight: Math.max(180, spaceBelow) });
} else {
// Above: bottom edge anchored just over the trigger (grows upward),
// so it stays adjacent no matter how tall the calendar is.
setPos({ left, bottom: vh - rect.top + GAP, maxHeight: Math.max(180, spaceAbove) });
}
}
// Outside-click + Escape + reposition on scroll/resize, while open.
useEffect(() => {
if (!open) return;
function onPointer(e: PointerEvent) {
if (triggerRef.current?.contains(e.target as Node)) return;
if (panelRef.current?.contains(e.target as Node)) return;
setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("pointerdown", onPointer);
document.addEventListener("keydown", onKey);
window.addEventListener("resize", recompute);
window.addEventListener("scroll", recompute, true);
return () => {
document.removeEventListener("pointerdown", onPointer);
document.removeEventListener("keydown", onKey);
window.removeEventListener("resize", recompute);
window.removeEventListener("scroll", recompute, true);
};
}, [open]);
function openMenu() {
const p = parseISO(value);
if (p) setView({ y: p.y, m0: p.m0 });
recompute();
setOpen(true);
}
function toggle() {
if (open) setOpen(false);
else openMenu();
}
function shiftMonth(delta: number) {
setView((v) => {
const d = new Date(v.y, v.m0 + delta, 1);
return { y: d.getFullYear(), m0: d.getMonth() };
});
}
function pick(day: number) {
onChange(toISO(view.y, view.m0, day));
setOpen(false);
}
const monthLabel = new Date(view.y, view.m0, 1).toLocaleDateString(undefined, {
month: "long",
year: "numeric",
});
const startWeekday = new Date(view.y, view.m0, 1).getDay();
const daysInMonth = new Date(view.y, view.m0 + 1, 0).getDate();
const cells: (number | null)[] = [
...Array(startWeekday).fill(null),
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
];
function disabled(day: number) {
const iso = toISO(view.y, view.m0, day);
if (max && iso > max) return true;
if (min && iso < min) return true;
return false;
}
return (
<>
<button
ref={triggerRef}
type="button"
id={id}
aria-label={ariaLabel}
aria-haspopup="dialog"
aria-expanded={open}
onClick={toggle}
className={cn(
"w-full h-10 flex items-center gap-2 rounded-md border border-border bg-background px-3 text-sm text-left",
"outline-none focus-visible:ring-2 focus-visible:ring-primary/40",
)}
>
<CalendarDays className="size-4 shrink-0 text-muted-foreground" />
<span className={cn("truncate flex-1", !value && "text-muted-foreground")}>
{value ? prettyDate(value) : placeholder}
</span>
{value ? (
<span
role="button"
aria-label="Clear date"
onClick={(e) => {
e.stopPropagation();
onChange("");
setOpen(false);
}}
className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground hover:bg-muted"
>
<X className="size-3.5" />
</span>
) : null}
</button>
{open && pos && typeof document !== "undefined"
? createPortal(
<div
ref={panelRef}
style={{
position: "fixed",
top: pos.top,
bottom: pos.bottom,
left: pos.left,
width: PANEL_W,
maxHeight: pos.maxHeight,
overflowY: "auto",
zIndex: 70,
}}
className="rounded-md border border-border bg-card p-3 shadow-lg animate-in fade-in-0 zoom-in-95"
>
<div className="flex items-center justify-between mb-2">
<button
type="button"
aria-label="Previous month"
onClick={() => shiftMonth(-1)}
className="size-8 inline-flex items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
>
<ChevronLeft className="size-4" />
</button>
<span className="text-sm font-medium">{monthLabel}</span>
<button
type="button"
aria-label="Next month"
onClick={() => shiftMonth(1)}
className="size-8 inline-flex items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
>
<ChevronRight className="size-4" />
</button>
</div>
<div className="grid grid-cols-7 gap-0.5 mb-1">
{WEEKDAYS.map((w) => (
<div
key={w}
className="h-7 flex items-center justify-center text-xs text-muted-foreground"
>
{w}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-0.5">
{cells.map((day, i) => {
if (day === null) return <div key={`b${i}`} className="h-9" />;
const iso = toISO(view.y, view.m0, day);
const isSelected = iso === value;
const isToday = iso === todayISO;
const isDisabled = disabled(day);
return (
<button
key={iso}
type="button"
disabled={isDisabled}
onClick={() => pick(day)}
className={cn(
"h-9 rounded-md text-sm inline-flex items-center justify-center transition-colors",
isSelected
? "bg-primary text-primary-foreground font-medium"
: "hover:bg-muted",
!isSelected && isToday ? "ring-1 ring-primary/40" : "",
isDisabled
? "opacity-30 cursor-not-allowed hover:bg-transparent"
: "",
)}
>
{day}
</button>
);
})}
</div>
</div>,
document.body,
)
: null}
</>
);
}