Skip to content

Commit 30fd12b

Browse files
authored
[Feature] Dialog: use native <dialog> element (#343) (#435)
* [Feature] Dialog: use native <dialog> element (#343) Replace the template-clone pattern with a native <dialog> element. DialogContent now renders as <dialog> 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. * [Bug Fix] Dialog: clear body scroll lock on disconnect and rebuild MCP registry (#435) Clear overflow-hidden from body in disconnect() so Turbo navigation while a dialog is open does not leave a permanent scroll lock. Also rebuilt the MCP registry to include the native-dialog changes from this PR.
1 parent 0897b2a commit 30fd12b

4 files changed

Lines changed: 119 additions & 31 deletions

File tree

gem/lib/ruby_ui/dialog/dialog_content.rb

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,20 @@ def initialize(size: :md, **attrs)
1717
end
1818

1919
def view_template
20-
template(data: {ruby_ui__dialog_target: "content"}) do
21-
div(data_controller: "ruby-ui--dialog") do
22-
backdrop
23-
div(**attrs) do
24-
yield
25-
close_button
26-
end
27-
end
20+
dialog(**attrs) do
21+
yield
22+
close_button
2823
end
2924
end
3025

3126
private
3227

3328
def default_attrs
3429
{
35-
data_state: "open",
30+
data_ruby_ui__dialog_target: "dialog",
31+
data_action: "click->ruby-ui--dialog#backdropClick",
3632
class: [
37-
"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",
33+
"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",
3834
SIZES[@size]
3935
]
4036
}
@@ -43,7 +39,7 @@ def default_attrs
4339
def close_button
4440
button(
4541
type: "button",
46-
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",
42+
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",
4743
data_action: "click->ruby-ui--dialog#dismiss"
4844
) do
4945
svg(
@@ -65,13 +61,5 @@ def close_button
6561
span(class: "sr-only") { "Close" }
6662
end
6763
end
68-
69-
def backdrop
70-
div(
71-
data_state: "open",
72-
data_action: "click->ruby-ui--dialog#dismiss esc->ruby-ui--dialog#dismiss",
73-
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"
74-
)
75-
end
7664
end
7765
end
Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { Controller } from "@hotwired/stimulus"
22

3-
// Connects to data-controller="dialog"
3+
// Connects to data-controller="ruby-ui--dialog"
44
export default class extends Controller {
5-
static targets = ["content"]
5+
static targets = ["dialog"]
66
static values = {
77
open: {
88
type: Boolean,
@@ -11,22 +11,34 @@ export default class extends Controller {
1111
}
1212

1313
connect() {
14+
this.dialogTarget.addEventListener("close", this.handleClose)
1415
if (this.openValue) {
1516
this.open()
1617
}
1718
}
1819

20+
disconnect() {
21+
this.dialogTarget.removeEventListener("close", this.handleClose)
22+
document.body.classList.remove("overflow-hidden")
23+
}
24+
1925
open(e) {
20-
e?.preventDefault();
21-
document.body.insertAdjacentHTML('beforeend', this.contentTarget.innerHTML)
22-
// prevent scroll on body
23-
document.body.classList.add('overflow-hidden')
26+
e?.preventDefault()
27+
this.dialogTarget.showModal()
28+
document.body.classList.add("overflow-hidden")
2429
}
2530

2631
dismiss() {
27-
// allow scroll on body
28-
document.body.classList.remove('overflow-hidden')
29-
// remove the element
30-
this.element.remove()
32+
this.dialogTarget.close()
33+
}
34+
35+
backdropClick(e) {
36+
if (e.target === this.dialogTarget) {
37+
this.dismiss()
38+
}
39+
}
40+
41+
handleClose = () => {
42+
document.body.classList.remove("overflow-hidden")
3143
}
3244
}

gem/test/ruby_ui/dialog_test.rb

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,92 @@ def test_render_with_all_items
3333

3434
assert_match(/Open Dialog/, output)
3535
end
36+
37+
# Regression test for #343: Dialog content must use native <dialog> element, not <div>
38+
def test_dialog_content_renders_native_dialog_element
39+
output = phlex do
40+
RubyUI.Dialog do
41+
RubyUI.DialogContent { "Content" }
42+
end
43+
end
44+
45+
assert_match(/<dialog[\s>]/, output, "DialogContent must render a native <dialog> element")
46+
refute_match(/<template[\s>]/, output, "DialogContent must not use a <template> element")
47+
end
48+
49+
def test_dialog_wrapper_renders_as_div_with_stimulus_controller
50+
output = phlex do
51+
RubyUI.Dialog do
52+
RubyUI.DialogContent { "Content" }
53+
end
54+
end
55+
56+
assert_match(/data-controller="ruby-ui--dialog"/, output)
57+
assert_match(/<div[^>]*data-controller="ruby-ui--dialog"/, output, "Dialog wrapper must be a <div>")
58+
end
59+
60+
def test_dialog_content_has_stimulus_target
61+
output = phlex do
62+
RubyUI.Dialog do
63+
RubyUI.DialogContent { "Content" }
64+
end
65+
end
66+
67+
assert_match(/data-ruby-ui--dialog-target="dialog"/, output)
68+
end
69+
70+
def test_dialog_content_has_backdrop_click_action
71+
output = phlex do
72+
RubyUI.Dialog do
73+
RubyUI.DialogContent { "Content" }
74+
end
75+
end
76+
77+
assert_match(/data-action="click->ruby-ui--dialog#backdropClick"/, output)
78+
end
79+
80+
def test_dialog_content_sizes
81+
{xs: "max-w-sm", sm: "max-w-md", md: "max-w-lg", lg: "max-w-2xl", xl: "max-w-4xl", full: "max-w-full"}.each do |size, expected_class|
82+
output = phlex do
83+
RubyUI.Dialog do
84+
RubyUI.DialogContent(size: size) { "Content" }
85+
end
86+
end
87+
88+
assert_match(/#{Regexp.escape(expected_class)}/, output, "Size #{size} should apply class #{expected_class}")
89+
end
90+
end
91+
92+
def test_dialog_open_value_is_set_on_wrapper
93+
output = phlex do
94+
RubyUI.Dialog(open: true) do
95+
RubyUI.DialogContent { "Content" }
96+
end
97+
end
98+
99+
assert_match(/data-ruby-ui--dialog-open-value/, output)
100+
end
101+
102+
def test_close_button_has_dismiss_action
103+
output = phlex do
104+
RubyUI.Dialog do
105+
RubyUI.DialogContent { "Content" }
106+
end
107+
end
108+
109+
assert_match(/data-action="click->ruby-ui--dialog#dismiss"/, output)
110+
end
111+
112+
def test_trigger_has_open_action
113+
output = phlex do
114+
RubyUI.Dialog do
115+
RubyUI.DialogTrigger do
116+
RubyUI.Button { "Open" }
117+
end
118+
RubyUI.DialogContent { "Content" }
119+
end
120+
end
121+
122+
assert_match(/data-action="click->ruby-ui--dialog#open"/, output)
123+
end
36124
end

mcp/data/registry.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1253,11 +1253,11 @@
12531253
},
12541254
{
12551255
"path": "dialog_content.rb",
1256-
"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 template(data: {ruby_ui__dialog_target: \"content\"}) do\n div(data_controller: \"ruby-ui--dialog\") do\n backdrop\n div(**attrs) do\n yield\n close_button\n end\n end\n end\n end\n\n private\n\n def default_attrs\n {\n data_state: \"open\",\n class: [\n \"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\",\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 data-[state=open]:bg-accent data-[state=open]:text-muted-foreground\",\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\n def backdrop\n div(\n data_state: \"open\",\n data_action: \"click->ruby-ui--dialog#dismiss esc->ruby-ui--dialog#dismiss\",\n 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\"\n )\n end\n end\nend\n"
1256+
"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 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"
12571257
},
12581258
{
12591259
"path": "dialog_controller.js",
1260-
"content": "import { Controller } from \"@hotwired/stimulus\"\n\n// Connects to data-controller=\"dialog\"\nexport default class extends Controller {\n static targets = [\"content\"]\n static values = {\n open: {\n type: Boolean,\n default: false\n },\n }\n\n connect() {\n if (this.openValue) {\n this.open()\n }\n }\n\n open(e) {\n e?.preventDefault();\n document.body.insertAdjacentHTML('beforeend', this.contentTarget.innerHTML)\n // prevent scroll on body\n document.body.classList.add('overflow-hidden')\n }\n\n dismiss() {\n // allow scroll on body\n document.body.classList.remove('overflow-hidden')\n // remove the element\n this.element.remove()\n }\n}\n"
1260+
"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"
12611261
},
12621262
{
12631263
"path": "dialog_description.rb",

0 commit comments

Comments
 (0)