feat(ui): calmer inspection form with sections, tap ratings and drafts

AuDHD-oriented rework of the most-used form, plus shared building blocks
for the remaining forms:

- Field: label, "Pflicht"/"optional", hint and the error right at the
  field instead of a vanishing toast.
- FormSection: numbered steps; optional parts start folded and say how
  many fields they hold.
- RatingScale (1-5 as five 44 px buttons with both ends named),
  ToggleChip (yes/no tiles with tick and colour), StickySubmit (save
  stays in thumb reach on phones).
- useFormDraft + DraftBanner: an unfinished entry is kept on the device
  and offered back explicitly ("Weitermachen" / "Verwerfen"), never
  applied silently; drafts expire after 3 days.

Inspection form: 21 flat fields became 5 steps, only colony and date are
required, observations are single taps, a hint appears when neither queen
nor eggs were seen, server messages show inline.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 07:32:20 +02:00
1 parent 8b6f48b996
commit 32dfd8d8a7
11 files changed
+1006 -311

No files matched your search

@@ -0,0 +1,44 @@
"use client";
import { History } from "lucide-react";
import { Button } from "@/components/ui/button";
interface DraftBannerProps {
savedAt: Date;
onRestore: () => void;
onDiscard: () => void;
}
/**
* Offers an unfinished entry back - never applies it silently, so the form
* is always what the person expects to see.
*/
export function DraftBanner({
savedAt,
onRestore,
onDiscard,
}: DraftBannerProps) {
const time = savedAt.toLocaleString("de-DE", {
dateStyle: "short",
timeStyle: "short",
});
return (
<section
aria-live="polite"
className="flex flex-col gap-3 rounded-xl border border-primary/40 bg-primary/5 p-3 sm:flex-row sm:items-center"
>
<p className="flex flex-1 items-center gap-2 text-sm">
<History className="size-4 shrink-0" aria-hidden />
Unfertiger Eintrag vom {time} gefunden.
</p>
<div className="flex gap-2">
<Button type="button" size="touch" onClick={onRestore}>
Weitermachen
</Button>
<Button type="button" size="touch" variant="ghost" onClick={onDiscard}>
Verwerfen
</Button>
</div>
</section>
);
}
@@ -0,0 +1,77 @@
import { ReactNode, useId } from "react";
import { cn } from "@/lib/utils";
interface FieldProps {
label: string;
/** Marks the field "Pflicht"; everything else is visibly "optional". */
required?: boolean;
hint?: string;
error?: string | null;
className?: string;
/** Receives the ids the control must carry for label, hint and error. */
children: (ids: {
id: string;
labelId: string;
describedBy: string | undefined;
invalid: boolean;
}) => ReactNode;
}
/**
* One form field with everything in one place: label, whether it is needed,
* a short hint and - right next to it, not in a toast that vanishes - what
* is wrong. Fewer things to keep in mind while filling a form.
*/
export function Field({
label,
required,
hint,
error,
className,
children,
}: FieldProps) {
const id = useId();
const hintId = hint ? `${id}-hint` : undefined;
const errorId = error ? `${id}-error` : undefined;
const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<label
id={`${id}-label`}
htmlFor={id}
className="flex items-baseline gap-2 text-sm font-medium"
>
{label}
<span
className={cn(
"text-xs font-normal",
required ? "text-foreground/80" : "text-muted-foreground",
)}
>
{required ? "Pflicht" : "optional"}
</span>
</label>
{children({
id,
labelId: `${id}-label`,
describedBy,
invalid: Boolean(error),
})}
{hint ? (
<p id={hintId} className="text-xs text-muted-foreground">
{hint}
</p>
) : null}
{error ? (
<p
id={errorId}
role="alert"
className="text-sm font-medium text-destructive"
>
{error}
</p>
) : null}
</div>
);
}
@@ -0,0 +1,83 @@
"use client";
import { ReactNode, useState } from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
interface FormSectionProps {
/** Shown as "1", "2", ... so the way through the form is visible. */
step?: number;
title: string;
description?: string;
/**
* Optional sections start folded: the form looks as short as what is
* actually needed, the rest is one tap away and says how many fields
* it holds.
*/
collapsible?: boolean;
defaultOpen?: boolean;
fieldCount?: number;
children: ReactNode;
}
export function FormSection({
step,
title,
description,
collapsible = false,
defaultOpen = true,
fieldCount,
children,
}: FormSectionProps) {
const [open, setOpen] = useState(collapsible ? defaultOpen : true);
const heading = (
<span className="flex items-center gap-2">
{step !== undefined ? (
<span
aria-hidden
className="flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold"
>
{step}
</span>
) : null}
<span className="font-heading text-base font-semibold">{title}</span>
</span>
);
return (
<fieldset className="flex flex-col gap-4 border-t border-border/60 pt-4 first:border-t-0 first:pt-0">
{collapsible ? (
<legend className="contents">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
className="flex min-h-11 w-full items-center justify-between gap-3 rounded-lg text-left"
>
{heading}
<span className="flex items-center gap-1 text-xs text-muted-foreground">
{open
? "Einklappen"
: fieldCount
? `${fieldCount} Angaben, optional`
: "Aufklappen"}
<ChevronDown
aria-hidden
className={cn(
"size-4 motion-safe:transition-transform",
open && "rotate-180",
)}
/>
</span>
</button>
</legend>
) : (
<legend className="contents">{heading}</legend>
)}
{description && open ? (
<p className="-mt-2 text-sm text-muted-foreground">{description}</p>
) : null}
{open ? <div className="flex flex-col gap-4">{children}</div> : null}
</fieldset>
);
}
@@ -0,0 +1,68 @@
"use client";
import { cn } from "@/lib/utils";
interface RatingScaleProps {
id: string;
value: number | null;
onChange: (value: number | null) => void;
/** Words for the ends, e.g. "stechlustig" / "sehr sanft". */
lowLabel: string;
highLabel: string;
describedBy?: string;
/** Id of the visible label, so the group is announced by name. */
labelledBy?: string;
}
/**
* 1-5 as five large buttons instead of a number field: one tap with gloves,
* no keyboard, and the meaning of both ends stays on screen. Tapping the
* chosen value again clears it.
*/
export function RatingScale({
id,
value,
onChange,
lowLabel,
highLabel,
describedBy,
labelledBy,
}: RatingScaleProps) {
return (
<div className="flex flex-col gap-1">
<div
id={id}
role="radiogroup"
aria-labelledby={labelledBy}
aria-describedby={describedBy}
className="grid grid-cols-5 gap-2"
>
{[1, 2, 3, 4, 5].map((option) => {
const selected = value === option;
return (
<button
key={option}
type="button"
role="radio"
aria-checked={selected}
aria-label={`${option} von 5`}
onClick={() => onChange(selected ? null : option)}
className={cn(
"h-11 rounded-lg border text-base font-semibold motion-safe:transition-colors",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background hover:bg-muted",
)}
>
{option}
</button>
);
})}
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>1 = {lowLabel}</span>
<span>5 = {highLabel}</span>
</div>
</div>
);
}
@@ -0,0 +1,20 @@
import { ReactNode } from "react";
/**
* Keeps the one primary action within thumb reach on a phone, however long
* the form is; on wider screens it simply ends the form.
*/
export function StickySubmit({
children,
note,
}: {
children: ReactNode;
note?: ReactNode;
}) {
return (
<div className="sticky bottom-16 z-10 -mx-4 flex flex-col gap-2 border-t border-border/60 bg-card/95 px-4 py-3 backdrop-blur sm:static sm:mx-0 sm:border-0 sm:bg-transparent sm:p-0 sm:backdrop-blur-none">
{note}
{children}
</div>
);
}
@@ -0,0 +1,41 @@
"use client";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
interface ToggleChipProps {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
}
/** A yes/no observation as a large tile; state shown by tick AND colour. */
export function ToggleChip({ label, checked, onChange }: ToggleChipProps) {
return (
<button
type="button"
role="checkbox"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
"flex min-h-11 items-center gap-2 rounded-lg border px-3 text-left text-sm motion-safe:transition-colors",
checked
? "border-primary bg-primary/10 font-medium"
: "border-border bg-background hover:bg-muted",
)}
>
<span
aria-hidden
className={cn(
"flex size-5 shrink-0 items-center justify-center rounded border",
checked
? "border-primary bg-primary text-primary-foreground"
: "border-border",
)}
>
{checked ? <Check className="size-3.5" /> : null}
</span>
{label}
</button>
);
}