Skip to content

Commit 04a22e3

Browse files
committed
feat: add optional portal rendering for modals
Add a `portal` attribute to the modal component allowing users to opt out of portal rendering. By default, modals continue to use portals (portal={true}) for proper z-index stacking, but can now render inline with portal={false}. Changes: - Add portal attribute (boolean, default: true) to modal component - Extract modal container to private function for reusability - Update JavaScript hook to detect and handle both portal and non-portal modes - Add comprehensive test coverage for non-portal functionality - Add documentation on portal behavior and CSS considerations The implementation is fully backward compatible - existing modals continue to use portals by default.
1 parent e1dd8f1 commit 04a22e3

File tree

6 files changed

+184
-18
lines changed

6 files changed

+184
-18
lines changed

assets/js/hooks/modal.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,18 @@ export default {
2424
},
2525

2626
setupElements() {
27-
this.modalEl = document.getElementById(this.el.id.replace('-portal', ''))
27+
// Check if this hook is attached to a portal or directly to the modal
28+
// The modal element has role="dialog", the portal wrapper does not
29+
if (this.el.getAttribute('role') === 'dialog') {
30+
// Non-portal mode: the hook element IS the modal
31+
this.modalEl = this.el
32+
} else {
33+
// Portal mode: find the actual modal element inside the portal
34+
this.modalEl = document.getElementById(this.el.id.replace('-portal', ''))
2835

29-
if (!this.modalEl) {
30-
throw new Error(`[Prima Modal] Could not find modal element for portal ${this.el.id}`)
36+
if (!this.modalEl) {
37+
throw new Error(`[Prima Modal] Could not find modal element for portal ${this.el.id}`)
38+
}
3139
}
3240

3341
if (!this.ref("modal-panel")) {

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@
3838
<.modal_focus_autofocus_fixture />
3939
</div>
4040

41+
<div :if={@live_action == :modal_without_portal}>
42+
<.modal_without_portal_fixture />
43+
</div>
44+
4145
<div :if={@live_action == :simple_combobox}>
4246
<.simple_combobox_fixture />
4347
</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.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.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.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
@@ -31,6 +31,7 @@ defmodule DemoWeb.Router do
3131
live "/fixtures/modal-title-custom-tag", FixturesLive, :modal_title_custom_tag
3232
live "/fixtures/modal-title-function", FixturesLive, :modal_title_function
3333
live "/fixtures/modal-focus-autofocus", FixturesLive, :modal_focus_autofocus
34+
live "/fixtures/modal-without-portal", FixturesLive, :modal_without_portal
3435
live "/fixtures/simple-combobox", FixturesLive, :simple_combobox
3536
live "/fixtures/async-combobox", FixturesLive, :async_combobox
3637
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: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,25 @@ 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.
57+
3958
## Advanced Usage
4059
4160
### Async Loading
@@ -77,6 +96,7 @@ defmodule Prima.Modal do
7796
attr :class, :string, default: ""
7897
attr :on_close, JS, default: %JS{}
7998
attr :show, :boolean, default: false
99+
attr :portal, :boolean, default: true
80100

81101
slot :inner_block
82102

@@ -93,6 +113,9 @@ defmodule Prima.Modal do
93113
* `class` - Additional CSS classes to apply
94114
* `on_close` - JavaScript commands to execute when modal closes
95115
* `show` - Boolean indicating initial visibility state
116+
* `portal` - Boolean controlling portal usage (default: true).
117+
When true (default), modal is teleported to document body for proper
118+
z-index stacking. When false, modal renders in its natural DOM position.
96119
97120
## Example
98121
@@ -106,22 +129,30 @@ defmodule Prima.Modal do
106129
"""
107130
def modal(assigns) do
108131
~H"""
109-
<.portal id={"#{@id}-portal"} target="body">
110-
<div
111-
id={@id}
112-
js-show={JS.show()}
113-
js-hide={@on_close |> JS.hide()}
114-
data-prima-show={@show}
115-
style="display: none;"
116-
phx-hook="Modal"
117-
class={@class}
118-
role="dialog"
119-
aria-modal="true"
120-
aria-hidden="true"
121-
>
122-
{render_slot(@inner_block)}
123-
</div>
132+
<.portal :if={@portal} id={"#{@id}-portal"} target="body">
133+
<.modal_container {assigns} />
124134
</.portal>
135+
136+
<.modal_container :if={!@portal} {assigns} />
137+
"""
138+
end
139+
140+
defp modal_container(assigns) do
141+
~H"""
142+
<div
143+
id={@id}
144+
js-show={JS.show()}
145+
js-hide={@on_close |> JS.hide()}
146+
data-prima-show={@show}
147+
style="display: none;"
148+
phx-hook="Modal"
149+
class={@class}
150+
role="dialog"
151+
aria-modal="true"
152+
aria-hidden="true"
153+
>
154+
{render_slot(@inner_block)}
155+
</div>
125156
"""
126157
end
127158

0 commit comments

Comments
 (0)