From 83cb7ac59729cfbfdf0bb942ddeadfdbcedc4133 Mon Sep 17 00:00:00 2001 From: Musiker15 Date: Sun, 21 Jun 2026 21:49:14 +0200 Subject: [PATCH] feat(forms): styled date picker matching the UI The native calendar popup is OS-rendered and can't be styled to match the app. Replace the `date` field with a custom on-brand calendar popover (DateField): styled trigger, month grid (Monday-first), prev/next, Today + Clear, outside-click/Escape to close. Emits the same `YYYY-MM-DD` string, so validation/submit are unchanged. Month/weekday names follow the browser locale. time/datetime keep the native input for now. New form labels dateToday/dateClear (EN/DE/HU). --- apps/web/src/app/f/[slug]/page.tsx | 2 + apps/web/src/components/form/date-field.tsx | 204 ++++++++++++++++++ apps/web/src/components/form/field-input.tsx | 18 +- .../web/src/components/form/form-renderer.tsx | 3 + apps/web/src/i18n/dictionaries.ts | 3 + 5 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/form/date-field.tsx diff --git a/apps/web/src/app/f/[slug]/page.tsx b/apps/web/src/app/f/[slug]/page.tsx index 1e0e86b..b289e2b 100644 --- a/apps/web/src/app/f/[slug]/page.tsx +++ b/apps/web/src/app/f/[slug]/page.tsx @@ -91,6 +91,8 @@ export default async function PublicFormPage({ next: t.next, back: t.back, step: t.step, + dateToday: t.dateToday, + dateClear: t.dateClear, }} /> diff --git a/apps/web/src/components/form/date-field.tsx b/apps/web/src/components/form/date-field.tsx new file mode 100644 index 0000000..18b1d8f --- /dev/null +++ b/apps/web/src/components/form/date-field.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { IconCalendar, IconChevronLeft, IconChevronRight } from "@tabler/icons-react"; +import { useEffect, useMemo, useRef, useState } from "react"; + +export interface DateFieldLabels { + today: string; + clear: string; +} + +const pad = (n: number) => String(n).padStart(2, "0"); +const toISO = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +function parseISO(s: string | undefined): Date | null { + if (!s) return null; + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s); + if (!m) return null; + const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); + return Number.isNaN(d.getTime()) ? null : d; +} +const sameDay = (a: Date, b: Date) => + a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); + +/** + * Styled date picker matching the app UI — a calendar popover instead of the + * browser's native (unstylable) date control. Emits a `YYYY-MM-DD` string, the + * same value shape as ``, so form handling is unchanged. + * Month/weekday names follow the browser locale; Monday-first week. + */ +export function DateField({ + id, + value, + onChange, + disabled, + invalid, + placeholder, + labels, +}: { + id?: string; + value?: string; + onChange: (value: string | undefined) => void; + disabled?: boolean; + invalid?: boolean; + placeholder?: string; + labels: DateFieldLabels; +}) { + const selected = parseISO(value); + const [open, setOpen] = useState(false); + const [view, setView] = useState(() => { + const base = selected ?? new Date(); + return new Date(base.getFullYear(), base.getMonth(), 1); + }); + const ref = useRef(null); + const locale = typeof navigator !== "undefined" ? navigator.language : "en"; + + useEffect(() => { + if (!open) return; + function onPointer(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + } + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + document.addEventListener("mousedown", onPointer); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onPointer); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + // Monday-first weekday short names (2024-01-01 is a Monday). + const weekdays = useMemo(() => { + const fmt = new Intl.DateTimeFormat(locale, { weekday: "short" }); + return Array.from({ length: 7 }, (_, i) => fmt.format(new Date(2024, 0, 1 + i))); + }, [locale]); + + const monthLabel = useMemo( + () => new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(view), + [locale, view], + ); + + const displayValue = selected + ? new Intl.DateTimeFormat(locale, { day: "2-digit", month: "long", year: "numeric" }).format(selected) + : ""; + + // Build the 6×7 grid of days (Monday-first), including leading/trailing days. + const days = useMemo(() => { + const first = new Date(view.getFullYear(), view.getMonth(), 1); + const offset = (first.getDay() + 6) % 7; // 0 = Monday + const start = new Date(first); + start.setDate(first.getDate() - offset); + return Array.from({ length: 42 }, (_, i) => { + const d = new Date(start); + d.setDate(start.getDate() + i); + return d; + }); + }, [view]); + + const today = new Date(); + const shiftMonth = (delta: number) => + setView((v) => new Date(v.getFullYear(), v.getMonth() + delta, 1)); + function pick(d: Date) { + onChange(toISO(d)); + setOpen(false); + } + + return ( +
+ + + {open && ( +
+
+ + {monthLabel} + +
+ +
+ {weekdays.map((w) => ( +
+ {w} +
+ ))} + {days.map((d) => { + const inMonth = d.getMonth() === view.getMonth(); + const isSelected = selected ? sameDay(d, selected) : false; + const isToday = sameDay(d, today); + return ( + + ); + })} +
+ +
+ + +
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/form/field-input.tsx b/apps/web/src/components/form/field-input.tsx index 818a8a3..f0051bf 100644 --- a/apps/web/src/components/form/field-input.tsx +++ b/apps/web/src/components/form/field-input.tsx @@ -10,6 +10,7 @@ import { Textarea, } from "@msk-forms/ui"; +import { DateField, type DateFieldLabels } from "./date-field"; import { FileField, type FileFieldLabels } from "./file-field"; import { MatrixField } from "./matrix-field"; import { ScaleButtons, SliderInput, StarRating } from "./rating-fields"; @@ -38,6 +39,7 @@ interface FieldInputProps { /** The form slug + labels — needed by file fields to drive uploads. */ slug: string; fileLabels: FileFieldLabels; + dateLabels: DateFieldLabels; } /** Renders the interactive control for a single (non-layout) form field. */ @@ -49,6 +51,7 @@ export function FieldInput({ disabled, slug, fileLabels, + dateLabels, }: FieldInputProps) { const id = field.id; const options = (field.options ?? []).map((o) => ({ value: o.value, label: o.label })); @@ -222,10 +225,21 @@ export function FieldInput({ ); case "date": + return ( + + ); + case "time": case "datetime": { - const inputType = - field.type === "date" ? "date" : field.type === "time" ? "time" : "datetime-local"; + const inputType = field.type === "time" ? "time" : "datetime-local"; return ( ), diff --git a/apps/web/src/i18n/dictionaries.ts b/apps/web/src/i18n/dictionaries.ts index 7bf437d..34b2875 100644 --- a/apps/web/src/i18n/dictionaries.ts +++ b/apps/web/src/i18n/dictionaries.ts @@ -50,6 +50,7 @@ const en = { fileUploading: "Uploading…", fileRemove: "Remove", uploadFailed: "Upload failed.", signatureClear: "Clear", next: "Next", back: "Back", step: "Step", + dateToday: "Today", dateClear: "Clear", }, status: { yourSubmission: "Your submission", activity: "Activity", yourAnswers: "Your answers", @@ -244,6 +245,7 @@ const de: Dictionary = { fileUploading: "Wird hochgeladen…", fileRemove: "Entfernen", uploadFailed: "Upload fehlgeschlagen.", signatureClear: "Löschen", next: "Weiter", back: "Zurück", step: "Schritt", + dateToday: "Heute", dateClear: "Löschen", }, status: { yourSubmission: "Deine Einreichung", activity: "Aktivität", yourAnswers: "Deine Antworten", @@ -436,6 +438,7 @@ const hu: Dictionary = { fileUploading: "Feltöltés…", fileRemove: "Eltávolítás", uploadFailed: "A feltöltés sikertelen.", signatureClear: "Törlés", next: "Tovább", back: "Vissza", step: "Lépés", + dateToday: "Ma", dateClear: "Törlés", }, status: { yourSubmission: "A beküldésed", activity: "Tevékenység", yourAnswers: "A válaszaid",