feat(ui): swap the date field's native picker for an app-controlled calendar

The date field opened the browser's native date picker, which rendered by
the browser and kept its intrinsic width on mobile. Replace it with the
shadcn calendar in a popover: a fully app-controlled react-day-picker
calendar that looks the same everywhere and never overflows a phone-width
sheet. The field keeps its preference-formatted, typeable display and its
yyyy-MM-dd value contract, so the date-time field, the time field and
every form that uses them are unchanged. The calendar's month and weekday
labels follow the UI language.
This commit is contained in:
Marc-André Bombeck committed 2026-06-30 20:47:23 +02:00
1 parent 5a96b58439
commit 09be190f9f
6 files changed
+483 -138

No files matched your search

+2
View File
@@ -67,6 +67,7 @@
"@types/sax": "^1.2.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"dotenv": "^17.4.2",
"fflate": "^0.8.3",
"jspdf": "^4.2.1",
@@ -83,6 +84,7 @@
"qrcode": "^1.5.4",
"radix-ui": "^1.6.0",
"react": "19.2.7",
"react-day-picker": "^10.0.1",
"react-dom": "19.2.7",
"react-hook-form": "^7.78.0",
"recharts": "^3.8.1",
+39 -5
View File
@@ -60,6 +60,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
date-fns:
specifier: ^4.4.0
version: 4.4.0
dotenv:
specifier: ^17.4.2
version: 17.4.2
@@ -80,7 +83,7 @@ importers:
version: 3.0.2
next:
specifier: 16.2.9
version: 16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 16.2.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -108,6 +111,9 @@ importers:
react:
specifier: 19.2.7
version: 19.2.7
react-day-picker:
specifier: ^10.0.1
version: 10.0.1(@types/react@19.2.14)(react@19.2.7)
react-dom:
specifier: 19.2.7
version: 19.2.7(react@19.2.7)
@@ -564,6 +570,9 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@date-fns/tz@1.5.0':
resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==}
'@discoveryjs/json-ext@0.5.7':
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
engines: {node: '>=10.0.0'}
@@ -3777,6 +3786,9 @@ packages:
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
engines: {node: '>= 0.4'}
date-fns@4.4.0:
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
debounce@1.2.1:
resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==}
@@ -5776,6 +5788,16 @@ packages:
rc9@3.0.1:
resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==}
react-day-picker@10.0.1:
resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==}
engines: {node: '>=18'}
peerDependencies:
'@types/react': '>=16.8.0'
react: '>=16.8.0'
peerDependenciesMeta:
'@types/react':
optional: true
react-dom@19.2.7:
resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==}
peerDependencies:
@@ -7339,6 +7361,8 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@date-fns/tz@1.5.0': {}
'@discoveryjs/json-ext@0.5.7': {}
'@dnd-kit/accessibility@3.1.1(react@19.2.7)':
@@ -10393,6 +10417,8 @@ snapshots:
es-errors: 1.3.0
is-data-view: 1.0.2
date-fns@4.4.0: {}
debounce@1.2.1: {}
debug@3.2.7:
@@ -11944,7 +11970,7 @@ snapshots:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
next@16.2.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
'@next/env': 16.2.9
'@swc/helpers': 0.5.15
@@ -11953,7 +11979,7 @@ snapshots:
postcss: 8.4.31
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.7)
styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.7)
optionalDependencies:
'@next/swc-darwin-arm64': 16.2.9
'@next/swc-darwin-x64': 16.2.9
@@ -12534,6 +12560,14 @@ snapshots:
defu: 6.1.7
destr: 2.0.5
react-day-picker@10.0.1(@types/react@19.2.14)(react@19.2.7):
dependencies:
'@date-fns/tz': 1.5.0
date-fns: 4.4.0
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.14
react-dom@19.2.7(react@19.2.7):
dependencies:
react: 19.2.7
@@ -13143,12 +13177,12 @@ snapshots:
strip-json-comments@5.0.3: {}
styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.7):
styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.7):
dependencies:
client-only: 0.0.1
react: 19.2.7
optionalDependencies:
'@babel/core': 7.29.7
'@babel/core': 7.29.0
supports-color@7.2.0:
dependencies:
+32 -19
View File
@@ -1,21 +1,29 @@
/**
* `<DateField>` contract (v1.21.0).
* `<DateField>` contract (v1.25.11 — app-controlled calendar).
*
* 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 native `<input type="date">` picker is gone: the date field now opens a
* shadcn `<Calendar>` (react-day-picker) inside a `<Popover>`, so the picker
* renders identically on every browser / OS. The popover content only mounts
* once the popover is open, so under the SSR-only convention (no
* `@testing-library/react`, `environment: "node"`, `renderToStaticMarkup`) the
* calendar itself isn't in the static markup — the same way the old picker /
* typed-entry paths could never be driven here. 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 committed VALUE stays ISO `yyyy-MM-dd` on a hidden mirror input
* carrying `name`, 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.
* - disabled / placeholder thread through;
* - height + target-size parity classes are present;
* - the calendar popover trigger is exposed as a labelled affordance.
*
* 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.
* `src/lib/__tests__/date-format.test.ts` against the pure formatter; the
* min/max bounds (calendar matcher + ISO clamp) ride the interactive picker
* and are not observable in the static markup.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -30,13 +38,14 @@ function render(node: React.ReactNode, locale: "de" | "en" = "en"): string {
}
describe("<DateField>", () => {
it("keeps the ISO value on a hidden native date input", () => {
it("keeps the ISO value on a hidden mirror input carrying the name", () => {
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"/);
// The real value lives on a hidden mirror input (the form's source of
// truth + native-submit carrier), not a native date picker anymore.
expect(html).toMatch(/<input[^>]*type="hidden"[^>]*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/);
// …and there is no native date picker in the tree.
expect(html).not.toContain('type="date"');
});
it("paints the AUTO-locale display string over the value (en → MM/dd/yyyy)", () => {
@@ -59,7 +68,7 @@ describe("<DateField>", () => {
expect(html).toContain('placeholder="MM/DD/YYYY"');
});
it("threads disabled, min and max onto the native input", () => {
it("threads disabled onto the overlay and the hidden mirror input", () => {
const html = render(
<DateField
value="2026-02-19"
@@ -68,9 +77,13 @@ describe("<DateField>", () => {
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/);
// Both the visible overlay and the hidden mirror reflect the disabled state.
expect(html).toMatch(/<input[^>]*type="hidden"[^>]*disabled/);
expect(html).toMatch(/disabled/);
// min/max are enforced by the calendar matcher + ISO clamp, not by a native
// input attribute, so they no longer appear in the static markup.
expect(html).not.toContain('min="2020-01-01"');
expect(html).not.toContain('max="2030-12-31"');
});
it("ships the WCAG target-size + height-parity classes", () => {
@@ -1,18 +1,20 @@
/**
* `<DateTimeField>` contract (v1.25.4).
*
* The field now composes a native-calendar `<DateField>` (date part) with the
* app-controlled `<TimeField>` (time part) so the time picker always follows
* the user's hour-cycle preference rather than the browser UI language. Like
* `<DateField>`, the SSR-only convention (`renderToStaticMarkup`,
* `environment: "node"`) means the interactive picker path can't be driven
* here. What the static markup pins is the load-bearing contract:
* The field now composes an app-controlled `<DateField>` (date part, a shadcn
* `<Calendar>` in a popover) with the app-controlled `<TimeField>` (time part)
* so both pickers follow the user's preferences rather than the browser UI
* language and render identically on every OS. Like `<DateField>`, the SSR-only
* convention (`renderToStaticMarkup`, `environment: "node"`) means the
* interactive picker path can't be driven here. What the static markup pins is
* the load-bearing contract:
*
* - the committed VALUE stays a local `yyyy-MM-ddTHH:mm` string on a hidden
* input carrying `name`, so this is a drop-in for the old field;
* - the date overlay paints the date-order preference; the time overlay paints
* the hour-cycle preference;
* - disabled threads through; min / max gate the native calendar by date part;
* - disabled threads through; min / max gate the calendar (matcher + ISO
* clamp) and are not observable in the static markup;
* - height + target-size parity classes are present on both halves.
*
* Under SSR both preferences resolve AUTO (no `window`), so each half follows
@@ -59,7 +61,7 @@ describe("<DateTimeField>", () => {
expect(html).toContain('placeholder="Pick a moment"');
});
it("gates the native calendar by the min/max date part and threads disabled", () => {
it("threads disabled onto both halves; min/max ride the calendar matcher", () => {
const html = render(
<DateTimeField
value="2026-12-31T14:05"
@@ -68,9 +70,14 @@ describe("<DateTimeField>", () => {
max="2030-12-31T23:59"
/>,
);
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/);
// No native date picker anymore — the date half is the hidden mirror +
// overlay, both disabled.
expect(html).not.toContain('type="date"');
expect(html).toMatch(/<input[^>]*type="hidden"[^>]*disabled/);
// min/max gate the popover calendar (matcher) + ISO clamp, so the date-part
// bounds are not present as native input attributes in the static markup.
expect(html).not.toContain('min="2020-01-01"');
expect(html).not.toContain('max="2030-12-31"');
});
it("ships the WCAG target-size + height-parity classes on both halves", () => {
+220
View File
@@ -0,0 +1,220 @@
"use client";
import * as React from "react";
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react";
import {
DayPicker,
getDefaultClassNames,
type DayButton,
} from "react-day-picker";
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months,
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav,
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_next,
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption,
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns,
),
dropdown_root: cn(
"relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50",
defaultClassNames.dropdown_root,
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown,
),
caption_label: cn(
"font-medium select-none",
captionLayout === "label"
? "text-sm"
: "flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label,
),
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none",
defaultClassNames.weekday,
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-(--cell-size) select-none",
defaultClassNames.week_number_header,
),
week_number: cn(
"text-[0.8rem] text-muted-foreground select-none",
defaultClassNames.week_number,
),
day: cn(
"group/day relative aspect-square h-full w-full p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-md",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day,
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start,
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none",
defaultClassNames.today,
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside,
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled,
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
);
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
);
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
);
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
);
},
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className,
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };
+172 -103
View File
@@ -1,31 +1,43 @@
"use client";
import * as React from "react";
import { Calendar } from "lucide-react";
import { CalendarIcon } from "lucide-react";
import type { Matcher } from "react-day-picker";
import { de, enUS, es, fr, it, pl } from "date-fns/locale";
import type { Locale as DateFnsLocale } from "date-fns";
import { cn } from "@/lib/utils";
import { useTranslations, useDateFormatPreference } from "@/lib/i18n/context";
import { formatDate, parseIsoDate } from "@/lib/date-format";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
/**
* 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.
* while editing rides a fully app-controlled calendar — no 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.
* - A text overlay paints the formatted display string (or the placeholder)
* and stays editable: the user can type a date directly. On blur / Enter we
* parse what they typed against the active locale order and, on a clean
* parse, commit the ISO value through `commitIso`.
* - The calendar button is a `<Popover>` trigger; the popover holds the
* shadcn `<Calendar>` (react-day-picker). Picking a day commits the ISO
* value and closes the popover. The calendar renders identically on every
* browser / OS — the native picker's per-platform chrome (and its mobile
* quirks) is gone.
* - A hidden mirror input carries `name` + the ISO value so any caller that
* relies on native form submission still sees a normal field; the forwarded
* ref points at it for react-hook-form `register()` parity.
*
* 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,
@@ -33,7 +45,7 @@ import { formatDate, parseIsoDate } from "@/lib/date-format";
*/
// Mirrors the `<DateInput>` height contract (minus the date-shadow rule, which
// only applies to the native value text — here the native input is hidden).
// only applies to the native value text — here no native input is rendered).
const FIELD_HEIGHT_CLASSES = "min-h-11 h-11 sm:min-h-10 sm:h-10";
const FIELD_BASE_CLASSES =
@@ -50,7 +62,7 @@ export interface DateFieldProps {
onBlur?: () => void;
disabled?: boolean;
required?: boolean;
/** Autofill hint forwarded to the native date input (e.g. "bday"). */
/** Autofill hint forwarded to the hidden mirror input (e.g. "bday"). */
autoComplete?: string;
/** ISO `yyyy-MM-dd`. */
min?: string;
@@ -91,16 +103,6 @@ export const DateField = React.forwardRef<HTMLInputElement, DateFieldProps>(
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 ?? "");
@@ -110,30 +112,21 @@ export const DateField = React.forwardRef<HTMLInputElement, DateFieldProps>(
// formatted string whenever the committed ISO value changes.
const [typed, setTyped] = React.useState<string | null>(null);
// Calendar popover open state — selecting a day commits + closes.
const [open, setOpen] = React.useState(false);
const display = formatDate(iso, dateFormat, locale);
const overlayValue = typed ?? display;
const minDate = min ? isoToDate(min) : undefined;
const maxDate = max ? isoToDate(max) : undefined;
const selectedDate = isoToDate(iso);
function commitIso(next: string) {
if (value === undefined) setInternal(next);
const clamped = clampIso(next, min, max);
if (value === undefined) setInternal(clamped);
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);
onChange?.(clamped);
}
// Progressive enhancement: parse free-typed text against the active locale
@@ -156,69 +149,84 @@ export const DateField = React.forwardRef<HTMLInputElement, DateFieldProps>(
}
}
// react-day-picker gates out-of-range days; `commitIso` still clamps typed
// entry, so the two agree on the bounds.
const disabledMatchers: Matcher[] = [];
if (minDate) disabledMatchers.push({ before: minDate });
if (maxDate) disabledMatchers.push({ after: maxDate });
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}
autoComplete={autoComplete}
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}
<Popover open={open} onOpenChange={setOpen}>
<div
className={cn(FIELD_BASE_CLASSES, FIELD_HEIGHT_CLASSES, className)}
data-slot="date-field"
data-disabled={disabled ? "" : undefined}
>
<Calendar className="h-4 w-4" aria-hidden="true" />
</button>
</div>
{/* Hidden mirror — carries `name` + value for native submits and is
the target of the forwarded ref. */}
<input
{...rest}
ref={forwardedRef}
id={id}
name={name}
type="hidden"
value={iso}
disabled={disabled}
required={required}
autoComplete={autoComplete}
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 ?? formatPlaceholder(dateFormat, locale)}
disabled={disabled}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
aria-invalid={ariaInvalid}
onChange={(e) => setTyped(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commitTyped();
}
}}
onBlur={() => {
commitTyped();
onBlur?.();
}}
/>
<PopoverTrigger asChild>
<button
type="button"
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"
>
<CalendarIcon className="h-4 w-4" aria-hidden="true" />
</button>
</PopoverTrigger>
</div>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selectedDate}
onSelect={(d) => {
commitIso(d ? dateToIso(d) : "");
setOpen(false);
}}
disabled={disabledMatchers.length ? disabledMatchers : undefined}
defaultMonth={selectedDate ?? new Date()}
locale={resolveDateFnsLocale(locale)}
autoFocus
/>
</PopoverContent>
</Popover>
);
},
);
@@ -280,6 +288,67 @@ function resolveOrder(
return locale === "en" ? "MDY" : "DMY";
}
/**
* Map the app locale → the date-fns locale so the calendar's month / weekday
* names match the UI language. Defaults to enUS for anything unmapped.
*/
function resolveDateFnsLocale(
locale: ReturnType<typeof useTranslations>["locale"],
): DateFnsLocale {
switch (locale) {
case "de":
return de;
case "es":
return es;
case "fr":
return fr;
case "it":
return it;
case "pl":
return pl;
default:
return enUS;
}
}
/**
* ISO `yyyy-MM-dd` → a LOCAL `Date` at midnight (no UTC shift — the value is a
* plain calendar date, so it must read back as the same day in the user's
* zone). Returns undefined for an empty / malformed value.
*/
function isoToDate(value: string): Date | undefined {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
if (!match) return undefined;
const [, y, m, d] = match;
const date = new Date(Number(y), Number(m) - 1, Number(d));
// Reject rollover (e.g. 2026-02-31): a real date round-trips its parts.
if (
date.getFullYear() !== Number(y) ||
date.getMonth() !== Number(m) - 1 ||
date.getDate() !== Number(d)
) {
return undefined;
}
return date;
}
/** LOCAL `Date` → ISO `yyyy-MM-dd` read in the user's own zone (no UTC shift). */
function dateToIso(date: Date): string {
return `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1, 2)}-${pad(
date.getDate(),
2,
)}`;
}
/** Clamp an ISO `yyyy-MM-dd` string to `[min, max]` (lexical = chronological). */
function clampIso(value: string, min?: string, max?: string): string {
if (value === "") return value;
let out = value;
if (min && out < min) out = min;
if (max && out > max) out = max;
return out;
}
function toIso(date: Date): string {
return `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1, 2)}-${pad(
date.getUTCDate(),