Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions assets/js/hooks/dropdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ export default {
menu.setAttribute('aria-labelledby', triggerId)

this.setupMenuitemIds()
this.setupSectionLabels()
},

setupMenuitemIds() {
Expand All @@ -344,6 +345,24 @@ export default {
})
},

setupSectionLabels() {
const dropdownId = this.el.id
const sections = this.el.querySelectorAll('[role="group"]')

sections.forEach((section, sectionIndex) => {
// Check if the first child is a heading (role="presentation")
const firstChild = section.firstElementChild
if (firstChild && firstChild.getAttribute('role') === 'presentation') {
// Ensure the heading has an ID
if (!firstChild.id) {
firstChild.id = `${dropdownId}-section-${sectionIndex}-heading`
}
// Link the section to the heading
section.setAttribute('aria-labelledby', firstChild.id)
}
})
},

positionMenu() {
if (!this.refs.menuWrapper) return

Expand Down
15 changes: 15 additions & 0 deletions demo/lib/demo_web/live/demo_live/dropdown_page.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,21 @@
variant to style disabled items differently, such as reducing opacity or changing text color.
</p>

<h2>Sections</h2>
<p>
Organize complex dropdown menus using <code>.dropdown_section</code>, <code>.dropdown_heading</code>, and
<code>.dropdown_separator</code>
components.
</p>

<div class="not-prose">
<.code_example file="dropdown/sections.html.heex" />
</div>

<p>
Sections provide semantic grouping with proper ARIA labels, headings label each section, and separators create visual divisions between groups.
</p>

<h2>Styling</h2>

<h3>Highlighting Active Items</h3>
Expand Down
4 changes: 4 additions & 0 deletions demo/lib/demo_web/live/fixtures_live.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
<.dropdown_rerender_trigger_fixture {assigns} />
</div>

<div :if={@live_action == :dropdown_sections}>
<.dropdown_sections_fixture />
</div>

<div :if={@live_action == :simple_modal}>
<.simple_modal_fixture />
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<.dropdown id="dropdown-sections">
<.dropdown_trigger as={&button/1}>
Account Menu
</.dropdown_trigger>
<.dropdown_menu>
<.dropdown_section>
<.dropdown_heading>
Account
</.dropdown_heading>
<.dropdown_item>
Profile
</.dropdown_item>
<.dropdown_item>
Settings
</.dropdown_item>
</.dropdown_section>

<.dropdown_separator />

<.dropdown_section>
<.dropdown_heading>
Support
</.dropdown_heading>
<.dropdown_item>
Documentation
</.dropdown_item>
<.dropdown_item>
Contact Us
</.dropdown_item>
</.dropdown_section>

<.dropdown_separator />

<.dropdown_item>
Sign Out
</.dropdown_item>
</.dropdown_menu>
</.dropdown>

<div id="outside-area">Outside area</div>
1 change: 1 addition & 0 deletions demo/lib/demo_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ defmodule DemoWeb.Router do
live "/fixtures/dropdown-with-disabled", FixturesLive, :dropdown_with_disabled
live "/fixtures/dropdown-custom-components", FixturesLive, :dropdown_custom_components
live "/fixtures/dropdown-rerender-trigger", FixturesLive, :dropdown_rerender_trigger
live "/fixtures/dropdown-sections", FixturesLive, :dropdown_sections
live "/fixtures/simple-modal", FixturesLive, :simple_modal
live "/fixtures/async-modal", FixturesLive, :async_modal
live "/fixtures/modal-rerender-title", FixturesLive, :modal_rerender_title
Expand Down
50 changes: 50 additions & 0 deletions demo/priv/code_examples/dropdown/sections.html.heex
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<.dropdown id="sections-dropdown">
<.dropdown_trigger as={&button/1}>
Account Menu
<svg
class="-mr-1 h-5 w-5 text-white"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
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"
clip-rule="evenodd"
/>
</svg>
</.dropdown_trigger>
<.dropdown_menu class="w-56 py-1 rounded-md bg-white shadow-xs ring-1 ring-gray-300 focus:outline-none">
<.dropdown_section class="px-1 py-1">
<.dropdown_heading class="px-3 py-2 text-xs font-semibold text-gray-500 uppercase">
Account
</.dropdown_heading>
<.dropdown_item class="text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 block px-4 py-2 text-sm rounded">
Profile
</.dropdown_item>
<.dropdown_item class="text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 block px-4 py-2 text-sm rounded">
Settings
</.dropdown_item>
</.dropdown_section>

<.dropdown_separator class="my-1 border-t border-gray-200" />

<.dropdown_section class="px-1 py-1">
<.dropdown_heading class="px-3 py-2 text-xs font-semibold text-gray-500 uppercase">
Support
</.dropdown_heading>
<.dropdown_item class="text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 block px-4 py-2 text-sm rounded">
Documentation
</.dropdown_item>
<.dropdown_item class="text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 block px-4 py-2 text-sm rounded">
Contact Us
</.dropdown_item>
</.dropdown_section>

<.dropdown_separator class="my-1 border-t border-gray-200" />

<.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">
Sign Out
</.dropdown_item>
</.dropdown_menu>
</.dropdown>
88 changes: 88 additions & 0 deletions demo/test/wallaby/demo_web/dropdown_sections_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
defmodule DemoWeb.DropdownSectionsTest do
use Prima.WallabyCase, async: true

@dropdown_button Query.css("#dropdown-sections [aria-haspopup=menu]")
@dropdown_menu Query.css("#dropdown-sections [role=menu]")

feature "renders sections with proper ARIA roles", %{session: session} do
session
|> visit_fixture("/fixtures/dropdown-sections", "#dropdown-sections")
|> click(@dropdown_button)
|> assert_has(@dropdown_menu |> Query.visible(true))
|> assert_has(Query.css("#dropdown-sections [role=group]", count: 2))
end

feature "renders headings with proper ARIA presentation role", %{session: session} do
session
|> visit_fixture("/fixtures/dropdown-sections", "#dropdown-sections")
|> click(@dropdown_button)
|> assert_has(Query.css("#dropdown-sections [role=presentation]", count: 2))
end

feature "renders separators with proper ARIA role", %{session: session} do
session
|> visit_fixture("/fixtures/dropdown-sections", "#dropdown-sections")
|> click(@dropdown_button)
|> assert_has(
Query.css("#dropdown-sections [role=separator]", count: 2)
|> Query.visible(false)
)
end

feature "sections are automatically labeled by headings via JS hook", %{session: session} do
session
|> visit_fixture("/fixtures/dropdown-sections", "#dropdown-sections")
|> click(@dropdown_button)
# Check that both sections have aria-labelledby attributes (auto-generated by JS)
|> assert_has(
Query.css(
"#dropdown-sections [role=group][aria-labelledby^='dropdown-sections-section-']",
count: 2
)
)
# Verify the first section's heading ID matches its aria-labelledby
|> execute_script("""
const firstSection = document.querySelector('#dropdown-sections [role=group]');
const labelId = firstSection.getAttribute('aria-labelledby');
const heading = document.getElementById(labelId);
return heading && heading.getAttribute('role') === 'presentation';
""")
end

feature "keyboard navigation skips headings and separators", %{session: session} do
session
|> visit_fixture("/fixtures/dropdown-sections", "#dropdown-sections")
|> click(@dropdown_button)
|> assert_has(@dropdown_menu |> Query.visible(true))
|> send_keys([:down_arrow])
|> assert_has(Query.css("#dropdown-sections-item-0[data-focus]"))
|> send_keys([:down_arrow])
|> assert_has(Query.css("#dropdown-sections-item-1[data-focus]"))
|> send_keys([:down_arrow])
|> assert_has(Query.css("#dropdown-sections-item-2[data-focus]"))
|> send_keys([:down_arrow])
|> assert_has(Query.css("#dropdown-sections-item-3[data-focus]"))
|> send_keys([:down_arrow])
|> assert_has(Query.css("#dropdown-sections-item-4[data-focus]"))
end

feature "Home key navigates to first menu item, skipping headings", %{session: session} do
session
|> visit_fixture("/fixtures/dropdown-sections", "#dropdown-sections")
|> click(@dropdown_button)
|> send_keys([:end])
|> assert_has(Query.css("#dropdown-sections [role=menuitem]:last-of-type[data-focus]"))
|> send_keys([:home])
|> assert_has(Query.css("#dropdown-sections [role=menuitem]:first-of-type[data-focus]"))
end

feature "End key navigates to last menu item, skipping separators", %{session: session} do
session
|> visit_fixture("/fixtures/dropdown-sections", "#dropdown-sections")
|> click(@dropdown_button)
|> send_keys([:home])
|> assert_has(Query.css("#dropdown-sections [role=menuitem]:first-of-type[data-focus]"))
|> send_keys([:end])
|> assert_has(Query.css("#dropdown-sections [role=menuitem]:last-of-type[data-focus]"))
end
end
113 changes: 113 additions & 0 deletions lib/prima/dropdown.ex
Original file line number Diff line number Diff line change
Expand Up @@ -207,4 +207,117 @@ defmodule Prima.Dropdown do

render_as(assigns, %{tag_name: "button", type: "button"})
end

attr :class, :string, default: ""
attr :rest, :global

@doc """
A visual separator for grouping related menu items.

This component renders a separator line between groups of menu items to create
visual organization within the dropdown menu. It uses the `separator` ARIA role
for proper accessibility.

## Attributes

* `class` - CSS classes for styling the separator

## Examples

<.dropdown_separator class="my-1 border-t border-gray-200" />

## Accessibility

The separator uses `role="separator"` which is properly announced by screen
readers as a divider between menu sections.
"""
def dropdown_separator(assigns) do
~H"""
<div role="separator" class={@class} {@rest}></div>
"""
end

attr :class, :string, default: ""
attr :rest, :global
slot :inner_block, required: true

@doc """
A container for grouping related menu items within a dropdown.

This component provides semantic grouping of related menu items with proper
ARIA structure. Use this to create logical sections within your dropdown menu.

## Attributes

* `class` - CSS classes for styling the section container

## Examples

# Basic section grouping
<.dropdown_section>
<.dropdown_item>Profile</.dropdown_item>
<.dropdown_item>Settings</.dropdown_item>
</.dropdown_section>

# Section with heading
<.dropdown_section>
<.dropdown_heading>Account</.dropdown_heading>
<.dropdown_item>Profile</.dropdown_item>
<.dropdown_item>Settings</.dropdown_item>
</.dropdown_section>

## Accessibility

The section uses `role="group"` to indicate a logical grouping of menu items
to assistive technologies. When used with a heading as the first child, the
JavaScript hook automatically establishes the `aria-labelledby` relationship
between the section and the heading.
"""
def dropdown_section(assigns) do
~H"""
<div role="group" class={@class} {@rest}>
{render_slot(@inner_block)}
</div>
"""
end

attr :id, :string, default: nil
attr :class, :string, default: ""
attr :rest, :global
slot :inner_block, required: true

@doc """
A heading for a group of menu items within a dropdown.

This component provides a semantic heading for sections of menu items with
proper ARIA labeling. When used as the first child of a `dropdown_section`,
the JavaScript hook automatically establishes the aria-labelledby relationship.

## Attributes

* `id` - Optional unique identifier for the heading. Auto-generated by the JS hook if not provided.
* `class` - CSS classes for styling the heading

## Examples

<.dropdown_section>
<.dropdown_heading>Recent Files</.dropdown_heading>
<.dropdown_item>Document.pdf</.dropdown_item>
<.dropdown_item>Spreadsheet.xlsx</.dropdown_item>
</.dropdown_section>

## Accessibility

The heading uses `role="presentation"` to prevent it from being treated as
a menu item while still providing semantic structure. When used as the first
child of a section, the JavaScript hook automatically generates an ID for the
heading and sets the section's `aria-labelledby` to reference it.
"""
def dropdown_heading(assigns) do
~H"""
<div id={@id} role="presentation" class={@class} {@rest}>
{render_slot(@inner_block)}
</div>
"""
end
end