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}
>
+
{render_slot(@inner_block)}
"""
diff --git a/mix.exs b/mix.exs
index 9242b29..556c75d 100644
--- a/mix.exs
+++ b/mix.exs
@@ -30,7 +30,7 @@ defmodule Prima.MixProject do
[
{:phoenix, ">= 1.7.0"},
{:phoenix_html, "~> 4.2"},
- {:phoenix_live_view, "~> 1.1"},
+ {:phoenix_live_view, "~> 1.2"},
{:esbuild, "~> 0.7", runtime: Mix.env() == :dev},
{:ex_doc, "~> 0.32", only: :dev, runtime: false}
]
diff --git a/mix.lock b/mix.lock
index 4b039b8..ca9f02a 100644
--- a/mix.lock
+++ b/mix.lock
@@ -39,7 +39,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.14", "cae84abc4cd00dde4bb200b8516db556704c585c267aff9cd4955ff83cceb86c", [: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", "b827980e2bc00fddd8674e3b567519a4e855b5de04bf8607140414f1101e2627"},
+ "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.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
"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"},