Skip to content

feat(alert_dialog): the confirm/cancel gate before destructive actions - #635

Draft
mplatts wants to merge 2 commits into
mainfrom
feat/alert-dialog
Draft

feat(alert_dialog): the confirm/cancel gate before destructive actions#635
mplatts wants to merge 2 commits into
mainfrom
feat/alert-dialog

Conversation

@mplatts

@mplatts mplatts commented Aug 12, 2026

Copy link
Copy Markdown
Member

Closes #600

Summary

<.alert_dialog> is the two-answer question you ask before something irreversible. <.modal> is the general-purpose overlay and it lets you out cheaply, which is the wrong behaviour when the next click deletes an account, so this inverts it: role="alertdialog", focus opens on cancel, Escape cancels, backdrop clicks do nothing.

What shipped:

  • lib/petal_components/alert_dialog.ex - alert_dialog/1 plus the open_alert_dialog/2 helper, exported via use PetalComponents. Every attr carries a doc:; the moduledoc covers alert_dialog vs modal and both variants.
  • pc-alert-dialog section appended to assets/default.css, inside @layer components. Nothing above it moved.
  • test/petal/alert_dialog_test.exs - 32 tests.
  • test/js/alert_dialog.test.js - 15 tests for the two hooks.
  • PetalComponents.Showcase.AlertDialog with four examples, registered in showcase/registry.ex.
  • Playground page at /c/alert-dialog with the three dials, a keyboard map, and the showcase examples.
  • ### Unreleased / #### Added entry at the top of CHANGELOG.md.

Native <dialog> vs the modal's overlay machinery

Native <dialog>, the same call command_dialog/1 made. Reasoning:

  • showModal() gives the top layer, focus containment, and focus restoration to the opener on close. The WAI-ARIA alertdialog pattern requires all three, and <.modal> does not actually contain focus today. Hand-rolling a focus trap in an overlay div would have been the largest and least reliable part of this component.
  • The suppression the issue flagged turned out not to be needed. A native modal dialog already ignores backdrop clicks - light dismiss is opt-in via closedby="any", which this component simply never sets. So "click-away does not close" is the default rather than something fought for. The only native behaviour intercepted is Escape, and only so it runs your on_cancel rather than closing silently.
  • ::backdrop replaces the overlay div and the body { overflow: hidden } juggling entirely.

Focus, Escape and backdrop

  • Focus: cancel carries autofocus (which showModal() honours) and the hook focuses it explicitly on open, so the least destructive action is the default however a consumer's body content is composed. Cancel is also rendered before confirm, so Tab order reaches the safe action first. Focus restores to the opener via the native element.
  • Escape: the hook listens for the native cancel event, calls preventDefault(), and clicks the cancel button. Escape and a Cancel click therefore run the identical on_cancel path - one code path, not two that can drift.
  • Backdrop: nothing is wired. No phx-click-away, no click handler on the dialog element, no closedby. A test asserts the absence rather than trusting it stays absent.

The hook, and why

House rule is CSS-first, so naming what forced it: showModal() and close() are DOM methods with no Phoenix.LiveView.JS equivalent - there is no JS command that invokes a DOM method - and the native cancel event has to be intercepted in JS to reroute Escape through on_cancel. Everything else (surface, radius token, transitions, prefers-reduced-motion, dark mode) is CSS.

PetalAlertDialogTrigger is a second ~10-line hook backing the :trigger slot, mirroring PetalCommandTrigger for the same documented reason: hooks mount on dead views, phx-click JS commands only run inside a LiveView.

Both are unit-tested in test/js/alert_dialog.test.js. No new dependencies, hex or npm.

Deviations from the issue's API sketch

None on the public surface - every attr, slot, default and the open_alert_dialog/2 signature match the sketch as written.

Two additions the sketch did not spell out, both internal:

  • The CSS section grew pc-alert-dialog__panel, __header, __body, __content, __icon, __icon-svg, __cancel, __confirm and __trigger alongside the names the issue listed. The header/body/actions split is what keeps the title and the action row visible while a long body scrolls.
  • data-pc-alert-dialog-close / -cancel / -confirm markers on the action buttons are how the hook finds them.

One thing worth a maintainer's eye: the confirm and cancel buttons are <.button> (color="danger"/"primary" and color="gray" variant="outline") rather than bespoke pc-alert-dialog button styles, so they inherit the theme rail and the radius rules for free. Say the word if you would rather they were standalone.

Verification

Check Result
mix format --check-formatted pass
mix compile --force --warnings-as-errors pass
mix credo zero new entries vs origin/main (diffed --all --format=oneline)
mix test 913 -> 945, 0 failures, 1 skipped
npm test 157 -> 172 passing

