diff --git a/assets/js/hooks/dropdown.js b/assets/js/hooks/dropdown.js index 2dad03c..8bbc3bb 100644 --- a/assets/js/hooks/dropdown.js +++ b/assets/js/hooks/dropdown.js @@ -333,6 +333,7 @@ export default { menu.setAttribute('aria-labelledby', triggerId) this.setupMenuitemIds() + this.setupSectionLabels() }, setupMenuitemIds() { @@ -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 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 b17d727..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,6 +51,21 @@ 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. +

+ +
+ <.code_example file="dropdown/sections.html.heex" /> +
+ +

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

+

Styling

Highlighting Active Items

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/lib/demo_web/live/fixtures_live/dropdown_sections_fixture.html.heex b/demo/lib/demo_web/live/fixtures_live/dropdown_sections_fixture.html.heex new file mode 100644 index 0000000..3c885d0 --- /dev/null +++ b/demo/lib/demo_web/live/fixtures_live/dropdown_sections_fixture.html.heex @@ -0,0 +1,40 @@ +<.dropdown id="dropdown-sections"> + <.dropdown_trigger as={&button/1}> + Account Menu + + <.dropdown_menu> + <.dropdown_section> + <.dropdown_heading> + Account + + <.dropdown_item> + Profile + + <.dropdown_item> + Settings + + + + <.dropdown_separator /> + + <.dropdown_section> + <.dropdown_heading> + Support + + <.dropdown_item> + Documentation + + <.dropdown_item> + Contact Us + + + + <.dropdown_separator /> + + <.dropdown_item> + Sign Out + + + + +
Outside area
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""" + + """ + end end