Skip to content

Commit b5b3f1e

Browse files
authored
Merge pull request #19 from plausible/optional-portal
Add optional portal rendering for modals
2 parents 6d8d0b1 + 0d60016 commit b5b3f1e

File tree

5 files changed

+172
-15
lines changed

5 files changed

+172
-15
lines changed

demo/lib/demo_web/live/fixtures_live.html.heex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@
4242
<.modal_push_event_fixture />
4343
</div>
4444

45+
<div :if={@live_action == :modal_without_portal}>
46+
<.modal_without_portal_fixture />
47+
</div>
48+
4549
<div :if={@live_action == :simple_combobox}>
4650
<.simple_combobox_fixture />
4751
</div>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<div id="modal-without-portal">
2+
<.button type="button" phx-click={Prima.Modal.JS.open("no-portal-modal")}>
3+
Open Modal (No Portal)
4+
</.button>
5+
6+
<.modal id="no-portal-modal" portal={false}>
7+
<.modal_overlay />
8+
<.modal_panel id="no-portal-modal-panel">
9+
<button phx-click={Prima.Modal.JS.close()} testing-ref="close-button">
10+
Close
11+
</button>
12+
<.modal_title>Modal Without Portal</.modal_title>
13+
<p>This modal renders inline without using a portal.</p>
14+
<.button phx-click={Prima.Modal.JS.close()} type="button">
15+
Got it
16+
</.button>
17+
</.modal_panel>
18+
</.modal>
19+
</div>

demo/lib/demo_web/router.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ defmodule DemoWeb.Router do
3232
live "/fixtures/modal-title-function", FixturesLive, :modal_title_function
3333
live "/fixtures/modal-focus-autofocus", FixturesLive, :modal_focus_autofocus
3434
live "/fixtures/modal-push-event", FixturesLive, :modal_push_event
35+
live "/fixtures/modal-without-portal", FixturesLive, :modal_without_portal
3536
live "/fixtures/simple-combobox", FixturesLive, :simple_combobox
3637
live "/fixtures/async-combobox", FixturesLive, :async_combobox
3738
live "/fixtures/creatable-combobox", FixturesLive, :creatable_combobox
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
defmodule DemoWeb.ModalWithoutPortalTest do
2+
use Prima.WallabyCase, async: true
3+
4+
@modal_panel Query.css("#no-portal-modal [data-prima-ref=modal-panel]")
5+
@modal_overlay Query.css("#no-portal-modal [data-prima-ref=modal-overlay]")
6+
@modal_container Query.css("#no-portal-modal")
7+
8+
feature "modal without portal shows when button is clicked", %{session: session} do
9+
session
10+
|> visit_fixture("/fixtures/modal-without-portal", "#no-portal-modal")
11+
|> assert_has(@modal_container |> Query.visible(false))
12+
|> assert_has(@modal_overlay |> Query.visible(false))
13+
|> assert_has(@modal_panel |> Query.visible(false))
14+
|> click(Query.css("#modal-without-portal button"))
15+
|> assert_has(@modal_container |> Query.visible(true))
16+
|> assert_has(@modal_overlay |> Query.visible(true))
17+
|> assert_has(@modal_panel |> Query.visible(true))
18+
end
19+
20+
feature "modal without portal closes when user clicks close button", %{session: session} do
21+
session
22+
|> visit_fixture("/fixtures/modal-without-portal", "#no-portal-modal")
23+
|> click(Query.css("#modal-without-portal button"))
24+
|> assert_has(@modal_container |> Query.visible(true))
25+
|> click(Query.css("#no-portal-modal [testing-ref=close-button]"))
26+
|> assert_has(@modal_container |> Query.visible(false))
27+
|> assert_has(@modal_overlay |> Query.visible(false))
28+
|> assert_has(@modal_panel |> Query.visible(false))
29+
end
30+
31+
feature "modal without portal closes on escape key", %{session: session} do
32+
session
33+
|> visit_fixture("/fixtures/modal-without-portal", "#no-portal-modal")
34+
|> click(Query.css("#modal-without-portal button"))
35+
|> assert_has(@modal_container |> Query.visible(true))
36+
|> send_keys([:escape])
37+
|> assert_has(@modal_container |> Query.visible(false))
38+
|> assert_has(@modal_overlay |> Query.visible(false))
39+
|> assert_has(@modal_panel |> Query.visible(false))
40+
end
41+
42+
feature "modal without portal prevents body scroll", %{session: session} do
43+
session
44+
|> visit_fixture("/fixtures/modal-without-portal", "#no-portal-modal")
45+
|> execute_script("return document.body.style.overflow", fn overflow ->
46+
assert overflow == ""
47+
end)
48+
|> click(Query.css("#modal-without-portal button"))
49+
|> assert_has(@modal_container |> Query.visible(true))
50+
|> execute_script("return document.body.style.overflow", fn overflow ->
51+
assert overflow == "hidden"
52+
end)
53+
|> click(Query.css("#no-portal-modal [testing-ref=close-button]"))
54+
|> assert_has(@modal_container |> Query.visible(false))
55+
|> execute_script("return document.body.style.overflow", fn overflow ->
56+
assert overflow == ""
57+
end)
58+
end
59+
60+
feature "modal without portal has proper ARIA attributes", %{session: session} do
61+
session
62+
|> visit("/fixtures/modal-without-portal")
63+
|> assert_has(
64+
Query.css("#no-portal-modal[role=dialog][aria-modal=true]")
65+
|> Query.visible(false)
66+
)
67+
end
68+
69+
feature "modal without portal manages aria-hidden state", %{session: session} do
70+
session
71+
|> visit_fixture("/fixtures/modal-without-portal", "#no-portal-modal")
72+
|> assert_has(
73+
Query.css("#no-portal-modal[aria-hidden=true]")
74+
|> Query.visible(false)
75+
)
76+
|> click(Query.css("#modal-without-portal button"))
77+
|> assert_has(@modal_container |> Query.visible(true))
78+
|> assert_has(Query.css("#no-portal-modal:not([aria-hidden])"))
79+
|> send_keys([:escape])
80+
|> assert_has(@modal_container |> Query.visible(false))
81+
|> assert_has(
82+
Query.css("#no-portal-modal[aria-hidden=true]")
83+
|> Query.visible(false)
84+
)
85+
end
86+
87+
feature "modal without portal auto-generates ARIA label relationships", %{session: session} do
88+
session
89+
|> visit_fixture("/fixtures/modal-without-portal", "#no-portal-modal")
90+
|> click(Query.css("#modal-without-portal button"))
91+
|> assert_has(@modal_container |> Query.visible(true))
92+
|> assert_has(Query.css("#no-portal-modal[aria-labelledby='no-portal-modal-title']"))
93+
|> assert_has(Query.css("#no-portal-modal-title"))
94+
end
95+
96+
feature "modal without portal manages focus correctly", %{session: session} do
97+
session
98+
|> visit_fixture("/fixtures/modal-without-portal", "#no-portal-modal")
99+
|> click(Query.css("#modal-without-portal button"))
100+
|> assert_has(@modal_container |> Query.visible(true))
101+
|> assert_has(Query.css("#no-portal-modal [testing-ref=close-button]:focus"))
102+
end
103+
end

