PetalComponents.AlertDialog (petal_components v4.15.3)

Copy Markdown View Source

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 than role="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

alert_dialog(assigns)

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 to nil.
  • 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 to nil.
  • 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.

close_alert_dialog(js \\ %JS{}, id)

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.

open_alert_dialog(js \\ %JS{}, 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")}.