diff --git a/assets/js/hooks/dropdown.js b/assets/js/hooks/dropdown.js index 476f09b..0b0c793 100644 --- a/assets/js/hooks/dropdown.js +++ b/assets/js/hooks/dropdown.js @@ -330,42 +330,19 @@ export default { }, setupAriaRelationships(button, menu) { - const dropdownId = this.el.id - const triggerId = button.id || `${dropdownId}-trigger` - const menuId = menu.id || `${dropdownId}-menu` + button.setAttribute('aria-controls', menu.id) + menu.setAttribute('aria-labelledby', button.id) - if (!button.id) button.id = triggerId - button.setAttribute('aria-controls', menuId) - if (!menu.id) menu.id = menuId - menu.setAttribute('aria-labelledby', triggerId) - - this.setupMenuitemIds() this.setupSectionLabels() }, - setupMenuitemIds() { - const dropdownId = this.el.id - const items = this.el.querySelectorAll(SELECTORS.MENUITEM) - - items.forEach((item, index) => { - if (!item.id) { - item.id = `${dropdownId}-item-${index}` - } - }) - }, - setupSectionLabels() { - const dropdownId = this.el.id const sections = this.el.querySelectorAll('[role="group"]') - sections.forEach((section, sectionIndex) => { + sections.forEach((section) => { // 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) } diff --git a/demo/lib/demo_web/live/fixtures_live/dropdown_custom_components_fixture.html.heex b/demo/lib/demo_web/live/fixtures_live/dropdown_custom_components_fixture.html.heex index 5f2f81a..ce4bc8b 100644 --- a/demo/lib/demo_web/live/fixtures_live/dropdown_custom_components_fixture.html.heex +++ b/demo/lib/demo_web/live/fixtures_live/dropdown_custom_components_fixture.html.heex @@ -1,16 +1,20 @@
<.dropdown id="dropdown-custom"> - <.dropdown_trigger as={&custom_button/1} data-custom-attr="test-value"> + <.dropdown_trigger + id="dropdown-custom-trigger" + as={&custom_button/1} + data-custom-attr="test-value" + > Open Dropdown - <.dropdown_menu> - <.dropdown_item> + <.dropdown_menu id="dropdown-custom-menu"> + <.dropdown_item id="dropdown-custom-item-0"> Regular Item - <.dropdown_item as={&link/1} navigate={~p"/"}> + <.dropdown_item id="dropdown-custom-item-1" as={&link/1} navigate={~p"/"}> Link Item - <.dropdown_item disabled={true} as={&link/1} href="#disabled"> + <.dropdown_item id="dropdown-custom-item-2" disabled={true} as={&link/1} href="#disabled"> Disabled Link diff --git a/demo/lib/demo_web/live/fixtures_live/dropdown_custom_ids_fixture.html.heex b/demo/lib/demo_web/live/fixtures_live/dropdown_custom_ids_fixture.html.heex index ebc395b..d710098 100644 --- a/demo/lib/demo_web/live/fixtures_live/dropdown_custom_ids_fixture.html.heex +++ b/demo/lib/demo_web/live/fixtures_live/dropdown_custom_ids_fixture.html.heex @@ -10,7 +10,7 @@ <.dropdown_item id="my-item-2"> Banana - <.dropdown_item> + <.dropdown_item id="dropdown-custom-ids-item-2"> Cherry diff --git a/demo/lib/demo_web/live/fixtures_live/dropdown_fixture.html.heex b/demo/lib/demo_web/live/fixtures_live/dropdown_fixture.html.heex index 03f63aa..1204f39 100644 --- a/demo/lib/demo_web/live/fixtures_live/dropdown_fixture.html.heex +++ b/demo/lib/demo_web/live/fixtures_live/dropdown_fixture.html.heex @@ -1,19 +1,19 @@
<.dropdown id="dropdown"> - <.dropdown_trigger> + <.dropdown_trigger id="dropdown-trigger"> Open Dropdown - <.dropdown_menu> - <.dropdown_item> + <.dropdown_menu id="dropdown-menu"> + <.dropdown_item id="dropdown-item-0"> Apple - <.dropdown_item> + <.dropdown_item id="dropdown-item-1"> Banana - <.dropdown_item> + <.dropdown_item id="dropdown-item-2"> Cherry - <.dropdown_item> + <.dropdown_item id="dropdown-item-3"> Apricot diff --git a/demo/lib/demo_web/live/fixtures_live/dropdown_rerender_trigger_fixture.html.heex b/demo/lib/demo_web/live/fixtures_live/dropdown_rerender_trigger_fixture.html.heex index 7146913..672c4e4 100644 --- a/demo/lib/demo_web/live/fixtures_live/dropdown_rerender_trigger_fixture.html.heex +++ b/demo/lib/demo_web/live/fixtures_live/dropdown_rerender_trigger_fixture.html.heex @@ -1,16 +1,16 @@
<.dropdown id="dropdown"> - <.dropdown_trigger> + <.dropdown_trigger id="dropdown-trigger"> {@trigger_label} - <.dropdown_menu> - <.dropdown_item> + <.dropdown_menu id="dropdown-menu"> + <.dropdown_item id="dropdown-item-0"> Apple - <.dropdown_item> + <.dropdown_item id="dropdown-item-1"> Banana - <.dropdown_item> + <.dropdown_item id="dropdown-item-2"> Cherry 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 index 3c885d0..42fd710 100644 --- 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 @@ -1,16 +1,16 @@ <.dropdown id="dropdown-sections"> - <.dropdown_trigger as={&button/1}> + <.dropdown_trigger id="dropdown-sections-trigger" as={&button/1}> Account Menu - <.dropdown_menu> + <.dropdown_menu id="dropdown-sections-menu"> <.dropdown_section> - <.dropdown_heading> + <.dropdown_heading id="dropdown-sections-heading-account"> Account - <.dropdown_item> + <.dropdown_item id="dropdown-sections-item-0"> Profile - <.dropdown_item> + <.dropdown_item id="dropdown-sections-item-1"> Settings @@ -18,20 +18,20 @@ <.dropdown_separator /> <.dropdown_section> - <.dropdown_heading> + <.dropdown_heading id="dropdown-sections-heading-support"> Support - <.dropdown_item> + <.dropdown_item id="dropdown-sections-item-2"> Documentation - <.dropdown_item> + <.dropdown_item id="dropdown-sections-item-3"> Contact Us <.dropdown_separator /> - <.dropdown_item> + <.dropdown_item id="dropdown-sections-item-4"> Sign Out diff --git a/demo/lib/demo_web/live/fixtures_live/dropdown_with_disabled_fixture.html.heex b/demo/lib/demo_web/live/fixtures_live/dropdown_with_disabled_fixture.html.heex index 422aaa8..8b4f425 100644 --- a/demo/lib/demo_web/live/fixtures_live/dropdown_with_disabled_fixture.html.heex +++ b/demo/lib/demo_web/live/fixtures_live/dropdown_with_disabled_fixture.html.heex @@ -1,16 +1,16 @@
<.dropdown id="dropdown-with-disabled"> - <.dropdown_trigger> + <.dropdown_trigger id="dropdown-with-disabled-trigger"> Open Dropdown - <.dropdown_menu> - <.dropdown_item> + <.dropdown_menu id="dropdown-with-disabled-menu"> + <.dropdown_item id="dropdown-with-disabled-item-0"> Enabled Item 1 - <.dropdown_item disabled={true}> + <.dropdown_item id="dropdown-with-disabled-item-1" disabled={true}> Disabled Item - <.dropdown_item> + <.dropdown_item id="dropdown-with-disabled-item-2"> Enabled Item 2 diff --git a/demo/priv/code_examples/dropdown/basic.html.heex b/demo/priv/code_examples/dropdown/basic.html.heex index 31ea291..c6a16de 100644 --- a/demo/priv/code_examples/dropdown/basic.html.heex +++ b/demo/priv/code_examples/dropdown/basic.html.heex @@ -1,5 +1,5 @@ <.dropdown id="basic-dropdown"> - <.dropdown_trigger as={&button/1}> + <.dropdown_trigger id="basic-dropdown-trigger" as={&button/1}> Open Dropdown - <.dropdown_menu class="w-56 py-1 rounded-md bg-white shadow-xs ring-1 ring-gray-300 focus:outline-none"> + <.dropdown_menu + id="basic-dropdown-menu" + class="w-56 py-1 rounded-md bg-white shadow-xs ring-1 ring-gray-300 focus:outline-none" + > <.dropdown_item - :for={option <- ["Account settings", "Billing", "Documentation"]} + :for={{option, index} <- Enum.with_index(["Account settings", "Billing", "Documentation"])} + id={"basic-dropdown-item-#{index}"} class="text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 block px-4 py-2 text-sm" > {option} diff --git a/demo/priv/code_examples/dropdown/disabled.html.heex b/demo/priv/code_examples/dropdown/disabled.html.heex index a108577..b92241f 100644 --- a/demo/priv/code_examples/dropdown/disabled.html.heex +++ b/demo/priv/code_examples/dropdown/disabled.html.heex @@ -1,5 +1,5 @@ <.dropdown id="disabled-demo-dropdown"> - <.dropdown_trigger as={&button/1}> + <.dropdown_trigger id="disabled-demo-trigger" as={&button/1}> Open Dropdown - <.dropdown_menu class="w-56 py-1 rounded-md bg-white shadow-xs ring-1 ring-gray-300 focus:outline-none"> - <.dropdown_item class="text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 block px-4 py-2 text-sm"> + <.dropdown_menu + id="disabled-demo-menu" + class="w-56 py-1 rounded-md bg-white shadow-xs ring-1 ring-gray-300 focus:outline-none" + > + <.dropdown_item + id="disabled-demo-item-0" + class="text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 block px-4 py-2 text-sm" + > Edit Profile <.dropdown_item + id="disabled-demo-item-1" disabled={true} class="text-gray-400 data-disabled:opacity-50 block px-4 py-2 text-sm" > Archive (disabled) - <.dropdown_item class="text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 block px-4 py-2 text-sm"> + <.dropdown_item + id="disabled-demo-item-2" + class="text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 block px-4 py-2 text-sm" + > Settings - <.dropdown_item class="text-red-700 data-focus:bg-red-100 data-focus:text-red-900 block px-4 py-2 text-sm"> + <.dropdown_item + id="disabled-demo-item-3" + class="text-red-700 data-focus:bg-red-100 data-focus:text-red-900 block px-4 py-2 text-sm" + > Sign Out diff --git a/demo/priv/code_examples/dropdown/positioned.html.heex b/demo/priv/code_examples/dropdown/positioned.html.heex index d3b4176..8fd157c 100644 --- a/demo/priv/code_examples/dropdown/positioned.html.heex +++ b/demo/priv/code_examples/dropdown/positioned.html.heex @@ -1,7 +1,10 @@
<.dropdown id="top-dropdown"> - <.dropdown_trigger class="inline-flex justify-center rounded-lg bg-zinc-900 gap-x-1.5 px-3 py-2 text-sm font-semibold text-white hover:bg-zinc-700 active:text-white/80"> + <.dropdown_trigger + id="top-dropdown-trigger" + class="inline-flex justify-center rounded-lg bg-zinc-900 gap-x-1.5 px-3 py-2 text-sm font-semibold text-white hover:bg-zinc-700 active:text-white/80" + > Top Menu <.dropdown_menu + id="top-dropdown-menu" placement="top-start" class="w-48 py-1rounded-md bg-white shadow-xs ring-1 ring-gray-300 focus:outline-none" transition_enter={ @@ -28,10 +32,16 @@ "transform opacity-0 scale-95"} } > - <.dropdown_item class="text-gray-700 data-focus:bg-blue-100 data-focus:text-blue-900 block px-4 py-2 text-sm"> + <.dropdown_item + id="top-dropdown-item-0" + class="text-gray-700 data-focus:bg-blue-100 data-focus:text-blue-900 block px-4 py-2 text-sm" + > Option A - <.dropdown_item class="text-gray-700 data-focus:bg-blue-100 data-focus:text-blue-900 block px-4 py-2 text-sm"> + <.dropdown_item + id="top-dropdown-item-1" + class="text-gray-700 data-focus:bg-blue-100 data-focus:text-blue-900 block px-4 py-2 text-sm" + > Option B @@ -39,7 +49,10 @@ <.dropdown id="right-dropdown"> - <.dropdown_trigger class="inline-flex justify-center rounded-lg bg-zinc-900 gap-x-1.5 px-3 py-2 text-sm font-semibold text-white hover:bg-zinc-700 active:text-white/80"> + <.dropdown_trigger + id="right-dropdown-trigger" + class="inline-flex justify-center rounded-lg bg-zinc-900 gap-x-1.5 px-3 py-2 text-sm font-semibold text-white hover:bg-zinc-700 active:text-white/80" + > Right Menu
- <.dropdown_item class="text-gray-700 data-focus:bg-indigo-100 data-focus:text-indigo-900 block px-4 py-2 text-sm transition-colors duration-150"> + <.dropdown_item + id="styled-dropdown-item-0" + class="text-gray-700 data-focus:bg-indigo-100 data-focus:text-indigo-900 block px-4 py-2 text-sm transition-colors duration-150" + > Edit Profile - <.dropdown_item class="text-gray-700 data-focus:bg-indigo-100 data-focus:text-indigo-900 block px-4 py-2 text-sm transition-colors duration-150"> + <.dropdown_item + id="styled-dropdown-item-1" + class="text-gray-700 data-focus:bg-indigo-100 data-focus:text-indigo-900 block px-4 py-2 text-sm transition-colors duration-150" + > Settings - <.dropdown_item class="text-gray-700 data-focus:bg-indigo-100 data-focus:text-indigo-900 block px-4 py-2 text-sm transition-colors duration-150"> + <.dropdown_item + id="styled-dropdown-item-2" + class="text-gray-700 data-focus:bg-indigo-100 data-focus:text-indigo-900 block px-4 py-2 text-sm transition-colors duration-150" + >
- <.dropdown_item class="text-gray-700 data-focus:bg-red-100 data-focus:text-red-900 block px-4 py-2 text-sm transition-colors duration-150"> + <.dropdown_item + id="styled-dropdown-item-3" + class="text-gray-700 data-focus:bg-red-100 data-focus:text-red-900 block px-4 py-2 text-sm transition-colors duration-150" + > visit_fixture("/fixtures/dropdown-sections", "#dropdown-sections") |> click(@dropdown_button) - # Check that both sections have aria-labelledby attributes (auto-generated by JS) + # Check that both sections have aria-labelledby attributes pointing to their headings |> assert_has( Query.css( - "#dropdown-sections [role=group][aria-labelledby^='dropdown-sections-section-']", - count: 2 + "#dropdown-sections [role=group][aria-labelledby='dropdown-sections-heading-account']", + count: 1 + ) + ) + |> assert_has( + Query.css( + "#dropdown-sections [role=group][aria-labelledby='dropdown-sections-heading-support']", + count: 1 ) ) # Verify the first section's heading ID matches its aria-labelledby diff --git a/lib/prima/dropdown.ex b/lib/prima/dropdown.ex index 4282e53..ea111ac 100644 --- a/lib/prima/dropdown.ex +++ b/lib/prima/dropdown.ex @@ -3,7 +3,7 @@ defmodule Prima.Dropdown do import Prima.Component, only: [render_as: 2] alias Phoenix.LiveView.JS - attr :id, :string, default: "" + attr :id, :string, required: true attr :rest, :global slot :inner_block, required: true @@ -15,6 +15,7 @@ defmodule Prima.Dropdown do """ end + attr :id, :string, required: true attr :class, :string, default: "" attr :as, :any, default: nil attr :rest, :global @@ -29,18 +30,19 @@ defmodule Prima.Dropdown do ## Attributes + * `id` - Unique identifier for the trigger, required for ARIA relationships * `class` - CSS classes for styling the trigger * `as` - Custom function component to render instead of the default button element ## Examples # Basic trigger - <.dropdown_trigger> + <.dropdown_trigger id="my-dropdown-trigger"> Open Menu # With custom component - <.dropdown_trigger as={&my_custom_button/1}> + <.dropdown_trigger id="my-dropdown-trigger" as={&my_custom_button/1}> Open Menu @@ -72,6 +74,7 @@ defmodule Prima.Dropdown do def dropdown_trigger(assigns) do assigns = assign(assigns, %{ + id: assigns.id, "aria-haspopup": "menu", "aria-expanded": "false" }) @@ -79,6 +82,7 @@ defmodule Prima.Dropdown do render_as(assigns, %{tag_name: "button", type: "button"}) end + attr :id, :string, required: true attr :transition_enter, :any, default: nil attr :transition_leave, :any, default: nil attr :class, :string, default: "" @@ -113,6 +117,7 @@ defmodule Prima.Dropdown do data-offset={@offset} >