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:
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
Reference in New Issue
Block a user