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:
@@ -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
|
- Browse folders, upload (drag & drop), download, create folders, rename, move, delete
|
||||||
|
|
||||||
**Settings**
|
**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
|
- 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**
|
**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.
|
- **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.
|
- **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).
|
- 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.
|
- 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
|
## Roadmap / not yet
|
||||||
|
|||||||
@@ -5,8 +5,15 @@ import {
|
|||||||
formatDateTime,
|
formatDateTime,
|
||||||
formatDayMonth,
|
formatDayMonth,
|
||||||
formatFullDateTime,
|
formatFullDateTime,
|
||||||
|
formatDateInput,
|
||||||
formatHourLabel,
|
formatHourLabel,
|
||||||
|
formatTimeInput,
|
||||||
|
dateInputPattern,
|
||||||
|
dateInputPlaceholder,
|
||||||
localeOptions,
|
localeOptions,
|
||||||
|
parseDateInput,
|
||||||
|
parseTimeInput,
|
||||||
|
timeInputPlaceholder,
|
||||||
normalizeLocale,
|
normalizeLocale,
|
||||||
resolvedLocale,
|
resolvedLocale,
|
||||||
setDateTimePrefs,
|
setDateTimePrefs,
|
||||||
@@ -196,3 +203,90 @@ describe("locale options", () => {
|
|||||||
expect(tags).toContain("de-DE-u-ca-buddhist");
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -296,6 +296,198 @@ export function formatFullDateTime(d: Date): string {
|
|||||||
return `${formatWeekday(d, "short")}, ${numeric(d, true)} ${formatClock(d)}`;
|
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 */
|
/* Relative times */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|||||||
@@ -850,3 +850,39 @@ img { max-width: 100%; }
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; }
|
*, *::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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,10 +26,12 @@ interface PopoverProps {
|
|||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
closeOnClick?: boolean;
|
closeOnClick?: boolean;
|
||||||
role?: string;
|
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. */
|
/** 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 ref = useRef<HTMLDivElement>(null);
|
||||||
const [pos, setPos] = useState<{ left: number; top: number; maxHeight: number } | null>(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
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
role={role}
|
role={role}
|
||||||
|
aria-label={ariaLabel}
|
||||||
className={`popover ${className ?? ""}`}
|
className={`popover ${className ?? ""}`}
|
||||||
style={{ left: pos?.left ?? -9999, top: pos?.top ?? -9999, visibility: pos ? "visible" : "hidden", width, maxHeight: pos?.maxHeight, ...style }}
|
style={{ left: pos?.left ?? -9999, top: pos?.top ?? -9999, visibility: pos ? "visible" : "hidden", width, maxHeight: pos?.maxHeight, ...style }}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useLocation, useSearch } from "wouter";
|
|||||||
import { Search, SlidersHorizontal, X } from "lucide-react";
|
import { Search, SlidersHorizontal, X } from "lucide-react";
|
||||||
import { useMail } from "@/store/mail";
|
import { useMail } from "@/store/mail";
|
||||||
import { keyboard } from "@/lib/keyboard";
|
import { keyboard } from "@/lib/keyboard";
|
||||||
|
import { DateField } from "@/ui/datefield";
|
||||||
|
|
||||||
export function SearchBar() {
|
export function SearchBar() {
|
||||||
const [location, navigate] = useLocation();
|
const [location, navigate] = useLocation();
|
||||||
@@ -78,7 +79,7 @@ export function SearchBar() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div className="field"><span className="label">Date</span>
|
<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>
|
</div>
|
||||||
<div className="row" style={{ justifyContent: "space-between", marginTop: 4 }}>
|
<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 { ColorSwatches, Switch } from "@/ui/misc";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { RecipientInput } from "../compose/RecipientInput";
|
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 { browserTimeZone, dateToZonedLocal, formatDuration, fromInputDateTime, listTimeZones, parseDuration, toInputDateTime, toLocalDateOnly, zonedToDate, DAY_MS, humanDuration } from "@/lib/dates";
|
||||||
import { formatClock, formatNumericDate, formatWeekday } from "@/lib/datetime";
|
import { formatClock, formatNumericDate, formatWeekday } from "@/lib/datetime";
|
||||||
import { WEEKDAYS, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence";
|
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">
|
<div className="time-row mb-8">
|
||||||
{allDay ? (
|
{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>
|
<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>
|
<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>
|
</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 }); }}>
|
<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>
|
<option value="never">never</option><option value="until">on date</option><option value="count">after N times</option>
|
||||||
</select>
|
</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)) })} />}
|
{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>
|
||||||
<div className="hint mt-8">{describeRule(customRule)}</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 { useContacts } from "@/store/contacts";
|
||||||
import { buildName, contactDisplayName, nameParts, newKey } from "@/lib/contacts";
|
import { buildName, contactDisplayName, nameParts, newKey } from "@/lib/contacts";
|
||||||
import { Dialog } from "@/ui/dialog";
|
import { Dialog } from "@/ui/dialog";
|
||||||
|
import { DateField } from "@/ui/datefield";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { client } from "@/jmap/client";
|
import { client } from "@/jmap/client";
|
||||||
|
|
||||||
@@ -270,7 +271,7 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="field-row">
|
<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 className="field"><label>Website</label><input className="input" value={website} onChange={(e) => setWebsite(e.target.value)} placeholder="https://" /></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="field"><label>Notes</label><textarea className="textarea" value={note} onChange={(e) => setNote(e.target.value)} /></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 { Switch } from "@/ui/misc";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { toInputDateTime, fromInputDateTime, toUTCDate } from "@/lib/dates";
|
import { toInputDateTime, fromInputDateTime, toUTCDate } from "@/lib/dates";
|
||||||
import { formatFullDateTime } from "@/lib/datetime";
|
import { DateTimeField } from "@/ui/datefield";
|
||||||
import { dateTimeKey, useSettings } from "@/store/settings";
|
|
||||||
import { client, CAP } from "@/jmap/client";
|
import { client, CAP } from "@/jmap/client";
|
||||||
|
|
||||||
export function VacationSettings() {
|
export function VacationSettings() {
|
||||||
@@ -18,13 +17,6 @@ export function VacationSettings() {
|
|||||||
const [to, setTo] = useState("");
|
const [to, setTo] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const available = client.hasCapability(CAP.vacation);
|
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(() => {
|
useEffect(() => {
|
||||||
void load();
|
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>
|
<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" />
|
<Switch checked={enabled} onChange={setEnabled} label="Auto-reply enabled" />
|
||||||
<div className="field-row mt-16">
|
<div className="field-row mt-16">
|
||||||
<div className="field">
|
<div className="field"><label>Starts (optional)</label><DateTimeField aria-label="Starts" value={from} onChange={setFrom} /></div>
|
||||||
<label>Starts (optional)</label>
|
<div className="field"><label>Ends (optional)</label><DateTimeField aria-label="Ends" value={to} onChange={setTo} /></div>
|
||||||
<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>
|
</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>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>
|
<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