Skip to content

Commit dcf2a7b

Browse files
authored
Merge pull request #20 from plausible/dropdown-sections
Add dropdown sections, headings, and separators
2 parents 5b79f6f + 0c3b88b commit dcf2a7b

File tree

8 files changed

+330
-0
lines changed

8 files changed

+330
-0
lines changed

assets/js/hooks/dropdown.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@ export default {
333333
menu.setAttribute('aria-labelledby', triggerId)
334334

335335
this.setupMenuitemIds()
336+
this.setupSectionLabels()
336337
},
337338

338339
setupMenuitemIds() {
@@ -344,6 +345,24 @@ export default {
344345
})
345346
},
346347

348+
setupSectionLabels() {
349+
const dropdownId = this.el.id
350+
const sections = this.el.querySelectorAll('[role="group"]')
351+
352+
sections.forEach((section, sectionIndex) => {
353+
// Check if the first child is a heading (role="presentation")
354+
const firstChild = section.firstElementChild
355+
if (firstChild && firstChild.getAttribute('role') === 'presentation') {
356+
// Ensure the heading has an ID
357+
if (!firstChild.id) {
358+
firstChild.id = `${dropdownId}-section-${sectionIndex}-heading`
359+
}
360+
// Link the section to the heading
361+
section.setAttribute('aria-labelledby', firstChild.id)
362+
}
363+
})
364+
},
365+
347366
positionMenu() {
348367
if (!this.refs.menuWrapper) return
349368

demo/lib/demo_web/live/demo_live/dropdown_page.html.heex

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,21 @@
5151
variant to style disabled items differently, such as reducing opacity or changing text color.
5252
</p>
5353

54+
<h2>Sections</h2>
55+
<p>
56+
Organize complex dropdown menus using <code>.dropdown_section</code>, <code>.dropdown_heading</code>, and
57+
<code>.dropdown_separator</code>
58+
components.
59+
</p>
60+
61+
<div class="not-prose">
62+
<.code_example file="dropdown/sections.html.heex" />
63+
</div>
64+
65+
<p>
66+
Sections provide semantic grouping with proper ARIA labels, headings label each section, and separators create visual divisions between groups.
67+
</p>
68+
5469
<h2>Styling</h2>
5570

5671
<h3>Highlighting Active Items</h3>

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
<.dropdown_rerender_trigger_fixture {assigns} />
1515
</div>
1616

17+
<div :if={@live_action == :dropdown_sections}>
18+
<.dropdown_sections_fixture />
19+
</div>
20+
1721
<div :if={@live_action == :simple_modal}>
1822
<.simple_modal_fixture />
1923
</div>
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<.dropdown id="dropdown-sections">
2+
<.dropdown_trigger as={&button/1}>
3+
Account Menu
4+
</.dropdown_trigger>
5+
<.dropdown_menu>
6+
<.dropdown_section>
7+
<.dropdown_heading>
8+
Account
9+
</.dropdown_heading>
10+
<.dropdown_item>
11+
Profile
12+
</.dropdown_item>
13+
<.dropdown_item>
14+
Settings
15+
</.dropdown_item>
16+
</.dropdown_section>
17+
18+
<.dropdown_separator />
19+
20+
<.dropdown_section>
21+
<.dropdown_heading>
22+
Support
23+
</.dropdown_heading>
24+
<.dropdown_item>
25+
Documentation
26+
</.dropdown_item>
27+
<.dropdown_item>
28+
Contact Us
29+
</.dropdown_item>
30+
</.dropdown_section>
31+
32+
<.dropdown_separator />
33+
34+
<.dropdown_item>
35+
Sign Out
36+
</.dropdown_item>
37+
</.dropdown_menu>
38+
</.dropdown>
39+
40+
<div id="outside-area">Outside area</div>

demo/lib/demo_web/router.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ defmodule DemoWeb.Router do
2525
live "/fixtures/dropdown-with-disabled", FixturesLive, :dropdown_with_disabled
2626
live "/fixtures/dropdown-custom-components", FixturesLive, :dropdown_custom_components
2727
live "/fixtures/dropdown-rerender-trigger", FixturesLive, :dropdown_rerender_trigger
28+
live "/fixtures/dropdown-sections", FixturesLive, :dropdown_sections
2829
live "/fixtures/simple-modal", FixturesLive, :simple_modal
2930
live "/fixtures/async-modal", FixturesLive, :async_modal
3031
live "/fixtures/modal-rerender-title", FixturesLive, :modal_rerender_title
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<.dropdown id="sections-dropdown">
2+
<.dropdown_trigger as={&button/1}>
3+
Account Menu
4+
<svg
5+
class="-mr-1 h-5 w-5 text-white"
6+
viewBox="0 0 20 20"
7+
fill="currentColor"
8+
aria-hidden="true"
9+
>
10+
<path
11+
fill-rule="evenodd"
12+
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
13+
clip-rule="evenodd"
14+
/>
15+
</svg>
16+
</.dropdown_trigger>
17+
<.dropdown_menu class="w-56 py-1 rounded-md bg-white shadow-xs ring-1 ring-gray-300 focus:outline-none">
18+
<.dropdown_section class="px-1 py-1">
19+
<.dropdown_heading class="px-3 py-2 text-xs font-semibold text-gray-500 uppercase">
20+
Account
21+
</.dropdown_heading>
22+
<.dropdown_item class="text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 block px-4 py-2 text-sm rounded">
23+
Profile
24+
</.dropdown_item>
25+
<.dropdown_item class="text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 block px-4 py-2 text-sm rounded">
26+
Settings
27+
</.dropdown_item>
28+
</.dropdown_section>
29+
30+
<.dropdown_separator class="my-1 border-t border-gray-200" />
31+
32+
<.dropdown_section class="px-1 py-1">
33+
<.dropdown_heading class="px-3 py-2 text-xs font-semibold text-gray-500 uppercase">
34+
Support
35+
</.dropdown_heading>
36+
<.dropdown_item class="text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 block px-4 py-2 text-sm rounded">
37+
Documentation
38+
</.dropdown_item>
39+
<.dropdown_item class="text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 block px-4 py-2 text-sm rounded">
40+
Contact Us
41+
</.dropdown_item>
42+
</.dropdown_section>
43+
44+
<.dropdown_separator class="my-1 border-t border-gray-200" />
45+
46+
<.dropdown_item class="text-red-700 data-focus:bg-red-100 data-focus:text-red-900 block px-4 py-2 text-sm rounded mx-1">
47+
Sign Out
48+
</.dropdown_item>
49+
</.dropdown_menu>
50+
</.dropdown>
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
defmodule DemoWeb.DropdownSectionsTest do
2+
use Prima.WallabyCase, async: true
3+
4+
@dropdown_button Query.css("#dropdown-sections [aria-haspopup=menu]")
5+
@dropdown_menu Query.css("#dropdown-sections [role=menu]")
6+
7+
feature "renders sections with proper ARIA roles", %{session: session} do
8+
session
9+
|> visit_fixture("/fixtures/dropdown-sections", "#dropdown-sections")
10+
|> click(@dropdown_button)
11+
|> assert_has(@dropdown_menu |> Query.visible(true))
12+
|> assert_has(Query.css("#dropdown-sections [role=group]", count: 2))
13+
end
14+
15+
feature "renders headings with proper ARIA presentation role", %{session: session} do
16+
session
17+
|> visit_fixture("/fixtures/dropdown-sections", "#dropdown-sections")
18+
|> click(@dropdown_button)
19+
|> assert_has(Query.css("#dropdown-sections [role=presentation]", count: 2))
20+
end
21+
22+
feature "renders separators with proper ARIA role", %{session: session} do
23+
session
24+
|> visit_fixture("/fixtures/dropdown-sections", "#dropdown-sections")
25+
|> click(@dropdown_button)
26+
|> assert_has(
27+
Query.css("#dropdown-sections [role=separator]", count: 2)
28+
|> Query.visible(false)
29+
)
30+
end
31+
32+
feature "sections are automatically labeled by headings via JS hook", %{session: session} do
33+
session
34+
|> visit_fixture("/fixtures/dropdown-sections", "#dropdown-sections")
35+
|> click(@dropdown_button)
36+
# Check that both sections have aria-labelledby attributes (auto-generated by JS)
37+
|> assert_has(
38+
Query.css(
39+
"#dropdown-sections [role=group][aria-labelledby^='dropdown-sections-section-']",
40+
count: 2
41+
)
42+
)
43+
# Verify the first section's heading ID matches its aria-labelledby
44+
|> execute_script("""
45+
const firstSection = document.querySelector('#dropdown-sections [role=group]');
46+
const labelId = firstSection.getAttribute('aria-labelledby');
47+
const heading = document.getElementById(labelId);
48+
return heading && heading.getAttribute('role') === 'presentation';
49+
""")
50+
end
51+
52+
feature "keyboard navigation skips headings and separators", %{session: session} do
53+
session
54+
|> visit_fixture("/fixtures/dropdown-sections", "#dropdown-sections")
55+
|> click(@dropdown_button)
56+
|> assert_has(@dropdown_menu |> Query.visible(true))
57+
|> send_keys([:down_arrow])
58+
|> assert_has(Query.css("#dropdown-sections-item-0[data-focus]"))
59+
|> send_keys([:down_arrow])
60+
|> assert_has(Query.css("#dropdown-sections-item-1[data-focus]"))
61+
|> send_keys([:down_arrow])
62+
|> assert_has(Query.css("#dropdown-sections-item-2[data-focus]"))
63+
|> send_keys([:down_arrow])
64+
|> assert_has(Query.css("#dropdown-sections-item-3[data-focus]"))
65+
|> send_keys([:down_arrow])
66+
|> assert_has(Query.css("#dropdown-sections-item-4[data-focus]"))
67+
end
68+
69+
feature "Home key navigates to first menu item, skipping headings", %{session: session} do
70+
session
71+
|> visit_fixture("/fixtures/dropdown-sections", "#dropdown-sections")
72+
|> click(@dropdown_button)
73+
|> send_keys([:end])
74+
|> assert_has(Query.css("#dropdown-sections [role=menuitem]:last-of-type[data-focus]"))
75+
|> send_keys([:home])
76+
|> assert_has(Query.css("#dropdown-sections [role=menuitem]:first-of-type[data-focus]"))
77+
end
78+
79+
feature "End key navigates to last menu item, skipping separators", %{session: session} do
80+
session
81+
|> visit_fixture("/fixtures/dropdown-sections", "#dropdown-sections")
82+
|> click(@dropdown_button)
83+
|> send_keys([:home])
84+
|> assert_has(Query.css("#dropdown-sections [role=menuitem]:first-of-type[data-focus]"))
85+
|> send_keys([:end])
86+
|> assert_has(Query.css("#dropdown-sections [role=menuitem]:last-of-type[data-focus]"))
87+
end
88+
end

lib/prima/dropdown.ex

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,4 +207,117 @@ defmodule Prima.Dropdown do
207207

208208
render_as(assigns, %{tag_name: "button", type: "button"})
209209
end
210+
211+
attr :class, :string, default: ""
212+
attr :rest, :global
213+
214+
@doc """
215+
A visual separator for grouping related menu items.
216+
217+
This component renders a separator line between groups of menu items to create
218+
visual organization within the dropdown menu. It uses the `separator` ARIA role
219+
for proper accessibility.
220+
221+
## Attributes
222+
223+
* `class` - CSS classes for styling the separator
224+
225+
## Examples
226+
227+
<.dropdown_separator class="my-1 border-t border-gray-200" />
228+
229+
## Accessibility
230+
231+
The separator uses `role="separator"` which is properly announced by screen
232+
readers as a divider between menu sections.
233+
"""
234+
def dropdown_separator(assigns) do
235+
~H"""
236+
<div role="separator" class={@class} {@rest}></div>
237+
"""
238+
end
239+
240+
attr :class, :string, default: ""
241+
attr :rest, :global
242+
slot :inner_block, required: true
243+
244+
@doc """
245+
A container for grouping related menu items within a dropdown.
246+
247+
This component provides semantic grouping of related menu items with proper
248+
ARIA structure. Use this to create logical sections within your dropdown menu.
249+
250+
## Attributes
251+
252+
* `class` - CSS classes for styling the section container
253+
254+
## Examples
255+
256+
# Basic section grouping
257+
<.dropdown_section>
258+
<.dropdown_item>Profile</.dropdown_item>
259+
<.dropdown_item>Settings</.dropdown_item>
260+
</.dropdown_section>
261+
262+
# Section with heading
263+
<.dropdown_section>
264+
<.dropdown_heading>Account</.dropdown_heading>
265+
<.dropdown_item>Profile</.dropdown_item>
266+
<.dropdown_item>Settings</.dropdown_item>
267+
</.dropdown_section>
268+
269+
## Accessibility
270+
271+
The section uses `role="group"` to indicate a logical grouping of menu items
272+
to assistive technologies. When used with a heading as the first child, the
273+
JavaScript hook automatically establishes the `aria-labelledby` relationship
274+
between the section and the heading.
275+
"""
276+
def dropdown_section(assigns) do
277+
~H"""
278+
<div role="group" class={@class} {@rest}>
279+
{render_slot(@inner_block)}
280+
</div>
281+
"""
282+
end
283+
284+
attr :id, :string, default: nil
285+
attr :class, :string, default: ""
286+
attr :rest, :global
287+
slot :inner_block, required: true
288+
289+
@doc """
290+
A heading for a group of menu items within a dropdown.
291+
292+
This component provides a semantic heading for sections of menu items with
293+
proper ARIA labeling. When used as the first child of a `dropdown_section`,
294+
the JavaScript hook automatically establishes the aria-labelledby relationship.
295+
296+
## Attributes
297+
298+
* `id` - Optional unique identifier for the heading. Auto-generated by the JS hook if not provided.
299+
* `class` - CSS classes for styling the heading
300+
301+
## Examples
302+
303+
<.dropdown_section>
304+
<.dropdown_heading>Recent Files</.dropdown_heading>
305+
<.dropdown_item>Document.pdf</.dropdown_item>
306+
<.dropdown_item>Spreadsheet.xlsx</.dropdown_item>
307+
</.dropdown_section>
308+
309+
## Accessibility
310+
311+
The heading uses `role="presentation"` to prevent it from being treated as
312+
a menu item while still providing semantic structure. When used as the first
313+
child of a section, the JavaScript hook automatically generates an ID for the
314+
heading and sets the section's `aria-labelledby` to reference it.
315+
"""
316+
def dropdown_heading(assigns) do
317+
~H"""
318+
<div id={@id} role="presentation" class={@class} {@rest}>
319+
{render_slot(@inner_block)}
320+
</div>
321+
"""
322+
end
210323
end

0 commit comments

Comments
 (0)