date selector

This commit is contained in:
Lars Nolden
2026-09-11 15:00:21 +02:00
parent c33e8d5573
commit 5a4937afe4
3 changed files with 389 additions and 37 deletions
+19 -19
View File
@@ -2,7 +2,7 @@ import { useState } from "react";
import { Sparkles, ShieldCheck, Check, X, ArrowRight } from "lucide-react";
import type { Dataset, Enrichment, Preview, State } from "./api";
import { categoryPath, request } from "./api";
import { Empty, ErrorMessage, Field, Modal } from "./ui";
import { DateField, Empty, ErrorMessage, Field, Modal } from "./ui";
export function Classification({
state,
acceptState,
@@ -92,6 +92,12 @@ export function Classification({
className="form-body"
onSubmit={async (e) => {
e.preventDefault();
// The month-name picker is not a native date control, so the
// range is validated here instead of by form constraints.
if (!from || !to) {
setError("Choose a start and an end date for the range.");
return;
}
setBusy(true);
setError("");
try {
@@ -132,24 +138,18 @@ export function Classification({
}}
>
<div className="two-columns">
<Field label="From">
<input
required
type="date"
max={to || undefined}
value={from}
onChange={(e) => setFrom(e.target.value)}
/>
</Field>
<Field label="To">
<input
required
type="date"
min={from || undefined}
value={to}
onChange={(e) => setTo(e.target.value)}
/>
</Field>
<DateField
label="From"
value={from}
max={to || undefined}
onChange={setFrom}
/>
<DateField
label="To"
value={to}
min={from || undefined}
onChange={setTo}
/>
</div>
<Field
label="Model"
+111
View File
@@ -2095,6 +2095,117 @@ footer span:first-child {
color: var(--muted);
font-size: 12px;
}
.date-select {
position: relative;
}
.date-trigger {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 39px;
padding: 10px 11px;
border: 1px solid #dbe2ea;
border-radius: 5px;
background: #fff;
color: #33445a;
font-weight: 400;
cursor: pointer;
text-align: left;
}
.date-trigger:hover {
border-color: #c2d3ce;
}
.date-trigger svg {
color: #8ba59d;
}
.filters .date-trigger {
height: 35px;
min-height: 35px;
font-size: 11px;
padding: 7px 9px;
background: #fcfdfe;
}
.date-placeholder {
color: #93a0ad;
}
.date-popover {
position: absolute;
z-index: 30;
top: calc(100% + 4px);
left: 0;
width: 252px;
padding: 10px;
background: #fff;
border: 1px solid #dbe2ea;
border-radius: 8px;
box-shadow: 0 10px 30px #10223418;
}
.date-nav {
display: flex;
align-items: center;
gap: 5px;
margin-bottom: 8px;
}
.date-nav select {
flex: 1;
min-width: 0;
height: 30px;
min-height: 30px;
padding: 4px 6px;
border: 1px solid #e3e9ef;
border-radius: 5px;
background: #fff;
color: #33445a;
font-size: 12px;
font-weight: 400;
}
.date-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.date-weekday {
text-align: center;
font-size: 10px;
font-weight: 600;
color: #93a0ad;
padding-bottom: 3px;
}
.date-day {
height: 29px;
border: 0;
border-radius: 5px;
background: none;
color: #35485c;
font-size: 12px;
cursor: pointer;
}
.date-day:hover:not(:disabled) {
background: #f0f7f4;
}
.date-day:disabled {
color: #c4ccd4;
cursor: not-allowed;
}
.date-day.today {
box-shadow: inset 0 0 0 1px #b9ded0;
}
.date-day.selected {
background: var(--emerald);
color: #fff;
font-weight: 600;
}
.date-actions {
display: flex;
gap: 6px;
margin-top: 8px;
}
.date-actions .button {
flex: 1;
min-height: 30px;
font-size: 11px;
}
.category-tree {
padding: 0 17px 23px;
+259 -18
View File
@@ -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}