Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions assets/js/ensure_id.js
Original file line number Diff line number Diff line change
@@ -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
}
7 changes: 3 additions & 4 deletions assets/js/hooks/combobox.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { computePosition, flip, offset, autoUpdate } from '@floating-ui/dom';
import { ensureId } from '../ensure_id';

const KEYS = {
ARROW_UP: 'ArrowUp',
Expand Down Expand Up @@ -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())
})
},

Expand Down
24 changes: 21 additions & 3 deletions assets/js/hooks/dropdown.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { computePosition, flip, offset, autoUpdate } from '@floating-ui/dom';
import { ensureId } from '../ensure_id';

const KEYS = {
ARROW_UP: 'ArrowUp',
Expand Down Expand Up @@ -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)
}
Expand Down
8 changes: 4 additions & 4 deletions assets/js/hooks/modal.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ensureId } from '../ensure_id';

export default {
mounted() {
this.setupPushEventListeners()
Expand Down Expand Up @@ -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')
}
},

Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions demo/lib/demo_web/live/fixtures_live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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, [])
Expand Down Expand Up @@ -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")}
Expand Down
4 changes: 4 additions & 0 deletions demo/lib/demo_web/live/fixtures_live.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
<.dropdown_sections_fixture />
</div>

<div :if={@live_action == :dropdown_generated_ids}>
<.dropdown_generated_ids_fixture dropdown_sections={@dropdown_sections} />
</div>

<div :if={@live_action == :simple_modal}>
<.simple_modal_fixture />
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<div>
<.dropdown id="generated-dropdown">
<.dropdown_trigger>Open Dropdown</.dropdown_trigger>
<.dropdown_menu>
<.dropdown_section
:for={section <- @dropdown_sections}
:key={section}
data-section={section}
>
<.dropdown_heading>{section}</.dropdown_heading>
<.dropdown_item data-item={section}>{section}</.dropdown_item>
</.dropdown_section>
<.dropdown_section>
<.dropdown_heading id="generated-dropdown-section-1-heading-1">
Custom
</.dropdown_heading>
<.dropdown_item id="generated-dropdown-item-1-1" data-item="Custom">
Custom
</.dropdown_item>
</.dropdown_section>
</.dropdown_menu>
</.dropdown>
<button id="update-items" phx-click="update-dropdown-items">Update items</button>
</div>
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
<div>
<.dropdown id="dropdown">
<.dropdown_trigger id="dropdown-trigger">
<.dropdown_trigger>
{@trigger_label}
</.dropdown_trigger>
<.dropdown_menu id="dropdown-menu">
<.dropdown_item id="dropdown-item-0">
<.dropdown_menu>
<.dropdown_item>
Apple
</.dropdown_item>
<.dropdown_item id="dropdown-item-1">
<.dropdown_item>
Banana
</.dropdown_item>
<.dropdown_item id="dropdown-item-2">
<.dropdown_item>
Cherry
</.dropdown_item>
</.dropdown_menu>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
</.dropdown_trigger>
<.dropdown_menu id="dropdown-sections-menu">
<.dropdown_section>
<.dropdown_heading id="dropdown-sections-heading-account">
<.dropdown_heading>
Account
</.dropdown_heading>
<.dropdown_item id="dropdown-sections-item-0">
Expand All @@ -18,7 +18,7 @@
<.dropdown_separator />

<.dropdown_section>
<.dropdown_heading id="dropdown-sections-heading-support">
<.dropdown_heading>
Support
</.dropdown_heading>
<.dropdown_item id="dropdown-sections-item-2">
Expand Down
1 change: 1 addition & 0 deletions demo/lib/demo_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion demo/mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion demo/mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
8 changes: 4 additions & 4 deletions demo/priv/code_examples/combobox/positioned.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@
</.combobox_options>
</.combobox>
</div>
<!-- Right Placement -->

<!-- Right Placement -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Color (opens to right)</label>
<.combobox class="w-64" id="right-combobox" name="right_color">
Expand Down Expand Up @@ -85,8 +85,8 @@
</.combobox_options>
</.combobox>
</div>
<!-- Bottom with Large Offset -->

<!-- Bottom with Large Offset -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Country (12px offset)</label>
<.combobox class="w-64" id="offset-combobox" name="offset_country">
Expand Down
8 changes: 4 additions & 4 deletions demo/priv/code_examples/dropdown/positioned.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@
</.dropdown_item>
</.dropdown_menu>
</.dropdown>
<!-- Right-start placement -->

<!-- Right-start placement -->
<.dropdown id="right-dropdown">
<.dropdown_trigger
id="right-dropdown-trigger"
Expand Down Expand Up @@ -89,8 +89,8 @@
</.dropdown_item>
</.dropdown_menu>
</.dropdown>
<!-- Bottom-end placement with larger offset -->

<!-- Bottom-end placement with larger offset -->
<.dropdown id="bottom-dropdown">
<.dropdown_trigger
id="bottom-dropdown-trigger"
Expand Down
70 changes: 70 additions & 0 deletions demo/test/wallaby/demo_web/dropdown_generated_ids_test.exs
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading