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

+1
View File
@@ -8844,6 +8844,7 @@
"saveError": "Lebensstil-Kontext konnte nicht gespeichert werden",
"loadError": "Lebensstil-Kontext konnte nicht geladen werden",
"remove": "{kind} entfernen",
"removeShort": "Entfernen",
"removeConfirmTitle": "{kind} entfernen?",
"removeConfirmDescription": "Der aktuelle Wert gilt danach als nicht erfasst. Der datierte Verlauf bleibt erhalten.",
"removedToast": "{kind} aus dem aktuellen Lebensstil-Kontext entfernt",
+1
View File
@@ -8844,6 +8844,7 @@
"saveError": "Could not save lifestyle context",
"loadError": "Could not load lifestyle context",
"remove": "Remove {kind}",
"removeShort": "Remove",
"removeConfirmTitle": "Remove {kind}?",
"removeConfirmDescription": "The current value will become not recorded. Its dated history will be retained.",
"removedToast": "{kind} removed from current lifestyle context",
+1
View File
@@ -8844,6 +8844,7 @@
"saveError": "No se pudo guardar el contexto del estilo de vida",
"loadError": "No se pudo cargar el contexto del estilo de vida",
"remove": "Eliminar {kind}",
"removeShort": "Quitar",
"removeConfirmTitle": "¿Eliminar {kind}?",
"removeConfirmDescription": "El valor actual pasará a constar como no registrado. Se conservará su historial fechado.",
"removedToast": "{kind} eliminado del contexto actual del estilo de vida",
+1
View File
@@ -8844,6 +8844,7 @@
"saveError": "Impossible d'enregistrer le contexte du mode de vie",
"loadError": "Impossible de charger le contexte du mode de vie",
"remove": "Supprimer : {kind}",
"removeShort": "Supprimer",
"removeConfirmTitle": "Supprimer : {kind} ?",
"removeConfirmDescription": "La valeur actuelle sera considérée comme non renseignée. Son historique daté sera conservé.",
"removedToast": "{kind} supprimé du contexte actuel du mode de vie",
+1
View File
@@ -8844,6 +8844,7 @@
"saveError": "Impossibile salvare il contesto dello stile di vita",
"loadError": "Impossibile caricare il contesto dello stile di vita",
"remove": "Rimuovi {kind}",
"removeShort": "Rimuovi",
"removeConfirmTitle": "Rimuovere {kind}?",
"removeConfirmDescription": "Il valore attuale risulterà non registrato. La cronologia datata verrà conservata.",
"removedToast": "{kind} rimosso dal contesto attuale dello stile di vita",
+1
View File
@@ -8844,6 +8844,7 @@
"saveError": "Nie udało się zapisać kontekstu stylu życia",
"loadError": "Nie udało się wczytać kontekstu stylu życia",
"remove": "Usuń: {kind}",
"removeShort": "Usuń",
"removeConfirmTitle": "Usunąć: {kind}?",
"removeConfirmDescription": "Bieżąca wartość zmieni się na brak danych. Historia z datami zostanie zachowana.",
"removedToast": "Usunięto {kind} z bieżącego kontekstu stylu życia",
@@ -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>
);