From 8adc1c35d2651d44bec604246049f8946685c190 Mon Sep 17 00:00:00 2001 From: Musiker15 Date: Sun, 21 Jun 2026 21:56:30 +0200 Subject: [PATCH] feat(forms): styled time & datetime pickers (same UI as date) Generalize DateField to `mode: date | time | datetime`. time/datetime now use the on-brand popover instead of the native controls: calendar (date/datetime) + styled hour/minute selects (time/datetime), with Today/Now/Clear. Values keep the native shapes (`HH:MM`, `YYYY-MM-DDTHH:MM`) so validation/submit are unchanged. New `dateNow` label (EN/DE/HU). --- apps/web/src/app/f/[slug]/page.tsx | 1 + apps/web/src/components/form/date-field.tsx | 255 +++++++++++++----- apps/web/src/components/form/field-input.tsx | 18 +- .../web/src/components/form/form-renderer.tsx | 3 +- apps/web/src/i18n/dictionaries.ts | 6 +- 5 files changed, 190 insertions(+), 93 deletions(-) diff --git a/apps/web/src/app/f/[slug]/page.tsx b/apps/web/src/app/f/[slug]/page.tsx index b289e2b..f86e02d 100644 --- a/apps/web/src/app/f/[slug]/page.tsx +++ b/apps/web/src/app/f/[slug]/page.tsx @@ -93,6 +93,7 @@ export default async function PublicFormPage({ step: t.step, dateToday: t.dateToday, dateClear: t.dateClear, + dateNow: t.dateNow, }} /> diff --git a/apps/web/src/components/form/date-field.tsx b/apps/web/src/components/form/date-field.tsx index 18b1d8f..a24f9d8 100644 --- a/apps/web/src/components/form/date-field.tsx +++ b/apps/web/src/components/form/date-field.tsx @@ -1,33 +1,62 @@ "use client"; -import { IconCalendar, IconChevronLeft, IconChevronRight } from "@tabler/icons-react"; +import { IconCalendar, IconChevronLeft, IconChevronRight, IconClock } from "@tabler/icons-react"; import { useEffect, useMemo, useRef, useState } from "react"; +export type DateFieldMode = "date" | "time" | "datetime"; + export interface DateFieldLabels { today: string; clear: string; + now: 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(); +interface Parsed { + date: Date | null; + h: number | null; + m: number | null; +} + +/** Split a stored value into its date + time parts according to the mode. */ +function parseValue(value: string | undefined, mode: DateFieldMode): Parsed { + const out: Parsed = { date: null, h: null, m: null }; + if (!value) return out; + if (mode === "time") { + const t = /^(\d{2}):(\d{2})/.exec(value); + if (t) { + out.h = Number(t[1]); + out.m = Number(t[2]); + } + return out; + } + const [datePart, timePart] = value.split("T"); + const d = /^(\d{4})-(\d{2})-(\d{2})$/.exec(datePart ?? ""); + if (d) { + const parsed = new Date(Number(d[1]), Number(d[2]) - 1, Number(d[3])); + if (!Number.isNaN(parsed.getTime())) out.date = parsed; + } + const t = timePart ? /^(\d{2}):(\d{2})/.exec(timePart) : null; + if (t) { + out.h = Number(t[1]); + out.m = Number(t[2]); + } + return out; +} + /** - * 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. + * Styled date / time / datetime picker matching the app UI, replacing the + * browser's native (unstylable) controls. Emits the same value shapes as the + * native inputs — `YYYY-MM-DD` (date), `HH:MM` (time), `YYYY-MM-DDTHH:MM` + * (datetime) — so form handling is unchanged. Locale-aware names; Monday-first. */ export function DateField({ id, + mode, value, onChange, disabled, @@ -36,6 +65,7 @@ export function DateField({ labels, }: { id?: string; + mode: DateFieldMode; value?: string; onChange: (value: string | undefined) => void; disabled?: boolean; @@ -43,10 +73,13 @@ export function DateField({ placeholder?: string; labels: DateFieldLabels; }) { - const selected = parseISO(value); + const showCalendar = mode !== "time"; + const showTime = mode !== "date"; + const parsed = parseValue(value, mode); + const [open, setOpen] = useState(false); const [view, setView] = useState(() => { - const base = selected ?? new Date(); + const base = parsed.date ?? new Date(); return new Date(base.getFullYear(), base.getMonth(), 1); }); const ref = useRef(null); @@ -68,7 +101,6 @@ export function DateField({ }; }, [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))); @@ -79,14 +111,9 @@ export function DateField({ [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 offset = (first.getDay() + 6) % 7; const start = new Date(first); start.setDate(first.getDate() - offset); return Array.from({ length: 42 }, (_, i) => { @@ -97,13 +124,44 @@ export function DateField({ }, [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); + + // Build the stored value from the current date + time parts. + function emit(date: Date | null, h: number | null, m: number | null) { + if (mode === "time") { + if (h === null || m === null) return onChange(undefined); + return onChange(`${pad(h)}:${pad(m)}`); + } + if (!date) return onChange(undefined); + if (mode === "date") return onChange(toISO(date)); + return onChange(`${toISO(date)}T${pad(h ?? 0)}:${pad(m ?? 0)}`); + } + + function pickDay(d: Date) { + emit(d, parsed.h, parsed.m); + if (mode === "date") setOpen(false); // datetime stays open to set the time + } + function setTime(h: number, m: number) { + emit(parsed.date ?? today, h, m); } + const displayValue = useMemo(() => { + const hasTime = parsed.h !== null && parsed.m !== null; + const timeStr = hasTime ? `${pad(parsed.h!)}:${pad(parsed.m!)}` : ""; + if (mode === "time") return timeStr; + if (!parsed.date) return ""; + const dateStr = new Intl.DateTimeFormat(locale, { + day: "2-digit", + month: "long", + year: "numeric", + }).format(parsed.date); + return mode === "datetime" && timeStr ? `${dateStr} · ${timeStr}` : dateStr; + }, [parsed.date, parsed.h, parsed.m, mode, locale]); + + const hours = Array.from({ length: 24 }, (_, i) => i); + const minutes = Array.from({ length: 60 }, (_, i) => i); + const selectClass = + "h-9 rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"; + return (
{open && ( @@ -126,54 +188,97 @@ export function DateField({ role="dialog" className="absolute left-0 z-50 mt-1.5 w-72 rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-md" > -
- - {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 ( + {showCalendar && ( + <> +
- ); - })} -
+ {monthLabel} + +
+ +
+ {weekdays.map((w) => ( +
+ {w} +
+ ))} + {days.map((d) => { + const inMonth = d.getMonth() === view.getMonth(); + const isSelected = parsed.date ? sameDay(d, parsed.date) : false; + const isToday = sameDay(d, today); + return ( + + ); + })} +
+ + )} + + {showTime && ( +
+ + + : + +
+ )}
diff --git a/apps/web/src/components/form/field-input.tsx b/apps/web/src/components/form/field-input.tsx index f0051bf..6679cf1 100644 --- a/apps/web/src/components/form/field-input.tsx +++ b/apps/web/src/components/form/field-input.tsx @@ -225,9 +225,12 @@ export function FieldInput({ ); case "date": + case "time": + case "datetime": return ( ); - case "time": - case "datetime": { - const inputType = field.type === "time" ? "time" : "datetime-local"; - return ( - onChange(e.target.value)} - /> - ); - } - // short_text, email, phone, url, password and any other single-line text. default: { const inputType = diff --git a/apps/web/src/components/form/form-renderer.tsx b/apps/web/src/components/form/form-renderer.tsx index a0c49a7..2622cd7 100644 --- a/apps/web/src/components/form/form-renderer.tsx +++ b/apps/web/src/components/form/form-renderer.tsx @@ -48,6 +48,7 @@ export interface FormLabels { step: string; dateToday: string; dateClear: string; + dateNow: string; } export function FormRenderer({ @@ -211,7 +212,7 @@ export function FormRenderer({ uploadFailed: labels.uploadFailed, clear: labels.signatureClear, }} - dateLabels={{ today: labels.dateToday, clear: labels.dateClear }} + dateLabels={{ today: labels.dateToday, clear: labels.dateClear, now: labels.dateNow }} /> ), diff --git a/apps/web/src/i18n/dictionaries.ts b/apps/web/src/i18n/dictionaries.ts index 34b2875..1dfe299 100644 --- a/apps/web/src/i18n/dictionaries.ts +++ b/apps/web/src/i18n/dictionaries.ts @@ -50,7 +50,7 @@ const en = { fileUploading: "Uploading…", fileRemove: "Remove", uploadFailed: "Upload failed.", signatureClear: "Clear", next: "Next", back: "Back", step: "Step", - dateToday: "Today", dateClear: "Clear", + dateToday: "Today", dateClear: "Clear", dateNow: "Now", }, status: { yourSubmission: "Your submission", activity: "Activity", yourAnswers: "Your answers", @@ -245,7 +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", + dateToday: "Heute", dateClear: "Löschen", dateNow: "Jetzt", }, status: { yourSubmission: "Deine Einreichung", activity: "Aktivität", yourAnswers: "Deine Antworten", @@ -438,7 +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", + dateToday: "Ma", dateClear: "Törlés", dateNow: "Most", }, status: { yourSubmission: "A beküldésed", activity: "Tevékenység", yourAnswers: "A válaszaid",