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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-26 12:15:22 +02:00
1 parent 9b7980d020
commit d6430528e9
2 files changed
+375

No files matched your search

@@ -0,0 +1,87 @@
/**
* `<DateField>` 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 `<DateInput>` 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(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<DateField>", () => {
it("keeps the ISO value on a hidden native date input", () => {
const html = render(<DateField id="dob" name="dob" value="2026-02-19" />);
// The real value lives on a type=date input (the form's source of truth).
expect(html).toMatch(/<input[^>]*type="date"[^>]*value="2026-02-19"/);
expect(html).toContain('name="dob"');
// …and it is visually hidden so only the overlay shows.
expect(html).toMatch(/<input[^>]*type="date"[^>]*class="[^"]*sr-only/);
});
it("paints the AUTO-locale display string over the value (en → MM/dd/yyyy)", () => {
const html = render(<DateField value="2026-02-19" />, "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(<DateField value="2026-02-19" />, "de");
expect(html).toContain('value="19.02.2026"');
});
it("renders the placeholder shape when empty", () => {
const html = render(<DateField value="" placeholder="Pick a date" />);
expect(html).toContain('placeholder="Pick a date"');
});
it("derives a format-shaped placeholder when none is supplied (en → MM/DD/YYYY)", () => {
const html = render(<DateField value="" />, "en");
expect(html).toContain('placeholder="MM/DD/YYYY"');
});
it("threads disabled, min and max onto the native input", () => {
const html = render(
<DateField
value="2026-02-19"
disabled
min="2020-01-01"
max="2030-12-31"
/>,
);
expect(html).toMatch(/<input[^>]*type="date"[^>]*min="2020-01-01"/);
expect(html).toMatch(/<input[^>]*type="date"[^>]*max="2030-12-31"/);
expect(html).toMatch(/<input[^>]*type="date"[^>]*disabled/);
});
it("ships the WCAG target-size + height-parity classes", () => {
const html = render(<DateField value="2026-02-19" />);
// Same 44px mobile / 40px sm+ floor as <DateInput>.
expect(html).toContain("min-h-11");
expect(html).toContain("sm:h-10");
});
it("exposes a labelled calendar picker affordance", () => {
const html = render(<DateField value="2026-02-19" />);
expect(html).toContain('aria-label="Open date picker"');
});
});
+288
View File
@@ -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
* `<input type="date">` contract `<DateInput>` ships — so this is a drop-in
* for `<DateInput>` and existing react-hook-form wiring is unchanged.
*
* How it works:
* - A visually-hidden native `<input type="date">` 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 `<DateInput>`: 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 `<DateInput>` 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<HTMLInputElement, DateFieldProps>(
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<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],
);
// 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<string | null>(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<HTMLInputElement>) {
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 (
<div
className={cn(FIELD_BASE_CLASSES, FIELD_HEIGHT_CLASSES, className)}
data-slot="date-field"
data-disabled={disabled ? "" : undefined}
>
{/* Visually-hidden native date input — owns the real value + events. */}
<input
{...rest}
ref={setRefs}
id={id}
name={name}
type="date"
lang={locale}
className="sr-only"
tabIndex={-1}
aria-hidden="true"
value={value !== undefined ? value : undefined}
defaultValue={value === undefined ? defaultValue : undefined}
disabled={disabled}
required={required}
min={min}
max={max}
onChange={handleNativeChange}
/>
{/* 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 ?? formatPlaceholder(dateFormat, locale)}
disabled={disabled}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
aria-invalid={ariaInvalid}
onChange={(e) => setTyped(e.target.value)}
onClick={openPicker}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commitTyped();
}
}}
onBlur={() => {
commitTyped();
onBlur?.();
}}
/>
<button
type="button"
tabIndex={-1}
disabled={disabled}
aria-label={t("common.openDatePicker")}
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"
onClick={openPicker}
>
<Calendar className="h-4 w-4" aria-hidden="true" />
</button>
</div>
);
},
);
/** Placeholder that mirrors the active field order, e.g. "DD.MM.YYYY". */
function formatPlaceholder(
dateFormat: ReturnType<typeof useDateFormatPreference>,
locale: ReturnType<typeof useTranslations>["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<typeof useDateFormatPreference>,
locale: ReturnType<typeof useTranslations>["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<typeof useDateFormatPreference>,
locale: ReturnType<typeof useTranslations>["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");
}