diff --git a/demo/lib/demo_web/router.ex b/demo/lib/demo_web/router.ex
index 798f03f..e427f45 100644
--- a/demo/lib/demo_web/router.ex
+++ b/demo/lib/demo_web/router.ex
@@ -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
diff --git a/demo/priv/code_examples/dropdown/sections.html.heex b/demo/priv/code_examples/dropdown/sections.html.heex
new file mode 100644
index 0000000..3547ae7
--- /dev/null
+++ b/demo/priv/code_examples/dropdown/sections.html.heex
@@ -0,0 +1,50 @@
+<.dropdown id="sections-dropdown">
+ <.dropdown_trigger as={&button/1}>
+ Account Menu
+
+
+ <.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_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 class="text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 block px-4 py-2 text-sm rounded">
+ Settings
+
+
+
+ <.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_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 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_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
+
+
+
diff --git a/demo/test/wallaby/demo_web/dropdown_sections_test.exs b/demo/test/wallaby/demo_web/dropdown_sections_test.exs
new file mode 100644
index 0000000..ada5bd8
--- /dev/null
+++ b/demo/test/wallaby/demo_web/dropdown_sections_test.exs
@@ -0,0 +1,87 @@
+defmodule DemoWeb.DropdownSectionsTest do
+ use Prima.WallabyCase, async: true
+
+ @dropdown_container Query.css("#dropdown-sections")
+ @dropdown_button Query.css("#dropdown-sections [aria-haspopup=menu]")
+ @dropdown_menu Query.css("#dropdown-sections [role=menu]")
+ @dropdown_items Query.css("#dropdown-sections [role=menuitem]")
+
+ 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))
+ 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 [role=menuitem]:nth-of-type(1)[data-focus]"))
+ |> send_keys([:down_arrow])
+ |> assert_has(Query.css("#dropdown-sections [role=menuitem]:nth-of-type(2)[data-focus]"))
+ |> send_keys([:down_arrow])
+ |> assert_has(Query.css("#dropdown-sections [role=menuitem]:nth-of-type(3)[data-focus]"))
+ |> send_keys([:down_arrow])
+ |> assert_has(Query.css("#dropdown-sections [role=menuitem]:nth-of-type(4)[data-focus]"))
+ |> send_keys([:down_arrow])
+ |> assert_has(Query.css("#dropdown-sections [role=menuitem]:nth-of-type(5)[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
diff --git a/lib/prima/dropdown.ex b/lib/prima/dropdown.ex
index 9c19897..88bcd68 100644
--- a/lib/prima/dropdown.ex
+++ b/lib/prima/dropdown.ex
@@ -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"""
+
+ """
+ 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>Settings
+
+
+ # Section with heading
+ <.dropdown_section>
+ <.dropdown_heading>Account
+ <.dropdown_item>Profile
+ <.dropdown_item>Settings
+
+
+ ## 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"""
+
+ {render_slot(@inner_block)}
+
+ """
+ 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_item>Document.pdf
+ <.dropdown_item>Spreadsheet.xlsx
+
+
+ ## 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"""
+
+ {render_slot(@inner_block)}
+
+ """
+ end
end
From 18dc57ea69e9c1280f6e6b90bca12f8ad22d8961 Mon Sep 17 00:00:00 2001
From: Uku Taht
Date: Thu, 27 Nov 2025 17:22:55 +0200
Subject: [PATCH 2/3] mix format
---
demo/lib/demo_web/live/demo_live/dropdown_page.html.heex | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/demo/lib/demo_web/live/demo_live/dropdown_page.html.heex b/demo/lib/demo_web/live/demo_live/dropdown_page.html.heex
index 350a64a..7b0082d 100644
--- a/demo/lib/demo_web/live/demo_live/dropdown_page.html.heex
+++ b/demo/lib/demo_web/live/demo_live/dropdown_page.html.heex
@@ -51,17 +51,20 @@
variant to style disabled items differently, such as reducing opacity or changing text color.
-
Sections
- Organize complex dropdown menus using .dropdown_section, .dropdown_heading, and .dropdown_separator components.
+ Organize complex dropdown menus using .dropdown_section, .dropdown_heading, and
+ .dropdown_separator
+ components.
Sections provide semantic grouping with proper ARIA labels, headings label each section, and separators create visual divisions between groups.
+
+ Sections provide semantic grouping with proper ARIA labels, headings label each section, and separators create visual divisions between groups.
+
Styling
From 0c3b88b644d1c19db3e18c300044bd7d701627fb Mon Sep 17 00:00:00 2001
From: Uku Taht
Date: Thu, 27 Nov 2025 17:48:49 +0200
Subject: [PATCH 3/3] fix: dropdown sections fixture loading and test selectors
The dropdown sections tests were failing because the fixture template
was not wired up in the main template file. Additionally, the tests
had incorrect selectors for keyboard navigation and separator visibility.
Changes:
- Add dropdown_sections entry to fixtures_live.html.heex
- Remove unused @dropdown_container module attribute
- Fix separator test to handle invisible elements with Query.visible(false)
- Replace :nth-of-type selectors with specific IDs for keyboard navigation
---
demo/lib/demo_web/live/fixtures_live.html.heex | 4 ++++
.../wallaby/demo_web/dropdown_sections_test.exs | 17 +++++++++--------
2 files changed, 13 insertions(+), 8 deletions(-)
diff --git a/demo/lib/demo_web/live/fixtures_live.html.heex b/demo/lib/demo_web/live/fixtures_live.html.heex
index dd78cf3..0790175 100644
--- a/demo/lib/demo_web/live/fixtures_live.html.heex
+++ b/demo/lib/demo_web/live/fixtures_live.html.heex
@@ -14,6 +14,10 @@
<.dropdown_rerender_trigger_fixture {assigns} />
+
+ <.dropdown_sections_fixture />
+
+
<.simple_modal_fixture />
diff --git a/demo/test/wallaby/demo_web/dropdown_sections_test.exs b/demo/test/wallaby/demo_web/dropdown_sections_test.exs
index ada5bd8..d0ff1ff 100644
--- a/demo/test/wallaby/demo_web/dropdown_sections_test.exs
+++ b/demo/test/wallaby/demo_web/dropdown_sections_test.exs
@@ -1,10 +1,8 @@
defmodule DemoWeb.DropdownSectionsTest do
use Prima.WallabyCase, async: true
- @dropdown_container Query.css("#dropdown-sections")
@dropdown_button Query.css("#dropdown-sections [aria-haspopup=menu]")
@dropdown_menu Query.css("#dropdown-sections [role=menu]")
- @dropdown_items Query.css("#dropdown-sections [role=menuitem]")
feature "renders sections with proper ARIA roles", %{session: session} do
session
@@ -25,7 +23,10 @@ defmodule DemoWeb.DropdownSectionsTest do
session
|> visit_fixture("/fixtures/dropdown-sections", "#dropdown-sections")
|> click(@dropdown_button)
- |> assert_has(Query.css("#dropdown-sections [role=separator]", count: 2))
+ |> 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
@@ -54,15 +55,15 @@ defmodule DemoWeb.DropdownSectionsTest do
|> click(@dropdown_button)
|> assert_has(@dropdown_menu |> Query.visible(true))
|> send_keys([:down_arrow])
- |> assert_has(Query.css("#dropdown-sections [role=menuitem]:nth-of-type(1)[data-focus]"))
+ |> assert_has(Query.css("#dropdown-sections-item-0[data-focus]"))
|> send_keys([:down_arrow])
- |> assert_has(Query.css("#dropdown-sections [role=menuitem]:nth-of-type(2)[data-focus]"))
+ |> assert_has(Query.css("#dropdown-sections-item-1[data-focus]"))
|> send_keys([:down_arrow])
- |> assert_has(Query.css("#dropdown-sections [role=menuitem]:nth-of-type(3)[data-focus]"))
+ |> assert_has(Query.css("#dropdown-sections-item-2[data-focus]"))
|> send_keys([:down_arrow])
- |> assert_has(Query.css("#dropdown-sections [role=menuitem]:nth-of-type(4)[data-focus]"))
+ |> assert_has(Query.css("#dropdown-sections-item-3[data-focus]"))
|> send_keys([:down_arrow])
- |> assert_has(Query.css("#dropdown-sections [role=menuitem]:nth-of-type(5)[data-focus]"))
+ |> assert_has(Query.css("#dropdown-sections-item-4[data-focus]"))
end
feature "Home key navigates to first menu item, skipping headings", %{session: session} do