[Bug Fix] AlertDialog: render a native <dialog> and play the exit animation - #518
Open
tvq wants to merge 7 commits into
Open
[Bug Fix] AlertDialog: render a native <dialog> and play the exit animation#518tvq wants to merge 7 commits into
tvq wants to merge 7 commits into
Conversation
…mation AlertDialogContent rendered a <template> that the controller cloned to the end of <body> on open and removed on dismiss: closing was a hard cut, there was no Escape handling, no focus trap, no focus restore and the page behind stayed interactive. Render a native <dialog role="alertdialog"> in place and open it with showModal(): top layer, inert background, focus trap and focus return come from the platform. Behaviour matches shadcn/Radix AlertDialog: Escape closes (cancel is intercepted so the exit animates), clicking the backdrop does not, focus lands on Cancel (autofocus), Action dismisses too. Enter and exit animate on the panel and on ::backdrop via data-state and tw-animate-css, settled with the same "Overlay exit" block as ruby-ui#506 (copied unchanged; afterExit() calls dialog.close()). ::backdrop animation events are dispatched on the <dialog> under the same keyframe name, so the backdrop gets backdrop:duration-200 to end together with the panel; with the default 150 ms it settled the close early and cut the panel's exit short. A second Escape during the exit is non-cancelable in Chrome (close watcher) and closes hard; the close listener drops the exit listeners and the body scroll lock so the next open starts clean. The backdrop now uses bg-background/80 like Dialog, Sheet and CommandDialog instead of bg-black/80. Public API and the docs example are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
All reported issues were addressed across 6 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
shadcn's alert-dialog demo opens from <Button variant="outline">; the primary button is reserved for the confirming action (Continue), with Cancel as outline. Sheet and Drawer docs already follow that convention — this brings AlertDialog in line so triggers read the same across overlays. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t the <dialog> When the <dialog> target is removed before its controller element (a morph, a partial re-render), `disconnect()` threw on `this.dialogTarget` before it reached the body class, leaving the page scroll locked. Guard the target-specific teardown with `hasDialogTarget`; the class is removed either way. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…op included
The exit block matched animationend/animationcancel by keyframe name,
which left two holes on a native <dialog>:
- Reopened mid-exit and dismissed again before the next frame, the first
run's animationcancel still matched "exit" and closed the dialog under
the second run (32 ms instead of 200 ms in headless Chrome).
- getAnimations() does not list ::backdrop, so the block only knew the
panel while the backdrop's events arrived under the same name;
whichever ended first closed the dialog and cut the other as soon as
the durations differed (a duration-* override on AlertDialogContent).
dismiss() now collects the panel's and the backdrop's CSSAnimations
(getAnimations({subtree: true}) filtered to effect.target === dialog),
waits on their `finished` promises and settles only when no later
dismiss or close superseded the run. A second Escape mid-exit is no
longer preventDefault()ed: Chrome makes it non-cancelable anyway, so
browsers that keep it cancelable now close at once as well.
Same block as Dialog in ruby-ui#517.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
tvq
added a commit
to tvq/ruby_ui
that referenced
this pull request
Sep 4, 2026
Ports two review fixes from the sibling PRs: - disconnect() reached this.dialogTarget unconditionally; a <dialog> removed before its wrapper made Stimulus throw and left the body locked (ruby-ui#518). The target-specific teardown is guarded with hasDialogTarget, the lock is lifted either way. - The body class was removed unconditionally on close and disconnect. A Dialog closed or removed on top of another modal <dialog> (a nested Dialog, a Sheet) unlocked the page under the remaining one (ruby-ui#520). The release is now guarded by `dialog:modal`: the class goes only when no modal <dialog> is left in the document. Verified in headless Chrome: dialog removed before its wrapper — no error, lock lifted; second Dialog closed or removed on top of the first — lock kept while the first is modal, released with it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 2 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="gem/lib/ruby_ui/alert_dialog/alert_dialog_controller.js">
<violation number="1" location="gem/lib/ruby_ui/alert_dialog/alert_dialog_controller.js:71">
P2: When the backdrop has a different exit duration, this filter drops its animation because `effect.target` is a `CSSPseudoElement`, not the dialog. `close()` then runs after the panel animation and cuts off the longer backdrop; include pseudo-element targets whose originating element is `animated`.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| // subtree: true is what lists the ::backdrop's animation; descendants are filtered back out. | ||
| const exitAnimations = animated | ||
| .getAnimations({ subtree: true }) | ||
| .filter((animation) => animation instanceof CSSAnimation && animation.effect?.target === animated); |
There was a problem hiding this comment.
P2: When the backdrop has a different exit duration, this filter drops its animation because effect.target is a CSSPseudoElement, not the dialog. close() then runs after the panel animation and cuts off the longer backdrop; include pseudo-element targets whose originating element is animated.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/lib/ruby_ui/alert_dialog/alert_dialog_controller.js, line 71:
<comment>When the backdrop has a different exit duration, this filter drops its animation because `effect.target` is a `CSSPseudoElement`, not the dialog. `close()` then runs after the panel animation and cuts off the longer backdrop; include pseudo-element targets whose originating element is `animated`.</comment>
<file context>
@@ -49,47 +48,45 @@ export default class extends Controller {
- .getAnimations()
- .filter((animation) => animation instanceof CSSAnimation);
+ .getAnimations({ subtree: true })
+ .filter((animation) => animation instanceof CSSAnimation && animation.effect?.target === animated);
- // No exit animation, or no box to run it in: animationend would never fire.
</file context>
Suggested change
| .filter((animation) => animation instanceof CSSAnimation && animation.effect?.target === animated); | |
| .filter((animation) => { | |
| const target = animation.effect?.target; | |
| return animation instanceof CSSAnimation && (target === animated || target?.element === animated); | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
AlertDialogContentrendered 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.bg-black/80while every other overlay usesbg-background/80.Change
<dialog role="alertdialog">rendered in place, opened withshowModal()— top layer, inert background, focus trap and focus return from the platform. No template/clone, no nested controller.cancelintercepted 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).::backdropviadata-state+ tw-animate-css.dismiss()setsdata-state="closed", waits for the panel's and the backdrop's exit animations (getAnimations({subtree: true})on the<dialog>filtered to its owneffect.target, theirfinishedpromises, a token per run so a superseded run cannot settle a later one), then callsclose(). This differs from theanimationendblock of the other overlays ([Bug Fix] Overlays: play the exit animation before hiding #506):getAnimations()withoutsubtreenever 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 staleanimationcancelfrom a previous run could close a new run early. Same block as Dialog ([Bug Fix] Dialog: play the exit animation before closing the native <dialog> #517).backdrop:duration-200so it fades in step with the panel by default (--tw-durationis registeredinherits: false, so::backdropdoes not pick the panel's duration up). The controller does not depend on the two matching: aduration-*override closes after the longer of the two.disconnect()lifts it even if the<dialog>is already gone.bg-background/80 backdrop-blur-sm. Public API and the docs example unchanged.mcp/data/registry.jsonrebuilt (separate commit).Dialog and Combobox get the same treatment in sibling PRs.
Test
cd gem && bundle exec rake— 13 new tests inalert_dialog_test.rb.AlertDialog(open: true)→ open on page load.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 longerduration-*on either panel or backdrop closes after the longer one;requestClose()mid-exit closes at once. No console errors.🤖 Generated with Claude Code