Custom date and time pickers that follow the configured format

Browsers render <input type="date"> and datetime-local in their own locale and
ignore the page's, so #1 left a German user on an English browser reading
22.11.2025 everywhere but still entering dates through an mm/dd/yyyy widget.
#3 makes the case that people use the picker rather than typing, which is
where the AM/PM mistakes happen.

New DateField and DateTimeField (web/src/ui/datefield.tsx) replace all nine
native controls — event editor (all-day and timed start/end, recurrence
until), out-of-office, contact birthday, advanced search. They take and emit
the same ISO strings the native inputs did, so call sites barely changed.

Each is a text box in the configured order plus a popover: a month grid
(week start from settings, locale weekday and month names, today and the
selection marked) and, for date-times, a list of times in the configured
clock. Keyboard: arrows move by day, PageUp/PageDown by month, Home/End
across the week, Enter picks, Escape closes, ArrowDown opens; the focused day
holds DOM focus so screen readers follow, and the dialog has an accessible
name (Popover gained an ariaLabel prop).

Text entry is lenient — the configured order with any separator, unseparated
digits (221125), day and month alone, non-Latin digits, and bare ISO always;
times take 18:23, 1823, 6:23pm, 930. What will not parse reverts on blur
rather than clearing the field, and impossible dates like 31 February are
rejected instead of rolling into March.

Editable boxes stay Gregorian and Latin-digit even where display does not
(fa-IR, th-TH, ar-EG): the locale's field order and separator are kept, but a
Buddhist-era year in a text box cannot round-trip against a Gregorian grid.
Noted in the README.

The out-of-office format echo added in #2 is gone — the fields now show the
right format themselves.

