[Bug Fix] Dialog: play the exit animation before closing the native <dialog> - #517
[Bug Fix] Dialog: play the exit animation before closing the native <dialog>#517tvq wants to merge 4 commits into
Conversation
…dialog> dialog.close() drops the open attribute — and with it display — in the same frame, so the panel and its ::backdrop vanished with a hard cut, and Escape took the native path untouched. Every other overlay animates out since ruby-ui#506; Dialog now does the same. - DialogContent keys its animations on data-state (set by the controller) instead of the open: variant, so the closed state can still render the exit. open:flex stays, so a closed dialog remains hidden. - The backdrop gets backdrop:duration-200: its animationend is dispatched on the <dialog> under the same keyframe name, so both exits must end together or the shorter one closes the dialog early. --tw-duration is registered with inherits: false, so ::backdrop does not pick the panel's duration up on its own. - dismiss() sets data-state="closed", waits for the exit with the shared overlay block, then calls close(). cancel (Escape, requestClose()) is intercepted and routed through it. A close the controller did not start (a second Escape mid-exit is non-cancelable in Chrome) settles the listeners. closedby="any" is not used: it would double-fire with the click handler and Safari support is recent. 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.
3 issues found across 4 files
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/dialog/dialog_controller.js">
<violation number="1" location="gem/lib/ruby_ui/dialog/dialog_controller.js:60">
P2: When a second Escape arrives during an existing exit, this unconditional `preventDefault()` blocks browsers that expose that `cancel` event as cancelable. Return before preventing the event when `data-state` is already `closed`.</violation>
<violation number="2" location="gem/lib/ruby_ui/dialog/dialog_controller.js:75">
P2: `getAnimations()` does not include `::backdrop` animations by default, so this controller never tracks the backdrop exit. The native dialog closes on the panel's event, cutting off the backdrop whenever their timing differs. Track the `::backdrop` animation explicitly and settle only after the required exits complete.</violation>
<violation number="3" location="gem/lib/ruby_ui/dialog/dialog_controller.js:84">
P1: When a dialog is reopened and dismissed again before the prior animation event is delivered, the stale `exit` event matches this name-only map and closes the new run immediately. Track a dismissal generation or the specific animation run so events from older exits cannot settle the current one.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| return; | ||
| } | ||
|
|
||
| this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName)); |
There was a problem hiding this comment.
P1: When a dialog is reopened and dismissed again before the prior animation event is delivered, the stale exit event matches this name-only map and closes the new run immediately. Track a dismissal generation or the specific animation run so events from older exits cannot settle the current one.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/lib/ruby_ui/dialog/dialog_controller.js, line 84:
<comment>When a dialog is reopened and dismissed again before the prior animation event is delivered, the stale `exit` event matches this name-only map and closes the new run immediately. Track a dismissal generation or the specific animation run so events from older exits cannot settle the current one.</comment>
<file context>
@@ -1,44 +1,106 @@
+ return;
+ }
+
+ this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName));
+ animated.addEventListener("animationend", this.handleExitAnimationEnd);
+ animated.addEventListener("animationcancel", this.handleExitAnimationEnd);
</file context>
| // A cancelled file picker inside the dialog bubbles its own cancel event. | ||
| if (e.target !== this.dialogTarget) return; | ||
|
|
||
| e.preventDefault(); |
There was a problem hiding this comment.
P2: When a second Escape arrives during an existing exit, this unconditional preventDefault() blocks browsers that expose that cancel event as cancelable. Return before preventing the event when data-state is already closed.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/lib/ruby_ui/dialog/dialog_controller.js, line 60:
<comment>When a second Escape arrives during an existing exit, this unconditional `preventDefault()` blocks browsers that expose that `cancel` event as cancelable. Return before preventing the event when `data-state` is already `closed`.</comment>
<file context>
@@ -1,44 +1,106 @@
+ // A cancelled file picker inside the dialog bubbles its own cancel event.
+ if (e.target !== this.dialogTarget) return;
+
+ e.preventDefault();
+ this.dismiss();
+ };
</file context>
| e.preventDefault(); | |
| if (this.dialogTarget.dataset.state === "closed") return; |
|
|
||
| hideAfterExitAnimation(animated) { | ||
| const exitAnimations = animated | ||
| .getAnimations() |
There was a problem hiding this comment.
P2: getAnimations() does not include ::backdrop animations by default, so this controller never tracks the backdrop exit. The native dialog closes on the panel's event, cutting off the backdrop whenever their timing differs. Track the ::backdrop animation explicitly and settle only after the required exits complete.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/lib/ruby_ui/dialog/dialog_controller.js, line 75:
<comment>`getAnimations()` does not include `::backdrop` animations by default, so this controller never tracks the backdrop exit. The native dialog closes on the panel's event, cutting off the backdrop whenever their timing differs. Track the `::backdrop` animation explicitly and settle only after the required exits complete.</comment>
<file context>
@@ -1,44 +1,106 @@
+
+ hideAfterExitAnimation(animated) {
+ const exitAnimations = animated
+ .getAnimations()
+ .filter((animation) => animation instanceof CSSAnimation);
+
</file context>
shadcn's dialog demo opens from <Button variant="outline">; the primary button is reserved for the footer action (Save), with Cancel as outline. Sheet and Drawer docs already follow that convention — this brings Dialog in line so triggers read the same across all overlays. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
shadcn's DialogContent takes showCloseButton (default true) so a dialog can drop the corner close control and offer its own; the option is unchanged across all three of their current bases. DialogContent renders the × itself, so without an option there is no way to opt out. Adds a "No close button" docs example alongside it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closing a
Dialogcut the panel and its backdrop off in the same frame:dialog.close()dropsopen(and with itdisplay) immediately, and Escape went through the native path untouched. Every other overlay animates out since #506; Dialog now does too.DialogContentkeys its animations ondata-state(set by the controller) instead of theopen:variant, so the closed state can still render the exit.open:flexstays — a closed dialog remains hidden.backdrop:duration-200: itsanimationendis dispatched on the<dialog>under the same keyframe name, so both exits must end together or the shorter one closes the dialog early (--tw-durationis registeredinherits: false, so::backdropdoes not pick the panel's duration up).dismiss()setsdata-state="closed", waits for the exit with the same block as the other overlays, then callsclose().cancel(Escape,requestClose()) is intercepted and routed through it. A close the controller did not start (a second Escape mid-exit is non-cancelable in Chrome) settles the listeners. Backdrop click, focus restore and body scroll lock are unchanged.closedby="any"is not used: it would double-fire with the click handler and Safari support is recent.AlertDialog and Combobox get the same treatment in separate PRs.
Test:
/docs/dialog, each example — close via ×, Cancel, Escape and a backdrop click: panel and backdrop fade out (~200 ms) before the dialog disappears, focus returns to the trigger, page scroll is restored. Click inside stays open; reopen mid-exit comes back; open/close quickly still plays the full exit; Escape twice closes on the second press and the next cycle animates normally.🤖 Generated with Claude Code
Summary by cubic
Fixes the Dialog closing so the panel and backdrop animate out before the native
<dialog>closes, and adds ashow_close_buttonoption toDialogContent.Bug Fixes
data-stateinstead of theopen:variant, so the closed state can render the exit;open:flexstill keeps a closed dialog hidden.backdrop:duration-200so neither exit can end early.dismiss()setsdata-state="closed", waits for the exit animation, then callsclose(); Escape andrequestClose()now go through this path.New Features
DialogContentacceptsshow_close_button: falseto hide the corner close button.Button(variant: :outline)to match the Sheet and Drawer docs.Written for commit 9abe94c. Summary will update on new commits.