diff --git a/README.md b/README.md index 9fab876..5c3aef6 100644 --- a/README.md +++ b/README.md @@ -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 `` 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** (`` 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 diff --git a/web/src/lib/__tests__/datetime.test.ts b/web/src/lib/__tests__/datetime.test.ts index 5c9f5e0..385c64e 100644 --- a/web/src/lib/__tests__/datetime.test.ts +++ b/web/src/lib/__tests__/datetime.test.ts @@ -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(); + } + }); +}); diff --git a/web/src/lib/datetime.ts b/web/src/lib/datetime.ts index 239c8c1..d31d06b 100644 --- a/web/src/lib/datetime.ts +++ b/web/src/lib/datetime.ts @@ -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(); + +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 */ /* ------------------------------------------------------------------ */ diff --git a/web/src/styles/app.css b/web/src/styles/app.css index ca61a5b..48b49c0 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -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; } +} diff --git a/web/src/ui/datefield.tsx b/web/src/ui/datefield.tsx new file mode 100644 index 0000000..1e11b8b --- /dev/null +++ b/web/src/ui/datefield.tsx @@ -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 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(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) => { + const keys: Record 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('button[tabindex="0"]')?.focus(); + }, [focus]); + + return ( + + + setAnchor(addMonths(anchor, -1))} aria-label="Previous month"> + {formatMonthYear(anchor)} + setAnchor(addMonths(anchor, 1))} aria-label="Next month"> + + {dow.map((d, i) => {d})} + + {grid.map((d) => { + const focused = isSameDay(d, focus); + return ( + onPick(d)} + > + {d.getDate()} + + ); + })} + + + onPick(startOfDay(new Date()))}>Today + Close + + + ); +} + +/* ------------------------------------------------------------------ */ +/* Time list */ +/* ------------------------------------------------------------------ */ + +const STEP_MINUTES = 30; + +function TimeList({ selected, onPick }: { selected: Date | null; onPick: (hours: number, minutes: number) => void }) { + const listRef = useRef(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(".dp-time.selected, .dp-time.near")?.scrollIntoView({ block: "center" }); + }, []); + + return ( + + {slots.map((t, i) => ( + onPick(t.getHours(), t.getMinutes())} + > + {formatClock(t)} + + ))} + + ); +} + +/* ------------------------------------------------------------------ */ +/* 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(null); + const inputRef = useRef(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 ( + + { 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(); } + }} + /> + + + + {anchor && ( + { setAnchor(null); inputRef.current?.focus(); }} role="dialog" className="dp-pop" closeOnClick={false} ariaLabel="Choose a date"> + { setAnchor(null); inputRef.current?.focus(); }} + onPick={(d) => { onChange(toLocalDateOnly(d)); setAnchor(null); inputRef.current?.focus(); }} + /> + + )} + + ); +} + +export function DateTimeField({ value, onChange, className, disabled, required, id, ...rest }: FieldProps) { + const [anchor, setAnchor] = useState(null); + const dateRef = useRef(null); + const timeRef = useRef(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 ( + + + { 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(); } + }} + /> + + + + + + { timeField.setEditing(true); timeField.setText(e.target.value); }} + onBlur={timeField.onBlur} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); (e.target as HTMLInputElement).blur(); } }} + /> + + + {anchor && ( + + + { + const keep = valid ?? new Date(); + d.setHours(keep.getHours(), keep.getMinutes(), 0, 0); + setParts(d); + }} + /> + { + const d = new Date(valid ?? new Date()); + d.setHours(h, m, 0, 0); + setParts(d); + close(); + }} + /> + + + )} + + ); +} diff --git a/web/src/ui/popover.tsx b/web/src/ui/popover.tsx index 68c2178..518c77c 100644 --- a/web/src/ui/popover.tsx +++ b/web/src/ui/popover.tsx @@ -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(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", { diff --git a/web/src/views/SearchBar.tsx b/web/src/views/SearchBar.tsx index b52b2c5..7bdf179 100644 --- a/web/src/views/SearchBar.tsx +++ b/web/src/views/SearchBar.tsx @@ -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() { Date - setAdvFields({ ...advFields, after: e.target.value })} />to setAdvFields({ ...advFields, before: e.target.value })} /> + setAdvFields({ ...advFields, after: v })} />to setAdvFields({ ...advFields, before: v })} /> diff --git a/web/src/views/calendar/EventEditor.tsx b/web/src/views/calendar/EventEditor.tsx index c365e79..83e1e8f 100644 --- a/web/src/views/calendar/EventEditor.tsx +++ b/web/src/views/calendar/EventEditor.tsx @@ -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 {allDay ? ( <> - onStartChange(new Date(`${e.target.value}T00:00:00`))} /> + v && onStartChange(new Date(`${v}T00:00:00`))} /> to - setEnd(new Date(new Date(`${e.target.value}T00:00:00`).getTime() + DAY_MS))} /> + v && setEnd(new Date(new Date(`${v}T00:00:00`).getTime() + DAY_MS))} /> > ) : ( <> - onStartChange(fromInputDateTime(e.target.value))} /> + v && onStartChange(fromInputDateTime(v))} /> to - setEnd(fromInputDateTime(e.target.value))} /> + v && setEnd(fromInputDateTime(v))} /> > )} @@ -258,7 +259,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE { 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 }); }}> neveron dateafter N times - {customRule.until && setRule({ ...customRule, until: `${e.target.value}T23:59:59` })} />} + {customRule.until && v && setRule({ ...customRule, until: `${v}T23:59:59` })} />} {customRule.count && setRule({ ...customRule, count: Math.max(1, Number(e.target.value)) })} />} {describeRule(customRule)} diff --git a/web/src/views/contacts/ContactEditor.tsx b/web/src/views/contacts/ContactEditor.tsx index d2d17a6..5a744eb 100644 --- a/web/src/views/contacts/ContactEditor.tsx +++ b/web/src/views/contacts/ContactEditor.tsx @@ -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) - Birthday setBirthday(e.target.value)} /> + Birthday Website setWebsite(e.target.value)} placeholder="https://" /> Notes setNote(e.target.value)} /> diff --git a/web/src/views/settings/VacationSettings.tsx b/web/src/views/settings/VacationSettings.tsx index 6bbed54..bb7cf8d 100644 --- a/web/src/views/settings/VacationSettings.tsx +++ b/web/src/views/settings/VacationSettings.tsx @@ -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() { Automatically reply to people who email you while you're away. Each sender gets at most one reply. - - Starts (optional) - setFrom(e.target.value)} /> - {echo(from) && {echo(from)}} - - - Ends (optional) - setTo(e.target.value)} /> - {echo(to) && {echo(to)}} - + Starts (optional) + Ends (optional) Subject setSubject(e.target.value)} placeholder="Out of office" /> Message setBody(e.target.value)} placeholder="Thanks for your message. I'm away until … and will reply when I'm back." />
Automatically reply to people who email you while you're away. Each sender gets at most one reply.
{echo(from)}
{echo(to)}