Closes #3
This commit is contained in:
2026-08-23 13:12:17 -07:00
parent d0828d67ed
commit 36c19d639b
10 changed files with 703 additions and 29 deletions
+94
View File
@@ -5,8 +5,15 @@ import {
formatDateTime,
formatDayMonth,
formatFullDateTime,
formatDateInput,
formatHourLabel,
formatTimeInput,
dateInputPattern,
dateInputPlaceholder,
localeOptions,
parseDateInput,
parseTimeInput,
timeInputPlaceholder,
normalizeLocale,
resolvedLocale,
setDateTimePrefs,
@@ -196,3 +203,90 @@ describe("locale options", () => {
expect(tags).toContain("de-DE-u-ca-buddhist");
});
});
describe("editable date fields", () => {
it("lays out the input in the configured order", () => {
setDateTimePrefs({ locale: "en-US", dateFormat: "dmy-dot" });
expect(dateInputPattern()).toEqual({ order: ["d", "m", "y"], separator: "." });
expect(formatDateInput(SAMPLE)).toBe("22.11.2025");
expect(dateInputPlaceholder()).toBe("dd.mm.yyyy");
setDateTimePrefs({ dateFormat: "ymd-dash" });
expect(formatDateInput(SAMPLE)).toBe("2025-11-22");
expect(dateInputPlaceholder()).toBe("yyyy-mm-dd");
});
it("takes the order from the locale when the format is automatic", () => {
setDateTimePrefs({ locale: "de-DE", dateFormat: "auto" });
expect(dateInputPattern().order).toEqual(["d", "m", "y"]);
expect(formatDateInput(SAMPLE)).toBe("22.11.2025");
setDateTimePrefs({ locale: "en-US", dateFormat: "auto" });
expect(dateInputPattern().order).toEqual(["m", "d", "y"]);
expect(formatDateInput(SAMPLE)).toBe("11/22/2025");
});
it("stays Gregorian and Latin in the box even where display is not", () => {
// fa-IR displays a Persian-calendar date and Persian digits; an editable
// field must still round-trip against the Gregorian grid.
setDateTimePrefs({ locale: "fa-IR", dateFormat: "auto" });
expect(formatDateInput(SAMPLE)).toMatch(/^[\d/.-]+$/);
expect(parseDateInput(formatDateInput(SAMPLE))?.getFullYear()).toBe(2025);
});
const iso = (d: Date | null) => (d ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}` : null);
it("parses the configured order, loosely", () => {
setDateTimePrefs({ locale: "de-DE", dateFormat: "dmy-dot" });
for (const text of ["22.11.2025", "22/11/2025", "22-11-2025", "22.11.25", "2.1.2025", "22112025", "221125"]) {
expect(iso(parseDateInput(text))).toBe(text.includes("2.1.") ? "2025-01-02" : "2025-11-22");
}
// Bare ISO is unambiguous and always accepted.
expect(iso(parseDateInput("2025-11-22"))).toBe("2025-11-22");
// Day and month only fills in the current year.
expect(parseDateInput("22.11")?.getFullYear()).toBe(new Date().getFullYear());
// Non-Latin digits are accepted too.
expect(iso(parseDateInput("٢٢.١١.٢٠٢٥"))).toBe("2025-11-22");
});
it("respects the order when the same text means two things", () => {
setDateTimePrefs({ dateFormat: "dmy-slash" });
expect(iso(parseDateInput("11/12/2025"))).toBe("2025-12-11");
setDateTimePrefs({ dateFormat: "mdy-slash" });
expect(iso(parseDateInput("11/12/2025"))).toBe("2025-11-12");
});
it("rejects what is not a date", () => {
setDateTimePrefs({ dateFormat: "dmy-dot" });
for (const bad of ["", " ", "hello", "31.02.2025", "45.11.2025", "22.13.2025", "1.2.3.4"]) {
expect(parseDateInput(bad)).toBeNull();
}
});
it("formats and parses times in both clocks", () => {
setDateTimePrefs({ locale: "en-US", timeFormat: "24" });
expect(formatTimeInput(SAMPLE)).toBe("18:23");
expect(timeInputPlaceholder()).toBe("hh:mm");
setDateTimePrefs({ timeFormat: "12" });
expect(formatTimeInput(SAMPLE)).toBe("6:23 PM");
expect(timeInputPlaceholder()).toBe("h:mm AM");
expect(parseTimeInput("18:23")).toEqual({ hours: 18, minutes: 23 });
expect(parseTimeInput("1823")).toEqual({ hours: 18, minutes: 23 });
expect(parseTimeInput("6:23 pm")).toEqual({ hours: 18, minutes: 23 });
expect(parseTimeInput("6:23PM")).toEqual({ hours: 18, minutes: 23 });
expect(parseTimeInput("6.23")).toEqual({ hours: 6, minutes: 23 });
expect(parseTimeInput("18")).toEqual({ hours: 18, minutes: 0 });
expect(parseTimeInput("9am")).toEqual({ hours: 9, minutes: 0 });
expect(parseTimeInput("12am")).toEqual({ hours: 0, minutes: 0 });
expect(parseTimeInput("12pm")).toEqual({ hours: 12, minutes: 0 });
expect(parseTimeInput("930")).toEqual({ hours: 9, minutes: 30 });
});
it("rejects what is not a time", () => {
for (const bad of ["", "noon", "25:00", "18:75", "13pm", "0pm"]) {
expect(parseTimeInput(bad)).toBeNull();
}
});
});
+192
View File
@@ -296,6 +296,198 @@ export function formatFullDateTime(d: Date): string {
return `${formatWeekday(d, "short")}, ${numeric(d, true)} ${formatClock(d)}`;
}
/* ------------------------------------------------------------------ */
/* Editable fields */
/* ------------------------------------------------------------------ */
/**
* Text fields are a different problem from display: whatever we print has to
* parse back unambiguously. So the pickers keep the locale's *order and
* separator* but always use the Gregorian calendar and Latin digits — a
* Buddhist-era year or Arabic-Indic digits in an editable box round-trip badly
* and fight the keyboard. Parsing is lenient in return: any separator, any
* digit system, 2- or 4-digit years, and bare ISO is always accepted.
*/
export interface DatePattern {
/** Field order, e.g. ["d", "m", "y"]. */
order: Array<"d" | "m" | "y">;
separator: string;
}
const AUTO_PATTERNS = new Map<string, DatePattern>();
function localePattern(): DatePattern {
const loc = resolvedLocale() ?? "";
const hit = AUTO_PATTERNS.get(loc);
if (hit) return hit;
let pattern: DatePattern = { order: ["m", "d", "y"], separator: "/" };
try {
const parts = new Intl.DateTimeFormat(loc || undefined, {
calendar: "gregory",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date(2025, 10, 22));
const order = parts
.filter((p) => p.type === "day" || p.type === "month" || p.type === "year")
.map((p) => (p.type === "day" ? "d" : p.type === "month" ? "m" : "y") as "d" | "m" | "y");
const literal = parts.find((p) => p.type === "literal")?.value.trim();
if (order.length === 3) pattern = { order, separator: literal || "/" };
} catch {
/* keep the default */
}
AUTO_PATTERNS.set(loc, pattern);
return pattern;
}
/** How an editable date is laid out under the current preferences. */
export function dateInputPattern(): DatePattern {
switch (prefs.dateFormat) {
case "dmy-dot":
return { order: ["d", "m", "y"], separator: "." };
case "dmy-slash":
return { order: ["d", "m", "y"], separator: "/" };
case "mdy-slash":
return { order: ["m", "d", "y"], separator: "/" };
case "ymd-dash":
return { order: ["y", "m", "d"], separator: "-" };
default:
return localePattern();
}
}
/** "dd.mm.yyyy" — the shape to show as a placeholder. */
export function dateInputPlaceholder(): string {
const { order, separator } = dateInputPattern();
return order.map((f) => (f === "y" ? "yyyy" : f === "m" ? "mm" : "dd")).join(separator);
}
/** A date in the editable form: always Gregorian, always Latin digits. */
export function formatDateInput(d: Date): string {
if (Number.isNaN(d.getTime())) return "";
const { order, separator } = dateInputPattern();
const parts: Record<"d" | "m" | "y", string> = {
d: String(d.getDate()).padStart(2, "0"),
m: String(d.getMonth() + 1).padStart(2, "0"),
y: String(d.getFullYear()).padStart(4, "0"),
};
return order.map((f) => parts[f]).join(separator);
}
/** Map Arabic-Indic, Persian, Devanagari … digits onto ASCII. */
function latinDigits(text: string): string {
return text.replace(/[^\x00-\x7F]/g, (ch) => {
const code = ch.codePointAt(0)!;
for (const zero of [0x0660, 0x06f0, 0x0966, 0x09e6, 0x0a66, 0x0ae6, 0x0b66, 0x0be6, 0x0c66, 0x0ce6, 0x0d66, 0x0e50, 0x0ed0, 0x0f20, 0x1040, 0x17e0]) {
if (code >= zero && code <= zero + 9) return String(code - zero);
}
return ch;
});
}
/** Two-digit years land in the current century's ±50-year window. */
function expandYear(y: number): number {
if (y >= 100) return y;
const pivot = new Date().getFullYear();
const century = Math.floor(pivot / 100) * 100;
const guess = century + y;
return guess - pivot > 50 ? guess - 100 : guess;
}
/**
* Read a typed date. Accepts the configured order with any separator, bare
* ISO (`2025-11-22`), and unseparated digits (`22112025`, `221125`).
*/
export function parseDateInput(text: string): Date | null {
const raw = latinDigits(text).trim();
if (!raw) return null;
const iso = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(raw);
if (iso) return validDate(+iso[1]!, +iso[2]!, +iso[3]!);
const { order } = dateInputPattern();
const groups = raw.split(/[^\d]+/).filter(Boolean);
let nums: number[];
if (groups.length === 3) {
nums = groups.map(Number);
} else if (groups.length === 1 && (groups[0]!.length === 6 || groups[0]!.length === 8)) {
const digits = groups[0]!;
const yLen = digits.length === 8 ? 4 : 2;
const widths = order.map((f) => (f === "y" ? yLen : 2));
let at = 0;
nums = widths.map((w) => Number(digits.slice(at, (at += w))));
} else if (groups.length === 2) {
// Day and month only — assume the current year.
const withYear = [...order];
const yAt = withYear.indexOf("y");
const vals = [...groups.map(Number)];
vals.splice(yAt, 0, new Date().getFullYear());
nums = vals;
} else {
return null;
}
if (nums.some((n) => !Number.isFinite(n))) return null;
const pick = (f: "d" | "m" | "y") => nums[order.indexOf(f)]!;
return validDate(expandYear(pick("y")), pick("m"), pick("d"));
}
function validDate(year: number, month: number, day: number): Date | null {
if (month < 1 || month > 12 || day < 1 || day > 31 || year < 1 || year > 9999) return null;
const d = new Date(year, month - 1, day);
// Rejects overflow like 31 February, which Date would roll into March.
if (d.getFullYear() !== year || d.getMonth() !== month - 1 || d.getDate() !== day) return null;
return d;
}
/** A time in the editable form: "18:23" or "6:23 PM". */
export function formatTimeInput(d: Date): string {
if (Number.isNaN(d.getTime())) return "";
const h = d.getHours();
const m = String(d.getMinutes()).padStart(2, "0");
if (uses24Hour()) return `${String(h).padStart(2, "0")}:${m}`;
const h12 = h % 12 === 0 ? 12 : h % 12;
return `${h12}:${m} ${h < 12 ? "AM" : "PM"}`;
}
export function timeInputPlaceholder(): string {
return uses24Hour() ? "hh:mm" : "h:mm AM";
}
/**
* Read a typed time. Accepts "18:23", "1823", "18", "6:23 pm", "6pm",
* "6.23" and, in 12-hour mode, a bare "6" (morning) — anything unambiguous.
*/
export function parseTimeInput(text: string): { hours: number; minutes: number } | null {
const raw = latinDigits(text).trim().toLowerCase();
if (!raw) return null;
const suffix = /(a\.?m\.?|p\.?m\.?)\s*$/.exec(raw);
const meridiem = suffix ? (suffix[1]!.startsWith("a") ? "am" : "pm") : null;
const body = (suffix ? raw.slice(0, suffix.index) : raw).trim();
const digits = body.split(/[^\d]+/).filter(Boolean);
let h: number;
let m = 0;
if (digits.length === 2) {
h = Number(digits[0]);
m = Number(digits[1]);
} else if (digits.length === 1) {
const only = digits[0]!;
if (only.length <= 2) h = Number(only);
else if (only.length === 3) {
h = Number(only.slice(0, 1));
m = Number(only.slice(1));
} else if (only.length === 4) {
h = Number(only.slice(0, 2));
m = Number(only.slice(2));
} else return null;
} else return null;
if (!Number.isFinite(h) || !Number.isFinite(m) || m > 59) return null;
if (meridiem) {
if (h < 1 || h > 12) return null;
h = (h % 12) + (meridiem === "pm" ? 12 : 0);
}
if (h > 23) return null;
return { hours: h, minutes: m };
}
/* ------------------------------------------------------------------ */
/* Relative times */
/* ------------------------------------------------------------------ */
+36
View File
@@ -850,3 +850,39 @@ img { max-width: 100%; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; }
}
/* ---- Date & time fields (custom pickers; see ui/datefield.tsx) ---- */
.dp-field { position: relative; display: inline-flex; align-items: center; width: 100%; }
.dp-field .input { width: 100%; padding-right: 30px; }
.dp-open { position: absolute; right: 4px; display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border: 0; background: none; color: var(--fg-muted); cursor: pointer; border-radius: var(--radius-sm); }
button.dp-open:hover { background: var(--bg-hover); color: var(--fg); }
button.dp-open:disabled { cursor: default; opacity: .5; }
.dp-field.w-auto { width: auto; }
.dp-field.w-auto .input { width: 10em; }
.dp-datetime { display: flex; gap: 6px; align-items: center; }
.dp-datetime .dp-field { flex: 1 1 auto; }
.dp-datetime .dp-time-field { flex: 0 0 8.5em; }
.dp-pop { padding: 10px; }
.dp-pop-wide { display: flex; }
.dp-split { display: flex; gap: 10px; align-items: stretch; }
.dp-cal { width: 15.5em; }
.dp-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; font-weight: 650; }
.dp-dow, .dp-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; text-align: center; }
.dp-dow { color: var(--fg-faint); font-size: .8em; padding-bottom: 2px; }
.dp-day { height: 28px; border: 0; background: none; color: inherit; font: inherit; font-size: .9em; border-radius: 50%; cursor: pointer; }
.dp-day:hover { background: var(--bg-hover); }
.dp-day:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.dp-day.other { color: var(--fg-faint); }
.dp-day.today { font-weight: 700; color: var(--accent); }
.dp-day.selected { background: var(--accent); color: var(--accent-fg); font-weight: 600; }
.dp-foot { display: flex; justify-content: space-between; margin-top: 6px; }
.dp-times { display: flex; flex-direction: column; gap: 1px; overflow-y: auto; max-height: 17em; min-width: 7.5em; border-left: 1px solid var(--border); padding-left: 8px; }
.dp-time { border: 0; background: none; color: inherit; font: inherit; font-size: .9em; text-align: left; padding: 4px 8px; border-radius: var(--radius-sm); cursor: pointer; white-space: nowrap; }
.dp-time:hover { background: var(--bg-hover); }
.dp-time.selected { background: var(--accent); color: var(--accent-fg); }
@media (max-width: 480px) {
.dp-datetime { flex-wrap: wrap; }
.dp-datetime .dp-time-field { flex: 1 1 100%; }
.dp-split { flex-direction: column; }
.dp-times { flex-direction: row; overflow-x: auto; max-height: none; border-left: 0; border-top: 1px solid var(--border); padding: 6px 0 0; }
}
+362
View File
@@ -0,0 +1,362 @@
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { Calendar as CalIcon, ChevronLeft, ChevronRight, Clock } from "lucide-react";
import { addDays, addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates";
import {
dateInputPlaceholder,
formatClock,
formatDateInput,
formatMonthYear,
formatTimeInput,
formatWeekday,
parseDateInput,
parseTimeInput,
timeInputPlaceholder,
} from "@/lib/datetime";
import { dateTimeKey, useSettings } from "@/store/settings";
import { anchorFromEl, Popover, type Anchor } from "./popover";
/*
* Date and time fields that follow the user's configured format.
*
* Browsers render <input type="date"> in their own locale and ignore the
* page's, so a German user on an English browser gets mm/dd/yyyy no matter
* what the app says. These replace those controls: a text box in the
* configured order (see lib/datetime) plus a calendar or time-list popover.
* Values in and out keep the native ISO shapes, so they drop straight into
* the places the native inputs used to sit.
*/
/* ------------------------------------------------------------------ */
/* Calendar grid */
/* ------------------------------------------------------------------ */
function CalendarGrid({ selected, onPick, onClose }: { selected: Date | null; onPick: (d: Date) => void; onClose: () => void }) {
const weekStart = useSettings((s) => s.settings.weekStart);
const [focus, setFocus] = useState(() => startOfDay(selected ?? new Date()));
const [anchor, setAnchor] = useState(() => startOfDay(selected ?? new Date()));
const gridRef = useRef<HTMLDivElement>(null);
const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]);
const dow = useMemo(() => grid.slice(0, 7).map((d) => formatWeekday(d, "narrow")), [grid]);
const move = (to: Date) => {
setFocus(to);
if (to.getMonth() !== anchor.getMonth() || to.getFullYear() !== anchor.getFullYear()) setAnchor(startOfDay(to));
};
const onKey = (e: KeyboardEvent<HTMLDivElement>) => {
const keys: Record<string, () => Date> = {
ArrowLeft: () => addDays(focus, -1),
ArrowRight: () => addDays(focus, 1),
ArrowUp: () => addDays(focus, -7),
ArrowDown: () => addDays(focus, 7),
PageUp: () => addMonths(focus, -1),
PageDown: () => addMonths(focus, 1),
Home: () => addDays(focus, -((focus.getDay() - weekStart + 7) % 7)),
End: () => addDays(focus, 6 - ((focus.getDay() - weekStart + 7) % 7)),
};
const next = keys[e.key];
if (next) {
e.preventDefault();
move(next());
return;
}
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onPick(focus);
}
};
// Keep DOM focus on the focused day so screen readers follow the cursor.
useEffect(() => {
gridRef.current?.querySelector<HTMLButtonElement>('button[tabindex="0"]')?.focus();
}, [focus]);
return (
<div className="dp-cal">
<div className="dp-head">
<button type="button" className="icon-btn xs" onClick={() => setAnchor(addMonths(anchor, -1))} aria-label="Previous month"><ChevronLeft size={16} /></button>
<span aria-live="polite">{formatMonthYear(anchor)}</span>
<button type="button" className="icon-btn xs" onClick={() => setAnchor(addMonths(anchor, 1))} aria-label="Next month"><ChevronRight size={16} /></button>
</div>
<div className="dp-dow" aria-hidden="true">{dow.map((d, i) => <span key={i}>{d}</span>)}</div>
<div className="dp-grid" role="grid" ref={gridRef} onKeyDown={onKey}>
{grid.map((d) => {
const focused = isSameDay(d, focus);
return (
<button
key={d.toISOString()}
type="button"
role="gridcell"
tabIndex={focused ? 0 : -1}
aria-selected={selected ? isSameDay(d, selected) : false}
aria-label={d.toDateString()}
className={`dp-day${d.getMonth() !== anchor.getMonth() ? " other" : ""}${isToday(d) ? " today" : ""}${selected && isSameDay(d, selected) ? " selected" : ""}`}
onClick={() => onPick(d)}
>
{d.getDate()}
</button>
);
})}
</div>
<div className="dp-foot">
<button type="button" className="btn btn-ghost xs" onClick={() => onPick(startOfDay(new Date()))}>Today</button>
<button type="button" className="btn btn-ghost xs" onClick={onClose}>Close</button>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Time list */
/* ------------------------------------------------------------------ */
const STEP_MINUTES = 30;
function TimeList({ selected, onPick }: { selected: Date | null; onPick: (hours: number, minutes: number) => void }) {
const listRef = useRef<HTMLDivElement>(null);
const slots = useMemo(() => {
const out: Date[] = [];
const base = new Date(2000, 0, 1);
for (let m = 0; m < 24 * 60; m += STEP_MINUTES) out.push(new Date(base.getTime() + m * 60_000));
return out;
}, []);
const currentSlot = selected ? Math.round((selected.getHours() * 60 + selected.getMinutes()) / STEP_MINUTES) : -1;
useEffect(() => {
listRef.current?.querySelector<HTMLElement>(".dp-time.selected, .dp-time.near")?.scrollIntoView({ block: "center" });
}, []);
return (
<div className="dp-times" ref={listRef} role="listbox" aria-label="Time">
{slots.map((t, i) => (
<button
key={i}
type="button"
role="option"
aria-selected={i === currentSlot}
className={`dp-time${i === currentSlot ? " selected" : ""}${i === 18 && currentSlot < 0 ? " near" : ""}`}
onClick={() => onPick(t.getHours(), t.getMinutes())}
>
{formatClock(t)}
</button>
))}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Fields */
/* ------------------------------------------------------------------ */
interface FieldProps {
/** "YYYY-MM-DD" for DateField, "YYYY-MM-DDTHH:MM" for DateTimeField; "" when empty. */
value: string;
onChange: (value: string) => void;
className?: string;
disabled?: boolean;
required?: boolean;
"aria-label"?: string;
id?: string;
}
function pad(n: number): string {
return String(n).padStart(2, "0");
}
function toIsoDateTime(d: Date): string {
return `${toLocalDateOnly(d)}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** Shared text-box behaviour: type freely, commit on blur or Enter, revert what won't parse. */
function useTextField(value: string, display: (v: string) => string, commit: (text: string) => boolean) {
const [text, setText] = useState(() => display(value));
const [editing, setEditing] = useState(false);
const key = useSettings((s) => dateTimeKey(s.settings));
useEffect(() => {
if (!editing) setText(display(value));
// `key` re-renders the text when the user changes the date format.
}, [value, editing, key]); // eslint-disable-line react-hooks/exhaustive-deps
const onBlur = () => {
setEditing(false);
if (!commit(text)) setText(display(value));
};
return { text, setText, setEditing, onBlur };
}
export function DateField({ value, onChange, className, disabled, required, id, ...rest }: FieldProps) {
const [anchor, setAnchor] = useState<Anchor | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const display = useCallback((v: string) => {
if (!v) return "";
const d = new Date(`${v}T00:00:00`);
return Number.isNaN(d.getTime()) ? "" : formatDateInput(d);
}, []);
const commit = useCallback((text: string) => {
if (!text.trim()) {
onChange("");
return true;
}
const d = parseDateInput(text);
if (!d) return false;
onChange(toLocalDateOnly(d));
return true;
}, [onChange]);
const field = useTextField(value, display, commit);
const selected = value ? new Date(`${value}T00:00:00`) : null;
const open = () => setAnchor(anchorFromEl(inputRef.current?.parentElement ?? inputRef.current));
return (
<span className={`dp-field ${className ?? ""}`}>
<input
ref={inputRef}
id={id}
className="input"
type="text"
inputMode="numeric"
autoComplete="off"
spellCheck={false}
disabled={disabled}
required={required}
placeholder={dateInputPlaceholder()}
aria-label={rest["aria-label"]}
aria-haspopup="dialog"
aria-expanded={Boolean(anchor)}
value={field.text}
onChange={(e) => { field.setEditing(true); field.setText(e.target.value); }}
onBlur={field.onBlur}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); (e.target as HTMLInputElement).blur(); }
if (e.key === "ArrowDown" && !anchor) { e.preventDefault(); open(); }
}}
/>
<button type="button" className="dp-open" disabled={disabled} onClick={open} aria-label="Choose a date" tabIndex={-1}>
<CalIcon size={15} />
</button>
{anchor && (
<Popover anchor={anchor} onClose={() => { setAnchor(null); inputRef.current?.focus(); }} role="dialog" className="dp-pop" closeOnClick={false} ariaLabel="Choose a date">
<CalendarGrid
selected={selected}
onClose={() => { setAnchor(null); inputRef.current?.focus(); }}
onPick={(d) => { onChange(toLocalDateOnly(d)); setAnchor(null); inputRef.current?.focus(); }}
/>
</Popover>
)}
</span>
);
}
export function DateTimeField({ value, onChange, className, disabled, required, id, ...rest }: FieldProps) {
const [anchor, setAnchor] = useState<Anchor | null>(null);
const dateRef = useRef<HTMLInputElement>(null);
const timeRef = useRef<HTMLInputElement>(null);
const current = value ? new Date(value) : null;
const valid = current && !Number.isNaN(current.getTime()) ? current : null;
const setParts = (d: Date) => onChange(toIsoDateTime(d));
const dateDisplay = useCallback((v: string) => (v ? formatDateInput(new Date(v)) : ""), []);
const dateCommit = useCallback((text: string) => {
if (!text.trim()) { onChange(""); return true; }
const d = parseDateInput(text);
if (!d) return false;
const keep = valid ?? new Date();
d.setHours(keep.getHours(), keep.getMinutes(), 0, 0);
setParts(d);
return true;
}, [onChange, value]); // eslint-disable-line react-hooks/exhaustive-deps
const timeDisplay = useCallback((v: string) => (v ? formatTimeInput(new Date(v)) : ""), []);
const timeCommit = useCallback((text: string) => {
if (!text.trim()) return Boolean(!value);
const t = parseTimeInput(text);
if (!t) return false;
const d = new Date(valid ?? new Date());
d.setHours(t.hours, t.minutes, 0, 0);
setParts(d);
return true;
}, [onChange, value]); // eslint-disable-line react-hooks/exhaustive-deps
const dateField = useTextField(value, dateDisplay, dateCommit);
const timeField = useTextField(value, timeDisplay, timeCommit);
const open = () => setAnchor(anchorFromEl(dateRef.current?.parentElement?.parentElement ?? dateRef.current));
const close = () => { setAnchor(null); dateRef.current?.focus(); };
return (
<span className={`dp-datetime ${className ?? ""}`}>
<span className="dp-field">
<input
ref={dateRef}
id={id}
className="input"
type="text"
inputMode="numeric"
autoComplete="off"
spellCheck={false}
disabled={disabled}
required={required}
placeholder={dateInputPlaceholder()}
aria-label={rest["aria-label"] ? `${rest["aria-label"]} (date)` : "Date"}
aria-haspopup="dialog"
aria-expanded={Boolean(anchor)}
value={dateField.text}
onChange={(e) => { dateField.setEditing(true); dateField.setText(e.target.value); }}
onBlur={dateField.onBlur}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); (e.target as HTMLInputElement).blur(); }
if (e.key === "ArrowDown" && !anchor) { e.preventDefault(); open(); }
}}
/>
<button type="button" className="dp-open" disabled={disabled} onClick={open} aria-label="Choose a date and time" tabIndex={-1}>
<CalIcon size={15} />
</button>
</span>
<span className="dp-field dp-time-field">
<input
ref={timeRef}
className="input"
type="text"
inputMode="numeric"
autoComplete="off"
spellCheck={false}
disabled={disabled}
placeholder={timeInputPlaceholder()}
aria-label={rest["aria-label"] ? `${rest["aria-label"]} (time)` : "Time"}
value={timeField.text}
onChange={(e) => { timeField.setEditing(true); timeField.setText(e.target.value); }}
onBlur={timeField.onBlur}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); (e.target as HTMLInputElement).blur(); } }}
/>
<span className="dp-open" aria-hidden="true"><Clock size={15} /></span>
</span>
{anchor && (
<Popover anchor={anchor} onClose={close} role="dialog" className="dp-pop dp-pop-wide" closeOnClick={false} ariaLabel="Choose a date and time">
<div className="dp-split">
<CalendarGrid
selected={valid}
onClose={close}
onPick={(d) => {
const keep = valid ?? new Date();
d.setHours(keep.getHours(), keep.getMinutes(), 0, 0);
setParts(d);
}}
/>
<TimeList
selected={valid}
onPick={(h, m) => {
const d = new Date(valid ?? new Date());
d.setHours(h, m, 0, 0);
setParts(d);
close();
}}
/>
</div>
</Popover>
)}
</span>
);
}
+4 -1
View File
@@ -26,10 +26,12 @@ interface PopoverProps {
style?: CSSProperties;
closeOnClick?: boolean;
role?: string;
/** Accessible name — dialogs need one; menus take it from their trigger. */
ariaLabel?: string;
}
/** Generic anchored popover rendered in a portal; closes on outside click / Escape. */
export function Popover({ anchor, onClose, children, className, align = "start", side = "bottom", width, style, closeOnClick = true, role = "menu" }: PopoverProps) {
export function Popover({ anchor, onClose, children, className, align = "start", side = "bottom", width, style, closeOnClick = true, role = "menu", ariaLabel }: PopoverProps) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ left: number; top: number; maxHeight: number } | null>(null);
@@ -96,6 +98,7 @@ export function Popover({ anchor, onClose, children, className, align = "start",
<div
ref={ref}
role={role}
aria-label={ariaLabel}
className={`popover ${className ?? ""}`}
style={{ left: pos?.left ?? -9999, top: pos?.top ?? -9999, visibility: pos ? "visible" : "hidden", width, maxHeight: pos?.maxHeight, ...style }}
onClick={(e) => {
+2 -1
View File
@@ -3,6 +3,7 @@ import { useLocation, useSearch } from "wouter";
import { Search, SlidersHorizontal, X } from "lucide-react";
import { useMail } from "@/store/mail";
import { keyboard } from "@/lib/keyboard";
import { DateField } from "@/ui/datefield";
export function SearchBar() {
const [location, navigate] = useLocation();
@@ -78,7 +79,7 @@ export function SearchBar() {
</select>
</label>
<div className="field"><span className="label">Date</span>
<div className="row"><input className="input sm" type="date" value={advFields.after} onChange={(e) => setAdvFields({ ...advFields, after: e.target.value })} /><span className="muted">to</span><input className="input sm" type="date" value={advFields.before} onChange={(e) => setAdvFields({ ...advFields, before: e.target.value })} /></div>
<div className="row"><DateField aria-label="After" value={advFields.after} onChange={(v) => setAdvFields({ ...advFields, after: v })} /><span className="muted">to</span><DateField aria-label="Before" value={advFields.before} onChange={(v) => setAdvFields({ ...advFields, before: v })} /></div>
</div>
</div>
<div className="row" style={{ justifyContent: "space-between", marginTop: 4 }}>
+6 -5
View File
@@ -9,6 +9,7 @@ import { Dialog } from "@/ui/dialog";
import { ColorSwatches, Switch } from "@/ui/misc";
import { toast } from "@/ui/toast";
import { RecipientInput } from "../compose/RecipientInput";
import { DateField, DateTimeField } from "@/ui/datefield";
import { browserTimeZone, dateToZonedLocal, formatDuration, fromInputDateTime, listTimeZones, parseDuration, toInputDateTime, toLocalDateOnly, zonedToDate, DAY_MS, humanDuration } from "@/lib/dates";
import { formatClock, formatNumericDate, formatWeekday } from "@/lib/datetime";
import { WEEKDAYS, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence";
@@ -206,15 +207,15 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
<div className="time-row mb-8">
{allDay ? (
<>
<input className="input" type="date" value={toLocalDateOnly(start)} onChange={(e) => onStartChange(new Date(`${e.target.value}T00:00:00`))} />
<DateField aria-label="Starts" value={toLocalDateOnly(start)} onChange={(v) => v && onStartChange(new Date(`${v}T00:00:00`))} />
<span className="muted center">to</span>
<input className="input" type="date" value={toLocalDateOnly(new Date(end.getTime() - 1))} onChange={(e) => setEnd(new Date(new Date(`${e.target.value}T00:00:00`).getTime() + DAY_MS))} />
<DateField aria-label="Ends" value={toLocalDateOnly(new Date(end.getTime() - 1))} onChange={(v) => v && setEnd(new Date(new Date(`${v}T00:00:00`).getTime() + DAY_MS))} />
</>
) : (
<>
<input className="input" type="datetime-local" value={toInputDateTime(start)} onChange={(e) => onStartChange(fromInputDateTime(e.target.value))} />
<DateTimeField aria-label="Starts" value={toInputDateTime(start)} onChange={(v) => v && onStartChange(fromInputDateTime(v))} />
<span className="muted center">to</span>
<input className="input" type="datetime-local" value={toInputDateTime(end)} onChange={(e) => setEnd(fromInputDateTime(e.target.value))} />
<DateTimeField aria-label="Ends" value={toInputDateTime(end)} onChange={(v) => v && setEnd(fromInputDateTime(v))} />
</>
)}
</div>
@@ -258,7 +259,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
<select className="select" style={{ width: "auto" }} value={customRule.until ? "until" : customRule.count ? "count" : "never"} onChange={(e) => { const v = e.target.value; setRule({ ...customRule, until: v === "until" ? `${toLocalDateOnly(new Date(start.getTime() + 30 * DAY_MS))}T23:59:59` : undefined, count: v === "count" ? 10 : undefined }); }}>
<option value="never">never</option><option value="until">on date</option><option value="count">after N times</option>
</select>
{customRule.until && <input className="input" type="date" style={{ width: "auto" }} value={customRule.until.slice(0, 10)} onChange={(e) => setRule({ ...customRule, until: `${e.target.value}T23:59:59` })} />}
{customRule.until && <DateField aria-label="Repeat until" className="w-auto" value={customRule.until.slice(0, 10)} onChange={(v) => v && setRule({ ...customRule, until: `${v}T23:59:59` })} />}
{customRule.count && <input className="input" type="number" min={1} style={{ width: 80 }} value={customRule.count} onChange={(e) => setRule({ ...customRule, count: Math.max(1, Number(e.target.value)) })} />}
</div>
<div className="hint mt-8">{describeRule(customRule)}</div>
+2 -1
View File
@@ -4,6 +4,7 @@ import type { ContactCard, JSContactAddress, JSContactEmail, JSContactPhone } fr
import { useContacts } from "@/store/contacts";
import { buildName, contactDisplayName, nameParts, newKey } from "@/lib/contacts";
import { Dialog } from "@/ui/dialog";
import { DateField } from "@/ui/datefield";
import { toast } from "@/ui/toast";
import { client } from "@/jmap/client";
@@ -270,7 +271,7 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
</div>
</div>
<div className="field-row">
<div className="field"><label>Birthday</label><input className="input" type="date" value={birthday} onChange={(e) => setBirthday(e.target.value)} /></div>
<div className="field"><label>Birthday</label><DateField aria-label="Birthday" value={birthday} onChange={setBirthday} /></div>
<div className="field"><label>Website</label><input className="input" value={website} onChange={(e) => setWebsite(e.target.value)} placeholder="https://" /></div>
</div>
<div className="field"><label>Notes</label><textarea className="textarea" value={note} onChange={(e) => setNote(e.target.value)} /></div>
+3 -19
View File
@@ -3,8 +3,7 @@ import { useMail } from "@/store/mail";
import { Switch } from "@/ui/misc";
import { toast } from "@/ui/toast";
import { toInputDateTime, fromInputDateTime, toUTCDate } from "@/lib/dates";
import { formatFullDateTime } from "@/lib/datetime";
import { dateTimeKey, useSettings } from "@/store/settings";
import { DateTimeField } from "@/ui/datefield";
import { client, CAP } from "@/jmap/client";
export function VacationSettings() {
@@ -18,13 +17,6 @@ export function VacationSettings() {
const [to, setTo] = useState("");
const [busy, setBusy] = useState(false);
const available = client.hasCapability(CAP.vacation);
// The date pickers themselves are native controls and follow the browser's
// locale; echo the value back in the user's chosen format so there is no doubt.
useSettings((s) => dateTimeKey(s.settings));
const echo = (v: string) => {
const d = fromInputDateTime(v);
return v && !Number.isNaN(d.getTime()) ? formatFullDateTime(d) : "";
};
useEffect(() => {
void load();
@@ -65,16 +57,8 @@ export function VacationSettings() {
<p className="lead">Automatically reply to people who email you while you're away. Each sender gets at most one reply.</p>
<Switch checked={enabled} onChange={setEnabled} label="Auto-reply enabled" />
<div className="field-row mt-16">
<div className="field">
<label>Starts (optional)</label>
<input className="input" type="datetime-local" value={from} onChange={(e) => setFrom(e.target.value)} />
{echo(from) && <p className="hint">{echo(from)}</p>}
</div>
<div className="field">
<label>Ends (optional)</label>
<input className="input" type="datetime-local" value={to} onChange={(e) => setTo(e.target.value)} />
{echo(to) && <p className="hint">{echo(to)}</p>}
</div>
<div className="field"><label>Starts (optional)</label><DateTimeField aria-label="Starts" value={from} onChange={setFrom} /></div>
<div className="field"><label>Ends (optional)</label><DateTimeField aria-label="Ends" value={to} onChange={setTo} /></div>
</div>
<div className="field"><label>Subject</label><input className="input" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Out of office" /></div>
<div className="field"><label>Message</label><textarea className="textarea" rows={7} value={body} onChange={(e) => setBody(e.target.value)} placeholder="Thanks for your message. I'm away until and will reply when I'm back." /></div>