src/features/reports/month-field.tsx
Linked repository file from this build.
"use client";
import { useEffect, useRef, useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { FieldCalendarIcon } from "@/components/ui/input";
import {
isCompleteMonthInput,
monthFieldYear,
parseSampleMonth,
SAMPLE_MONTH_LABELS,
} from "./sample-month";
const DEBOUNCE_MS = 400;
type MonthFieldProps = {
id: string;
label: string;
value: string;
onChange(next: string): void;
};
export function MonthField({ id, label, value, onChange }: MonthFieldProps) {
const [draft, setDraft] = useState(value);
const [open, setOpen] = useState(false);
const [pickerYear, setPickerYear] = useState(() => monthFieldYear(value));
const root = useRef<HTMLDivElement>(null);
const committed = useRef(value);
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
const [previousValue, setPreviousValue] = useState(value);
if (previousValue !== value) {
setPreviousValue(value);
setDraft(value);
setPickerYear((current) => monthFieldYear(value, current));
}
useEffect(() => {
committed.current = value;
}, [value]);
useEffect(() => {
if (draft === value) return;
const trimmed = draft.trim();
if (trimmed && !isCompleteMonthInput(trimmed)) return;
const parsed = trimmed === "" ? "" : parseSampleMonth(trimmed);
if (parsed === null || parsed === committed.current) return;
const timer = window.setTimeout(() => {
committed.current = parsed;
onChangeRef.current(parsed);
if (parsed) setDraft(parsed);
}, DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [draft, value]);
useEffect(() => {
if (!open) return;
const outside = (event: PointerEvent) => {
if (!root.current?.contains(event.target as Node)) setOpen(false);
};
const escape = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("pointerdown", outside);
document.addEventListener("keydown", escape);
return () => {
document.removeEventListener("pointerdown", outside);
document.removeEventListener("keydown", escape);
};
}, [open]);
function commitNow(raw: string) {
const trimmed = raw.trim();
if (!trimmed) {
committed.current = "";
setDraft("");
onChangeRef.current("");
return;
}
const parsed = parseSampleMonth(trimmed);
if (!parsed) {
setDraft(committed.current);
return;
}
committed.current = parsed;
setDraft(parsed);
setPickerYear(Number(parsed.slice(0, 4)));
onChangeRef.current(parsed);
}
function chooseMonth(month: number) {
const next = `${pickerYear}-${String(month).padStart(2, "0")}`;
committed.current = next;
setDraft(next);
onChangeRef.current(next);
setOpen(false);
}
const selected = parseSampleMonth(draft) ?? value;
return (
<div ref={root} className="grid gap-1 text-xs font-medium">
<label htmlFor={id}>{label}</label>
<div className="relative flex h-8 min-w-0 rounded-md border bg-background focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/50">
<input
id={id}
type="text"
inputMode="text"
autoComplete="off"
spellCheck={false}
placeholder="YYYY-MM"
className="min-w-0 flex-1 bg-transparent px-2 text-sm tabular-nums outline-none"
value={draft}
onChange={(event) => setDraft(event.target.value)}
onBlur={(event) => {
const staying = root.current?.contains(event.relatedTarget as Node);
const trimmed = event.currentTarget.value.trim();
if (!trimmed) {
if (!staying) commitNow("");
return;
}
const parsed = parseSampleMonth(trimmed);
if (parsed) {
committed.current = parsed;
setDraft(parsed);
setPickerYear(Number(parsed.slice(0, 4)));
onChangeRef.current(parsed);
return;
}
if (!staying) setDraft(committed.current);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commitNow(event.currentTarget.value);
}
}}
/>
<button
type="button"
className="px-2 text-foreground"
aria-label={`Choose ${label.toLowerCase()}`}
aria-expanded={open}
aria-haspopup="dialog"
onClick={() => {
setPickerYear(monthFieldYear(draft || value, pickerYear));
setOpen((current) => !current);
}}
>
<FieldCalendarIcon />
</button>
{open && (
<div
role="dialog"
aria-label={`Choose ${label.toLowerCase()}`}
className="absolute left-0 top-full z-50 mt-1 w-64 rounded-md border bg-popover p-3 text-popover-foreground shadow-md"
>
<div className="mb-2 flex items-center justify-between gap-2">
<button
type="button"
className="rounded p-1 hover:bg-muted"
aria-label="Previous year"
onClick={() => setPickerYear((year) => Math.max(2000, year - 1))}
>
<ChevronLeft className="size-4" aria-hidden />
</button>
<span className="text-sm font-medium tabular-nums">{pickerYear}</span>
<button
type="button"
className="rounded p-1 hover:bg-muted"
aria-label="Next year"
onClick={() => setPickerYear((year) => Math.min(2100, year + 1))}
>
<ChevronRight className="size-4" aria-hidden />
</button>
</div>
<div className="grid grid-cols-3 gap-1">
{SAMPLE_MONTH_LABELS.map((name, index) => {
const month = index + 1;
const key = `${pickerYear}-${String(month).padStart(2, "0")}`;
const active = selected === key;
return (
<button
key={key}
type="button"
aria-label={`${name} ${pickerYear}`}
aria-pressed={active}
className={`rounded px-1 py-1.5 text-xs ${
active ? "bg-primary text-primary-foreground" : "hover:bg-muted"
}`}
onClick={() => chooseMonth(month)}
>
{name.slice(0, 3)}
</button>
);
})}
</div>
</div>
)}
</div>
</div>
);
}