Skip to content

[Bug Fix] Dialog: play the exit animation before closing the native <dialog> - #517

Open
tvq wants to merge 4 commits into
ruby-ui:mainfrom
tvq:fix_dialog
Open

[Bug Fix] Dialog: play the exit animation before closing the native <dialog>#517
tvq wants to merge 4 commits into
ruby-ui:mainfrom
tvq:fix_dialog

Conversation

@tvq

@tvq tvq commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Closing a Dialog cut the panel and its backdrop off in the same frame: dialog.close() drops open (and with it display) immediately, and Escape went through the native path untouched. Every other overlay animates out since #506; Dialog now does too.

  • 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 — 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 inherits: false, so ::backdrop does not pick the panel's duration up).
  • Controller: dismiss() sets data-state="closed", waits for the exit with the same block as the other overlays, 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. 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.
  • Tests for the class contract; MCP registry rebuilt. Markup and usage unchanged.

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 a show_close_button option to DialogContent.

Bug Fixes

  • Animations key on data-state instead of the open: variant, so the closed state can render the exit; open:flex still keeps a closed dialog hidden.
  • The backdrop shares the panel's duration with backdrop:duration-200 so neither exit can end early.
  • dismiss() sets data-state="closed", waits for the exit animation, then calls close(); Escape and requestClose() now go through this path.
  • A second Escape mid-exit settles the animation listeners; backdrop click, focus restore, and body scroll lock are unchanged.

New Features

  • DialogContent accepts show_close_button: false to hide the corner close button.
  • Added a "No close button" docs example; docs triggers now use Button(variant: :outline) to match the Sheet and Drawer docs.

Written for commit 9abe94c. Summary will update on new commits.

Review in cubic

tvq and others added 2 commits August 28, 2026 21:19
…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>
@tvq
tvq requested a review from cirdes as a code owner August 28, 2026 19:20

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
e.preventDefault();
if (this.dialogTarget.dataset.state === "closed") return;


hideAfterExitAnimation(animated) {
const exitAnimations = animated
.getAnimations()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

tvq and others added 2 commits August 28, 2026 22:59
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>
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.

1 participant