The confirm/cancel decision dialog: one question, two answers, no way out without picking one. "Publish these changes?", "Discard unsaved edits?", "Delete this account?".
alert_dialog vs modal
PetalComponents.Modal.modal/1 is the light-dismissible general dialog
(shadcn calls it "Dialog") - close button, click away, Escape, all of them
cheap; alert_dialog/1 forces an explicit choice, so backdrop clicks do
nothing. Reach for modal/1 unless abandoning the choice would be
ambiguous - unless walking away leaves the user unsure what just happened
to their data.
The long version: modal/1 holds forms, detail panes, anything, and getting
out of it cheaply is the right behaviour for a container. alert_dialog/1
is the opposite. It asks one question with two answers, and it will not let
you leave without answering:
role="alertdialog"rather thanrole="dialog", so assistive tech announces it as an interruption that needs a decision.- Initial focus lands on the cancel button - the least destructive action - not on confirm. The hook places it with a script focus, so the focus ring follows the user's input modality: visible for a keyboard-driven open, absent for a tap or click.
- Escape cancels.
- Clicking the backdrop does nothing. The friction is the point.
Reach for modal/1 when the user needs to fill something in. Reach for
alert_dialog/1 when the user needs to choose.
Usage
Render the dialog anywhere in the template and open it from any element with
open_alert_dialog/2. The default is the calm confirm - primary button, no
media chip, nothing shouting:
<.button phx-click={PetalComponents.AlertDialog.open_alert_dialog("discard-changes")}>
Back to posts
</.button>
<.alert_dialog
id="discard-changes"
title="Discard unsaved changes?"
description="Your edits since the last save will be lost."
confirm_label="Discard"
cancel_label="Keep editing"
on_confirm={JS.push("discard")}
/>variant="destructive" is the explicit opt-in to the danger treatment -
danger confirm button, danger media chip. Ask for it; never assume it:
<.alert_dialog
id="delete-account"
variant="destructive"
title="Delete your account?"
description="This permanently removes your account and every project in it. This cannot be undone."
confirm_label="Delete account"
on_confirm={JS.push("delete_account")}
/>The :trigger slot renders the opener next to the dialog and wires it up for
you, so you never repeat the id:
<.alert_dialog id="revoke-key" variant="destructive" title="Revoke this API key?">
<:trigger>
<.button color="danger" variant="outline">Revoke</.button>
</:trigger>
</.alert_dialog>The :inner_block composes live data into the body, below the description:
<.alert_dialog id="bulk-delete" variant="destructive" title="Delete selected invoices?">
<p>You are about to delete <strong>{@selected_count} invoices</strong>.</p>
</.alert_dialog>The :media slot puts an icon or an image in a chip beside the title. Icons
want the pc-alert-dialog__media-icon class so they inherit the chip's
sizing contract; an <img> just fills the chip:
<.alert_dialog id="sign-out" title="Sign out of every device?">
<:media>
<.icon name="hero-arrow-right-start-on-rectangle" class="pc-alert-dialog__media-icon" />
</:media>
</.alert_dialog>Implementation
Built on the native <dialog> element (same approach as
PetalComponents.Command.command_dialog/1). showModal() supplies the top
layer, focus containment, the ::backdrop, focus restoration to the opener
on close, and - crucially for this component - no light dismiss: a native
modal dialog ignores backdrop clicks, which is exactly the behaviour the
WAI-ARIA alertdialog pattern wants.
Two small hooks carry the parts Phoenix.LiveView.JS cannot reach:
showModal()/close() are DOM methods with no JS-command equivalent, and
Escape's native cancel event has to be intercepted so it runs on_cancel
instead of silently closing.
The third thing the hook owns is the exit. dialog.close() is instant -
the element leaves the top layer in the same frame, so an out animation is
impossible to express in CSS alone. Every way out (Escape, the cancel
button, the confirm button, close_alert_dialog/2) therefore funnels
through one close-intent step: the hook adds a closing class, the CSS plays
the mirror of the entrance, and only when the animation ends - or a
watchdog timer fires - does the real close() run. Under
prefers-reduced-motion: reduce the hook skips straight to close(),
because no animation would ever fire the event it waits on. Everything else
- the surface, the animations themselves - is CSS.
One LiveView detail worth knowing: showModal() sets the open attribute
client-side, and the server never renders it. A patch reaching this element
would therefore merge open away and shut the dialog with no close
event. The hook restores its own state across patches, so an open dialog
stays open (and an in-flight exit keeps playing) no matter what the server
pushes while it is up.
Summary
Functions
Renders an alert dialog. See the module docs for when to use this over
PetalComponents.Modal.modal/1.
Returns a JS command that closes the alert dialog with the given id.
Returns a JS command that opens the alert dialog with the given id.
Compose it onto any element: phx-click={open_alert_dialog("delete-account")}.
Functions
Renders an alert dialog. See the module docs for when to use this over
PetalComponents.Modal.modal/1.
Attributes
id(:string) (required) - unique id; open the dialog with open_alert_dialog/2.title(:string) (required) - the question being asked. Becomes the dialog's accessible name via aria-labelledby.description(:string) - supporting copy explaining the consequence; wired to aria-describedby. Omit it when the inner_block carries the body. Defaults tonil.variant(:string) - default is the calm confirm - primary button, no media chip unless you pass one. destructive is the explicit opt-in: danger confirm button, and a danger-washed media chip carrying a warning glyph. Defaults to"default". Must be one of"default", or"destructive".confirm_label(:string) - confirm button text. Defaults to"Continue".cancel_label(:string) - cancel button text. Defaults to"Cancel".on_confirm(Phoenix.LiveView.JS) - JS commands run when the user confirms, e.g. JS.push("delete_account"). The dialog closes afterwards. Defaults to%Phoenix.LiveView.JS{ops: []}.on_cancel(Phoenix.LiveView.JS) - JS commands run when the user cancels, via the cancel button or Escape. The dialog closes afterwards. Defaults to%Phoenix.LiveView.JS{ops: []}.class(:any) - extra classes for the dialog element. Defaults tonil.- Global attributes are accepted.
Slots
inner_block- custom body content rendered below the description.media- an icon or image rendered in a chip beside the title. Give icons the pc-alert-dialog__media-icon class; an img fills the chip. The destructive variant supplies a warning glyph when this is empty; the default variant renders no chip unless you pass one.trigger- an opener rendered next to the dialog, pre-wired to open it.
Returns a JS command that closes the alert dialog with the given id.
You rarely need this - both actions close the dialog themselves. It exists
so a programmatic close (a push_event from the server, a parent component
tidying up) drains through the same close-intent funnel as Escape and the
buttons, and therefore plays the same exit animation instead of snapping
the dialog out from under the user.
Returns a JS command that opens the alert dialog with the given id.
Compose it onto any element: phx-click={open_alert_dialog("delete-account")}.