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..d0ff1ff
--- /dev/null
+++ b/demo/test/wallaby/demo_web/dropdown_sections_test.exs
@@ -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
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"""
+