mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
5a96b58439
commit
09be190f9f
6 files changed
+483
-138
No files matched your search
@@ -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",
|
||||
|
||||
Generated
+39
-5
@@ -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:
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user