Files
HealthLog/src/components/ui/time-field.tsx
T
Marc-André Bombeck 7e901e674a fix(ui): own the time picker so it follows the 24-hour preference
The native datetime-local / time picker renders its clock and AM/PM
toggle by the browser UI language, not the page lang or the app's
time-format preference — so an English-language browser showed AM/PM
even with 24-hour selected. Replace the native time control with an
app-controlled TimeField (text entry + a dependency-free popover of
hour/minute, plus AM/PM in 12-hour mode) whose hour cycle always tracks
the preference. DateTimeField now composes the native calendar (date)
with TimeField (time); the committed value stays a local
yyyy-MM-ddTHH:mm string, and min/max clamp the recombined value.

Also thread the time-format preference into the doctor-report PDF so its
generated-at timestamp honours it instead of the locale default.
2026-06-29 19:03:47 +02:00

512 lines
16 KiB
TypeScript

"use client";
import * as React from "react";
import { Clock } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
import { hourCycleOptions } from "@/lib/format-locale";
/**
* App-controlled time input. The committed VALUE is always a 24-hour
* `"HH:mm"` string — identical to the native `<input type="time">` contract —
* so this is a drop-in for it and existing form wiring is unchanged.
*
* Why it exists: the native `<input type="time">` / `datetime-local` picker
* renders its clock (and the AM/PM toggle) according to the BROWSER UI
* language, NOT the page `lang` and NOT the app's time-format preference. A
* user on an English-language browser saw an AM/PM picker even with H24
* selected. This component owns the picker, so the hour cycle always follows
* the user's preference.
*
* How it works mirrors `<DateField>`:
* - A text overlay paints (and lets you type) the time in the user's hour-
* cycle preference (H24 → "14:30", H12 → "2:30 PM", AUTO → locale default).
* Free-typed entry is parsed tolerantly ("1430", "2:30pm", "14:30").
* - A clock button opens a dependency-free Popover with hour / minute (and,
* in 12-hour mode, AM/PM) columns. The columns ALWAYS reflect the
* preference — never the browser.
*
* Height / a11y / target-size parity with `<DateField>`: the same
* `min-h-11 h-11 sm:min-h-10 sm:h-10` floor (WCAG 2.5.5) and field chrome.
*/
const FIELD_HEIGHT_CLASSES = "min-h-11 h-11 sm:min-h-10 sm:h-10";
const FIELD_BASE_CLASSES =
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-within:ring-ring relative flex w-full items-center rounded-md border ps-3 pe-2 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-2 focus-within:ring-offset-2 focus-within:outline-none";
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
export interface TimeFieldProps {
id?: string;
name?: string;
/** 24-hour `"HH:mm"` (controlled). */
value?: string;
/** Initial 24-hour `"HH:mm"` (uncontrolled). */
defaultValue?: string;
onChange?: (value: string) => void;
onBlur?: () => void;
disabled?: boolean;
required?: boolean;
placeholder?: string;
className?: string;
"aria-label"?: string;
"aria-describedby"?: string;
"aria-invalid"?: boolean | "true" | "false";
"aria-required"?: boolean | "true" | "false";
"data-testid"?: string;
}
export const TimeField = React.forwardRef<HTMLInputElement, TimeFieldProps>(
function TimeField(
{
id,
name,
value,
defaultValue,
onChange,
onBlur,
disabled,
required,
placeholder,
className,
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedBy,
"aria-invalid": ariaInvalid,
"aria-required": ariaRequired,
"data-testid": dataTestId,
},
forwardedRef,
) {
const { locale, t } = useTranslations();
const timeFormat = useTimeFormatPreference();
const use12h = prefersTwelveHour(timeFormat, locale);
const innerRef = React.useRef<HTMLInputElement | null>(null);
const setRefs = React.useCallback(
(node: HTMLInputElement | null) => {
innerRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
},
[forwardedRef],
);
const [internal, setInternal] = React.useState(defaultValue ?? "");
const hhmm = value ?? internal;
// Mirror what the user is typing; cleared back to the formatted string
// whenever the committed value changes.
const [typed, setTyped] = React.useState<string | null>(null);
const [open, setOpen] = React.useState(false);
const display = formatHhmm(hhmm, timeFormat, locale);
const overlayValue = typed ?? display;
function commit(next: string) {
if (value === undefined) setInternal(next);
setTyped(null);
onChange?.(next);
}
function commitTyped() {
if (typed === null) return;
const trimmed = typed.trim();
if (trimmed === "") {
commit("");
return;
}
const parsed = parseTypedTime(trimmed);
if (parsed) commit(parsed);
else setTyped(null); // snap back to the committed value
}
const parts = parseHhmm(hhmm);
return (
<Popover open={open} onOpenChange={setOpen}>
<div
className={cn(FIELD_BASE_CLASSES, FIELD_HEIGHT_CLASSES, className)}
data-slot="time-field"
data-disabled={disabled ? "" : undefined}
>
{/* Hidden mirror so the value participates in native form submits. */}
<input
ref={setRefs}
type="hidden"
id={id}
name={name}
value={hhmm}
disabled={disabled}
required={required}
readOnly
/>
{/* Editable display overlay. */}
<input
type="text"
inputMode="numeric"
autoComplete="off"
data-testid={dataTestId}
className="placeholder:text-muted-foreground h-full flex-1 bg-transparent py-1 outline-none disabled:cursor-not-allowed"
value={overlayValue}
placeholder={placeholder ?? (use12h ? "--:-- --" : "--:--")}
disabled={disabled}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
aria-invalid={ariaInvalid}
aria-required={ariaRequired}
onChange={(e) => setTyped(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commitTyped();
}
}}
onBlur={() => {
commitTyped();
onBlur?.();
}}
/>
<PopoverTrigger asChild>
<button
type="button"
tabIndex={-1}
disabled={disabled}
aria-label={t("common.openTimePicker")}
className="text-muted-foreground hover:text-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-md disabled:cursor-not-allowed disabled:opacity-50"
data-slot="time-field-trigger"
>
<Clock className="h-4 w-4" aria-hidden="true" />
</button>
</PopoverTrigger>
</div>
<PopoverContent
align="start"
className="w-auto p-0"
data-slot="time-field-popover"
>
<TimeColumns
use12h={use12h}
parts={parts}
onPick={(next) => commit(next)}
hoursLabel={t("common.hours")}
minutesLabel={t("common.minutes")}
periodLabel={t("common.period")}
amLabel={t("common.am")}
pmLabel={t("common.pm")}
/>
</PopoverContent>
</Popover>
);
},
);
// ────────────────────────────────────────────────────────────────────
// Popover columns
// ────────────────────────────────────────────────────────────────────
interface TimeColumnsProps {
use12h: boolean;
parts: { hour: number; minute: number } | null;
onPick: (hhmm: string) => void;
hoursLabel: string;
minutesLabel: string;
periodLabel: string;
amLabel: string;
pmLabel: string;
}
function TimeColumns({
use12h,
parts,
onPick,
hoursLabel,
minutesLabel,
periodLabel,
amLabel,
pmLabel,
}: TimeColumnsProps) {
const hour = parts?.hour ?? null;
const minute = parts?.minute ?? null;
// The selected period; default to AM so a fresh pick lands somewhere sane.
const isPm = hour !== null ? hour >= 12 : false;
const hourValues = use12h
? Array.from({ length: 12 }, (_, i) => i + 1) // 1..12
: Array.from({ length: 24 }, (_, i) => i); // 0..23
const minuteValues = Array.from({ length: 60 }, (_, i) => i);
// The hour cell currently highlighted, expressed in the column's own space.
const selectedHourCell =
hour === null ? null : use12h ? to12hHour(hour) : hour;
function pickHour(cell: number) {
const baseMinute = minute ?? 0;
const h24 = use12h ? from12hHour(cell, isPm) : cell;
onPick(`${pad(h24)}:${pad(baseMinute)}`);
}
function pickMinute(m: number) {
const baseHour = hour ?? (use12h ? 0 : 0);
onPick(`${pad(baseHour)}:${pad(m)}`);
}
function pickPeriod(pm: boolean) {
const cell = selectedHourCell ?? 12; // 12 = 12 AM / 12 PM anchor
const h24 = from12hHour(cell, pm);
onPick(`${pad(h24)}:${pad(minute ?? 0)}`);
}
return (
<div className="flex" data-slot="time-columns">
<Column
label={hoursLabel}
values={hourValues}
selected={selectedHourCell}
onPick={pickHour}
format={(v) => (use12h ? String(v) : pad(v))}
testid="time-field-hours"
/>
<Column
label={minutesLabel}
values={minuteValues}
selected={minute}
onPick={pickMinute}
format={(v) => pad(v)}
testid="time-field-minutes"
/>
{use12h && (
<div
className="flex flex-col border-s"
role="group"
aria-label={periodLabel}
data-slot="time-field-period"
>
<span className="text-muted-foreground px-3 pt-2 pb-1 text-xs font-medium">
{periodLabel}
</span>
<div className="flex flex-col gap-0.5 p-1">
<PeriodButton
active={hour !== null && !isPm}
label={amLabel}
onClick={() => pickPeriod(false)}
/>
<PeriodButton
active={hour !== null && isPm}
label={pmLabel}
onClick={() => pickPeriod(true)}
/>
</div>
</div>
)}
</div>
);
}
interface ColumnProps {
label: string;
values: number[];
selected: number | null;
onPick: (value: number) => void;
format: (value: number) => string;
testid: string;
}
function Column({
label,
values,
selected,
onPick,
format,
testid,
}: ColumnProps) {
const listRef = React.useRef<HTMLDivElement | null>(null);
const activeRef = React.useRef<HTMLButtonElement | null>(null);
// Bring the selected cell into view when the popover mounts.
React.useEffect(() => {
activeRef.current?.scrollIntoView({ block: "center" });
}, []);
return (
<div
className="flex flex-col not-last:border-e"
role="group"
aria-label={label}
>
<span className="text-muted-foreground px-3 pt-2 pb-1 text-xs font-medium">
{label}
</span>
<div
ref={listRef}
className="flex max-h-56 flex-col gap-0.5 overflow-y-auto p-1"
data-slot={testid}
>
{values.map((v) => {
const isActive = selected === v;
return (
<button
key={v}
ref={isActive ? activeRef : undefined}
type="button"
aria-pressed={isActive}
data-active={isActive ? "true" : "false"}
onClick={() => onPick(v)}
className={cn(
"focus-visible:ring-ring w-12 rounded-md px-2 py-1.5 text-center text-sm tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none",
isActive
? "bg-primary text-primary-foreground"
: "text-foreground hover:bg-muted",
)}
>
{format(v)}
</button>
);
})}
</div>
</div>
);
}
function PeriodButton({
active,
label,
onClick,
}: {
active: boolean;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
aria-pressed={active}
data-active={active ? "true" : "false"}
onClick={onClick}
className={cn(
"focus-visible:ring-ring w-12 rounded-md px-2 py-1.5 text-center text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none",
active
? "bg-primary text-primary-foreground"
: "text-foreground hover:bg-muted",
)}
>
{label}
</button>
);
}
// ────────────────────────────────────────────────────────────────────
// Pure helpers — exported for unit tests
// ────────────────────────────────────────────────────────────────────
/** AUTO follows the locale (en → 12h, every other shipped locale → 24h). */
export function prefersTwelveHour(
timeFormat: ReturnType<typeof useTimeFormatPreference>,
locale: string,
): boolean {
if (timeFormat === "H12") return true;
if (timeFormat === "H24") return false;
return locale === "en"; // AUTO
}
/** Split a 24-hour `"HH:mm"` string into parts, or null when malformed. */
export function parseHhmm(
value: string,
): { hour: number; minute: number } | null {
const m = TIME_RE.exec(value.trim());
if (!m) return null;
return { hour: Number(m[1]), minute: Number(m[2]) };
}
/**
* Format a 24-hour `"HH:mm"` string for display in the user's hour cycle.
* Returns "" for an empty / malformed value so the field paints its
* placeholder. Renders through `Intl` with the preference's hour cycle so the
* AM/PM affix (or its absence) matches every other clock in the app.
*/
export function formatHhmm(
value: string,
timeFormat: ReturnType<typeof useTimeFormatPreference>,
locale: string,
): string {
const parts = parseHhmm(value);
if (!parts) return "";
// Anchor on a fixed UTC date; only the wall-clock parts matter.
const date = new Date(Date.UTC(2000, 0, 1, parts.hour, parts.minute));
return date.toLocaleTimeString(locale, {
timeZone: "UTC",
hour: "2-digit",
minute: "2-digit",
...hourCycleOptions(timeFormat),
});
}
/**
* Tolerant parse of free-typed time into a 24-hour `"HH:mm"` string. Accepts
* "14:30", "1430", "2:30 pm", "2.30pm", "9" (→ 09:00). Returns null when the
* components don't form a real wall-clock time.
*/
export function parseTypedTime(input: string): string | null {
const raw = input.trim().toLowerCase();
if (raw === "") return null;
let pm: boolean | null = null;
let body = raw;
if (/\bp\.?m\.?$/.test(body) || body.endsWith("pm") || body.endsWith("p")) {
pm = true;
} else if (
/\ba\.?m\.?$/.test(body) ||
body.endsWith("am") ||
body.endsWith("a")
) {
pm = false;
}
body = body.replace(/[ap]\.?m?\.?$/i, "").trim();
const digits = body.replace(/[^\d]/g, "");
if (digits.length === 0) return null;
let hour: number;
let minute: number;
if (body.includes(":") || body.includes(".")) {
const [h, m] = body.split(/[.:]/);
hour = Number(h);
minute = m === undefined || m === "" ? 0 : Number(m);
} else if (digits.length <= 2) {
hour = Number(digits);
minute = 0;
} else {
// "1430" → 14:30, "930" → 9:30
hour = Number(digits.slice(0, digits.length - 2));
minute = Number(digits.slice(-2));
}
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
if (pm !== null) {
if (hour < 1 || hour > 12) return null;
hour = from12hHour(hour, pm);
}
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
return `${pad(hour)}:${pad(minute)}`;
}
/** 24-hour hour → its 12-hour clock cell (0/12 → 12, 13 → 1, …). */
export function to12hHour(hour24: number): number {
const h = hour24 % 12;
return h === 0 ? 12 : h;
}
/** 12-hour clock cell + period → 24-hour hour. */
export function from12hHour(cell: number, pm: boolean): number {
const base = cell % 12; // 12 → 0
return pm ? base + 12 : base;
}
function pad(value: number): string {
return String(value).padStart(2, "0");
}