From d6430528e9cc139febeaf42d6a2a107b6a62711f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:15:22 +0200 Subject: [PATCH] feat(ui): add the DateField date input Dependency-free date input that displays the value in the user's date-format preference while editing rides the native picker behind a calendar button. The committed value stays ISO yyyy-MM-dd, so it is a drop-in for DateInput. Height/target-size parity, disabled/min/max/ placeholder, and progressive typed entry parsed against the order. --- .../ui/__tests__/date-field.test.tsx | 87 ++++++ src/components/ui/date-field.tsx | 288 ++++++++++++++++++ 2 files changed, 375 insertions(+) create mode 100644 src/components/ui/__tests__/date-field.test.tsx create mode 100644 src/components/ui/date-field.tsx diff --git a/src/components/ui/__tests__/date-field.test.tsx b/src/components/ui/__tests__/date-field.test.tsx new file mode 100644 index 000000000..b789f42da --- /dev/null +++ b/src/components/ui/__tests__/date-field.test.tsx @@ -0,0 +1,87 @@ +/** + * `` contract (v1.21.0). + * + * The SSR-only convention (no `@testing-library/react`, `environment: "node"`) + * means the interactive picker / typed-entry paths can't be driven here — those + * ride `showPicker()` + change events the static renderer never fires. What the + * static markup CAN pin is the load-bearing contract: + * + * - the committed VALUE stays ISO `yyyy-MM-dd` on a hidden native date input, + * so this is a drop-in for `` and react-hook-form is unchanged; + * - the visible overlay paints the value formatted per the preference; + * - disabled / min / max / placeholder thread through; + * - height + target-size parity classes are present. + * + * Under SSR `useDateFormatPreference()` resolves AUTO (no `window`), so the + * overlay reflects the locale order — de → dd.MM.yyyy, en → MM/dd/yyyy. The + * per-preference order itself is covered exhaustively in + * `src/lib/__tests__/date-format.test.ts` against the pure formatter. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { DateField } from "../date-field"; + +function render(node: React.ReactNode, locale: "de" | "en" = "en"): string { + return renderToStaticMarkup( + {node}, + ); +} + +describe("", () => { + it("keeps the ISO value on a hidden native date input", () => { + const html = render(); + // The real value lives on a type=date input (the form's source of truth). + expect(html).toMatch(/]*type="date"[^>]*value="2026-02-19"/); + expect(html).toContain('name="dob"'); + // …and it is visually hidden so only the overlay shows. + expect(html).toMatch(/]*type="date"[^>]*class="[^"]*sr-only/); + }); + + it("paints the AUTO-locale display string over the value (en → MM/dd/yyyy)", () => { + const html = render(, "en"); + expect(html).toContain('value="02/19/2026"'); + }); + + it("paints the AUTO-locale display string over the value (de → dd.MM.yyyy)", () => { + const html = render(, "de"); + expect(html).toContain('value="19.02.2026"'); + }); + + it("renders the placeholder shape when empty", () => { + const html = render(); + expect(html).toContain('placeholder="Pick a date"'); + }); + + it("derives a format-shaped placeholder when none is supplied (en → MM/DD/YYYY)", () => { + const html = render(, "en"); + expect(html).toContain('placeholder="MM/DD/YYYY"'); + }); + + it("threads disabled, min and max onto the native input", () => { + const html = render( + , + ); + expect(html).toMatch(/]*type="date"[^>]*min="2020-01-01"/); + expect(html).toMatch(/]*type="date"[^>]*max="2030-12-31"/); + expect(html).toMatch(/]*type="date"[^>]*disabled/); + }); + + it("ships the WCAG target-size + height-parity classes", () => { + const html = render(); + // Same 44px mobile / 40px sm+ floor as . + expect(html).toContain("min-h-11"); + expect(html).toContain("sm:h-10"); + }); + + it("exposes a labelled calendar picker affordance", () => { + const html = render(); + expect(html).toContain('aria-label="Open date picker"'); + }); +}); diff --git a/src/components/ui/date-field.tsx b/src/components/ui/date-field.tsx new file mode 100644 index 000000000..049fe5884 --- /dev/null +++ b/src/components/ui/date-field.tsx @@ -0,0 +1,288 @@ +"use client"; + +import * as React from "react"; +import { Calendar } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { useTranslations, useDateFormatPreference } from "@/lib/i18n/context"; +import { formatDate, parseIsoDate } from "@/lib/date-format"; + +/** + * Dependency-free date input that DISPLAYS the value in the user's date-order + * preference (AUTO follows the locale; DMY / MDY / YMD pin the field order) + * while editing rides the browser's native date picker. + * + * The committed VALUE is always ISO `yyyy-MM-dd` — identical to the native + * `` contract `` ships — so this is a drop-in + * for `` and existing react-hook-form wiring is unchanged. + * + * How it works: + * - A visually-hidden native `` holds the real value and + * emits the change events the form listens to. Tapping the field (or its + * calendar button) calls `showPicker()` on that input, falling back to + * `focus()` where the API is unavailable. + * - A text overlay paints the formatted display string (or the placeholder). + * - As a progressive enhancement the user can type a date directly: the + * overlay is an editable text input; on blur / Enter we parse what they + * typed against the active locale order and, on a clean parse, write the + * ISO value back through the hidden input so the form sees a normal change. + * + * Height / a11y / target-size parity with ``: the same + * `min-h-11 h-11 sm:min-h-10 sm:h-10` floor (WCAG 2.5.5 — 44 px on mobile, + * 40 px on sm+) and focus vocabulary as the rest of the input primitives. + */ + +// Mirrors the `` height contract (minus the date-shadow rule, which +// only applies to the native value text — here the native input is hidden). +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"; + +export interface DateFieldProps { + id?: string; + name?: string; + /** ISO `yyyy-MM-dd` (controlled). */ + value?: string; + /** Initial ISO `yyyy-MM-dd` (uncontrolled). */ + defaultValue?: string; + onChange?: (value: string) => void; + onBlur?: () => void; + disabled?: boolean; + required?: boolean; + /** ISO `yyyy-MM-dd`. */ + min?: string; + /** ISO `yyyy-MM-dd`. */ + max?: string; + placeholder?: string; + className?: string; + "aria-label"?: string; + "aria-describedby"?: string; + "aria-invalid"?: boolean | "true" | "false"; + "data-testid"?: string; +} + +export const DateField = React.forwardRef( + function DateField( + { + id, + name, + value, + defaultValue, + onChange, + onBlur, + disabled, + required, + min, + max, + placeholder, + className, + "aria-label": ariaLabel, + "aria-describedby": ariaDescribedBy, + "aria-invalid": ariaInvalid, + "data-testid": dataTestId, + ...rest + }, + forwardedRef, + ) { + const { locale, t } = useTranslations(); + const dateFormat = useDateFormatPreference(); + + const innerRef = React.useRef(null); + const setRefs = React.useCallback( + (node: HTMLInputElement | null) => { + innerRef.current = node; + if (typeof forwardedRef === "function") forwardedRef(node); + else if (forwardedRef) forwardedRef.current = node; + }, + [forwardedRef], + ); + + // Track the ISO value so the overlay reflects controlled + uncontrolled + // updates. Controlled `value` always wins on render. + const [internal, setInternal] = React.useState(defaultValue ?? ""); + const iso = value ?? internal; + + // Mirror what the user is typing into the overlay; cleared back to the + // formatted string whenever the committed ISO value changes. + const [typed, setTyped] = React.useState(null); + + const display = formatDate(iso, dateFormat, locale); + const overlayValue = typed ?? display; + + function commitIso(next: string) { + if (value === undefined) setInternal(next); + setTyped(null); + onChange?.(next); + } + + function openPicker() { + const el = innerRef.current; + if (!el || disabled) return; + // showPicker() throws if not user-activated or unsupported — fall back + // to focusing the (hidden) input, which still opens the picker on most + // mobile engines. + try { + (el as HTMLInputElement & { showPicker?: () => void }).showPicker?.(); + } catch { + el.focus(); + } + } + + function handleNativeChange(e: React.ChangeEvent) { + commitIso(e.target.value); + } + + // Progressive enhancement: parse free-typed text against the active locale + // order. We feed the typed string to the browser's own parser via a throw- + // away Date only when it matches the ISO shape; otherwise we accept a small + // set of separator-flexible numeric forms keyed off the preference. + function commitTyped() { + if (typed === null) return; + const trimmed = typed.trim(); + if (trimmed === "") { + commitIso(""); + return; + } + const parsed = parseTypedDate(trimmed, dateFormat, locale); + if (parsed) { + commitIso(parsed); + } else { + // Unparseable — discard the draft and snap back to the committed value. + setTyped(null); + } + } + + return ( +
+ {/* Visually-hidden native date input — owns the real value + events. */} + + {/* Editable display overlay. */} + setTyped(e.target.value)} + onClick={openPicker} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitTyped(); + } + }} + onBlur={() => { + commitTyped(); + onBlur?.(); + }} + /> + +
+ ); + }, +); + +/** Placeholder that mirrors the active field order, e.g. "DD.MM.YYYY". */ +function formatPlaceholder( + dateFormat: ReturnType, + locale: ReturnType["locale"], +): string { + // 2026-12-31 renders unambiguously in every order, so its formatted shape + // doubles as a self-describing placeholder hint. + return formatDate("2026-12-31", dateFormat, locale) + .replace(/31/g, "DD") + .replace(/12/g, "MM") + .replace(/2026/g, "YYYY"); +} + +/** + * Best-effort parse of a free-typed date into ISO `yyyy-MM-dd`. Accepts the + * active preference's field order with `.`, `/`, or `-` separators, plus a + * bare ISO string. Returns null when the parts don't form a real calendar + * date so the caller can reject the draft. + */ +function parseTypedDate( + input: string, + dateFormat: ReturnType, + locale: ReturnType["locale"], +): string | null { + // A clean ISO string always wins. + const isoDirect = parseIsoDate(input); + if (isoDirect) return toIso(isoDirect); + + const parts = input.split(/[./-]/).map((p) => p.trim()); + if (parts.length !== 3 || parts.some((p) => p === "" || !/^\d+$/.test(p))) { + return null; + } + const nums = parts.map(Number) as [number, number, number]; + + const order = resolveOrder(dateFormat, locale); + let year: number, month: number, day: number; + if (order === "MDY") [month, day, year] = nums; + else if (order === "YMD") [year, month, day] = nums; + else [day, month, year] = nums; // DMY + + if (year < 100) year += year >= 70 ? 1900 : 2000; + const candidate = `${pad(year, 4)}-${pad(month, 2)}-${pad(day, 2)}`; + const round = parseIsoDate(candidate); + return round ? candidate : null; +} + +function resolveOrder( + dateFormat: ReturnType, + locale: ReturnType["locale"], +): "DMY" | "MDY" | "YMD" { + if (dateFormat === "DMY") return "DMY"; + if (dateFormat === "MDY") return "MDY"; + if (dateFormat === "YMD") return "YMD"; + // AUTO: en → MDY, every other shipped locale → DMY. + return locale === "en" ? "MDY" : "DMY"; +} + +function toIso(date: Date): string { + return `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1, 2)}-${pad( + date.getUTCDate(), + 2, + )}`; +} + +function pad(value: number, width: number): string { + return String(value).padStart(width, "0"); +}