Files
ihasmail-inbuxa/web/src/ui/popover.tsx
T
jcoffey-dev 2518605126 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
2026-08-23 13:12:17 -07:00

180 lines
6.1 KiB
TypeScript

import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type CSSProperties } from "react";
import { createPortal } from "react-dom";
export interface Anchor {
x: number;
y: number;
w?: number;
h?: number;
}
export function anchorFromEl(el: Element | null): Anchor | null {
if (!el) return null;
const r = el.getBoundingClientRect();
return { x: r.left, y: r.top, w: r.width, h: r.height };
}
interface PopoverProps {
anchor: Anchor | null;
onClose: () => void;
children: ReactNode;
className?: string;
align?: "start" | "end";
/** Prefer opening below (default) or above. */
side?: "bottom" | "top" | "right";
width?: number | string;
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", ariaLabel }: PopoverProps) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ left: number; top: number; maxHeight: number } | null>(null);
useLayoutEffect(() => {
if (!anchor || !ref.current) return;
const el = ref.current;
const vw = window.innerWidth;
const vh = window.innerHeight;
const rect = el.getBoundingClientRect();
const aw = anchor.w ?? 0;
const ah = anchor.h ?? 0;
let left = align === "end" ? anchor.x + aw - rect.width : anchor.x;
let top = side === "top" ? anchor.y - rect.height - 4 : anchor.y + ah + 4;
if (side === "right") {
left = anchor.x + aw + 4;
top = anchor.y;
}
if (left + rect.width > vw - 8) left = Math.max(8, vw - rect.width - 8);
if (left < 8) left = 8;
let maxHeight = Math.min(vh - 16, 560);
if (top + rect.height > vh - 8) {
// flip above if there is room, else clamp
const above = anchor.y - rect.height - 4;
if (above >= 8 && side !== "right") top = above;
else {
top = Math.max(8, vh - rect.height - 8);
maxHeight = vh - top - 8;
}
}
if (top < 8) top = 8;
setPos({ left, top, maxHeight });
}, [anchor, align, side]);
useEffect(() => {
if (!anchor) return;
const onDown = (e: MouseEvent | TouchEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
};
const onScroll = () => onClose();
// Defer so the opening click doesn't immediately close.
const t = window.setTimeout(() => {
document.addEventListener("mousedown", onDown, true);
document.addEventListener("touchstart", onDown, true);
document.addEventListener("keydown", onKey, true);
window.addEventListener("resize", onScroll);
}, 0);
return () => {
window.clearTimeout(t);
document.removeEventListener("mousedown", onDown, true);
document.removeEventListener("touchstart", onDown, true);
document.removeEventListener("keydown", onKey, true);
window.removeEventListener("resize", onScroll);
};
}, [anchor, onClose]);
if (!anchor) return null;
return createPortal(
<div
ref={ref}
role={role}
aria-label={ariaLabel}
className={`popover ${className ?? ""}`}
style={{ left: pos?.left ?? -9999, top: pos?.top ?? -9999, visibility: pos ? "visible" : "hidden", width, maxHeight: pos?.maxHeight, ...style }}
onClick={(e) => {
if (closeOnClick && (e.target as HTMLElement).closest(".menu-item")) onClose();
}}
>
{children}
</div>,
document.body,
);
}
export interface MenuItemProps {
icon?: ReactNode;
label: ReactNode;
onClick?: () => void;
disabled?: boolean;
danger?: boolean;
kbd?: string;
active?: boolean;
checked?: boolean;
}
export function MenuItem({ icon, label, onClick, disabled, danger, kbd, active, checked }: MenuItemProps) {
return (
<button type="button" className={`menu-item ${danger ? "danger" : ""} ${active ? "active" : ""}`} onClick={onClick} disabled={disabled} role="menuitem">
{checked !== undefined ? <span style={{ width: 16, display: "inline-flex" }}>{checked ? "✓" : ""}</span> : icon}
<span className="grow truncate">{label}</span>
{kbd && <span className="menu-kbd">{kbd}</span>}
</button>
);
}
export function MenuSep() {
return <div className="menu-sep" />;
}
export function MenuTitle({ children }: { children: ReactNode }) {
return <div className="menu-title">{children}</div>;
}
/** Hook to manage a menu anchored to a trigger element. */
export function useMenu() {
const [anchor, setAnchor] = useState<Anchor | null>(null);
return {
anchor,
open: (e: { currentTarget: Element } | Element) => setAnchor(anchorFromEl("currentTarget" in e ? e.currentTarget : e)),
openAt: (x: number, y: number) => setAnchor({ x, y, w: 0, h: 0 }),
close: () => setAnchor(null),
isOpen: anchor !== null,
};
}
/** Simple tooltip via title-like hover with delay. */
export function Tooltip({ text, children }: { text: string; children: ReactNode }) {
const [pos, setPos] = useState<{ x: number; y: number } | null>(null);
const timer = useRef<number | null>(null);
return (
<span
style={{ display: "inline-flex" }}
onMouseEnter={(e) => {
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
timer.current = window.setTimeout(() => setPos({ x: r.left + r.width / 2, y: r.bottom + 6 }), 500);
}}
onMouseLeave={() => {
if (timer.current) window.clearTimeout(timer.current);
setPos(null);
}}
onMouseDown={() => {
if (timer.current) window.clearTimeout(timer.current);
setPos(null);
}}
>
{children}
{pos && createPortal(<div className="tooltip" style={{ left: Math.max(8, Math.min(pos.x, window.innerWidth - 8)), top: pos.y, transform: "translateX(-50%)" }}>{text}</div>, document.body)}
</span>
);
}