lib/prima/modal.ex

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,24 @@ defmodule Prima.Modal do
3636
3737
Focus is automatically restored to the triggering element when the modal closes.
3838
39+
## Portal Behavior
40+
41+
By default, modals use Phoenix's portal component to teleport content to the
42+
document body, ensuring proper z-index stacking above other page content.
43+
44+
To render a modal inline without a portal:
45+
46+
<.modal id="my-modal" portal={false}>
47+
<.modal_overlay class="fixed inset-0 bg-gray-500/75" />
48+
<.modal_panel id="my-panel">
49+
<p>This modal renders inline</p>
50+
</.modal_panel>
51+
</.modal>
52+
53+
**Important:** When `portal={false}`, ensure your CSS positioning allows
54+
the modal to overlay the page properly. Parent containers with `overflow: hidden`
55+
or `position: relative` may interfere with modal display. If you cannot control
56+
parent styles, use the default portal mode instead.
3957
4058
## Modal Control Patterns
4159
@@ -111,6 +129,7 @@ defmodule Prima.Modal do
111129
attr :class, :string, default: ""
112130
attr :on_close, JS, default: %JS{}
113131
attr :show, :boolean, default: false
132+
attr :portal, :boolean, default: true
114133

115134
slot :inner_block
116135

@@ -127,6 +146,9 @@ defmodule Prima.Modal do
127146
* `class` - Additional CSS classes to apply
128147
* `on_close` - JavaScript commands to execute when modal closes
129148
* `show` - Boolean indicating initial visibility state
149+
* `portal` - Boolean controlling portal usage (default: true).
150+
When true (default), modal is teleported to document body for proper
151+
z-index stacking. When false, modal renders in its natural DOM position.
130152
131153
## Example
132154
@@ -140,22 +162,30 @@ defmodule Prima.Modal do
140162
"""
141163
def modal(assigns) do
142164
~H"""
143-
<.portal id={"#{@id}-portal"} target="body">
144-
<div
145-
id={@id}
146-
js-show={JS.show()}
147-
js-hide={@on_close |> JS.hide()}
148-
data-prima-show={@show}
149-
style="display: none;"
150-
phx-hook="Modal"
151-
class={@class}
152-
role="dialog"
153-
aria-modal="true"
154-
aria-hidden="true"
155-
>
156-
{render_slot(@inner_block)}
157-
</div>
165+
<.portal :if={@portal} id={"#{@id}-portal"} target="body">
166+
<.modal_container {assigns} />
158167
</.portal>
168+
169+
<.modal_container :if={!@portal} {assigns} />
170+
"""
171+
end
172+
173+
defp modal_container(assigns) do
174+
~H"""
175+
<div
176+
id={@id}
177+
js-show={JS.show()}
178+
js-hide={@on_close |> JS.hide()}
179+
data-prima-show={@show}
180+
style="display: none;"
181+
phx-hook="Modal"
182+
class={@class}
183+
role="dialog"
184+
aria-modal="true"
185+
aria-hidden="true"
186+
>
187+
{render_slot(@inner_block)}
188+
</div>
159189
"""
160190
end
161191

0 commit comments

Comments
 (0)