date selector
This commit is contained in:
+259
-18
@@ -1,6 +1,13 @@
|
||||
import { useEffect, useId, useRef } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { X, Inbox, AlertCircle } from "lucide-react";
|
||||
import {
|
||||
X,
|
||||
Inbox,
|
||||
AlertCircle,
|
||||
CalendarDays,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import type { Dataset, Filter } from "./api";
|
||||
import { categoryPath, emptyFilter } from "./api";
|
||||
export function Modal({
|
||||
@@ -62,6 +69,242 @@ export function Field({
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// Dates are handled as calendar days, never as instants: every helper works on
|
||||
// the ISO string's integer parts so a browser time zone can never shift a
|
||||
// booking date. "Sept" follows the four-letter form used in the journal UI.
|
||||
const MONTHS = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sept",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
];
|
||||
const WEEKDAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
|
||||
interface Month {
|
||||
year: number;
|
||||
month: number;
|
||||
}
|
||||
function dayParts(iso: string): (Month & { day: number }) | null {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return null;
|
||||
const [year, month, day] = iso.split("-").map(Number);
|
||||
if (month < 1 || month > 12 || day < 1 || day > daysInMonth({ year, month }))
|
||||
return null;
|
||||
return { year, month, day };
|
||||
}
|
||||
function isoDay({ year, month }: Month, day: number) {
|
||||
return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
}
|
||||
function daysInMonth({ year, month }: Month) {
|
||||
return new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
}
|
||||
// Monday-first column index of the first day of the month.
|
||||
function firstWeekday({ year, month }: Month) {
|
||||
return (new Date(Date.UTC(year, month - 1, 1)).getUTCDay() + 6) % 7;
|
||||
}
|
||||
function shiftMonth({ year, month }: Month, by: number): Month {
|
||||
const zero = year * 12 + (month - 1) + by;
|
||||
return { year: Math.floor(zero / 12), month: (zero % 12) + 1 };
|
||||
}
|
||||
// formatDay renders an ISO calendar day as "09 Sept 2026". Unparseable input is
|
||||
// returned unchanged so an unexpected stored value stays visible instead of
|
||||
// being replaced by an invented date.
|
||||
function formatDay(iso: string): string {
|
||||
const parts = dayParts(iso);
|
||||
if (!parts) return iso;
|
||||
return `${String(parts.day).padStart(2, "0")} ${MONTHS[parts.month - 1]} ${parts.year}`;
|
||||
}
|
||||
|
||||
// DateField picks a calendar day by month name instead of the browser's
|
||||
// numeric date control, which renders day and month ambiguously across
|
||||
// locales. The value stays an ISO day, so filters and API payloads are
|
||||
// unchanged. min/max are inclusive ISO days.
|
||||
export function DateField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
hint,
|
||||
clearable = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (day: string) => void;
|
||||
min?: string;
|
||||
max?: string;
|
||||
hint?: string;
|
||||
clearable?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const clock = new Date();
|
||||
const now = isoDay(
|
||||
{ year: clock.getFullYear(), month: clock.getMonth() + 1 },
|
||||
clock.getDate(),
|
||||
);
|
||||
const selected = dayParts(value);
|
||||
const [view, setView] = useState<Month>(
|
||||
() => selected ?? dayParts(now) ?? { year: clock.getFullYear(), month: 1 },
|
||||
);
|
||||
const blocked = (day: string) => (!!min && day < min) || (!!max && day > max);
|
||||
const choose = (day: string) => {
|
||||
onChange(day);
|
||||
setOpen(false);
|
||||
};
|
||||
const years = [];
|
||||
const firstYear = (min ? dayParts(min)?.year : undefined) ?? view.year - 12;
|
||||
const lastYear = (max ? dayParts(max)?.year : undefined) ?? view.year + 2;
|
||||
for (
|
||||
let year = Math.min(firstYear, view.year);
|
||||
year <= Math.max(lastYear, view.year);
|
||||
year++
|
||||
)
|
||||
years.push(year);
|
||||
const days = [];
|
||||
for (let day = 1; day <= daysInMonth(view); day++) days.push(day);
|
||||
// Dismissal must not depend on focus: a click does not focus a button on
|
||||
// every platform, and Escape then never reaches the popover. preventDefault
|
||||
// keeps an enclosing dialog open when Escape only closes this picker.
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const outside = (e: PointerEvent) => {
|
||||
if (!box.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const escape = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", outside);
|
||||
document.addEventListener("keydown", escape);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", outside);
|
||||
document.removeEventListener("keydown", escape);
|
||||
};
|
||||
}, [open]);
|
||||
return (
|
||||
<Field label={label} hint={hint}>
|
||||
<div className="date-select" ref={box}>
|
||||
<button
|
||||
type="button"
|
||||
className="date-trigger"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => {
|
||||
if (!open) setView(selected ?? dayParts(now) ?? view);
|
||||
setOpen(!open);
|
||||
}}
|
||||
>
|
||||
<CalendarDays size={14} />
|
||||
<span className={value ? "" : "date-placeholder"}>
|
||||
{value ? formatDay(value) : "Any date"}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="date-popover" role="dialog" aria-label={label}>
|
||||
<div className="date-nav">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Previous month"
|
||||
onClick={() => setView(shiftMonth(view, -1))}
|
||||
>
|
||||
<ChevronLeft size={15} />
|
||||
</button>
|
||||
<select
|
||||
aria-label="Month"
|
||||
value={view.month}
|
||||
onChange={(e) =>
|
||||
setView({ ...view, month: Number(e.target.value) })
|
||||
}
|
||||
>
|
||||
{MONTHS.map((name, index) => (
|
||||
<option key={name} value={index + 1}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="Year"
|
||||
value={view.year}
|
||||
onChange={(e) =>
|
||||
setView({ ...view, year: Number(e.target.value) })
|
||||
}
|
||||
>
|
||||
{years.map((year) => (
|
||||
<option key={year} value={year}>
|
||||
{year}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Next month"
|
||||
onClick={() => setView(shiftMonth(view, 1))}
|
||||
>
|
||||
<ChevronRight size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="date-grid">
|
||||
{WEEKDAYS.map((weekday) => (
|
||||
<span key={weekday} className="date-weekday">
|
||||
{weekday}
|
||||
</span>
|
||||
))}
|
||||
{Array.from({ length: firstWeekday(view) }, (_, i) => (
|
||||
<span key={`pad${i}`} />
|
||||
))}
|
||||
{days.map((day) => {
|
||||
const iso = isoDay(view, day);
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
className={`date-day${iso === value ? " selected" : ""}${iso === now ? " today" : ""}`}
|
||||
disabled={blocked(iso)}
|
||||
aria-current={iso === value ? "date" : undefined}
|
||||
aria-label={formatDay(iso)}
|
||||
onClick={() => choose(iso)}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="date-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="button subtle"
|
||||
disabled={blocked(now)}
|
||||
onClick={() => choose(now)}
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
{clearable && value && (
|
||||
<button
|
||||
type="button"
|
||||
className="button subtle"
|
||||
onClick={() => choose("")}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
export function ErrorMessage({ error }: { error: string }) {
|
||||
return error ? (
|
||||
<div className="alert error" role="alert">
|
||||
@@ -162,22 +405,20 @@ export function Filters({
|
||||
).sort();
|
||||
return (
|
||||
<div className="filters">
|
||||
<Field label="From">
|
||||
<input
|
||||
type="date"
|
||||
value={value.from}
|
||||
max={value.to || undefined}
|
||||
onChange={(e) => update("from", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="To">
|
||||
<input
|
||||
type="date"
|
||||
value={value.to}
|
||||
min={value.from || undefined}
|
||||
onChange={(e) => update("to", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<DateField
|
||||
label="From"
|
||||
clearable
|
||||
value={value.from}
|
||||
max={value.to || undefined}
|
||||
onChange={(day) => update("from", day)}
|
||||
/>
|
||||
<DateField
|
||||
label="To"
|
||||
clearable
|
||||
value={value.to}
|
||||
min={value.from || undefined}
|
||||
onChange={(day) => update("to", day)}
|
||||
/>
|
||||
<Field label="Currency">
|
||||
<select
|
||||
value={value.currency}
|
||||
|
||||
Reference in New Issue
Block a user