From 6cba4b09e2e9aad1df533b3761aeeec731d3a9c2 Mon Sep 17 00:00:00 2001 From: Tomek Date: Fri, 28 Aug 2026 21:19:59 +0200 Subject: [PATCH 1/4] [Bug Fix] Dialog: play the exit animation before closing the native MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #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 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 --- gem/lib/ruby_ui/dialog/dialog_content.rb | 4 +- gem/lib/ruby_ui/dialog/dialog_controller.js | 90 +++++++++++++++++---- gem/test/ruby_ui/dialog_test.rb | 54 +++++++++++-- 3 files changed, 127 insertions(+), 21 deletions(-) diff --git a/gem/lib/ruby_ui/dialog/dialog_content.rb b/gem/lib/ruby_ui/dialog/dialog_content.rb index 144504350..667a1d79c 100644 --- a/gem/lib/ruby_ui/dialog/dialog_content.rb +++ b/gem/lib/ruby_ui/dialog/dialog_content.rb @@ -30,7 +30,9 @@ def default_attrs data_ruby_ui__dialog_target: "dialog", data_action: "click->ruby-ui--dialog#backdropClick", class: [ - "fixed open:flex flex-col pointer-events-auto left-[50%] top-[50%] z-50 w-full max-h-screen overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 backdrop:bg-background/80 backdrop:backdrop-blur-sm open:animate-in open:fade-in-0 open:zoom-in-95 sm:rounded-lg md:w-full", + "fixed open:flex flex-col pointer-events-auto left-[50%] top-[50%] z-50 w-full max-h-screen overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg sm:rounded-lg md:w-full", + "duration-200 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:fill-mode-forwards", + "backdrop:bg-background/80 backdrop:backdrop-blur-sm backdrop:duration-200 data-[state=open]:backdrop:animate-in data-[state=open]:backdrop:fade-in-0 data-[state=closed]:backdrop:animate-out data-[state=closed]:backdrop:fade-out-0 data-[state=closed]:backdrop:fill-mode-forwards", SIZES[@size] ] } diff --git a/gem/lib/ruby_ui/dialog/dialog_controller.js b/gem/lib/ruby_ui/dialog/dialog_controller.js index 7b7e368cc..764f52e80 100644 --- a/gem/lib/ruby_ui/dialog/dialog_controller.js +++ b/gem/lib/ruby_ui/dialog/dialog_controller.js @@ -1,44 +1,106 @@ -import { Controller } from "@hotwired/stimulus" +import { Controller } from "@hotwired/stimulus"; // Connects to data-controller="ruby-ui--dialog" export default class extends Controller { - static targets = ["dialog"] + static targets = ["dialog"]; static values = { open: { type: Boolean, - default: false + default: false, }, - } + }; connect() { - this.dialogTarget.addEventListener("close", this.handleClose) + this.dialogTarget.addEventListener("close", this.handleClose); + this.dialogTarget.addEventListener("cancel", this.handleCancel); if (this.openValue) { - this.open() + this.open(); } } disconnect() { - this.dialogTarget.removeEventListener("close", this.handleClose) - document.body.classList.remove("overflow-hidden") + this.dialogTarget.removeEventListener("close", this.handleClose); + this.dialogTarget.removeEventListener("cancel", this.handleCancel); + // Nothing is left to wait for the exit animation, so apply the pending close now. + this.settleExit(this.dialogTarget); + document.body.classList.remove("overflow-hidden"); } open(e) { - e?.preventDefault() - this.dialogTarget.showModal() - document.body.classList.add("overflow-hidden") + e?.preventDefault(); + this.dialogTarget.dataset.state = "open"; + // Reopened mid-exit the dialog is still open; showModal() on an open dialog throws in older browsers. + if (!this.dialogTarget.open) this.dialogTarget.showModal(); + document.body.classList.add("overflow-hidden"); } dismiss() { - this.dialogTarget.close() + if (this.dialogTarget.dataset.state === "closed") return; + + this.dialogTarget.dataset.state = "closed"; + // The ::backdrop's animationend lands on the dialog too; panel and backdrop share one duration so either settles it. + this.hideAfterExitAnimation(this.dialogTarget); + } + + afterExit() { + this.dialogTarget.close(); } backdropClick(e) { if (e.target === this.dialogTarget) { - this.dismiss() + this.dismiss(); } } + // Escape (and requestClose()) fire cancel; route it through the exit animation. + handleCancel = (e) => { + // A cancelled file picker inside the dialog bubbles its own cancel event. + if (e.target !== this.dialogTarget) return; + + e.preventDefault(); + this.dismiss(); + }; + handleClose = () => { - document.body.classList.remove("overflow-hidden") + document.body.classList.remove("overflow-hidden"); + // A close this controller did not start (a second Escape mid-exit) must not leave the exit listeners behind. + this.settleExit(this.dialogTarget); + }; + + // Overlay exit — the same block in every overlay controller, so keep them in sync. + exitAnimationNames = new WeakMap(); + + hideAfterExitAnimation(animated) { + const exitAnimations = animated + .getAnimations() + .filter((animation) => animation instanceof CSSAnimation); + + // No exit animation, or no box to run it in: animationend would never fire. + if (exitAnimations.length === 0) { + this.settleExit(animated); + return; + } + + this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName)); + animated.addEventListener("animationend", this.handleExitAnimationEnd); + animated.addEventListener("animationcancel", this.handleExitAnimationEnd); + } + + handleExitAnimationEnd = (event) => { + // animationend bubbles — an animated child must not hide its container. + if (event.target !== event.currentTarget) return; + // Closing mid-open cancels the enter animation; only the exit run settles this. + if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return; + + this.settleExit(event.currentTarget); + }; + + settleExit(animated) { + animated.removeEventListener("animationend", this.handleExitAnimationEnd); + animated.removeEventListener("animationcancel", this.handleExitAnimationEnd); + // Reopened mid-exit: it is on its way back in, leave it visible. + if (animated.dataset.state !== "closed") return; + + this.afterExit(animated); } } diff --git a/gem/test/ruby_ui/dialog_test.rb b/gem/test/ruby_ui/dialog_test.rb index 90061d34d..3bad82bd2 100644 --- a/gem/test/ruby_ui/dialog_test.rb +++ b/gem/test/ruby_ui/dialog_test.rb @@ -81,13 +81,8 @@ def test_dialog_content_has_backdrop_click_action # utility (author CSS) overrides the UA `dialog:not([open]) { display: none }`, # making the dialog always visible. Display must be gated on the open: variant. def test_dialog_content_does_not_force_display_when_closed - output = phlex do - RubyUI.Dialog do - RubyUI.DialogContent { "Content" } - end - end + classes = dialog_classes - classes = output[/ to display; use `open:flex`" assert_includes classes, "open:flex", "Dialog must apply flex only when open (open:flex)" end @@ -136,4 +131,51 @@ def test_trigger_has_open_action assert_match(/data-action="click->ruby-ui--dialog#open"/, output) end + + # Animations key on data-state (set by the controller) so the closed state can still render the exit. + def test_dialog_content_animates_enter_and_exit_on_data_state + classes = dialog_classes + + %w[ + duration-200 + data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 + data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:fill-mode-forwards + ].each { |klass| assert_includes classes, klass } + + refute classes.any? { |klass| klass.start_with?("open:animate", "open:fade", "open:zoom") }, "Animations must key on data-state, not on the open: variant" + assert_includes classes, "open:flex", "Display must still be gated on the open attribute" + end + + # The ::backdrop's animationend lands on the itself, so both exits must share one duration. + def test_dialog_content_animates_backdrop_on_data_state + classes = dialog_classes + + %w[ + backdrop:bg-background/80 backdrop:backdrop-blur-sm backdrop:duration-200 + data-[state=open]:backdrop:animate-in data-[state=open]:backdrop:fade-in-0 + data-[state=closed]:backdrop:animate-out data-[state=closed]:backdrop:fade-out-0 data-[state=closed]:backdrop:fill-mode-forwards + ].each { |klass| assert_includes classes, klass } + end + + def test_dialog_content_does_not_render_data_state_when_closed + output = phlex do + RubyUI.Dialog do + RubyUI.DialogContent { "Content" } + end + end + + refute_match(/]*\sdata-state=/, output, "data-state is owned by the controller; a closed dialog must not render one") + end + + private + + def dialog_classes + output = phlex do + RubyUI.Dialog do + RubyUI.DialogContent { "Content" } + end + end + + output[/ Date: Fri, 28 Aug 2026 21:19:59 +0200 Subject: [PATCH 2/4] chore(mcp): rebuild registry after the Dialog change Co-Authored-By: Claude Fable 5 --- mcp/data/registry.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mcp/data/registry.json b/mcp/data/registry.json index ed4cd153a..6afbda41a 100644 --- a/mcp/data/registry.json +++ b/mcp/data/registry.json @@ -1329,11 +1329,11 @@ }, { "path": "dialog_content.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DialogContent < Base\n SIZES = {\n xs: \"max-w-sm\",\n sm: \"max-w-md\",\n md: \"max-w-lg\",\n lg: \"max-w-2xl\",\n xl: \"max-w-4xl\",\n full: \"max-w-full\"\n }\n\n def initialize(size: :md, **attrs)\n @size = size\n super(**attrs)\n end\n\n def view_template\n dialog(**attrs) do\n yield\n close_button\n end\n end\n\n private\n\n def default_attrs\n {\n data_ruby_ui__dialog_target: \"dialog\",\n data_action: \"click->ruby-ui--dialog#backdropClick\",\n class: [\n \"fixed open:flex flex-col pointer-events-auto left-[50%] top-[50%] z-50 w-full max-h-screen overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 backdrop:bg-background/80 backdrop:backdrop-blur-sm open:animate-in open:fade-in-0 open:zoom-in-95 sm:rounded-lg md:w-full\",\n SIZES[@size]\n ]\n }\n end\n\n def close_button\n button(\n type: \"button\",\n class: \"absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none\",\n data_action: \"click->ruby-ui--dialog#dismiss\"\n ) do\n svg(\n width: \"15\",\n height: \"15\",\n viewbox: \"0 0 15 15\",\n fill: \"none\",\n xmlns: \"http://www.w3.org/2000/svg\",\n class: \"h-4 w-4\"\n ) do |s|\n s.path(\n d:\n \"M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z\",\n fill: \"currentColor\",\n fill_rule: \"evenodd\",\n clip_rule: \"evenodd\"\n )\n end\n span(class: \"sr-only\") { \"Close\" }\n end\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DialogContent < Base\n SIZES = {\n xs: \"max-w-sm\",\n sm: \"max-w-md\",\n md: \"max-w-lg\",\n lg: \"max-w-2xl\",\n xl: \"max-w-4xl\",\n full: \"max-w-full\"\n }\n\n def initialize(size: :md, **attrs)\n @size = size\n super(**attrs)\n end\n\n def view_template\n dialog(**attrs) do\n yield\n close_button\n end\n end\n\n private\n\n def default_attrs\n {\n data_ruby_ui__dialog_target: \"dialog\",\n data_action: \"click->ruby-ui--dialog#backdropClick\",\n class: [\n \"fixed open:flex flex-col pointer-events-auto left-[50%] top-[50%] z-50 w-full max-h-screen overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg sm:rounded-lg md:w-full\",\n \"duration-200 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:fill-mode-forwards\",\n \"backdrop:bg-background/80 backdrop:backdrop-blur-sm backdrop:duration-200 data-[state=open]:backdrop:animate-in data-[state=open]:backdrop:fade-in-0 data-[state=closed]:backdrop:animate-out data-[state=closed]:backdrop:fade-out-0 data-[state=closed]:backdrop:fill-mode-forwards\",\n SIZES[@size]\n ]\n }\n end\n\n def close_button\n button(\n type: \"button\",\n class: \"absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none\",\n data_action: \"click->ruby-ui--dialog#dismiss\"\n ) do\n svg(\n width: \"15\",\n height: \"15\",\n viewbox: \"0 0 15 15\",\n fill: \"none\",\n xmlns: \"http://www.w3.org/2000/svg\",\n class: \"h-4 w-4\"\n ) do |s|\n s.path(\n d:\n \"M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z\",\n fill: \"currentColor\",\n fill_rule: \"evenodd\",\n clip_rule: \"evenodd\"\n )\n end\n span(class: \"sr-only\") { \"Close\" }\n end\n end\n end\nend\n" }, { "path": "dialog_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\"\n\n// Connects to data-controller=\"ruby-ui--dialog\"\nexport default class extends Controller {\n static targets = [\"dialog\"]\n static values = {\n open: {\n type: Boolean,\n default: false\n },\n }\n\n connect() {\n this.dialogTarget.addEventListener(\"close\", this.handleClose)\n if (this.openValue) {\n this.open()\n }\n }\n\n disconnect() {\n this.dialogTarget.removeEventListener(\"close\", this.handleClose)\n document.body.classList.remove(\"overflow-hidden\")\n }\n\n open(e) {\n e?.preventDefault()\n this.dialogTarget.showModal()\n document.body.classList.add(\"overflow-hidden\")\n }\n\n dismiss() {\n this.dialogTarget.close()\n }\n\n backdropClick(e) {\n if (e.target === this.dialogTarget) {\n this.dismiss()\n }\n }\n\n handleClose = () => {\n document.body.classList.remove(\"overflow-hidden\")\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"ruby-ui--dialog\"\nexport default class extends Controller {\n static targets = [\"dialog\"];\n static values = {\n open: {\n type: Boolean,\n default: false,\n },\n };\n\n connect() {\n this.dialogTarget.addEventListener(\"close\", this.handleClose);\n this.dialogTarget.addEventListener(\"cancel\", this.handleCancel);\n if (this.openValue) {\n this.open();\n }\n }\n\n disconnect() {\n this.dialogTarget.removeEventListener(\"close\", this.handleClose);\n this.dialogTarget.removeEventListener(\"cancel\", this.handleCancel);\n // Nothing is left to wait for the exit animation, so apply the pending close now.\n this.settleExit(this.dialogTarget);\n document.body.classList.remove(\"overflow-hidden\");\n }\n\n open(e) {\n e?.preventDefault();\n this.dialogTarget.dataset.state = \"open\";\n // Reopened mid-exit the dialog is still open; showModal() on an open dialog throws in older browsers.\n if (!this.dialogTarget.open) this.dialogTarget.showModal();\n document.body.classList.add(\"overflow-hidden\");\n }\n\n dismiss() {\n if (this.dialogTarget.dataset.state === \"closed\") return;\n\n this.dialogTarget.dataset.state = \"closed\";\n // The ::backdrop's animationend lands on the dialog too; panel and backdrop share one duration so either settles it.\n this.hideAfterExitAnimation(this.dialogTarget);\n }\n\n afterExit() {\n this.dialogTarget.close();\n }\n\n backdropClick(e) {\n if (e.target === this.dialogTarget) {\n this.dismiss();\n }\n }\n\n // Escape (and requestClose()) fire cancel; route it through the exit animation.\n handleCancel = (e) => {\n // A cancelled file picker inside the dialog bubbles its own cancel event.\n if (e.target !== this.dialogTarget) return;\n\n e.preventDefault();\n this.dismiss();\n };\n\n handleClose = () => {\n document.body.classList.remove(\"overflow-hidden\");\n // A close this controller did not start (a second Escape mid-exit) must not leave the exit listeners behind.\n this.settleExit(this.dialogTarget);\n };\n\n // Overlay exit — the same block in every overlay controller, so keep them in sync.\n exitAnimationNames = new WeakMap();\n\n hideAfterExitAnimation(animated) {\n const exitAnimations = animated\n .getAnimations()\n .filter((animation) => animation instanceof CSSAnimation);\n\n // No exit animation, or no box to run it in: animationend would never fire.\n if (exitAnimations.length === 0) {\n this.settleExit(animated);\n return;\n }\n\n this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName));\n animated.addEventListener(\"animationend\", this.handleExitAnimationEnd);\n animated.addEventListener(\"animationcancel\", this.handleExitAnimationEnd);\n }\n\n handleExitAnimationEnd = (event) => {\n // animationend bubbles — an animated child must not hide its container.\n if (event.target !== event.currentTarget) return;\n // Closing mid-open cancels the enter animation; only the exit run settles this.\n if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return;\n\n this.settleExit(event.currentTarget);\n };\n\n settleExit(animated) {\n animated.removeEventListener(\"animationend\", this.handleExitAnimationEnd);\n animated.removeEventListener(\"animationcancel\", this.handleExitAnimationEnd);\n // Reopened mid-exit: it is on its way back in, leave it visible.\n if (animated.dataset.state !== \"closed\") return;\n\n this.afterExit(animated);\n }\n}\n" }, { "path": "dialog_description.rb", From 88fc170e9cbcedba78cd73aa6c2427ccea4866f7 Mon Sep 17 00:00:00 2001 From: Tomek Date: Fri, 28 Aug 2026 22:59:18 +0200 Subject: [PATCH 3/4] [Documentation] Dialog: outline trigger button, matching shadcn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shadcn's dialog demo opens from