Verified by hand in the playground, light and dark, at /c/alert-dialog:

  • Initial focus lands on Cancel - opened the dialog and pressed Enter without touching anything else; the page acknowledged cancel, not confirm.
  • Escape cancels - closes the dialog and acknowledges cancel, i.e. it runs on_cancel rather than closing silently.
  • Backdrop click does not close - with the dialog open the rest of the page is inert (the a11y tree contains only the dialog's own heading and two buttons) and the dialog stays up.
  • Tab moves from Cancel to the confirm button with a visible focus-visible ring, and does not escape the dialog.
  • Long content scrolls inside the body while the title and the action row stay fixed.

One bug found and fixed during that pass: a percentage max-height on the panel resolved against the dialog's auto height and was ignored, so a long body overflowed and clipped its own header. Both now carry the same explicit --pc-alert-dialog-max-h.

Light and dark screenshots of the playground page were captured during that pass and are available on request - I have not pushed image assets to the repo.

<.alert_dialog> asks one question with two answers and will not let you
leave without answering. Distinct from <.modal>, which stays untouched:

- role="alertdialog" + aria-modal, aria-labelledby -> title,
  aria-describedby -> description (attribute omitted when absent)
- initial focus pinned to cancel (the least destructive action)
- Escape cancels and runs on_cancel; backdrop click does nothing
- default and destructive variants; destructive moves confirm to the
  danger ramp and adds a danger icon the :icon slot can override
- :trigger slot renders a pre-wired opener; open_alert_dialog/2 opens it
  from any element
- long bodies scroll inside the panel, header and action row stay put

Built on the native <dialog> rather than the modal's overlay divs:
showModal() supplies the top layer, focus containment and focus
restoration, and a native modal dialog already ignores backdrop clicks -
exactly what this pattern wants. Two small hooks cover only what
Phoenix.LiveView.JS cannot reach: showModal()/close() are DOM methods
with no JS-command equivalent, and Escape's native cancel event must be
intercepted so it runs on_cancel instead of quietly closing.

Showcase: PetalComponents.Showcase.AlertDialog (delete account, unsaved
changes, bulk delete with a live count, custom icon), registered in the
showcase registry.

Playground: /c/alert-dialog with variant, description and content-length
dials, a keyboard map, and the showcase examples.

Tests: mix test 913 -> 945 (0 failures, 1 skipped);
npm test 157 -> 172. Zero new credo entries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.48%. Comparing base (871b2cf) to head (a4a6ad3).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #635      +/-   ##
==========================================
+ Coverage   92.40%   92.48%   +0.08%     
==========================================
  Files         119      121       +2     
  Lines        5066     5123      +57     
==========================================
+ Hits         4681     4738      +57     
  Misses        385      385              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

mplatts added a commit that referenced this pull request Aug 12, 2026
@mplatts

mplatts commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

Screenshots

Dialog open

Alert dialog open, light
Alert dialog open, dark

Page

Alert dialog page, light
Alert dialog page, dark

Behaviour proofs — Escape, backdrop click, Enter landing on Cancel, long-content scroll

Escape
Backdrop click
Enter on cancel
Long content scroll

Verified independently

Check Result
mix test 945 tests, 0 failures, 1 skipped (+32)
npm test 172 passing (+15)
mix format / compile -Werror clean
mix credo zero new entries vs main
new dependencies none
slugs : clauses 61 : 61

A correction to the issue's premise

The brief expected backdrop-close would need suppressing. It doesn't — a native modal &lt;dialog&gt; already ignores backdrop clicks, and light dismiss is opt-in via closedby="any", which this component never sets. So the only native behaviour intercepted is Escape's cancel event, rerouted through the cancel button so Escape and a Cancel click share one on_cancel path. Less machinery than the issue anticipated.

The a11y requirements were verified functionally rather than visually: opening and pressing Enter without touching anything acknowledged cancel (proving initial focus is on the least destructive action), and with the dialog open the a11y tree contains only its heading and two buttons.

A real bug was caught and fixed mid-pass: with long content the panel's percentage max-height resolved against the dialog's auto height, was ignored, and the panel overflowed and clipped its own header. Found by opening a zoomed screenshot and looking at it. Both dialog and panel now carry the same explicit --pc-alert-dialog-max-h.

@nhobes — two calls for you: confirm/cancel are composed from &lt;.button&gt; rather than bespoke pc-alert-dialog button styles (buys the theme rail and nested-radius rules free, but you may want it the other way); and this page carries both dials and registry examples, where recent pages like modal and command are registry-only.

Images live on the pr-assets branch, which exists only to host PR screenshots - it never merges to main and ships in no Hex release.

…t, live-assign proof

Audit round. (1) The brief's 'page behind does not scroll' was claimed
by the playground caption but never implemented - the hook now locks
body scroll on open and unlocks in the native close event, the one
funnel every close path drains through; destroyed() also unlocks so a
patch removing an OPEN dialog can't leave the page frozen. All three
paths spec-pinned. (2) Chrome's close watcher lets a second rapid
Escape bypass the cancelable cancel event entirely - the dialog closed
natively and on_cancel silently never ran. A close the hook didn't
initiate now routes through the cancel button, spec-pinned. (3) The
icon span took the utilities-layer heroicon sizing bug (24px icons in
real apps) - doubled-selector + bang per the house contract. (4) The
action buttons carried the raw radius token, out-rounding their own
panel at large radii - they now derive from the panel's clamped radius
like modal/slideover actions. (5) The trigger wrapper was a block div
that broke toolbar placement - display:contents keeps bubbling intact.
(6) The dark icon wash rode a white mix; it now rides the gray ramp so
a re-themed gray dial carries it. (7) The brief's live-assign proof
exists now: a dev.exs-only bulk-delete example whose body counts
ticked rows from a socket assign (the showcase macro rightly forbids
interpolation, so it lives on the page). 945 Elixir + 175 JS green;
page + all four CSS markers verified served.
@nhobes

nhobes commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Polish pass done. One follow-up flagged outside this PR's scope: PetalCommandDialog shares the missing background scroll lock - the same body overflow-hidden treatment should mirror there (the alert dialog's landed hook-side, so the command dialog wants the same two lines + spec). Happy to do it as a tiny separate PR after this merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Component: Alert Dialog

2 participants