mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Show a refused profile field under the input it belongs to
The onboarding baseline step flashed a toast naming one refused field and walked on to the done screen, stamping the account as set up over a value the server never stored. The value the person typed was gone with the step. A refused field now writes its reason into that field's own error slot, marks the control invalid, and holds the step until the person corrects it. Every refused field is named, not only the first, and the reason comes from the validator code rather than the validator's own prose, in all seven shipped languages.
This commit is contained in:
1 parent
ae20ee3d9d
commit
36cbf48286
16 files changed
+438
-26
No files matched your search
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* The error slot that belongs to one input.
|
||||
*
|
||||
* Every form that writes the profile shows a refused field the same
|
||||
* way, or the same rejection reads differently depending on where the
|
||||
* person happened to be standing. This is that one way: a destructive
|
||||
* sentence directly under the control, carrying an id the control
|
||||
* points `aria-describedby` at, so the reason is announced with the
|
||||
* field rather than only painted next to it.
|
||||
*
|
||||
* `role="alert"` is deliberate. The sentence appears in response to a
|
||||
* save the person just asked for, and a person who has already moved
|
||||
* focus past the field would otherwise never learn the value did not
|
||||
* land.
|
||||
*/
|
||||
export function FieldError({
|
||||
id,
|
||||
message,
|
||||
}: {
|
||||
/** Conventionally `<control id>-error`. */
|
||||
id: string;
|
||||
message?: ReactNode;
|
||||
}) {
|
||||
if (!message) return null;
|
||||
return (
|
||||
<p id={id} role="alert" className="text-destructive text-sm">
|
||||
{message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* A rejection the server names has to reach the input it belongs to.
|
||||
*
|
||||
* `PUT /api/auth/profile` writes field by field and reports the fields
|
||||
* it refused. The onboarding step read that report only far enough to
|
||||
* flash a toast and then walked the person to the next screen, so an
|
||||
* out-of-range height was gone by the time anyone could act on it and
|
||||
* the wizard counted the account as set up regardless.
|
||||
*
|
||||
* This runs the whole strip — build the body, PUT it over a stubbed
|
||||
* `fetch`, read the answer, render the real inputs with what came back
|
||||
* — because the gap was in the join, not in either end. The English
|
||||
* bundle is the shipped one, so a sentence that reads as a key or
|
||||
* leaks validator prose fails here.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { getCachedMessages } from "@/lib/i18n/load-locale";
|
||||
import { resolveKey } from "@/lib/i18n/resolve-key";
|
||||
import {
|
||||
EMPTY_HEIGHT_DRAFT,
|
||||
resolveHeightUnitAdapter,
|
||||
} from "@/lib/profile/height-unit-display";
|
||||
import { BaselineFields } from "../baseline-fields";
|
||||
import {
|
||||
baselineFieldLabelKeys,
|
||||
buildBaselineProfileBody,
|
||||
describeBaselineSaveOutcome,
|
||||
putBaselineProfile,
|
||||
} from "../baseline-form-utils";
|
||||
|
||||
/** A `t` backed by the shipped bundle — no stand-in strings. */
|
||||
function translator(locale: "en" | "de") {
|
||||
const bundle = getCachedMessages(locale);
|
||||
if (!bundle) throw new Error(`No cached bundle for ${locale}`);
|
||||
return (key: string, params?: Record<string, string | number>) => {
|
||||
const raw = resolveKey(bundle as Record<string, unknown>, key);
|
||||
if (raw === undefined) return key;
|
||||
return Object.entries(params ?? {}).reduce(
|
||||
(acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)),
|
||||
raw,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const FILLED_FORM = {
|
||||
displayName: "Robin",
|
||||
dateOfBirth: "1988-04-02",
|
||||
gender: "OTHER",
|
||||
};
|
||||
|
||||
function stubProfilePut(status: number, body: unknown) {
|
||||
vi.stubGlobal("fetch", () =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Render the step's inputs exactly as the step renders them. */
|
||||
function renderFields(errors: Record<string, string>) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<BaselineFields
|
||||
value={{
|
||||
displayName: FILLED_FORM.displayName,
|
||||
height: { ...EMPTY_HEIGHT_DRAFT, cm: "940" },
|
||||
dateOfBirth: FILLED_FORM.dateOfBirth,
|
||||
gender: FILLED_FORM.gender,
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
heightAdapter={resolveHeightUnitAdapter("metric")}
|
||||
errors={errors}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("a refused baseline field reaches its own input", () => {
|
||||
it("paints the reason in the height field's slot and holds the step", async () => {
|
||||
stubProfilePut(200, {
|
||||
data: {
|
||||
id: "u1",
|
||||
displayName: "Robin",
|
||||
rejectedFields: [{ path: "heightCm", code: "too_big" }],
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
|
||||
const en = translator("en");
|
||||
const outcome = describeBaselineSaveOutcome(
|
||||
await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, 940)),
|
||||
en,
|
||||
baselineFieldLabelKeys(false),
|
||||
);
|
||||
|
||||
// A field was refused: the wizard stays put and does not stamp the
|
||||
// account as finished behind the person's back.
|
||||
expect(outcome.advance).toBe(false);
|
||||
expect(outcome.fieldErrors).toEqual({
|
||||
heightCm: en("settings.profileRejection.tooBig"),
|
||||
});
|
||||
|
||||
// What the outcome produced is what the input renders — no second
|
||||
// mapping in between for a rename to fall through.
|
||||
const html = renderFields(outcome.fieldErrors);
|
||||
expect(html).toContain(en("settings.profileRejection.tooBig"));
|
||||
// Neither a bare key nor the validator's own vocabulary.
|
||||
expect(html).not.toContain("settings.profileRejection");
|
||||
expect(html).not.toContain("too_big");
|
||||
// Announced with the input, not merely painted beside it.
|
||||
expect(html).toContain('id="ob-baseline-height-error"');
|
||||
expect(html).toContain('aria-describedby="ob-baseline-height-error"');
|
||||
expect(html).toContain('aria-invalid="true"');
|
||||
});
|
||||
|
||||
it("leaves every accepted field's slot empty", async () => {
|
||||
stubProfilePut(200, { data: { id: "u1" }, error: null });
|
||||
|
||||
const outcome = describeBaselineSaveOutcome(
|
||||
await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, 178)),
|
||||
translator("en"),
|
||||
baselineFieldLabelKeys(false),
|
||||
);
|
||||
|
||||
expect(outcome.advance).toBe(true);
|
||||
expect(outcome.fieldErrors).toEqual({});
|
||||
// (`aria-invalid:` also appears inside the primitives' Tailwind
|
||||
// class strings, so the assertion is on the rendered attribute.)
|
||||
const html = renderFields({});
|
||||
expect(html).not.toContain('aria-invalid="true"');
|
||||
expect(html).not.toContain('role="alert"');
|
||||
});
|
||||
|
||||
it("names every refused field, not only the first", async () => {
|
||||
stubProfilePut(422, {
|
||||
data: null,
|
||||
error: 'Nothing was saved. Fix the "dateOfBirth" field and try again.',
|
||||
meta: { errorCode: "profile.update.nothingSaved" },
|
||||
details: {
|
||||
issues: [
|
||||
{ path: "dateOfBirth", code: "invalid_value" },
|
||||
{ path: "gender", code: "invalid_value" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const outcome = describeBaselineSaveOutcome(
|
||||
await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, null)),
|
||||
translator("de"),
|
||||
baselineFieldLabelKeys(false),
|
||||
);
|
||||
|
||||
expect(outcome.advance).toBe(false);
|
||||
expect(Object.keys(outcome.fieldErrors).sort()).toEqual([
|
||||
"dateOfBirth",
|
||||
"gender",
|
||||
]);
|
||||
// The person's language, not the validator's.
|
||||
const de = translator("de");
|
||||
expect(outcome.fieldErrors.gender).toBe(
|
||||
de("settings.profileRejection.invalidValue"),
|
||||
);
|
||||
expect(outcome.fieldErrors.gender).not.toContain("invalid_value");
|
||||
});
|
||||
});
|
||||
@@ -60,7 +60,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("onboarding baseline profile save", () => {
|
||||
it("names the rejected field and still lets the accepted ones stand", async () => {
|
||||
it("names the rejected field, keeps the accepted ones, and holds the step", async () => {
|
||||
const calls = stubProfilePut(200, {
|
||||
data: {
|
||||
id: "u1",
|
||||
@@ -88,14 +88,21 @@ describe("onboarding baseline profile save", () => {
|
||||
gender: "OTHER",
|
||||
});
|
||||
|
||||
// The accepted fields are saved and the wizard moves on...
|
||||
expect(outcome.advance).toBe(true);
|
||||
// ...but the declined one is said out loud, by the name this screen
|
||||
// put next to the input.
|
||||
// The accepted fields are saved, and the declined one is said out
|
||||
// loud by the name this screen put next to the input.
|
||||
expect(outcome.notice).toEqual({
|
||||
tone: "warning",
|
||||
message: "Profile saved, but Height (cm) could not be updated.",
|
||||
});
|
||||
// This assertion was `true` until the rejection reached the input
|
||||
// it belongs to. Advancing here posted `step: 4`, stamped the
|
||||
// account as set up and replaced the only screen that could show
|
||||
// the refused value — a toast on the way out was the whole notice
|
||||
// the person got. The step now waits for a correction.
|
||||
expect(outcome.advance).toBe(false);
|
||||
expect(outcome.fieldErrors.heightCm).toBe(
|
||||
"That value is above the range this field accepts.",
|
||||
);
|
||||
});
|
||||
|
||||
it("says nothing when every field was accepted", async () => {
|
||||
@@ -107,7 +114,7 @@ describe("onboarding baseline profile save", () => {
|
||||
baselineFieldLabelKeys(false),
|
||||
);
|
||||
|
||||
expect(outcome).toEqual({ advance: true, notice: null });
|
||||
expect(outcome).toEqual({ advance: true, notice: null, fieldErrors: {} });
|
||||
});
|
||||
|
||||
it("names the blocking field and holds the step when nothing was saved", async () => {
|
||||
|
||||
@@ -24,7 +24,12 @@ import type {
|
||||
* own against a server answer. The step's whole defect lived between
|
||||
* the response and these inputs — the server named a field it refused
|
||||
* and nothing on screen changed — so the seam a test needs is exactly
|
||||
* here: field values in, markup out.
|
||||
* here: field values in, field rejections in, markup out.
|
||||
*
|
||||
* Each field id doubles as the id of its error paragraph
|
||||
* (`<id>-error`), which is what the control points `aria-describedby`
|
||||
* at, so a rejection is announced with the input rather than only
|
||||
* painted next to it.
|
||||
*/
|
||||
|
||||
export interface BaselineFieldValues {
|
||||
@@ -34,6 +39,15 @@ export interface BaselineFieldValues {
|
||||
gender: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-field sentences keyed by the SCHEMA field name the server uses
|
||||
* (`heightCm`, not `height`) — the key the rejection arrives under, so
|
||||
* nothing has to be renamed between the wire and the slot.
|
||||
*/
|
||||
export type BaselineFieldErrors = Partial<
|
||||
Record<"displayName" | "heightCm" | "dateOfBirth" | "gender", string>
|
||||
>;
|
||||
|
||||
const IDS = {
|
||||
displayName: "ob-baseline-display-name",
|
||||
heightCm: "ob-baseline-height",
|
||||
@@ -45,6 +59,7 @@ export function BaselineFields({
|
||||
value,
|
||||
onChange,
|
||||
heightAdapter,
|
||||
errors,
|
||||
}: {
|
||||
value: BaselineFieldValues;
|
||||
onChange: <K extends keyof BaselineFieldValues>(
|
||||
@@ -52,6 +67,7 @@ export function BaselineFields({
|
||||
next: BaselineFieldValues[K],
|
||||
) => void;
|
||||
heightAdapter: HeightUnitAdapter;
|
||||
errors: BaselineFieldErrors;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
@@ -63,6 +79,7 @@ export function BaselineFields({
|
||||
htmlFor={IDS.displayName}
|
||||
label={t("onboarding.baseline.displayNameLabel")}
|
||||
hint={t("onboarding.baseline.displayNameHint")}
|
||||
error={errors.displayName}
|
||||
>
|
||||
<Input
|
||||
id={IDS.displayName}
|
||||
@@ -71,6 +88,10 @@ export function BaselineFields({
|
||||
autoComplete="nickname"
|
||||
maxLength={50}
|
||||
placeholder={t("onboarding.baseline.displayNamePlaceholder")}
|
||||
aria-invalid={errors.displayName ? true : undefined}
|
||||
aria-describedby={
|
||||
errors.displayName ? `${IDS.displayName}-error` : undefined
|
||||
}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
@@ -82,6 +103,7 @@ export function BaselineFields({
|
||||
? t("onboarding.baseline.heightLabelFtIn")
|
||||
: t("onboarding.baseline.heightLabel")
|
||||
}
|
||||
error={errors.heightCm}
|
||||
>
|
||||
<HeightFieldControl
|
||||
idPrefix={IDS.heightCm}
|
||||
@@ -89,11 +111,14 @@ export function BaselineFields({
|
||||
value={value.height}
|
||||
onChange={(next) => onChange("height", next)}
|
||||
autoComplete="off"
|
||||
invalid={Boolean(errors.heightCm)}
|
||||
describedBy={errors.heightCm ? `${IDS.heightCm}-error` : undefined}
|
||||
/>
|
||||
</FieldGroup>
|
||||
<FieldGroup
|
||||
htmlFor={IDS.gender}
|
||||
label={t("onboarding.baseline.genderLabel")}
|
||||
error={errors.gender}
|
||||
>
|
||||
<Select
|
||||
// The design system's Radix Select uses an empty-string
|
||||
@@ -107,6 +132,10 @@ export function BaselineFields({
|
||||
id={IDS.gender}
|
||||
className="w-full"
|
||||
data-slot="onboarding-baseline-gender"
|
||||
aria-invalid={errors.gender ? true : undefined}
|
||||
aria-describedby={
|
||||
errors.gender ? `${IDS.gender}-error` : undefined
|
||||
}
|
||||
>
|
||||
<SelectValue placeholder={t("onboarding.baseline.genderNone")} />
|
||||
</SelectTrigger>
|
||||
@@ -129,6 +158,7 @@ export function BaselineFields({
|
||||
htmlFor={IDS.dateOfBirth}
|
||||
label={t("onboarding.baseline.dateOfBirthLabel")}
|
||||
hint={t("onboarding.baseline.dateOfBirthHint")}
|
||||
error={errors.dateOfBirth}
|
||||
>
|
||||
<DateField
|
||||
id={IDS.dateOfBirth}
|
||||
@@ -136,6 +166,10 @@ export function BaselineFields({
|
||||
onChange={(next) => onChange("dateOfBirth", next)}
|
||||
max={new Date().toISOString().slice(0, 10)}
|
||||
autoComplete="bday"
|
||||
aria-invalid={errors.dateOfBirth ? true : undefined}
|
||||
aria-describedby={
|
||||
errors.dateOfBirth ? `${IDS.dateOfBirth}-error` : undefined
|
||||
}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</fieldset>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
describeRejectedProfileField,
|
||||
describeRejectedProfileFields,
|
||||
type RejectedProfileField,
|
||||
} from "@/lib/profile/rejected-fields";
|
||||
import { toProfileSex } from "@/lib/profile/sex";
|
||||
@@ -15,14 +16,19 @@ import { toProfileSex } from "@/lib/profile/sex";
|
||||
* screen with nothing said, and the person walked away believing it was
|
||||
* stored.
|
||||
*
|
||||
* The settings account form already answers this question (see
|
||||
* `handleSaveProfile` in `src/components/settings/account-section`):
|
||||
* name the field in the person's own words, keep validator prose off
|
||||
* the screen, and only claim a clean save when there is one. The two
|
||||
* screens share the field-naming helper and the two sentences, so the
|
||||
* same rejection reads the same wherever it happens; only the labels
|
||||
* differ, because each screen calls the fields what its own inputs
|
||||
* call them.
|
||||
* The settings account form answers the same question the same way
|
||||
* (see `handleSaveProfile` in `src/components/settings/account-section`):
|
||||
* name the field in the person's own words, put the reason in that
|
||||
* field's own slot, keep validator prose off the screen, and only claim
|
||||
* a clean save when there is one. The two screens share the
|
||||
* field-naming helper and the sentences, so the same rejection reads
|
||||
* the same wherever it happens; only the labels differ, because each
|
||||
* screen calls the fields what its own inputs call them.
|
||||
*
|
||||
* A refused field also stops the wizard. Walking on would stamp
|
||||
* `onboardingCompletedAt` over a step the person has not finished, and
|
||||
* the only screen that could have shown them the refused value is the
|
||||
* one they just left.
|
||||
*/
|
||||
|
||||
/** The parts of the profile response this step reads. */
|
||||
@@ -41,6 +47,12 @@ export interface BaselineSaveOutcome {
|
||||
advance: boolean;
|
||||
/** A ready-to-show sentence, already localized. Null on a clean save. */
|
||||
notice: { tone: "warning" | "error"; message: string } | null;
|
||||
/**
|
||||
* One localized reason per refused field, keyed by the schema field
|
||||
* name so the step can drop each into the slot under its own input.
|
||||
* Empty on a clean save and on a failure that named no field.
|
||||
*/
|
||||
fieldErrors: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,22 +137,25 @@ export function describeBaselineSaveOutcome(
|
||||
if (res.ok) {
|
||||
const rejected = res.body?.data?.rejectedFields;
|
||||
const field = describeRejectedProfileField(rejected, t, labelKeys);
|
||||
if (!field) return { advance: true, notice: null };
|
||||
if (!field) return { advance: true, notice: null, fieldErrors: {} };
|
||||
// Some fields landed, at least one did not. The wizard stops here:
|
||||
// the accepted values are stored and stay in the inputs, the
|
||||
// refused ones say why under their own control, and the person can
|
||||
// correct and press the same button again. Moving on would mark
|
||||
// the account set up over a value nobody ever saw refused.
|
||||
return {
|
||||
advance: true,
|
||||
advance: false,
|
||||
notice: {
|
||||
tone: "warning",
|
||||
message: t("settings.profilePartiallySaved", { field }),
|
||||
},
|
||||
fieldErrors: describeRejectedProfileFields(rejected, t),
|
||||
};
|
||||
}
|
||||
|
||||
const errorCode = res.body?.meta?.errorCode;
|
||||
const blockingField = describeRejectedProfileField(
|
||||
res.body?.details?.issues,
|
||||
t,
|
||||
labelKeys,
|
||||
);
|
||||
const issues = res.body?.details?.issues;
|
||||
const blockingField = describeRejectedProfileField(issues, t, labelKeys);
|
||||
|
||||
if (errorCode === "profile.update.nothingSaved" && blockingField) {
|
||||
return {
|
||||
@@ -149,12 +164,14 @@ export function describeBaselineSaveOutcome(
|
||||
tone: "error",
|
||||
message: t("settings.profileNothingSaved", { field: blockingField }),
|
||||
},
|
||||
fieldErrors: describeRejectedProfileFields(issues, t),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
advance: false,
|
||||
notice: { tone: "error", message: localizedCode(errorCode, t) },
|
||||
fieldErrors: {},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@ import { toast } from "sonner";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BaselineFields } from "@/components/onboarding/baseline-fields";
|
||||
import {
|
||||
BaselineFields,
|
||||
type BaselineFieldErrors,
|
||||
} from "@/components/onboarding/baseline-fields";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import {
|
||||
EMPTY_HEIGHT_DRAFT,
|
||||
@@ -90,6 +93,10 @@ export function BaselineForm() {
|
||||
|
||||
const [form, setForm] = useState<BaselineFormState>(EMPTY_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
// Refusals from the last save, one sentence per field. Cleared on
|
||||
// every attempt so a slot never keeps a reason the server no longer
|
||||
// has, and cleared per field as it is edited.
|
||||
const [fieldErrors, setFieldErrors] = useState<BaselineFieldErrors>({});
|
||||
|
||||
// v1.17.1 — optional anamnesis (conditions + allergies). Persisted
|
||||
// through the existing encrypted self-context path
|
||||
@@ -127,11 +134,23 @@ export function BaselineForm() {
|
||||
value: BaselineFormState[K],
|
||||
) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
// The person is answering the objection; the objection goes away
|
||||
// while they type rather than sitting under a value they already
|
||||
// corrected. `height` is the draft's name here and `heightCm` the
|
||||
// schema's — the same field, so editing either box clears it.
|
||||
const slot = key === "height" ? "heightCm" : key;
|
||||
setFieldErrors((prev) => {
|
||||
if (!(slot in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[slot as keyof BaselineFieldErrors];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function advance(opts: { saveProfile: boolean }) {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
setFieldErrors({});
|
||||
try {
|
||||
if (opts.saveProfile) {
|
||||
const profileBody = buildBaselineProfileBody(
|
||||
@@ -150,9 +169,12 @@ export function BaselineForm() {
|
||||
show(outcome.notice.message);
|
||||
}
|
||||
if (!outcome.advance) {
|
||||
// Nothing was written. Staying on the step is the point —
|
||||
// the person has a named field to fix and the values they
|
||||
// typed are still in front of them.
|
||||
// A field was refused. Staying on the step is the point —
|
||||
// each refused input now says why under itself, the values
|
||||
// the person typed are still in front of them, and the
|
||||
// account is not stamped as set up over a value that never
|
||||
// landed.
|
||||
setFieldErrors(outcome.fieldErrors);
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
@@ -195,6 +217,7 @@ export function BaselineForm() {
|
||||
value={form}
|
||||
onChange={patch}
|
||||
heightAdapter={heightAdapter}
|
||||
errors={fieldErrors}
|
||||
/>
|
||||
|
||||
<AnamnesisCard
|
||||
|
||||
@@ -31,6 +31,10 @@ interface HeightFieldControlProps {
|
||||
onChange: (next: HeightDraft) => void;
|
||||
autoComplete?: string;
|
||||
enterKeyHint?: "next";
|
||||
/** Marks every box of the control as refused. */
|
||||
invalid?: boolean;
|
||||
/** Id of the error paragraph the control's boxes point at. */
|
||||
describedBy?: string;
|
||||
}
|
||||
|
||||
export function HeightFieldControl({
|
||||
@@ -40,6 +44,8 @@ export function HeightFieldControl({
|
||||
onChange,
|
||||
autoComplete,
|
||||
enterKeyHint,
|
||||
invalid,
|
||||
describedBy,
|
||||
}: HeightFieldControlProps) {
|
||||
const { t } = useTranslations();
|
||||
const { bounds } = adapter;
|
||||
@@ -58,6 +64,8 @@ export function HeightFieldControl({
|
||||
max={bounds.cm.max}
|
||||
step={0.1}
|
||||
autoComplete={autoComplete}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={describedBy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -77,6 +85,8 @@ export function HeightFieldControl({
|
||||
max={bounds.feet.max}
|
||||
step={1}
|
||||
autoComplete={autoComplete}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={describedBy}
|
||||
aria-label={t("common.heightFeet")}
|
||||
/>
|
||||
<span
|
||||
@@ -99,6 +109,8 @@ export function HeightFieldControl({
|
||||
max={bounds.inches.max}
|
||||
step={1}
|
||||
autoComplete={autoComplete}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={describedBy}
|
||||
aria-label={t("common.heightInches")}
|
||||
/>
|
||||
<span
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { FieldError } from "@/components/forms/field-error";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -10,6 +11,9 @@ import { cn } from "@/lib/utils";
|
||||
* is a no-op on rendered output.
|
||||
*
|
||||
* - `hint` renders below the control as muted fine print.
|
||||
* - `error` renders below the hint as the field's own error slot, with
|
||||
* the id `<htmlFor>-error` so the control can point
|
||||
* `aria-describedby` at it.
|
||||
* - `labelAccessory` sits right-aligned in the label row (e.g. a char counter);
|
||||
* when present the label row becomes a `justify-between` flex row.
|
||||
*/
|
||||
@@ -17,6 +21,7 @@ export function FieldGroup({
|
||||
htmlFor,
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
labelAccessory,
|
||||
className,
|
||||
labelClassName,
|
||||
@@ -25,6 +30,7 @@ export function FieldGroup({
|
||||
htmlFor: string;
|
||||
label: ReactNode;
|
||||
hint?: ReactNode;
|
||||
error?: ReactNode;
|
||||
labelAccessory?: ReactNode;
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
@@ -46,6 +52,7 @@ export function FieldGroup({
|
||||
)}
|
||||
{children}
|
||||
{hint ? <p className="text-muted-foreground text-xs">{hint}</p> : null}
|
||||
<FieldError id={`${htmlFor}-error`} message={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user