Skip to content

Commit 15a1d9f

Browse files
committed
[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.
1 parent 1fc5e6c commit 15a1d9f

3 files changed

Lines changed: 116 additions & 29 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: 21 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,33 @@ 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+
}
23+
1924
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')
25+
e?.preventDefault()
26+
this.dialogTarget.showModal()
27+
document.body.classList.add("overflow-hidden")
2428
}
2529

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

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

0 commit comments

Comments
 (0)