Merge pull request #5 from LINUXexpert-org/custom-date-time-pickers

Custom date and time pickers that follow the configured format
This commit is contained in:
LINUXexpert.org
2026-08-23 13:27:02 -07:00
committed by GitHub
10 changed files with 703 additions and 29 deletions
+2 -2
View File
@@ -50,7 +50,7 @@ ihasmail is a JMAP-first web client: mail, calendars, contacts, files, filters a
- Browse folders, upload (drag & drop), download, create folders, rename, move, delete
**Settings**
- **Dates & times**: language/region (every one of the ~620 locales CLDR has data for, each named in its own language and script), date order (locale default, `22.11.2025`, `22/11/2025`, `11/22/2025` or ISO `2025-11-22`) and 12h/24h clock, applied everywhere — message list and headers, calendar, contacts, files, sessions. The default comes from the locale configured for the account in Stalwart (`x:Account/get`), falling back to the browser's; POSIX forms are normalised (`de_DE.UTF-8``de-DE`) and script modifiers preserved (`sr_RS@latin``sr-Latn-RS`). Numerals follow the locale (`٢٢.١١.٢٠٢٥` for `ar-EG`), except under ISO 8601, which pins date *and* clock to Latin digits
- **Dates & times**: language/region (every one of the ~620 locales CLDR has data for, each named in its own language and script), date order (locale default, `22.11.2025`, `22/11/2025`, `11/22/2025` or ISO `2025-11-22`) and 12h/24h clock, applied everywhere — message list and headers, calendar, contacts, files, sessions. The default comes from the locale configured for the account in Stalwart (`x:Account/get`), falling back to the browser's; POSIX forms are normalised (`de_DE.UTF-8``de-DE`) and script modifiers preserved (`sr_RS@latin``sr-Latn-RS`). Numerals follow the locale (`٢٢.١١.٢٠٢٥` for `ar-EG`), except under ISO 8601, which pins date *and* clock to Latin digits. Dates are **entered** through custom pickers in the same format (browsers render `<input type="date">` in their own locale and ignore the page's), with a calendar popover, a time list, keyboard navigation, and lenient typing — `22.11.`, `221125`, `6:23pm` and bare ISO all parse
- Identities & signatures, **Sieve filters** (visual rule builder that round-trips to a Sieve script, plus a raw script editor with server-side validation), out-of-office (`VacationResponse`), folders, labels, templates, notifications, calendar defaults, sessions (sign out other devices), keyboard shortcuts, import/export of settings
**Platform**
@@ -130,7 +130,7 @@ Verified against the mock server and, for the core mail flows, against a live St
- **HTML signatures** — Stalwart caps identity signatures at 2 KB. ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker (other clients see a text fallback). The end-to-end flow (save → compose → send with inline logo) is implemented but not yet confirmed on the live server.
- **Files** — the live server runs an older Stalwart build than `main`; `FileNode/query` there rejects `isTopLevel`/`parentId` filters, so ihasmail falls back to listing all nodes and building the tree client-side. Upload/rename/move/delete still need a live pass.
- Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet).
- Date **pickers** (`<input type="datetime-local">` in the event editor and out-of-office settings) are native browser controls and always follow the browser's own locale — no page can restyle them. The chosen format is echoed underneath the out-of-office fields so the entered instant is unambiguous.
- Editable date boxes are always Gregorian and in Latin digits, even for locales whose *display* uses another calendar or numbering system (`fa-IR`, `th-TH`, `ar-EG`) — they keep the locale's field order and separator, but a Buddhist-era year in a text box does not round-trip against the Gregorian calendar grid. Non-Gregorian calendar support is not implemented.
- The account locale is read with Stalwart's `x:Account/get`, which needs the `sysAccountGet` permission; where a regular user is not granted it, ihasmail silently falls back to the browser locale and the setting can be chosen by hand.
## Roadmap / not yet
+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>