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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user