mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user