fix(ui): introduce FieldGroup and unify the add-form rhythm

The measurement add form opened with a horizontal type row (label beside
the select) while every field below it stacked label-above-control, so
the form read with two competing rhythms. Introduce a small FieldGroup
primitive — the long-standing space-y-2 label-above-control idiom in one
place — and route the measurement, mood and baseline forms through it so
they share one vertical rhythm. The type row now stacks like the rest.

Output stays byte-identical for the already-vertical groups; only the
type row changes, by design.
This commit is contained in:
Marc-André Bombeck committed 2026-06-30 12:35:36 +02:00
1 parent ca067bfe3d
commit d0b4b1e4cd
4 files changed
+128 -75

No files matched your search

@@ -12,6 +12,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { DateTimeField } from "@/components/ui/date-time-field";
import { FieldGroup } from "@/components/ui/field-group";
import { Label } from "@/components/ui/label";
import {
Select,
@@ -386,12 +387,9 @@ export function MeasurementForm({
return (
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
<div className="flex items-center gap-3">
<Label htmlFor="measurement-type" className="shrink-0">
{t("measurements.type")}
</Label>
<FieldGroup htmlFor="measurement-type" label={t("measurements.type")}>
<Select value={type} onValueChange={setType}>
<SelectTrigger id="measurement-type" className="flex-1">
<SelectTrigger id="measurement-type" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -402,7 +400,7 @@ export function MeasurementForm({
))}
</SelectContent>
</Select>
</div>
</FieldGroup>
{isBpMode ? (
<div className="grid gap-4 sm:grid-cols-3">
@@ -463,16 +461,16 @@ export function MeasurementForm({
</div>
</div>
) : (
<div className="space-y-2">
<Label htmlFor="value">
{t("measurements.valueWithUnit", {
unit: typeInfo
? "unitKey" in typeInfo
? t(typeInfo.unitKey)
: typeInfo.unit
: "",
})}
</Label>
<FieldGroup
htmlFor="value"
label={t("measurements.valueWithUnit", {
unit: typeInfo
? "unitKey" in typeInfo
? t(typeInfo.unitKey)
: typeInfo.unit
: "",
})}
>
<Input
id="value"
type="number"
@@ -490,14 +488,14 @@ export function MeasurementForm({
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
/>
</div>
</FieldGroup>
)}
{isGlucoseMode && (
<div className="space-y-2">
<Label htmlFor="glucose-context">
{t("measurements.glucoseContext")}
</Label>
<FieldGroup
htmlFor="glucose-context"
label={t("measurements.glucoseContext")}
>
<Select
value={glucoseContext}
onValueChange={(v) => setGlucoseContext(v as GlucoseContextValue)}
@@ -513,11 +511,17 @@ export function MeasurementForm({
))}
</SelectContent>
</Select>
</div>
</FieldGroup>
)}
<div className="space-y-2">
<Label htmlFor="measuredAt">{t("measurements.timestamp")}</Label>
<FieldGroup
htmlFor="measuredAt"
label={t("measurements.timestamp")}
// v1.17.1 — surface that the timestamp is editable. The picker
// already defaults to now and accepts any earlier instant; many
// users never notice they can backdate a reading.
hint={t("measurements.timestampBackdateHint")}
>
<DateTimeField
id="measuredAt"
value={measuredAt}
@@ -531,26 +535,24 @@ export function MeasurementForm({
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
/>
{/* v1.17.1 — surface that the timestamp is editable. The picker
already defaults to now and accepts any earlier instant; many
users never notice they can backdate a reading. */}
<p className="text-muted-foreground text-xs">
{t("measurements.timestampBackdateHint")}
</p>
</div>
</FieldGroup>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="notes">
<FieldGroup
htmlFor="notes"
label={
<>
{t("measurements.notes")}{" "}
<span className="text-muted-foreground font-normal">
({t("common.optional")})
</span>
</Label>
</>
}
labelAccessory={
<span className="text-muted-foreground text-xs">
{notes.length}/{MAX_COMMENT_LENGTH}
</span>
</div>
}
>
<Input
id="notes"
value={notes}
@@ -560,7 +562,7 @@ export function MeasurementForm({
enterKeyHint="done"
autoCapitalize="sentences"
/>
</div>
</FieldGroup>
{error && (
<div
+15 -11
View File
@@ -23,6 +23,7 @@ import {
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { DateTimeField } from "@/components/ui/date-time-field";
import { FieldGroup } from "@/components/ui/field-group";
import { Label } from "@/components/ui/label";
import {
ListChecks,
@@ -473,17 +474,20 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
</div>
{/* v1.8.5 (C1) — free-text note. */}
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="mood-note">
<FieldGroup
htmlFor="mood-note"
label={
<>
{t("mood.note")}{" "}
<span className="text-muted-foreground font-normal">
({t("common.optional")})
</span>
</Label>
{/* v1.11.5 — character counter so the `maxLength` cap no
</>
}
labelAccessory={
/* v1.11.5 — character counter so the `maxLength` cap no
longer truncates silently. Turns destructive (warns) as the
input approaches the limit. */}
input approaches the limit. */
<span
data-testid="mood-note-counter"
className={`text-xs tabular-nums ${
@@ -498,7 +502,8 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
max: String(NOTE_MAX_LENGTH),
})}
</span>
</div>
}
>
<Textarea
id="mood-note"
value={note}
@@ -507,10 +512,9 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
maxLength={NOTE_MAX_LENGTH}
rows={3}
/>
</div>
</FieldGroup>
<div className="space-y-2">
<Label htmlFor="mood-logged-at">{t("mood.timestamp")}</Label>
<FieldGroup htmlFor="mood-logged-at" label={t("mood.timestamp")}>
<DateTimeField
id="mood-logged-at"
value={moodLoggedAt}
@@ -522,7 +526,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
/>
</div>
</FieldGroup>
</SheetSection>
</div>
)}
+23 -27
View File
@@ -9,8 +9,8 @@ import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { DateField } from "@/components/ui/date-field";
import { FieldGroup } from "@/components/ui/field-group";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
@@ -170,10 +170,11 @@ export function BaselineForm() {
<fieldset className="bg-card border-border space-y-5 rounded-xl border p-5">
<legend className="sr-only">{t("onboarding.baseline.title")}</legend>
<div className="space-y-2">
<Label htmlFor="ob-baseline-display-name">
{t("onboarding.baseline.displayNameLabel")}
</Label>
<FieldGroup
htmlFor="ob-baseline-display-name"
label={t("onboarding.baseline.displayNameLabel")}
hint={t("onboarding.baseline.displayNameHint")}
>
<Input
id="ob-baseline-display-name"
value={form.displayName}
@@ -182,16 +183,13 @@ export function BaselineForm() {
maxLength={50}
placeholder={t("onboarding.baseline.displayNamePlaceholder")}
/>
<p className="text-muted-foreground text-xs">
{t("onboarding.baseline.displayNameHint")}
</p>
</div>
</FieldGroup>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="ob-baseline-height">
{t("onboarding.baseline.heightLabel")}
</Label>
<FieldGroup
htmlFor="ob-baseline-height"
label={t("onboarding.baseline.heightLabel")}
>
<Input
id="ob-baseline-height"
type="number"
@@ -204,11 +202,11 @@ export function BaselineForm() {
step={0.1}
autoComplete="off"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ob-baseline-gender">
{t("onboarding.baseline.genderLabel")}
</Label>
</FieldGroup>
<FieldGroup
htmlFor="ob-baseline-gender"
label={t("onboarding.baseline.genderLabel")}
>
<Select
// The design system's Radix Select uses an empty-string
// sentinel to mean "no selection"; map back and forth so
@@ -235,13 +233,14 @@ export function BaselineForm() {
</SelectItem>
</SelectContent>
</Select>
</div>
</FieldGroup>
</div>
<div className="space-y-2">
<Label htmlFor="ob-baseline-dob">
{t("onboarding.baseline.dateOfBirthLabel")}
</Label>
<FieldGroup
htmlFor="ob-baseline-dob"
label={t("onboarding.baseline.dateOfBirthLabel")}
hint={t("onboarding.baseline.dateOfBirthHint")}
>
<DateField
id="ob-baseline-dob"
value={form.dateOfBirth}
@@ -249,10 +248,7 @@ export function BaselineForm() {
max={new Date().toISOString().slice(0, 10)}
autoComplete="bday"
/>
<p className="text-muted-foreground text-xs">
{t("onboarding.baseline.dateOfBirthHint")}
</p>
</div>
</FieldGroup>
</fieldset>
<AnamnesisCard
+51
View File
@@ -0,0 +1,51 @@
import type { ReactNode } from "react";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
/**
* Central form field-group primitive: one label-above-control rhythm so every
* add form reads with the same vertical spacing. Mirrors the long-standing
* `space-y-2` + `<Label htmlFor>` + control idiom byte-for-byte so adopting it
* is a no-op on rendered output.
*
* - `hint` renders below the control as muted fine print.
* - `labelAccessory` sits right-aligned in the label row (e.g. a char counter);
* when present the label row becomes a `justify-between` flex row.
*/
export function FieldGroup({
htmlFor,
label,
hint,
labelAccessory,
className,
labelClassName,
children,
}: {
htmlFor: string;
label: ReactNode;
hint?: ReactNode;
labelAccessory?: ReactNode;
className?: string;
labelClassName?: string;
children: ReactNode;
}) {
return (
<div className={cn("space-y-2", className)}>
{labelAccessory ? (
<div className="flex items-center justify-between gap-3">
<Label htmlFor={htmlFor} className={labelClassName}>
{label}
</Label>
{labelAccessory}
</div>
) : (
<Label htmlFor={htmlFor} className={labelClassName}>
{label}
</Label>
)}
{children}
{hint ? <p className="text-muted-foreground text-xs">{hint}</p> : null}
</div>
);
}