Files
HealthLog/src/components/ui/confirm-button.tsx
T
Marc-André Bombeck 3ebef19ac3 fix(settings): one save model and one confirm per card
The Telegram, ntfy, webhook and email cards saved their enable switch on
tap and the rest of the form on Save; the switch is now part of the form
and the one Save sends it. The display selects confirm a change with a
toast. Destructive confirmations in Settings go through ConfirmButton or
ConfirmDialog with the destructive style. Two structural guards hold both
rules: a card may not mix instant and explicit saves, and no settings file
reaches for a raw AlertDialog without a written reason.
2026-10-21 19:42:53 +02:00

218 lines
6.2 KiB
TypeScript

"use client";
/**
* The destructive-control confirmation, in the two shapes the tree needs.
*
* The security card's "sign out everywhere" and its per-row revoke, and the
* same pair on trusted devices, fired on a single tap with no confirmation —
* while every other destructive surface in the app confirms. Signing every
* other device out is not recoverable by an undo; the other devices simply
* have to log in again, and on a shared screen a mis-tap is a support call.
*
* Wrapping the pattern rather than inlining a fourth dialog is the point: the
* next destructive control gets the confirmation by using this, instead of by
* someone remembering to add one.
*
* `ConfirmButton` covers the common case — a button that opens its own dialog.
* `ConfirmDialog` is the same dialog without the trigger, for the destructive
* controls that are not buttons: a switch whose off position deletes what it
* was holding, a menu item, a key binding. Those had no shared path at all,
* which is a fair part of why they were the ones that shipped unguarded — a
* control that cannot reach for the primitive reaches for nothing.
*
* The copy contract matches the rest of the app after the delete-confirmation
* sweep — say what happens, and claim no more finality than is true. Callers
* pass their own body text so each control can be specific about its own
* consequence.
*/
import type { ReactNode } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { useTranslations } from "@/lib/i18n/context";
interface ConfirmCopy {
title: string;
body: string;
/** The dialog's action label. Names the act, not "OK". */
confirmLabel: string;
onConfirm: () => void;
pending?: boolean;
/** `data-slot` for tests and the design guards. */
slot?: string;
/**
* `data-testid` for the trigger; the dialog's action carries
* `<testId>-confirm`. For call sites whose tests already address the
* control by test id.
*/
testId?: string;
/** A control that belongs with the question, such as an opt-out checkbox. */
extra?: ReactNode;
/**
* The action deletes something: it wears the destructive style, as the
* data-list selection bar's confirm already does.
*/
destructive?: boolean;
/**
* The confirm keeps the dialog open: the caller closes it once the act
* settled, and Cancel waits while `pending`. For an act whose in-flight
* state must stay on screen (an irreversible delete), where a dismissal
* mid-request would read as cancelled. Only meaningful on `ConfirmDialog`,
* whose caller owns `open`.
*/
holdOpen?: boolean;
/** The confirm stays disabled, e.g. until a field in `extra` is valid. */
confirmDisabled?: boolean;
}
function ConfirmBody({
title,
body,
confirmLabel,
onConfirm,
pending = false,
slot,
testId,
extra,
destructive = false,
holdOpen = false,
confirmDisabled = false,
}: ConfirmCopy) {
const { t } = useTranslations();
return (
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{body}</AlertDialogDescription>
</AlertDialogHeader>
{extra}
<AlertDialogFooter>
<AlertDialogCancel disabled={holdOpen && pending}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant={destructive ? "destructive" : "default"}
data-slot={slot ? `${slot}-confirm` : undefined}
data-testid={testId ? `${testId}-confirm` : undefined}
disabled={pending || confirmDisabled}
aria-busy={pending || undefined}
onClick={
holdOpen
? (event) => {
event.preventDefault();
onConfirm();
}
: onConfirm
}
>
{pending && (
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
)}
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
);
}
export function ConfirmButton({
label,
title,
body,
confirmLabel,
onConfirm,
pending = false,
disabled = false,
size,
variant = "outline",
className,
icon,
ariaLabel,
slot,
testId,
extra,
destructive = false,
}: ConfirmCopy & {
/** The control's own label, before the dialog opens. */
label: string;
disabled?: boolean;
size?: "sm" | "icon";
variant?: "outline" | "ghost" | "destructive";
className?: string;
/** For an icon-only control, where `label` renders as an icon. */
ariaLabel?: string;
icon?: ReactNode;
}) {
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant={variant}
size={size}
className={className}
aria-label={ariaLabel}
data-slot={slot}
data-testid={testId}
disabled={disabled || pending}
>
{pending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
icon
)}
{label}
</Button>
</AlertDialogTrigger>
<ConfirmBody
title={title}
body={body}
confirmLabel={confirmLabel}
onConfirm={onConfirm}
pending={pending}
slot={slot}
testId={testId}
extra={extra}
destructive={destructive}
/>
</AlertDialog>
);
}
/**
* The trigger-less half. The caller owns `open` and decides what a dismissal
* means for its own control — a switch, for instance, has to stay ON when the
* dialog its OFF position raised is cancelled, which is a state a
* self-triggering dialog cannot express.
*/
export function ConfirmDialog({
open,
onOpenChange,
...copy
}: ConfirmCopy & {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<ConfirmBody {...copy} />
</AlertDialog>
);
}