diff --git a/package.json b/package.json index f897c5180..9fb561421 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 727372991..94a282d5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/src/components/ui/__tests__/date-field.test.tsx b/src/components/ui/__tests__/date-field.test.tsx index b789f42da..53a429e6b 100644 --- a/src/components/ui/__tests__/date-field.test.tsx +++ b/src/components/ui/__tests__/date-field.test.tsx @@ -1,21 +1,29 @@ /** - * `` contract (v1.21.0). + * `` 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 `` picker is gone: the date field now opens a + * shadcn `` (react-day-picker) inside a ``, 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 `` 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 `` 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("", () => { - 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(); - // The real value lives on a type=date input (the form's source of truth). - expect(html).toMatch(/]*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(/]*type="hidden"[^>]*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/); + // …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("", () => { 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( ", () => { max="2030-12-31" />, ); - expect(html).toMatch(/]*type="date"[^>]*min="2020-01-01"/); - expect(html).toMatch(/]*type="date"[^>]*max="2030-12-31"/); - expect(html).toMatch(/]*type="date"[^>]*disabled/); + // Both the visible overlay and the hidden mirror reflect the disabled state. + expect(html).toMatch(/]*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", () => { diff --git a/src/components/ui/__tests__/date-time-field.test.tsx b/src/components/ui/__tests__/date-time-field.test.tsx index 67e8366d3..e3656be08 100644 --- a/src/components/ui/__tests__/date-time-field.test.tsx +++ b/src/components/ui/__tests__/date-time-field.test.tsx @@ -1,18 +1,20 @@ /** * `` contract (v1.25.4). * - * The field now composes a native-calendar `` (date part) with the - * app-controlled `` (time part) so the time picker always follows - * the user's hour-cycle preference rather than the browser UI language. Like - * ``, 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 `` (date part, a shadcn + * `` in a popover) with the app-controlled `` (time part) + * so both pickers follow the user's preferences rather than the browser UI + * language and render identically on every OS. Like ``, 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("", () => { 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( ", () => { max="2030-12-31T23:59" />, ); - expect(html).toMatch(/]*type="date"[^>]*min="2020-01-01"/); - expect(html).toMatch(/]*type="date"[^>]*max="2030-12-31"/); - expect(html).toMatch(/]*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(/]*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", () => { diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx new file mode 100644 index 000000000..b0721b904 --- /dev/null +++ b/src/components/ui/calendar.tsx @@ -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 & { + buttonVariant?: React.ComponentProps["variant"]; +}) { + const defaultClassNames = getDefaultClassNames(); + + return ( + 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 ( +
+ ); + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ); + } + + if (orientation === "right") { + return ( + + ); + } + + return ( + + ); + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ); + }, + ...components, + }} + {...props} + /> + ); +} + +function CalendarDayButton({ + className, + day, + modifiers, + ...props +}: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames(); + + const ref = React.useRef(null); + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus(); + }, [modifiers.focused]); + + return ( + -
+ {/* Hidden mirror — carries `name` + value for native submits and is + the target of the forwarded ref. */} + + {/* Editable display overlay. */} + setTyped(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitTyped(); + } + }} + onBlur={() => { + commitTyped(); + onBlur?.(); + }} + /> + + + + + + { + commitIso(d ? dateToIso(d) : ""); + setOpen(false); + }} + disabled={disabledMatchers.length ? disabledMatchers : undefined} + defaultMonth={selectedDate ?? new Date()} + locale={resolveDateFnsLocale(locale)} + autoFocus + /> + +
); }, ); @@ -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["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(),