refactor(anamnesis): give each lifestyle entry a proper action row

The three entries stacked two full-width buttons under the value — a
disabled grey Save above a red-outlined "Remove smoking status" — so each
one read as a slab of controls with the same weight as the field it
belonged to, and scanning them ran the two labels together.

Each entry now ends in one right-aligned action row: the quiet destructive
first, the primary Save last, both at the mobile tap-target floor. The
remove control drops the field name from its visible label, which the
Label directly above already carries, and keeps the full phrase as its
accessible name and in the dialog it opens — so three of them side by side
stay distinguishable to a screen reader. Six languages.

Follows the card action-row convention: primary bottom right, secondary
beside it, nothing after the row.
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 16:45:17 +02:00
1 parent d7a95874c2
commit 41faf165ee
8 files changed
+52 -24

No files matched your search

@@ -114,7 +114,12 @@ describe("health-profile fact removal control", () => {
.filter(
(props) =>
props.type === "button" &&
props.variant === "outline" &&
// The Save control is the entry's PRIMARY action, so it carries the
// default variant. Matching on `variant === "outline"` used to be the
// discriminator and silently stopped matching anything when the
// action row was rebuilt — the button was still there, the selector
// was not.
props.variant === undefined &&
typeof props.onClick === "function",
)
.map((props) => {
@@ -132,12 +137,18 @@ describe("health-profile fact removal control", () => {
const { html } = render();
expect(confirmButtons).toHaveLength(1);
// The visible label is short — three entries side by side each carrying
// their own field name read as one run of words — but the ACCESSIBLE name
// still says which entry it removes, and so does the dialog it opens.
expect(confirmButtons[0]).toMatchObject({
slot: "health-profile-fact-remove-SMOKING_STATUS",
label: "Remove Smoking status",
label: "Remove",
ariaLabel: "Remove Smoking status",
confirmLabel: "Remove Smoking status",
title: "Remove Smoking status?",
body: "The current value will become not recorded. Its dated history will be retained.",
});
expect(html).toContain("Remove Smoking status");
expect(html).toContain("Remove");
expect(apiDelete).not.toHaveBeenCalled();
});
@@ -173,7 +184,7 @@ describe("health-profile fact removal control", () => {
const saveButton = renderedButtons.find(
(props) =>
props.type === "button" &&
props.variant === "outline" &&
props.variant === undefined &&
typeof props.onClick === "function",
);
@@ -207,7 +218,7 @@ describe("health-profile fact removal control", () => {
const saveButton = renderedButtons.find(
(props) =>
props.type === "button" &&
props.variant === "outline" &&
props.variant === undefined &&
typeof props.onClick === "function",
);
@@ -228,26 +228,21 @@ export function HealthProfileFactsManager() {
))}
</SelectContent>
</Select>
<div className="space-y-2">
<Button
type="button"
size="sm"
variant="outline"
className="min-h-11 w-full sm:min-h-9"
disabled={disabled || !dirty || !value}
onClick={() => save.mutate({ kind, value, current })}
>
{save.isPending && save.variables?.kind === kind && (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
)}
{t("records.profileFacts.save")}
</Button>
{/* One action row per entry (UI-STANDARDS §11 + the action-row
rule): right-aligned, primary last, the quiet destructive
beside it. Two full-width stacked blocks — a grey disabled
slab over a red-outlined one — carried the same visual weight
as the field they belong to and ran into each other when
scanned, which is what "Save" directly above "Remove smoking
status" read as. */}
<div className="flex items-center justify-end gap-2 pt-1">
{current ? (
<ConfirmButton
label={t("records.profileFacts.remove", {
// Short label inside the row; the full phrase stays the
// accessible name, because "Remove" on its own is
// ambiguous once three of them are on screen.
label={t("records.profileFacts.removeShort")}
ariaLabel={t("records.profileFacts.remove", {
kind: t(KIND_LABEL[kind]),
})}
title={t("records.profileFacts.removeConfirmTitle", {
@@ -262,12 +257,28 @@ export function HealthProfileFactsManager() {
remove.isPending && remove.variables?.id === current.id
}
disabled={disabled}
variant="ghost"
size="sm"
className="text-destructive min-h-11 w-full sm:min-h-9"
className="text-destructive min-h-11 sm:min-h-9"
icon={<Trash2 className="size-4" aria-hidden />}
slot={`health-profile-fact-remove-${kind}`}
/>
) : null}
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={disabled || !dirty || !value}
onClick={() => save.mutate({ kind, value, current })}
>
{save.isPending && save.variables?.kind === kind && (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
)}
{t("records.profileFacts.save")}
</Button>
</div>
</div>
);