[Bug Fix] AlertDialog: render a native <dialog> and play the exit animation

TV
2mo ago
+215-45

Problem

  • AlertDialogContent rendered a <template> that the controller cloned to the end of <body> and removed on dismiss: closing was a hard cut, no Escape, no focus trap/restore, the page behind stayed interactive.
  • Backdrop was bg-black/80 while every other overlay uses bg-background/80.

Change

  • Native <dialog role="alertdialog"> rendered in place, opened with showModal() — top layer, inert background, focus trap and focus return from the platform. No template/clone, no nested controller.
  • shadcn/Radix parity: Escape closes (cancel intercepted so the exit animates), backdrop click does not close, focus lands on Cancel (autofocus), Action dismisses too (a link/form submit on it still navigates).
  • Enter/exit animations on panel and ::backdrop via data-state + tw-animate-css. dismiss() sets data-state="closed", waits for the panel's and the backdrop's exit animations (getAnimations({subtree: true}) on the <dialog> filtered to its own effect.target, their finished promises, a token per run so a superseded run cannot settle a later one), then calls close(). This differs from the animationend block of the other overlays (#506): getAnimations() without subtree never lists the ::backdrop, and its events land on the <dialog> under the same keyframe names as the panel's, so that block closed on whichever exit ended first and a stale animationcancel from a previous run could close a new run early. Same block as Dialog (#517).
  • The backdrop gets backdrop:duration-200 so it fades in step with the panel by default (--tw-duration is registered inherits: false, so ::backdrop does not pick the panel's duration up). The controller does not depend on the two matching: a duration-* override closes after the longer of the two.
  • A second Escape mid-exit is left to the browser (non-cancelable in Chrome, closes at once where it stays cancelable). A close the controller did not start settles the pending run and lifts the body scroll lock, so the next open starts clean; disconnect() lifts it even if the <dialog> is already gone.
  • Backdrop aligned to bg-background/80 backdrop-blur-sm. Public API and the docs example unchanged.
  • mcp/data/registry.json rebuilt (separate commit).

Dialog and Combobox get the same treatment in sibling PRs.

Test

  • cd gem && bundle exec rake — 13 new tests in alert_dialog_test.rb.
  • Manual, on the AlertDialog docs page:
    1. Open → panel and backdrop fade/zoom in, focus is on Cancel, page scroll is locked.
    2. Cancel / Escape / Continue → exit animation plays, then the dialog closes, focus returns to the trigger, scroll is restored.
    3. Click the backdrop → stays open.
    4. Escape twice quickly → closes hard; opening again works normally.
    5. AlertDialog(open: true) → open on page load.
  • Timing cases checked in headless Chrome (CDP), before/after: normal close, Escape once/twice, reopen mid-exit, dismiss mid-enter, disconnect mid-exit and an external close() all close after the 200 ms exit (or at once where the browser closes natively); reopen + re-dismiss within the first frame now runs the full 200 ms (was ~30 ms); a longer duration-* on either panel or backdrop closes after the longer one; requestClose() mid-exit closes at once. No console errors.

🤖 Generated with Claude Code