Skip to content

Commit 0dd2897

Browse files
authored
Merge pull request #12 from plausible/dropdown-custom-rendering
feat: add support for custom component rendering in dropdown
2 parents 13361b5 + a0d7e49 commit 0dd2897

File tree

6 files changed

+240
-15
lines changed

6 files changed

+240
-15
lines changed

demo/lib/demo_web/live/fixtures_live.ex

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,15 @@ defmodule DemoWeb.FixturesLive do
8383
</span>
8484
"""
8585
end
86+
87+
attr :rest, :global
88+
slot :inner_block, required: true
89+
90+
defp custom_button(assigns) do
91+
~H"""
92+
<button type="button" {@rest}>
93+
{render_slot(@inner_block)}
94+
</button>
95+
"""
96+
end
8697
end

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
<.dropdown_with_disabled_fixture />
77
</div>
88

9+
<div :if={@live_action == :dropdown_custom_components}>
10+
<.dropdown_custom_components_fixture />
11+
</div>
12+
913
<div :if={@live_action == :simple_modal}>
1014
<.simple_modal_fixture />
1115
</div>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<div>
2+
<.dropdown id="dropdown-custom">
3+
<.dropdown_trigger as={&custom_button/1} data-custom-attr="test-value">
4+
Open Dropdown
5+
</.dropdown_trigger>
6+
<.dropdown_menu>
7+
<.dropdown_item>
8+
Regular Item
9+
</.dropdown_item>
10+
<.dropdown_item as={&link/1} navigate={~p"/"}>
11+
Link Item
12+
</.dropdown_item>
13+
<.dropdown_item disabled={true} as={&link/1} href="#disabled">
14+
Disabled Link
15+
</.dropdown_item>
16+
</.dropdown_menu>
17+
</.dropdown>
18+
</div>

demo/lib/demo_web/router.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ defmodule DemoWeb.Router do
2323
if Mix.env() in [:dev, :test] do
2424
live "/fixtures/dropdown", FixturesLive, :dropdown
2525
live "/fixtures/dropdown-with-disabled", FixturesLive, :dropdown_with_disabled
26+
live "/fixtures/dropdown-custom-components", FixturesLive, :dropdown_custom_components
2627
live "/fixtures/simple-modal", FixturesLive, :simple_modal
2728
live "/fixtures/async-modal", FixturesLive, :async_modal
2829
live "/fixtures/modal-title-custom-tag", FixturesLive, :modal_title_custom_tag
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
defmodule DemoWeb.DropdownCustomComponentsTest do
2+
use Prima.WallabyCase, async: true
3+
4+
@dropdown_container Query.css("#dropdown-custom")
5+
@dropdown_trigger Query.css("#dropdown-custom button[aria-haspopup=menu]")
6+
@dropdown_menu Query.css("#dropdown-custom [role=menu]")
7+
8+
feature "dropdown_trigger as custom component receives accessibility attributes", %{
9+
session: session
10+
} do
11+
session
12+
|> visit_fixture("/fixtures/dropdown-custom-components", "#dropdown-custom")
13+
|> assert_has(@dropdown_container)
14+
|> assert_has(@dropdown_trigger)
15+
|> assert_has(Query.css("#dropdown-custom button[aria-haspopup='menu']"))
16+
|> assert_has(Query.css("#dropdown-custom button[aria-expanded='false']"))
17+
|> assert_has(Query.css("#dropdown-custom button[data-custom-attr='test-value']"))
18+
end
19+
20+
feature "dropdown_item as custom component receives accessibility attributes", %{
21+
session: session
22+
} do
23+
session
24+
|> visit_fixture("/fixtures/dropdown-custom-components", "#dropdown-custom")
25+
|> click(@dropdown_trigger)
26+
|> assert_has(@dropdown_menu |> Query.visible(true))
27+
|> assert_has(Query.css("#dropdown-custom a[role=menuitem]") |> Query.count(2))
28+
|> assert_has(Query.css("#dropdown-custom a[role=menuitem][tabindex='-1']") |> Query.count(2))
29+
|> assert_has(Query.css("#dropdown-custom a[role=menuitem]:nth-child(2)", text: "Link Item"))
30+
|> assert_has(
31+
Query.css("#dropdown-custom a[role=menuitem][data-phx-link='redirect']", text: "Link Item")
32+
)
33+
end
34+
35+
feature "dropdown_item disabled attribute is passed through to custom component", %{
36+
session: session
37+
} do
38+
session
39+
|> visit_fixture("/fixtures/dropdown-custom-components", "#dropdown-custom")
40+
|> click(@dropdown_trigger)
41+
|> assert_has(@dropdown_menu |> Query.visible(true))
42+
|> assert_has(Query.css("#dropdown-custom a[role=menuitem][aria-disabled='true']"))
43+
|> assert_has(
44+
Query.css("#dropdown-custom a[role=menuitem][aria-disabled='true'][data-disabled='true']")
45+
)
46+
|> assert_has(
47+
Query.css("#dropdown-custom [role=menuitem]:nth-child(3)", text: "Disabled Link")
48+
)
49+
end
50+
51+
feature "keyboard navigation works with custom component items", %{session: session} do
52+
session
53+
|> visit_fixture("/fixtures/dropdown-custom-components", "#dropdown-custom")
54+
|> click(@dropdown_trigger)
55+
|> assert_has(@dropdown_menu |> Query.visible(true))
56+
# Navigate down to first item (regular div)
57+
|> send_keys([:down_arrow])
58+
|> assert_has(Query.css("#dropdown-custom [role=menuitem]:nth-child(1)[data-focus]"))
59+
# Navigate down to second item (link)
60+
|> send_keys([:down_arrow])
61+
|> assert_has(Query.css("#dropdown-custom a[role=menuitem]:nth-child(2)[data-focus]"))
62+
|> assert_missing(Query.css("#dropdown-custom [role=menuitem]:nth-child(1)[data-focus]"))
63+
end
64+
end

lib/prima/dropdown.ex

Lines changed: 142 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,70 @@ defmodule Prima.Dropdown do
1616

1717
attr :class, :string, default: ""
1818
attr :as, :any, default: nil
19+
attr :rest, :global
1920
slot :inner_block, required: true
2021

22+
@doc """
23+
The trigger button/element for a dropdown menu.
24+
25+
This component renders the clickable element that opens and closes the dropdown
26+
menu. By default, it renders as a `button` element, but can be customized to
27+
render as any component using the `as` attribute.
28+
29+
## Attributes
30+
31+
* `class` - CSS classes for styling the trigger
32+
* `as` - Custom function component to render instead of the default button element
33+
34+
## Examples
35+
36+
# Basic trigger
37+
<.dropdown_trigger>
38+
Open Menu
39+
</.dropdown_trigger>
40+
41+
# With custom component
42+
<.dropdown_trigger as={&my_custom_button/1}>
43+
Open Menu
44+
</.dropdown_trigger>
45+
46+
## Custom Component Requirements
47+
48+
When using the `as` attribute, the custom component receives accessibility
49+
attributes including `aria-haspopup="menu"` and `aria-expanded`.
50+
51+
**IMPORTANT**: Custom components must accept and pass through global attributes
52+
using the `:global` attribute type (commonly via `@rest`). This ensures that
53+
Prima's accessibility attributes are properly applied to the rendered element.
54+
55+
Example of a properly configured custom component:
56+
57+
attr :rest, :global
58+
slot :inner_block, required: true
59+
60+
def my_custom_button(assigns) do
61+
~H\"\"\"
62+
<button type="button" {@rest}>
63+
{\render_slot(@inner_block)}
64+
</button>
65+
\"\"\"
66+
end
67+
68+
Without `{@rest}`, accessibility attributes will not be applied and the dropdown
69+
will not function correctly with keyboard navigation and screen readers.
70+
"""
2171
def dropdown_trigger(assigns) do
2272
assigns =
2373
assign(assigns, %{
2474
"aria-haspopup": "menu",
2575
"aria-expanded": "false"
2676
})
2777

28-
if assigns[:as] do
29-
{as, assigns} = Map.pop(assigns, :as)
78+
{as, assigns} = Map.pop(assigns, :as)
79+
{rest, assigns} = Map.pop(assigns, :rest, %{})
80+
assigns = Map.merge(assigns, rest)
81+
82+
if as do
3083
as.(assigns)
3184
else
3285
dynamic_tag(
@@ -86,22 +139,96 @@ defmodule Prima.Dropdown do
86139

87140
attr :class, :string, default: ""
88141
attr :disabled, :boolean, default: false
142+
attr :as, :any, default: nil
143+
144+
# Workaround - unfortunately there seems to be no way to pass through arbitrary assigns without emitting compile warnings
145+
# Since dropdown items are often rendered as links, we add the <.link> attributes here as well.
146+
attr :rest, :global, include: ~w(navigate patch href)
89147
slot :inner_block, required: true
90148

149+
@doc """
150+
A menu item component for use within a dropdown menu.
151+
152+
This component represents an individual item in a dropdown menu with proper
153+
ARIA attributes and keyboard navigation support. By default, it renders as a
154+
`div` element, but can be customized to render as any component using the `as`
155+
attribute.
156+
157+
## Attributes
158+
159+
* `class` - CSS classes for styling the menu item
160+
* `disabled` - Boolean to mark the item as disabled (default: false)
161+
* `as` - Custom function component to render instead of the default div element
162+
163+
## Examples
164+
165+
# Basic menu item
166+
<.dropdown_item>
167+
Save
168+
</.dropdown_item>
169+
170+
# Disabled menu item
171+
<.dropdown_item disabled={true}>
172+
Delete (unavailable)
173+
</.dropdown_item>
174+
175+
# With custom component (e.g., a link)
176+
<.dropdown_item as={&my_link_component/1}>
177+
View Profile
178+
</.dropdown_item>
179+
180+
# With Phoenix.Component.link
181+
<.dropdown_item as={&link/1} navigate={~p"/profile"}>
182+
View Profile
183+
</.dropdown_item>
184+
185+
## Custom Component Requirements
186+
187+
When using the `as` attribute, the custom component receives all the standard
188+
attributes including `role="menuitem"`, `tabindex="-1"`, and accessibility
189+
attributes like `aria-disabled` when appropriate.
190+
191+
**IMPORTANT**: Custom components must accept and pass through global attributes
192+
using the `:global` attribute type (commonly via `@rest`). This ensures that
193+
Prima's accessibility attributes are properly applied to the rendered element.
194+
195+
Example of a properly configured custom component:
196+
197+
attr :rest, :global
198+
slot :inner_block, required: true
199+
200+
def my_custom_item(assigns) do
201+
~H\"\"\"
202+
<a {@rest}>
203+
{\render_slot(@inner_block)}
204+
</a>
205+
\"\"\"
206+
end
207+
208+
Without `{@rest}`, accessibility attributes will not be applied and the component
209+
will not function correctly with keyboard navigation and screen readers.
210+
"""
91211
def dropdown_item(assigns) do
92-
assigns = assign(assigns, :aria_disabled, if(assigns.disabled, do: "true", else: nil))
93-
assigns = assign(assigns, :data_disabled, if(assigns.disabled, do: "true", else: nil))
212+
{as, assigns} = Map.pop(assigns, :as)
213+
{rest, assigns} = Map.pop(assigns, :rest, %{})
214+
assigns = Map.merge(assigns, rest)
94215

95-
~H"""
96-
<div
97-
class={@class}
98-
role="menuitem"
99-
tabindex="-1"
100-
aria-disabled={@aria_disabled}
101-
data-disabled={@data_disabled}
102-
>
103-
{render_slot(@inner_block)}
104-
</div>
105-
"""
216+
assigns =
217+
assign(assigns, %{
218+
role: "menuitem",
219+
tabindex: "-1",
220+
"aria-disabled": if(assigns.disabled, do: "true", else: nil),
221+
"data-disabled": if(assigns.disabled, do: "true", else: nil)
222+
})
223+
224+
if as do
225+
as.(assigns)
226+
else
227+
dynamic_tag(
228+
Map.merge(assigns, %{
229+
tag_name: "div"
230+
})
231+
)
232+
end
106233
end
107234
end

0 commit comments

Comments
 (0)