From 15a1d9f17d8598bd73a3c8eed25b23a06828d007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 22 Jun 2026 17:07:44 -0300 Subject: [PATCH 1/2] [Feature] Dialog: use native element (#343) Replace the template-clone pattern with a native element. DialogContent now renders as opened via showModal() and closed via close(), gaining free top-layer rendering, native backdrop, built-in focus trapping, and Esc-to-close. Body scroll-lock is preserved. All existing public component API and Stimulus controller actions are backward-compatible. --- gem/lib/ruby_ui/dialog/dialog_content.rb | 26 ++---- gem/lib/ruby_ui/dialog/dialog_controller.js | 31 +++++--- gem/test/ruby_ui/dialog_test.rb | 88 +++++++++++++++++++++ 3 files changed, 116 insertions(+), 29 deletions(-) diff --git a/gem/lib/ruby_ui/dialog/dialog_content.rb b/gem/lib/ruby_ui/dialog/dialog_content.rb index d6df40b06..5ed38a0e5 100644 --- a/gem/lib/ruby_ui/dialog/dialog_content.rb +++ b/gem/lib/ruby_ui/dialog/dialog_content.rb @@ -17,14 +17,9 @@ def initialize(size: :md, **attrs) end def view_template - template(data: {ruby_ui__dialog_target: "content"}) do - div(data_controller: "ruby-ui--dialog") do - backdrop - div(**attrs) do - yield - close_button - end - end + dialog(**attrs) do + yield + close_button end end @@ -32,9 +27,10 @@ def view_template def default_attrs { - data_state: "open", + data_ruby_ui__dialog_target: "dialog", + data_action: "click->ruby-ui--dialog#backdropClick", class: [ - "fixed 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 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:rounded-lg md:w-full", + "fixed 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", SIZES[@size] ] } @@ -43,7 +39,7 @@ def default_attrs def close_button button( type: "button", - 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 data-[state=open]:bg-accent data-[state=open]:text-muted-foreground", + 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", data_action: "click->ruby-ui--dialog#dismiss" ) do svg( @@ -65,13 +61,5 @@ def close_button span(class: "sr-only") { "Close" } end end - - def backdrop - div( - data_state: "open", - data_action: "click->ruby-ui--dialog#dismiss esc->ruby-ui--dialog#dismiss", - class: "fixed pointer-events-auto inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=open]:fade-in-0" - ) - end end end diff --git a/gem/lib/ruby_ui/dialog/dialog_controller.js b/gem/lib/ruby_ui/dialog/dialog_controller.js index 26bf1fa00..57986e779 100644 --- a/gem/lib/ruby_ui/dialog/dialog_controller.js +++ b/gem/lib/ruby_ui/dialog/dialog_controller.js @@ -1,8 +1,8 @@ import { Controller } from "@hotwired/stimulus" -// Connects to data-controller="dialog" +// Connects to data-controller="ruby-ui--dialog" export default class extends Controller { - static targets = ["content"] + static targets = ["dialog"] static values = { open: { type: Boolean, @@ -11,22 +11,33 @@ export default class extends Controller { } connect() { + this.dialogTarget.addEventListener("close", this.handleClose) if (this.openValue) { this.open() } } + disconnect() { + this.dialogTarget.removeEventListener("close", this.handleClose) + } + open(e) { - e?.preventDefault(); - document.body.insertAdjacentHTML('beforeend', this.contentTarget.innerHTML) - // prevent scroll on body - document.body.classList.add('overflow-hidden') + e?.preventDefault() + this.dialogTarget.showModal() + document.body.classList.add("overflow-hidden") } dismiss() { - // allow scroll on body - document.body.classList.remove('overflow-hidden') - // remove the element - this.element.remove() + this.dialogTarget.close() + } + + backdropClick(e) { + if (e.target === this.dialogTarget) { + this.dismiss() + } + } + + handleClose = () => { + document.body.classList.remove("overflow-hidden") } } diff --git a/gem/test/ruby_ui/dialog_test.rb b/gem/test/ruby_ui/dialog_test.rb index b6814feb4..19307ba06 100644 --- a/gem/test/ruby_ui/dialog_test.rb +++ b/gem/test/ruby_ui/dialog_test.rb @@ -33,4 +33,92 @@ def test_render_with_all_items assert_match(/Open Dialog/, output) end + + # Regression test for #343: Dialog content must use native element, not
+ def test_dialog_content_renders_native_dialog_element + output = phlex do + RubyUI.Dialog do + RubyUI.DialogContent { "Content" } + end + end + + assert_match(/]/, output, "DialogContent must render a native element") + refute_match(/]/, output, "DialogContent must not use a