diff --git a/README.md b/README.md index 8e22c68..e836650 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ Prima is a Phoenix LiveView component library providing unstyled, accessible UI ## Installation +Prima requires Phoenix LiveView 1.2 or later, including the matching JavaScript client. + Add `prima` to your list of dependencies in `mix.exs`: ```elixir diff --git a/assets/js/ensure_id.js b/assets/js/ensure_id.js new file mode 100644 index 0000000..8d19243 --- /dev/null +++ b/assets/js/ensure_id.js @@ -0,0 +1,14 @@ +// Preserve server IDs and sticky client IDs when keyed LiveView nodes move. +// A new node's preferred ID may already belong to a surviving or custom element. +export function ensureId(el, preferredId, js) { + if (el.id) return el.id + + let id = preferredId + let suffix = 1 + while (el.ownerDocument.getElementById(id)) { + id = `${preferredId}-${suffix++}` + } + + js.setAttribute(el, 'id', id) + return id +} diff --git a/assets/js/hooks/combobox.js b/assets/js/hooks/combobox.js index 14c436e..1c72dc0 100644 --- a/assets/js/hooks/combobox.js +++ b/assets/js/hooks/combobox.js @@ -1,4 +1,5 @@ import { computePosition, flip, offset, autoUpdate } from '@floating-ui/dom'; +import { ensureId } from '../ensure_id'; const KEYS = { ARROW_UP: 'ArrowUp', @@ -125,10 +126,8 @@ export default { const options = this.refs.optionsContainer.querySelectorAll(SELECTORS.OPTION) options.forEach((option, index) => { - if (!option.id) { - const comboboxId = this.el.id || 'combobox' - option.id = `${comboboxId}-option-${index}` - } + const comboboxId = this.el.id || 'combobox' + ensureId(option, `${comboboxId}-option-${index}`, this.js()) }) }, diff --git a/assets/js/hooks/dropdown.js b/assets/js/hooks/dropdown.js index 540bbab..bee96cd 100644 --- a/assets/js/hooks/dropdown.js +++ b/assets/js/hooks/dropdown.js @@ -1,4 +1,5 @@ import { computePosition, flip, offset, autoUpdate } from '@floating-ui/dom'; +import { ensureId } from '../ensure_id'; const KEYS = { ARROW_UP: 'ArrowUp', @@ -349,19 +350,36 @@ export default { }, setupAriaRelationships(button, menu) { - this.js().setAttribute(button, 'aria-controls', menu.id) - this.js().setAttribute(menu, 'aria-labelledby', button.id) + const dropdownId = this.el.id + const triggerId = ensureId(button, `${dropdownId}-trigger`, this.js()) + const menuId = ensureId(menu, `${dropdownId}-menu`, this.js()) + this.js().setAttribute(button, 'aria-controls', menuId) + this.js().setAttribute(menu, 'aria-labelledby', triggerId) + + this.setupMenuitemIds() this.setupSectionLabels() }, + setupMenuitemIds() { + const dropdownId = this.el.id + const items = this.el.querySelectorAll(SELECTORS.MENUITEM) + + items.forEach((item, index) => { + ensureId(item, `${dropdownId}-item-${index}`, this.js()) + }) + }, + setupSectionLabels() { + const dropdownId = this.el.id const sections = this.el.querySelectorAll('[role="group"]') - sections.forEach((section) => { + 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 + ensureId(firstChild, `${dropdownId}-section-${sectionIndex}-heading`, this.js()) // Link the section to the heading this.js().setAttribute(section, 'aria-labelledby', firstChild.id) } diff --git a/assets/js/hooks/modal.js b/assets/js/hooks/modal.js index 79708ed..11f6a5b 100644 --- a/assets/js/hooks/modal.js +++ b/assets/js/hooks/modal.js @@ -1,3 +1,5 @@ +import { ensureId } from '../ensure_id'; + export default { mounted() { this.setupPushEventListeners() @@ -123,7 +125,7 @@ export default { this.maybeExecJS(this.ref("modal-panel"), "js-hide"); this.maybeExecJS(this.ref("modal-loader"), "js-hide"); if (this.async) { - this.ref("modal-panel").dataset.primaDirty = true + this.js().setAttribute(this.ref("modal-panel"), 'data-prima-dirty', 'true') } }, @@ -171,9 +173,7 @@ export default { if (titleElement) { // Generate ID for the title if it doesn't have one - if (!titleElement.id) { - titleElement.id = `${modalId}-title` - } + ensureId(titleElement, `${modalId}-title`, this.js()) // Set aria-labelledby on the modal container this.js().setAttribute(this.el, 'aria-labelledby', titleElement.id) diff --git a/demo/lib/demo_web/live/fixtures_live.ex b/demo/lib/demo_web/live/fixtures_live.ex index dd9856f..a328b6a 100644 --- a/demo/lib/demo_web/live/fixtures_live.ex +++ b/demo/lib/demo_web/live/fixtures_live.ex @@ -27,6 +27,7 @@ defmodule DemoWeb.FixturesLive do |> assign(submission_change: %{}) |> assign(listbox_disabled?: false, submitted_fruit: "not submitted") |> assign(trigger_label: "Open Dropdown") + |> assign(dropdown_sections: ["Apple", "Banana"]) |> assign(modal_title: "Good news") |> stream_configure(:suggestions, dom_id: &"suggestions-#{&1}") |> stream(:suggestions, []) @@ -111,6 +112,11 @@ defmodule DemoWeb.FixturesLive do {:noreply, assign(socket, trigger_label: "Updated Trigger")} end + @impl true + def handle_event("update-dropdown-items", _params, socket) do + {:noreply, assign(socket, dropdown_sections: ["Banana", "Cherry"])} + end + @impl true def handle_event("update-modal-title", _params, socket) do {:noreply, assign(socket, modal_title: "Updated Title")} diff --git a/demo/lib/demo_web/live/fixtures_live.html.heex b/demo/lib/demo_web/live/fixtures_live.html.heex index 71a7f11..232aab3 100644 --- a/demo/lib/demo_web/live/fixtures_live.html.heex +++ b/demo/lib/demo_web/live/fixtures_live.html.heex @@ -25,6 +25,10 @@ <.dropdown_sections_fixture /> +
+ <.dropdown_generated_ids_fixture dropdown_sections={@dropdown_sections} /> +
+
<.simple_modal_fixture />
diff --git a/demo/lib/demo_web/live/fixtures_live/dropdown_generated_ids_fixture.html.heex b/demo/lib/demo_web/live/fixtures_live/dropdown_generated_ids_fixture.html.heex new file mode 100644 index 0000000..7d61ab4 --- /dev/null +++ b/demo/lib/demo_web/live/fixtures_live/dropdown_generated_ids_fixture.html.heex @@ -0,0 +1,24 @@ +
+ <.dropdown id="generated-dropdown"> + <.dropdown_trigger>Open Dropdown + <.dropdown_menu> + <.dropdown_section + :for={section <- @dropdown_sections} + :key={section} + data-section={section} + > + <.dropdown_heading>{section} + <.dropdown_item data-item={section}>{section} + + <.dropdown_section> + <.dropdown_heading id="generated-dropdown-section-1-heading-1"> + Custom + + <.dropdown_item id="generated-dropdown-item-1-1" data-item="Custom"> + Custom + + + + + +
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 672c4e4..7146913 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 id="dropdown-trigger"> + <.dropdown_trigger> {@trigger_label} - <.dropdown_menu id="dropdown-menu"> - <.dropdown_item id="dropdown-item-0"> + <.dropdown_menu> + <.dropdown_item> Apple - <.dropdown_item id="dropdown-item-1"> + <.dropdown_item> Banana - <.dropdown_item id="dropdown-item-2"> + <.dropdown_item> 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 42fd710..32a2beb 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 @@ -4,7 +4,7 @@ <.dropdown_menu id="dropdown-sections-menu"> <.dropdown_section> - <.dropdown_heading id="dropdown-sections-heading-account"> + <.dropdown_heading> Account <.dropdown_item id="dropdown-sections-item-0"> @@ -18,7 +18,7 @@ <.dropdown_separator /> <.dropdown_section> - <.dropdown_heading id="dropdown-sections-heading-support"> + <.dropdown_heading> Support <.dropdown_item id="dropdown-sections-item-2"> diff --git a/demo/lib/demo_web/router.ex b/demo/lib/demo_web/router.ex index 3133647..a6137fd 100644 --- a/demo/lib/demo_web/router.ex +++ b/demo/lib/demo_web/router.ex @@ -29,6 +29,7 @@ defmodule DemoWeb.Router do live "/fixtures/dropdown-custom-ids", FixturesLive, :dropdown_custom_ids live "/fixtures/dropdown-rerender-trigger", FixturesLive, :dropdown_rerender_trigger live "/fixtures/dropdown-sections", FixturesLive, :dropdown_sections + live "/fixtures/dropdown-generated-ids", FixturesLive, :dropdown_generated_ids 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/mix.exs b/demo/mix.exs index f42a69f..2161803 100644 --- a/demo/mix.exs +++ b/demo/mix.exs @@ -40,7 +40,7 @@ defmodule Demo.MixProject do # Phoenix framework {:phoenix, ">= 1.7.0"}, {:phoenix_html, "~> 4.2"}, - {:phoenix_live_view, "~> 1.1"}, + {:phoenix_live_view, "~> 1.2"}, {:phoenix_live_reload, "~> 1.2", only: :dev}, # Assets diff --git a/demo/mix.lock b/demo/mix.lock index 64dde0c..14a3563 100644 --- a/demo/mix.lock +++ b/demo/mix.lock @@ -35,7 +35,7 @@ "phoenix": {:hex, :phoenix, "1.8.1", "865473a60a979551a4879db79fbfb4503e41cd809e77c85af79716578b6a456d", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "84d77d2b2e77c3c7e7527099bd01ef5c8560cd149c036d6b3a40745f11cd2fb2"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.1", "05df733a09887a005ed0d69a7fc619d376aea2730bf64ce52ac51ce716cc1ef0", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "74273843d5a6e4fef0bbc17599f33e3ec63f08e69215623a0cd91eea4288e5a0"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.16", "e42f95337b912a73a1c4ddb077af2eb13491712d7ab79b67e13de4237dfcac50", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f2a0093895b8ef4880af76d41de4a9cf7cff6c66ad130e15a70bdabc4d279feb"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.2.12", "35848150bbab579e9d0aff79525269d7c0801a4f61232b84f635c11222cee88f", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "656810d716e3369545dd63981196a5d68b77fdb253afe02ef0c6fa14cfd8dc2b"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, diff --git a/demo/priv/code_examples/combobox/positioned.html.heex b/demo/priv/code_examples/combobox/positioned.html.heex index 57911a4..cb33532 100644 --- a/demo/priv/code_examples/combobox/positioned.html.heex +++ b/demo/priv/code_examples/combobox/positioned.html.heex @@ -41,8 +41,8 @@
- - + +
<.combobox class="w-64" id="right-combobox" name="right_color"> @@ -85,8 +85,8 @@
- - + +
<.combobox class="w-64" id="offset-combobox" name="offset_country"> diff --git a/demo/priv/code_examples/dropdown/positioned.html.heex b/demo/priv/code_examples/dropdown/positioned.html.heex index 04ba8f5..6fad943 100644 --- a/demo/priv/code_examples/dropdown/positioned.html.heex +++ b/demo/priv/code_examples/dropdown/positioned.html.heex @@ -46,8 +46,8 @@ - - + + <.dropdown id="right-dropdown"> <.dropdown_trigger id="right-dropdown-trigger" @@ -89,8 +89,8 @@ - - + + <.dropdown id="bottom-dropdown"> <.dropdown_trigger id="bottom-dropdown-trigger" diff --git a/demo/test/wallaby/demo_web/dropdown_generated_ids_test.exs b/demo/test/wallaby/demo_web/dropdown_generated_ids_test.exs new file mode 100644 index 0000000..52501ed --- /dev/null +++ b/demo/test/wallaby/demo_web/dropdown_generated_ids_test.exs @@ -0,0 +1,70 @@ +defmodule DemoWeb.DropdownGeneratedIdsTest do + use Prima.WallabyCase, async: true + + @root "#generated-dropdown" + @trigger Query.css("#{@root} [aria-haspopup=menu]") + + feature "generated IDs and ARIA relationships survive a keyed removal and insertion", %{ + session: session + } do + session + |> visit_fixture("/fixtures/dropdown-generated-ids", @root) + |> click(@trigger) + |> assert_relationships() + |> execute_script(""" + const section = document.querySelector('[data-section="Banana"]'); + window.retainedItem = section.querySelector('[role=menuitem]'); + window.retainedHeading = section.querySelector('[role=presentation]'); + window.retainedIds = [window.retainedItem.id, window.retainedHeading.id]; + """) + |> click(Query.css("#update-items")) + |> click(@trigger) + |> assert_has(Query.css("#{@root} [data-item=Cherry]")) + |> assert_missing(Query.css("#{@root} [data-item=Apple]")) + |> execute_script( + """ + const section = document.querySelector('[data-section="Banana"]'); + const item = section.querySelector('[role=menuitem]'); + const heading = section.querySelector('[role=presentation]'); + return item === window.retainedItem && heading === window.retainedHeading && + item.id === window.retainedIds[0] && heading.id === window.retainedIds[1]; + """, + fn preserved -> assert preserved end + ) + |> assert_relationships() + |> send_keys([:down_arrow, :down_arrow]) + |> assert_has(Query.css("#{@root} [data-item=Cherry][data-focus]")) + |> execute_script( + """ + const menu = document.querySelector('#generated-dropdown [role=menu]'); + const active = document.getElementById(menu.getAttribute('aria-activedescendant')); + return active && active.dataset.item === 'Cherry'; + """, + fn correct_item -> assert correct_item end + ) + end + + defp assert_relationships(session) do + execute_script( + session, + """ + const root = document.querySelector('#generated-dropdown'); + const trigger = root.querySelector('[aria-haspopup=menu]'); + const menu = root.querySelector('[role=menu]'); + const items = [...root.querySelectorAll('[role=menuitem]')]; + const sections = [...root.querySelectorAll('[role=group]')]; + const ids = [...document.querySelectorAll('[id]')].map(el => el.id); + return new Set(ids).size === ids.length && + items.every(item => item.id) && + trigger.getAttribute('aria-controls') === menu.id && + menu.getAttribute('aria-labelledby') === trigger.id && + sections.every(section => + document.getElementById(section.getAttribute('aria-labelledby')) === + section.querySelector('[role=presentation]')) && + document.getElementById('generated-dropdown-item-1-1').dataset.item === 'Custom' && + document.getElementById('generated-dropdown-section-1-heading-1').textContent.trim() === 'Custom'; + """, + fn valid -> assert valid end + ) + end +end diff --git a/demo/test/wallaby/demo_web/dropdown_sections_test.exs b/demo/test/wallaby/demo_web/dropdown_sections_test.exs index 25ee87f..fb3e569 100644 --- a/demo/test/wallaby/demo_web/dropdown_sections_test.exs +++ b/demo/test/wallaby/demo_web/dropdown_sections_test.exs @@ -29,30 +29,65 @@ defmodule DemoWeb.DropdownSectionsTest do ) end - feature "sections are automatically labeled by headings via JS hook", %{session: session} do + feature "generates IDs for section headings and establishes aria-labelledby relationships", %{ + session: session + } do session |> visit_fixture("/fixtures/dropdown-sections", "#dropdown-sections") |> click(@dropdown_button) - # Check that both sections have aria-labelledby attributes pointing to their headings + # Verify that headings have auto-generated IDs following the pattern: {dropdown-id}-section-{index}-heading |> assert_has( Query.css( - "#dropdown-sections [role=group][aria-labelledby='dropdown-sections-heading-account']", + "#dropdown-sections [role=presentation]#dropdown-sections-section-0-heading", count: 1 ) ) |> assert_has( Query.css( - "#dropdown-sections [role=group][aria-labelledby='dropdown-sections-heading-support']", + "#dropdown-sections [role=presentation]#dropdown-sections-section-1-heading", count: 1 ) ) - # Verify the first section's heading ID matches its aria-labelledby - |> execute_script(""" + # Verify that sections have aria-labelledby pointing to the auto-generated heading IDs + |> assert_has( + Query.css( + "#dropdown-sections [role=group][aria-labelledby='dropdown-sections-section-0-heading']", + count: 1 + ) + ) + |> assert_has( + Query.css( + "#dropdown-sections [role=group][aria-labelledby='dropdown-sections-section-1-heading']", + count: 1 + ) + ) + # Verify the first section's heading ID matches its aria-labelledby and contains correct text + |> 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'; - """) + return heading && + heading.getAttribute('role') === 'presentation' && + labelId === 'dropdown-sections-section-0-heading' && + heading.textContent.trim().includes('Account'); + """, + fn valid -> assert valid end + ) + # Verify the second section's heading ID and text + |> execute_script( + """ + const sections = document.querySelectorAll('#dropdown-sections [role=group]'); + const secondSection = sections[1]; + const labelId = secondSection.getAttribute('aria-labelledby'); + const heading = document.getElementById(labelId); + return heading && + heading.getAttribute('role') === 'presentation' && + labelId === 'dropdown-sections-section-1-heading' && + heading.textContent.trim().includes('Support'); + """, + fn valid -> assert valid end + ) end feature "keyboard navigation skips headings and separators", %{session: session} do diff --git a/lib/prima/dropdown.ex b/lib/prima/dropdown.ex index 9a383af..8118a3f 100644 --- a/lib/prima/dropdown.ex +++ b/lib/prima/dropdown.ex @@ -15,7 +15,6 @@ defmodule Prima.Dropdown do """ end - attr :id, :string, required: true attr :class, :string, default: "" attr :as, :any, default: nil attr :rest, :global @@ -30,7 +29,6 @@ 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 @@ -74,7 +72,6 @@ defmodule Prima.Dropdown do def dropdown_trigger(assigns) do assigns = assign(assigns, %{ - id: assigns.id, "aria-haspopup": "menu", "aria-expanded": "false" }) @@ -82,7 +79,6 @@ 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: "" @@ -120,7 +116,6 @@ defmodule Prima.Dropdown do data-match-trigger-width={@match_trigger_width} >