diff --git a/assets/js/hooks/dropdown.js b/assets/js/hooks/dropdown.js
index 828f7d1..c3a7156 100644
--- a/assets/js/hooks/dropdown.js
+++ b/assets/js/hooks/dropdown.js
@@ -151,7 +151,12 @@ export default {
},
handleEnterOrSpace(e) {
- const focusedItem = this.el.querySelector(SELECTORS.FOCUSED_MENUITEM)
+ // Only trust a focused item while the menu is actually open - data-focus can linger
+ // on an item after Escape closes the menu, since clearing it happens in the async
+ // phx:hide-end handler, not synchronously in hideMenu(). Without this guard, a fast
+ // Escape followed by Enter/Space can "click" a stale focused item instead of
+ // reopening the menu.
+ const focusedItem = this.isMenuVisible() ? this.el.querySelector(SELECTORS.FOCUSED_MENUITEM) : null
if (focusedItem && focusedItem.getAttribute('aria-disabled') !== 'true') {
// A menu item is focused - click it
@@ -232,16 +237,35 @@ export default {
}
},
+ // phx:show-start/phx:hide-end are dispatched asynchronously by LiveView's transition
+ // machinery, and the actual display mutation on the inner menu happens as part of that
+ // same internal, multi-step async completion - not synchronously with these events. A
+ // rapid close-then-reopen (or reopen-then-close) can call execJS(show) and execJS(hide)
+ // back-to-back before the previous call's internal steps finish, so their completions
+ // interleave and whichever happens to run last wins, regardless of which was issued
+ // most recently. Both handlers defensively re-assert the inner menu's display against
+ // our own synchronous source of truth (the wrapper, which showMenuAndFocusFirst/Last
+ // and hideMenu/toggleMenu control directly) so a stale completion corrects itself
+ // instead of leaving the inner element in the wrong state.
handleShowStart() {
+ const shouldBeOpen = this.isMenuVisible()
+ this.refs.menu.style.display = shouldBeOpen ? '' : 'none'
+ if (!shouldBeOpen) return
+
this.refs.button.setAttribute('aria-expanded', 'true')
// Setup autoUpdate to reposition on scroll/resize
+ this.cleanupAutoUpdate()
this.autoUpdateCleanup = autoUpdate(this.refs.referenceElement, this.refs.menuWrapper, () => {
this.positionMenu()
})
},
handleHideEnd() {
+ const shouldBeOpen = this.isMenuVisible()
+ this.refs.menu.style.display = shouldBeOpen ? '' : 'none'
+ if (shouldBeOpen) return
+
this.clearFocus()
this.refs.menu.removeAttribute('aria-activedescendant')
this.refs.button.setAttribute('aria-expanded', 'false')
diff --git a/assets/js/hooks/listbox.js b/assets/js/hooks/listbox.js
new file mode 100644
index 0000000..dbfe35d
--- /dev/null
+++ b/assets/js/hooks/listbox.js
@@ -0,0 +1,437 @@
+import { computePosition, flip, offset, autoUpdate } from '@floating-ui/dom';
+
+const KEYS = {
+ ARROW_UP: 'ArrowUp',
+ ARROW_DOWN: 'ArrowDown',
+ ESCAPE: 'Escape',
+ ENTER: 'Enter',
+ SPACE: ' ',
+ HOME: 'Home',
+ END: 'End',
+ PAGE_UP: 'PageUp',
+ PAGE_DOWN: 'PageDown'
+}
+
+const SELECTORS = {
+ BUTTON: '[aria-haspopup="listbox"]',
+ TRIGGER_LABEL: '[data-prima-ref="trigger-label"]',
+ VALUE_INPUT: '[data-prima-ref="value-input"]',
+ OPTIONS_WRAPPER: '[data-prima-ref="options-wrapper"]',
+ LISTBOX: '[role="listbox"]',
+ OPTION: '[role="option"]',
+ ENABLED_OPTION: '[role="option"]:not([aria-disabled="true"])',
+ FOCUSED_OPTION: '[role="option"][data-focus]',
+ SELECTED_OPTION: '[role="option"][aria-selected="true"]'
+}
+
+export default {
+ mounted() {
+ this.initialize()
+ this.applyInitialSelection()
+ },
+
+ updated() {
+ this.initialize()
+ },
+
+ reconnected() {
+ this.initialize()
+ },
+
+ destroyed() {
+ this.cleanup()
+ },
+
+ // Selection state is owned by the client once mounted (same as Combobox) - re-deriving
+ // it from the server on every patch would clobber a just-made selection if an unrelated
+ // LiveView update re-renders this hook's element before the selection's own round-trip
+ // completes.
+ initialize() {
+ this.cleanup()
+ this.setupElements()
+ this.setupEventListeners()
+ this.el.setAttribute('data-prima-ready', 'true')
+ },
+
+ setupElements() {
+ const button = this.el.querySelector(SELECTORS.BUTTON)
+ const triggerLabel = this.el.querySelector(SELECTORS.TRIGGER_LABEL)
+ const valueInput = this.el.querySelector(SELECTORS.VALUE_INPUT)
+ const optionsWrapper = this.el.querySelector(SELECTORS.OPTIONS_WRAPPER)
+ const listbox = this.el.querySelector(SELECTORS.LISTBOX)
+
+ const referenceSelector = optionsWrapper?.getAttribute('data-reference')
+ const referenceElement = referenceSelector ? document.querySelector(referenceSelector) : button
+
+ this.setupAriaRelationships(button, listbox)
+ this.refs = { button, triggerLabel, valueInput, optionsWrapper, listbox, referenceElement }
+ },
+
+ setupAriaRelationships(button, listbox) {
+ button.setAttribute('aria-controls', listbox.id)
+ listbox.setAttribute('aria-labelledby', button.id)
+ },
+
+ applyInitialSelection() {
+ const option = this.findOptionByValue(this.refs.valueInput.value)
+ this.syncSelectedState(option)
+ },
+
+ setupEventListeners() {
+ this.listeners = [
+ [this.refs.button, 'click', this.handleToggle.bind(this)],
+ [this.refs.listbox, 'mouseover', this.handleMouseOver.bind(this)],
+ [this.refs.listbox, 'click', this.handleListboxClick.bind(this)],
+ [this.el, 'keydown', this.handleKeydown.bind(this)],
+ [this.el, 'prima:close', this.handleClose.bind(this)],
+ [this.refs.listbox, 'phx:show-start', this.handleShowStart.bind(this)],
+ [this.refs.listbox, 'phx:hide-end', this.handleHideEnd.bind(this)]
+ ]
+
+ this.listeners.forEach(([element, event, handler]) => {
+ element.addEventListener(event, handler)
+ })
+ },
+
+ cleanup() {
+ this.cleanupAutoUpdate()
+
+ if (this.listeners) {
+ this.listeners.forEach(([element, event, handler]) => {
+ element.removeEventListener(event, handler)
+ })
+ this.listeners = []
+ }
+ },
+
+ cleanupAutoUpdate() {
+ if (this.autoUpdateCleanup) {
+ this.autoUpdateCleanup()
+ this.autoUpdateCleanup = null
+ }
+ },
+
+ handleKeydown(e) {
+ const keyHandlers = {
+ [KEYS.ARROW_UP]: () => this.navigateUp(e),
+ [KEYS.ARROW_DOWN]: () => this.navigateDown(e),
+ [KEYS.ESCAPE]: () => this.handleEscape(),
+ [KEYS.ENTER]: () => this.handleEnterOrSpace(e),
+ [KEYS.SPACE]: () => this.handleEnterOrSpace(e),
+ [KEYS.HOME]: () => this.handleHome(e),
+ [KEYS.END]: () => this.handleEnd(e),
+ [KEYS.PAGE_UP]: () => this.handleHome(e),
+ [KEYS.PAGE_DOWN]: () => this.handleEnd(e)
+ }
+
+ const handler = keyHandlers[e.key]
+ if (handler) {
+ handler()
+ } else {
+ this.handleTypeahead(e)
+ }
+ },
+
+ navigateUp(e) {
+ e.preventDefault()
+
+ if (!this.isListboxVisible() && document.activeElement === this.refs.button) {
+ this.showListboxAndFocus(this.getLastEnabledOption())
+ return
+ }
+
+ const options = this.getEnabledOptions()
+ if (options.length === 0) return
+
+ const currentIndex = this.getCurrentFocusIndex(options)
+ const targetIndex = currentIndex === 0 ? options.length - 1 : currentIndex - 1
+ this.setFocus(options[targetIndex])
+ },
+
+ navigateDown(e) {
+ e.preventDefault()
+
+ if (!this.isListboxVisible() && document.activeElement === this.refs.button) {
+ this.showListboxAndFocus(this.getFirstEnabledOption())
+ return
+ }
+
+ const options = this.getEnabledOptions()
+ if (options.length === 0) return
+
+ const currentIndex = this.getCurrentFocusIndex(options)
+ const targetIndex = currentIndex === options.length - 1 ? 0 : currentIndex + 1
+ this.setFocus(options[targetIndex])
+ },
+
+ handleEscape() {
+ this.hideListbox()
+ this.refs.button.focus()
+ },
+
+ handleEnterOrSpace(e) {
+ // Only trust a focused option while the listbox is actually open - data-focus can
+ // linger on an option after Escape closes the listbox, since clearing it happens in
+ // the async phx:hide-end handler, not synchronously in hideListbox(). Without this
+ // guard, a fast Escape followed by Enter/Space can "click" a stale focused option
+ // instead of reopening the listbox.
+ const focusedOption = this.isListboxVisible() ? this.el.querySelector(SELECTORS.FOCUSED_OPTION) : null
+
+ if (focusedOption && focusedOption.getAttribute('aria-disabled') !== 'true') {
+ // An option is focused - click it
+ e.preventDefault()
+ focusedOption.click()
+ } else if (document.activeElement === this.refs.button) {
+ // Button is focused - open listbox
+ e.preventDefault()
+ this.showListboxAndFocus(this.getSelectedOrFirstEnabledOption())
+ }
+ },
+
+ handleHome(e) {
+ if (this.isListboxVisible()) {
+ e.preventDefault()
+ const options = this.getEnabledOptions()
+ if (options.length > 0) {
+ this.setFocus(options[0])
+ }
+ }
+ },
+
+ handleEnd(e) {
+ if (this.isListboxVisible()) {
+ e.preventDefault()
+ const options = this.getEnabledOptions()
+ if (options.length > 0) {
+ this.setFocus(options[options.length - 1])
+ }
+ }
+ },
+
+ handleTypeahead(e) {
+ if (!this.isListboxVisible() || e.key.length !== 1 || !/[a-zA-Z0-9]/.test(e.key)) return
+
+ e.preventDefault()
+
+ const searchChar = e.key.toLowerCase()
+ const options = this.getEnabledOptions()
+ const matchingOptions = Array.from(options).filter(option =>
+ option.textContent.trim().toLowerCase().startsWith(searchChar)
+ )
+
+ if (matchingOptions.length === 0) return
+
+ const currentFocused = this.el.querySelector(SELECTORS.FOCUSED_OPTION)
+ const currentIndex = currentFocused && matchingOptions.includes(currentFocused)
+ ? matchingOptions.indexOf(currentFocused)
+ : -1
+
+ const nextIndex = currentIndex >= 0 && currentIndex < matchingOptions.length - 1
+ ? currentIndex + 1
+ : 0
+
+ this.setFocus(matchingOptions[nextIndex])
+ },
+
+ handleClose() {
+ this.hideListbox()
+ },
+
+ handleToggle() {
+ this.toggleListbox()
+ },
+
+ handleMouseOver(e) {
+ if (e.target.getAttribute('role') === 'option' &&
+ e.target.getAttribute('aria-disabled') !== 'true') {
+ this.setFocus(e.target)
+ }
+ },
+
+ handleListboxClick(e) {
+ const option = e.target.closest(SELECTORS.OPTION)
+ if (option && option.getAttribute('aria-disabled') !== 'true') {
+ this.selectOption(option)
+ this.hideListbox()
+ this.refs.button.focus()
+ }
+ },
+
+ // User-driven selection: updates the form value and rewrites the trigger label
+ // instantly, ahead of any server round-trip.
+ selectOption(option) {
+ const value = option.getAttribute('data-value')
+
+ if (this.refs.valueInput.value !== value) {
+ this.refs.valueInput.value = value
+ this.refs.valueInput.dispatchEvent(new Event('input', { bubbles: true }))
+ }
+
+ this.syncSelectedState(option)
+ this.refs.triggerLabel.textContent = option.getAttribute('data-display')
+ },
+
+ // Mount-time sync only: the trigger label is rendered by the caller and is
+ // already correct on first paint, so only the ARIA/visual selection markers
+ // are synced here - the label itself is left untouched.
+ syncSelectedState(option) {
+ this.el.querySelector(SELECTORS.SELECTED_OPTION)?.removeAttribute('aria-selected')
+ this.el.querySelectorAll('[data-selected]').forEach(el => el.removeAttribute('data-selected'))
+
+ if (option) {
+ option.setAttribute('aria-selected', 'true')
+ option.setAttribute('data-selected', 'true')
+ }
+ },
+
+ findOptionByValue(value) {
+ if (!value) return null
+ return Array.from(this.getAllOptions()).find(option => option.getAttribute('data-value') === value)
+ },
+
+ getAllOptions() {
+ return this.el.querySelectorAll(SELECTORS.OPTION)
+ },
+
+ getEnabledOptions() {
+ return this.el.querySelectorAll(SELECTORS.ENABLED_OPTION)
+ },
+
+ getFirstEnabledOption() {
+ return this.getEnabledOptions()[0]
+ },
+
+ getLastEnabledOption() {
+ const options = this.getEnabledOptions()
+ return options[options.length - 1]
+ },
+
+ getSelectedOrFirstEnabledOption() {
+ const selected = this.el.querySelector(SELECTORS.SELECTED_OPTION)
+ if (selected && selected.getAttribute('aria-disabled') !== 'true') return selected
+ return this.getFirstEnabledOption()
+ },
+
+ isListboxVisible() {
+ const wrapper = this.refs.optionsWrapper
+ return wrapper && wrapper.style.display !== 'none' && wrapper.offsetParent !== null
+ },
+
+ getCurrentFocusIndex(options) {
+ return Array.prototype.findIndex.call(options, option => option.hasAttribute('data-focus'))
+ },
+
+ // The `aria-activedescendant` attribute is deliberately set on the button,
+ // not the listbox, because the button is what stays focused while you're
+ // browsing options - same idea as Combobox. Dropdown puts it on its menu
+ // instead, which is the right choice for a menu of commands, but not for
+ // a value picker like this one.
+ setFocus(el) {
+ this.clearFocus()
+ if (el && el.getAttribute('aria-disabled') !== 'true') {
+ el.setAttribute('data-focus', '')
+ this.refs.button.setAttribute('aria-activedescendant', el.id)
+ } else {
+ this.refs.button.removeAttribute('aria-activedescendant')
+ }
+ },
+
+ clearFocus() {
+ this.el.querySelector(SELECTORS.FOCUSED_OPTION)?.removeAttribute('data-focus')
+ },
+
+ hideListbox() {
+ liveSocket.execJS(this.refs.listbox, this.refs.listbox.getAttribute('js-hide'))
+ this.refs.optionsWrapper.style.display = 'none'
+ },
+
+ toggleListbox() {
+ if (this.isListboxVisible()) {
+ this.hideListbox()
+ } else {
+ this.showListboxAndFocus(null)
+ }
+ },
+
+ showListboxAndFocus(optionToFocus) {
+ // Wrapper pattern: Show wrapper first (display:block) so Floating UI can measure it,
+ // then position it, then trigger inner listbox transition. This prevents the listbox
+ // from briefly appearing at wrong position before jumping to correct position.
+ this.refs.optionsWrapper.style.display = 'block'
+ this.positionListbox()
+ liveSocket.execJS(this.refs.listbox, this.refs.listbox.getAttribute('js-show'))
+
+ if (optionToFocus) {
+ this.setFocus(optionToFocus)
+ }
+ },
+
+ // phx:show-start/phx:hide-end are dispatched asynchronously by LiveView's transition
+ // machinery, and the actual display mutation on the inner listbox happens as part of
+ // that same internal, multi-step async completion - not synchronously with these
+ // events. A rapid close-then-reopen (or reopen-then-close) can call execJS(show) and
+ // execJS(hide) back-to-back before the previous call's internal steps finish, so their
+ // completions interleave and whichever happens to run last wins, regardless of which
+ // was issued most recently. Both handlers defensively re-assert the inner listbox's
+ // display against our own synchronous source of truth (the wrapper, which
+ // showListboxAndFocus/hideListbox control directly) so a stale completion corrects
+ // itself instead of leaving the inner element in the wrong state.
+ handleShowStart() {
+ const shouldBeOpen = this.isListboxVisible()
+ this.refs.listbox.style.display = shouldBeOpen ? '' : 'none'
+ if (!shouldBeOpen) return
+
+ this.refs.button.setAttribute('aria-expanded', 'true')
+
+ // Setup autoUpdate to reposition on scroll/resize
+ this.cleanupAutoUpdate()
+ this.autoUpdateCleanup = autoUpdate(this.refs.referenceElement, this.refs.optionsWrapper, () => {
+ this.positionListbox()
+ })
+ },
+
+ handleHideEnd() {
+ const shouldBeOpen = this.isListboxVisible()
+ this.refs.listbox.style.display = shouldBeOpen ? '' : 'none'
+ if (shouldBeOpen) return
+
+ this.clearFocus()
+ this.refs.button.removeAttribute('aria-activedescendant')
+ this.refs.button.setAttribute('aria-expanded', 'false')
+ this.refs.optionsWrapper.style.display = 'none'
+ this.cleanupAutoUpdate()
+ },
+
+ positionListbox() {
+ if (!this.refs.optionsWrapper) return
+
+ const placement = this.refs.optionsWrapper.getAttribute('data-placement') || 'bottom-start'
+ const shouldFlip = this.refs.optionsWrapper.getAttribute('data-flip') !== 'false'
+ const offsetValue = this.refs.optionsWrapper.getAttribute('data-offset')
+
+ const middleware = []
+ if (offsetValue && !isNaN(parseInt(offsetValue))) {
+ middleware.push(offset(parseInt(offsetValue)))
+ }
+ if (shouldFlip) {
+ middleware.push(flip())
+ }
+
+ const matchTriggerWidth = this.refs.optionsWrapper.hasAttribute('data-match-trigger-width')
+ this.refs.optionsWrapper.style.minWidth = matchTriggerWidth
+ ? `${this.refs.referenceElement.offsetWidth}px`
+ : ''
+
+ computePosition(this.refs.referenceElement, this.refs.optionsWrapper, {
+ placement: placement,
+ middleware: middleware
+ }).then(({x, y}) => {
+ Object.assign(this.refs.optionsWrapper.style, {
+ top: `${y}px`,
+ left: `${x}px`
+ })
+ }).catch(error => {
+ console.error('[Prima Listbox] Failed to position listbox:', error)
+ })
+ }
+}
diff --git a/assets/js/prima.js b/assets/js/prima.js
index d86c5e3..d93173c 100644
--- a/assets/js/prima.js
+++ b/assets/js/prima.js
@@ -1,5 +1,6 @@
import Dropdown from "./hooks/dropdown"
import Modal from "./hooks/modal"
import Combobox from "./hooks/combobox"
+import Listbox from "./hooks/listbox"
-export { Dropdown, Modal, Combobox }
+export { Dropdown, Modal, Combobox, Listbox }
diff --git a/demo/assets/js/app.js b/demo/assets/js/app.js
index 185b376..63644b4 100644
--- a/demo/assets/js/app.js
+++ b/demo/assets/js/app.js
@@ -5,13 +5,14 @@ import { Socket } from "phoenix"
import { LiveSocket } from "phoenix_live_view"
import topbar from "../vendor/topbar"
// Import from built library bundle
-import { Dropdown, Modal, Combobox } from "../../../priv/static/assets/prima"
+import { Dropdown, Modal, Combobox, Listbox } from "../../../priv/static/assets/prima"
let Hooks = {}
Hooks.Dropdown = Dropdown
Hooks.Modal = Modal
Hooks.Combobox = Combobox
+Hooks.Listbox = Listbox
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, { params: { _csrf_token: csrfToken }, hooks: Hooks })
diff --git a/demo/lib/demo_web/components/code_example.ex b/demo/lib/demo_web/components/code_example.ex
index c4b0980..a880917 100644
--- a/demo/lib/demo_web/components/code_example.ex
+++ b/demo/lib/demo_web/components/code_example.ex
@@ -11,7 +11,8 @@ defmodule DemoWeb.CodeExample do
@live_component_modules [
DemoWeb.DemoLive.AsyncModalDemo,
DemoWeb.DemoLive.FormModalDemo,
- DemoWeb.DemoLive.AsyncComboboxDemo
+ DemoWeb.DemoLive.AsyncComboboxDemo,
+ DemoWeb.DemoLive.ListboxFormDemo
]
for module <- @live_component_modules, do: Code.ensure_compiled(module)
@@ -160,6 +161,7 @@ defmodule DemoWeb.CodeExample do
import Prima.Modal
import Prima.Dropdown
import Prima.Combobox
+ import Prima.Listbox
import DemoWeb.CoreComponents
alias Phoenix.LiveView.JS
diff --git a/demo/lib/demo_web/live/demo_live.html.heex b/demo/lib/demo_web/live/demo_live.html.heex
index c20a0c9..9276fe5 100644
--- a/demo/lib/demo_web/live/demo_live.html.heex
+++ b/demo/lib/demo_web/live/demo_live.html.heex
@@ -18,6 +18,10 @@
<.combobox_page {assigns} />
+
+
+ <.listbox_page {assigns} />
+
diff --git a/demo/lib/demo_web/live/demo_live/introduction.html.heex b/demo/lib/demo_web/live/demo_live/introduction.html.heex
index 7cad61a..f519373 100644
--- a/demo/lib/demo_web/live/demo_live/introduction.html.heex
+++ b/demo/lib/demo_web/live/demo_live/introduction.html.heex
@@ -86,6 +86,18 @@
+
+
+
+
+
+
Listbox
+
+ Single-select value picker for use as a form input, with a trigger that reflects the current selection.
+
+
+
+
diff --git a/demo/lib/demo_web/live/demo_live/listbox_form_demo.ex b/demo/lib/demo_web/live/demo_live/listbox_form_demo.ex
new file mode 100644
index 0000000..bc03f72
--- /dev/null
+++ b/demo/lib/demo_web/live/demo_live/listbox_form_demo.ex
@@ -0,0 +1,68 @@
+defmodule DemoWeb.DemoLive.ListboxFormDemo do
+ @moduledoc false
+ use DemoWeb, :live_component
+ import Prima.Listbox
+
+ @fruits ["Cherry", "Kiwi", "Grapefruit", "Orange", "Banana"]
+
+ @impl true
+ def mount(socket) do
+ {:ok, assign(socket, fruits: @fruits, selected_fruit: nil)}
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+
+
+ Selected fruit (from server state):
+ {@selected_fruit || "none"}
+
+
+ """
+ end
+
+ @impl true
+ def handle_event("favorite_fruit_changed", %{"favorite_fruit" => fruit}, socket) do
+ {:noreply, assign(socket, selected_fruit: fruit)}
+ end
+end
diff --git a/demo/lib/demo_web/live/demo_live/listbox_page.html.heex b/demo/lib/demo_web/live/demo_live/listbox_page.html.heex
new file mode 100644
index 0000000..d2a8341
--- /dev/null
+++ b/demo/lib/demo_web/live/demo_live/listbox_page.html.heex
@@ -0,0 +1,214 @@
+
+
+
Listbox
+
+
+
+ A single-select value picker for use as a form input. Unlike Dropdown
+ (an action menu, role="menu"), Listbox
+ is built for picking a value (role="listbox") — selecting an option updates a hidden form
+ field and the trigger's label, similar to a native <select>. Features the same
+
+ Floating UI
+
+ positioning as Dropdown, with the menu matching the trigger's width by default.
+
+
+
Quick Start
+
+ The most basic listbox requires three components: .listbox, .listbox_trigger, and
+ .listbox_options
+ with .listbox_option
+ elements. This example starts with "Cherry" pre-selected via the value
+ attribute on .listbox
+ — the trigger renders it directly on first paint, so there's no flash of
+ placeholder text. Click a different option and notice the label updates immediately, before any
+ server round-trip.
+
+
+
+ <.code_example file="listbox/basic.html.heex" id="basic-listbox-demo" />
+
+
+
Form Integration
+
+ .listbox
+ renders a hidden <input>
+ under the given name. Selecting an option updates the input's value and dispatches a bubbling
+ input
+ event, so a parent form's phx-change
+ fires exactly like it would for a native form field. Render the current value (or a placeholder) in the
+ trigger's slot so the initial page load is always correct — the JS hook only ever rewrites the label after
+ a user interaction, never on mount or on an unrelated re-render.
+
+
+
+ <.code_example
+ file="listbox/listbox_form_demo.ex"
+ module={DemoWeb.DemoLive.ListboxFormDemo}
+ id="listbox-form-demo"
+ />
+
+
+
+ The "Selected fruit" text below the trigger is rendered from server state, confirming the
+ phx-change
+ round-trip completed — while the trigger label itself already updated instantly on click.
+
+
+
Disabled Options
+
+ Listbox options can be disabled using the disabled={true}
+ attribute. Disabled options cannot be focused via keyboard navigation and are ignored on click.
+
+
+
+ <.code_example file="listbox/disabled.html.heex" id="disabled-listbox-demo" />
+
+
+
Keyboard Interaction
+
+ Keyboard support mirrors Dropdown: arrow keys move focus between options, and Enter/Space
+ commits the focused option as the new selection.
+
+
+
+
+
+
+
+
+ Key
+
+
+ Description
+
+
+
+
+
+
+ When the trigger button is focused:
+
+
+
+
+
+ Enter
+
+ /
+
+ Space
+
+
+
+ Opens the listbox and focuses the current selection (or the first option, if none)
+
+
+
+
+
+ ↓
+
+
+
+ Opens the listbox and focuses the first non-disabled option
+
+
+
+
+
+ ↑
+
+
+
+ Opens the listbox and focuses the last non-disabled option
+
+
+
+
+ When the listbox is open:
+
+
+
+
+
+ Esc
+
+
+
+ Closes the listbox without changing the selection, and returns focus to the trigger
+
+
+
+
+
+ ↑
+
+ /
+
+ ↓
+
+
+
+ Focuses the previous/next non-disabled option (wraps around)
+
+
+
+
+
+ Home
+
+ /
+
+ End
+
+
+
+ Focuses the first/last non-disabled option
+
+
+
+
+
+ Enter
+
+ /
+
+ Space
+
+
+
+ Selects the focused option, updates the trigger label, and closes the listbox
+
+
+
+
+
+ A-Z
+
+ /
+
+ 0-9
+
+
+
+ Focuses the first option that starts with the typed character. Repeated presses cycle through matching options.
+
+
+
+
+
+
+
diff --git a/demo/lib/demo_web/live/demo_live/sidebar.html.heex b/demo/lib/demo_web/live/demo_live/sidebar.html.heex
index ae4ba52..bd24306 100644
--- a/demo/lib/demo_web/live/demo_live/sidebar.html.heex
+++ b/demo/lib/demo_web/live/demo_live/sidebar.html.heex
@@ -58,5 +58,16 @@
>
Combobox
+
+ <.link
+ navigate="/listbox"
+ class={[
+ "group flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors",
+ @current_action == :listbox && "bg-blue-100 text-blue-700",
+ @current_action != :listbox && "text-gray-700 hover:bg-gray-100 hover:text-gray-900"
+ ]}
+ >
+ Listbox
+
diff --git a/demo/lib/demo_web/live/fixtures_live.ex b/demo/lib/demo_web/live/fixtures_live.ex
index 946a776..b389bc8 100644
--- a/demo/lib/demo_web/live/fixtures_live.ex
+++ b/demo/lib/demo_web/live/fixtures_live.ex
@@ -1,7 +1,7 @@
defmodule DemoWeb.FixturesLive do
@moduledoc false
use DemoWeb, :live_view
- import Prima.{Dropdown, Modal, Combobox}
+ import Prima.{Dropdown, Modal, Combobox, Listbox}
embed_templates "fixtures_live/*"
@options [
diff --git a/demo/lib/demo_web/live/fixtures_live.html.heex b/demo/lib/demo_web/live/fixtures_live.html.heex
index 587ff58..b5cf48d 100644
--- a/demo/lib/demo_web/live/fixtures_live.html.heex
+++ b/demo/lib/demo_web/live/fixtures_live.html.heex
@@ -93,3 +93,11 @@
<.async_combobox_form_change_fixture {assigns} />
+
+
+ <.listbox_fixture />
+
+
+
+ <.listbox_form_fixture {assigns} />
+
diff --git a/demo/lib/demo_web/live/fixtures_live/async_modal_fixture.html.heex b/demo/lib/demo_web/live/fixtures_live/async_modal_fixture.html.heex
index 2bd28dd..089e8dd 100644
--- a/demo/lib/demo_web/live/fixtures_live/async_modal_fixture.html.heex
+++ b/demo/lib/demo_web/live/fixtures_live/async_modal_fixture.html.heex
@@ -7,10 +7,10 @@
<.modal id="demo-form-modal" on_close={JS.push("close-async-modal")}>
- <.modal_overlay />
+ <.modal_overlay class="fixed inset-0 bg-gray-500/75" />
-
-
+
+
<.modal_loader>
Loader
diff --git a/demo/lib/demo_web/live/fixtures_live/listbox_fixture.html.heex b/demo/lib/demo_web/live/fixtures_live/listbox_fixture.html.heex
new file mode 100644
index 0000000..6d87d58
--- /dev/null
+++ b/demo/lib/demo_web/live/fixtures_live/listbox_fixture.html.heex
@@ -0,0 +1,42 @@
+
+ <.listbox id="listbox" name="fruit_choice" value="banana">
+ <.listbox_trigger
+ id="listbox-trigger"
+ class="w-64 inline-flex justify-between items-center rounded-lg bg-white border border-gray-300 px-3 py-2 text-sm text-gray-700"
+ >
+ Banana
+ <:icon>
+
+
+
+
+
+
+ <.listbox_options
+ id="listbox-options"
+ class="py-1 rounded-md bg-white shadow-xs ring-1 ring-gray-300"
+ >
+ <.listbox_option id="listbox-option-apple" value="apple" display="Apple">
+ Apple
+
+ <.listbox_option id="listbox-option-banana" value="banana" display="Banana">
+ Banana
+
+ <.listbox_option id="listbox-option-cherry" value="cherry" display="Cherry">
+ Cherry
+
+ <.listbox_option id="listbox-option-durian" value="durian" display="Durian" disabled>
+ Durian
+
+
+
+
+
+
+
diff --git a/demo/lib/demo_web/live/fixtures_live/listbox_form_fixture.html.heex b/demo/lib/demo_web/live/fixtures_live/listbox_form_fixture.html.heex
new file mode 100644
index 0000000..b08c707
--- /dev/null
+++ b/demo/lib/demo_web/live/fixtures_live/listbox_form_fixture.html.heex
@@ -0,0 +1,29 @@
+
diff --git a/demo/lib/demo_web/live/fixtures_live/modal_push_event_fixture.html.heex b/demo/lib/demo_web/live/fixtures_live/modal_push_event_fixture.html.heex
index 1e22688..3bc0815 100644
--- a/demo/lib/demo_web/live/fixtures_live/modal_push_event_fixture.html.heex
+++ b/demo/lib/demo_web/live/fixtures_live/modal_push_event_fixture.html.heex
@@ -9,9 +9,9 @@
<.modal id="frontend-modal">
- <.modal_overlay />
-
-
+ <.modal_overlay class="fixed inset-0 bg-gray-500/75" />
+
+
<.modal_panel id="frontend-modal-panel">
@@ -44,9 +44,9 @@
<.modal id="modal-one">
- <.modal_overlay />
-
-
+ <.modal_overlay class="fixed inset-0 bg-gray-500/75" />
+
+
<.modal_panel id="modal-one-panel">
@@ -70,9 +70,9 @@
<.modal id="modal-two">
- <.modal_overlay />
-
-
+ <.modal_overlay class="fixed inset-0 bg-gray-500/75" />
+
+
<.modal_panel id="modal-two-panel">
diff --git a/demo/lib/demo_web/live/fixtures_live/modal_without_portal_fixture.html.heex b/demo/lib/demo_web/live/fixtures_live/modal_without_portal_fixture.html.heex
index 1f37e16..b9a1fbd 100644
--- a/demo/lib/demo_web/live/fixtures_live/modal_without_portal_fixture.html.heex
+++ b/demo/lib/demo_web/live/fixtures_live/modal_without_portal_fixture.html.heex
@@ -4,8 +4,11 @@
<.modal id="no-portal-modal" portal={false}>
- <.modal_overlay />
- <.modal_panel id="no-portal-modal-panel">
+ <.modal_overlay class="fixed inset-0 bg-gray-500/75" />
+ <.modal_panel
+ id="no-portal-modal-panel"
+ class="fixed inset-0 z-10 flex items-center justify-center"
+ >
Close
diff --git a/demo/lib/demo_web/live/fixtures_live/simple_modal_fixture.html.heex b/demo/lib/demo_web/live/fixtures_live/simple_modal_fixture.html.heex
index a8a6480..5898d35 100644
--- a/demo/lib/demo_web/live/fixtures_live/simple_modal_fixture.html.heex
+++ b/demo/lib/demo_web/live/fixtures_live/simple_modal_fixture.html.heex
@@ -4,10 +4,10 @@
<.modal id="demo-modal">
- <.modal_overlay />
+ <.modal_overlay class="fixed inset-0 bg-gray-500/75" />
-
-
+
+
<.modal_panel id="demo-modal-panel">
+ <.listbox_trigger
+ id="basic-listbox-trigger"
+ class="w-56 inline-flex justify-between items-center rounded-lg bg-white border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
+ >
+ Cherry
+ <:icon>
+
+
+
+
+
+
+ <.listbox_options
+ id="basic-listbox-options"
+ class="py-1 rounded-md bg-white shadow-xs ring-1 ring-gray-300 focus:outline-none"
+ >
+ <.listbox_option
+ :for={
+ {fruit, index} <- Enum.with_index(["Cherry", "Kiwi", "Grapefruit", "Orange", "Banana"])
+ }
+ id={"basic-listbox-option-#{index}"}
+ value={fruit}
+ class="text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 data-selected:font-semibold block w-full px-4 py-2 text-sm text-left"
+ >
+ {fruit}
+
+
+
diff --git a/demo/priv/code_examples/listbox/disabled.html.heex b/demo/priv/code_examples/listbox/disabled.html.heex
new file mode 100644
index 0000000..b0a15dc
--- /dev/null
+++ b/demo/priv/code_examples/listbox/disabled.html.heex
@@ -0,0 +1,50 @@
+<.listbox id="disabled-demo-listbox" name="plan">
+ <.listbox_trigger
+ id="disabled-demo-listbox-trigger"
+ class="w-56 inline-flex justify-between items-center rounded-lg bg-white border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
+ >
+ Select a plan...
+ <:icon>
+
+
+
+
+
+
+ <.listbox_options
+ id="disabled-demo-listbox-options"
+ class="py-1 rounded-md bg-white shadow-xs ring-1 ring-gray-300 focus:outline-none"
+ >
+ <.listbox_option
+ id="disabled-demo-option-basic"
+ value="Basic"
+ class="text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 block w-full px-4 py-2 text-sm text-left"
+ >
+ Basic
+
+ <.listbox_option
+ id="disabled-demo-option-pro"
+ value="Pro"
+ class="text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 block w-full px-4 py-2 text-sm text-left"
+ >
+ Pro
+
+ <.listbox_option
+ id="disabled-demo-option-enterprise"
+ value="Enterprise"
+ disabled={true}
+ class="text-gray-400 data-disabled:opacity-50 block w-full px-4 py-2 text-sm text-left"
+ >
+ Enterprise (contact sales)
+
+
+
diff --git a/demo/priv/code_examples/listbox/listbox_form_demo.ex b/demo/priv/code_examples/listbox/listbox_form_demo.ex
new file mode 100644
index 0000000..bc03f72
--- /dev/null
+++ b/demo/priv/code_examples/listbox/listbox_form_demo.ex
@@ -0,0 +1,68 @@
+defmodule DemoWeb.DemoLive.ListboxFormDemo do
+ @moduledoc false
+ use DemoWeb, :live_component
+ import Prima.Listbox
+
+ @fruits ["Cherry", "Kiwi", "Grapefruit", "Orange", "Banana"]
+
+ @impl true
+ def mount(socket) do
+ {:ok, assign(socket, fruits: @fruits, selected_fruit: nil)}
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+
+
+ Selected fruit (from server state):
+ {@selected_fruit || "none"}
+
+
+ """
+ end
+
+ @impl true
+ def handle_event("favorite_fruit_changed", %{"favorite_fruit" => fruit}, socket) do
+ {:noreply, assign(socket, selected_fruit: fruit)}
+ end
+end
diff --git a/demo/test/wallaby/demo_web/combobox_test.exs b/demo/test/wallaby/demo_web/combobox_test.exs
index daccb2f..fe81ee8 100644
--- a/demo/test/wallaby/demo_web/combobox_test.exs
+++ b/demo/test/wallaby/demo_web/combobox_test.exs
@@ -298,6 +298,12 @@ defmodule DemoWeb.ComboboxTest do
with: "Orange"
)
|> assert_has(Query.css("#demo-async-combobox-options") |> Query.visible(true))
+ # "Orange" is always present, even in the unfiltered initial list (an empty search
+ # matches everything) - so this alone doesn't prove the debounced async search for
+ # "Orange" actually completed. Wait for the result count to narrow to the single
+ # match "Orange" produces among the fixture's options, so Enter can't land on a
+ # stale, not-yet-filtered option under load (e.g. a slow CI runner).
+ |> assert_has(Query.css("#demo-async-combobox [role=option]") |> Query.count(1))
|> assert_has(Query.css("#demo-async-combobox [role=option][data-value='Orange']"))
# Select Orange
|> send_keys([:enter])
diff --git a/demo/test/wallaby/demo_web/listbox_form_integration_test.exs b/demo/test/wallaby/demo_web/listbox_form_integration_test.exs
new file mode 100644
index 0000000..2c99a4a
--- /dev/null
+++ b/demo/test/wallaby/demo_web/listbox_form_integration_test.exs
@@ -0,0 +1,76 @@
+defmodule DemoWeb.ListboxFormIntegrationTest do
+ use Prima.WallabyCase, async: true
+
+ @button Query.css("#listbox-form [aria-haspopup=listbox]")
+ @listbox Query.css("#listbox-form [role=listbox]")
+ @trigger_label Query.css("#listbox-form [data-prima-ref='trigger-label']")
+ @selection_display Query.css("#listbox-selection-display")
+
+ defp assert_form_change_count(session, expected_count) do
+ actual_text = text(session, Query.css("#listbox-form-change-count"))
+
+ assert actual_text == "Form changes: #{expected_count}",
+ "Expected form change count to be #{expected_count} but got '#{actual_text}'"
+
+ session
+ end
+
+ feature "phx-change on the parent form fires when an option is selected", %{session: session} do
+ session
+ |> visit_fixture("/fixtures/listbox-form", "#listbox-form")
+ |> assert_has(@selection_display |> Query.text("Selected: none"))
+ |> click(@button)
+ |> assert_has(@listbox |> Query.visible(true))
+ |> click(Query.css("#listbox-form-option-apple"))
+ |> assert_has(@listbox |> Query.visible(false))
+ |> assert_has(@selection_display |> Query.text("Selected: Apple"))
+ |> assert_form_change_count(1)
+ end
+
+ feature "the trigger label updates instantly, ahead of the phx-change round-trip", %{
+ session: session
+ } do
+ session
+ |> visit_fixture("/fixtures/listbox-form", "#listbox-form")
+ |> assert_has(@trigger_label |> Query.text("Select a fruit..."))
+ |> click(@button)
+ |> click(Query.css("#listbox-form-option-mango"))
+ |> assert_has(@trigger_label |> Query.text("Mango"))
+ end
+
+ feature "phx-change fires again when the selection changes", %{session: session} do
+ session
+ |> visit_fixture("/fixtures/listbox-form", "#listbox-form")
+ |> click(@button)
+ |> click(Query.css("#listbox-form-option-apple"))
+ |> assert_has(@selection_display |> Query.text("Selected: Apple"))
+ |> assert_form_change_count(1)
+ |> click(@button)
+ |> click(Query.css("#listbox-form-option-pineapple"))
+ |> assert_has(@selection_display |> Query.text("Selected: Pineapple"))
+ |> assert_form_change_count(2)
+ end
+
+ feature "phx-change fires when a selection is made via keyboard", %{session: session} do
+ session
+ |> visit_fixture("/fixtures/listbox-form", "#listbox-form")
+ |> click(@button)
+ |> assert_has(@listbox |> Query.visible(true))
+ |> send_keys([:down_arrow])
+ |> send_keys([:enter])
+ |> assert_has(@listbox |> Query.visible(false))
+ |> assert_has(@selection_display |> Query.text("Selected: Apple"))
+ |> assert_form_change_count(1)
+ end
+
+ feature "does not fire phx-change when closing without selecting", %{session: session} do
+ session
+ |> visit_fixture("/fixtures/listbox-form", "#listbox-form")
+ |> click(@button)
+ |> assert_has(@listbox |> Query.visible(true))
+ |> send_keys([:escape])
+ |> assert_has(@listbox |> Query.visible(false))
+ |> assert_has(@selection_display |> Query.text("Selected: none"))
+ |> assert_form_change_count(0)
+ end
+end
diff --git a/demo/test/wallaby/demo_web/listbox_test.exs b/demo/test/wallaby/demo_web/listbox_test.exs
new file mode 100644
index 0000000..89e0041
--- /dev/null
+++ b/demo/test/wallaby/demo_web/listbox_test.exs
@@ -0,0 +1,168 @@
+defmodule DemoWeb.ListboxTest do
+ use Prima.WallabyCase, async: true
+
+ @button Query.css("#listbox [aria-haspopup=listbox]")
+ @listbox Query.css("#listbox [role=listbox]")
+ @options Query.css("#listbox [role=option]")
+ @trigger_label Query.css("#listbox [data-prima-ref='trigger-label']")
+
+ feature "default trigger has type='button' and aria-haspopup='listbox'", %{session: session} do
+ session
+ |> visit_fixture("/fixtures/listbox", "#listbox")
+ |> assert_has(Query.css("#listbox button[aria-haspopup=listbox][type=button]"))
+ end
+
+ feature "shows and hides the listbox when the trigger is clicked", %{session: session} do
+ session
+ |> visit_fixture("/fixtures/listbox", "#listbox")
+ |> assert_has(@listbox |> Query.visible(false))
+ |> click(@button)
+ |> assert_has(@listbox |> Query.visible(true))
+ |> assert_has(@options |> Query.count(4))
+ |> click(@button)
+ |> assert_has(@listbox |> Query.visible(false))
+ end
+
+ feature "closes when clicking outside", %{session: session} do
+ session
+ |> visit_fixture("/fixtures/listbox", "#listbox")
+ |> click(@button)
+ |> assert_has(@listbox |> Query.visible(true))
+ |> click(Query.css("#outside-area"))
+ |> assert_has(@listbox |> Query.visible(false))
+ end
+
+ feature "reflects the initial value on mount without opening the listbox", %{session: session} do
+ session
+ |> visit_fixture("/fixtures/listbox", "#listbox")
+ |> assert_has(@trigger_label |> Query.text("Banana"))
+ |> assert_has(
+ Query.css("#listbox-option-banana[aria-selected=true][data-selected]")
+ |> Query.visible(false)
+ )
+ |> assert_missing(Query.css("#listbox-option-apple[aria-selected=true]"))
+ end
+
+ feature "selecting an option updates the hidden input, ARIA state, and trigger label instantly",
+ %{session: session} do
+ session
+ |> visit_fixture("/fixtures/listbox", "#listbox")
+ |> click(@button)
+ |> click(Query.css("#listbox-option-cherry"))
+ |> assert_has(@listbox |> Query.visible(false))
+ |> assert_has(@trigger_label |> Query.text("Cherry"))
+ |> assert_has(
+ Query.css("#listbox-option-cherry[aria-selected=true][data-selected]")
+ |> Query.visible(false)
+ )
+ |> assert_missing(Query.css("#listbox-option-banana[aria-selected=true]"))
+ |> then(fn session ->
+ value =
+ session
+ |> find(
+ Query.css("#listbox input[type=hidden][name=fruit_choice]")
+ |> Query.visible(false)
+ )
+ |> Element.value()
+
+ assert value == "cherry", "Expected hidden input value to be 'cherry' but got '#{value}'"
+
+ session
+ end)
+ end
+
+ feature "keeps the trailing icon after a selection updates the trigger label", %{
+ session: session
+ } do
+ session
+ |> visit_fixture("/fixtures/listbox", "#listbox")
+ |> click(@button)
+ |> click(Query.css("#listbox-option-apple"))
+ |> assert_has(@trigger_label |> Query.text("Apple"))
+ |> assert_has(Query.css("#listbox-trigger-icon"))
+ end
+
+ feature "disabled options cannot be selected", %{session: session} do
+ session
+ |> visit_fixture("/fixtures/listbox", "#listbox")
+ |> click(@button)
+ |> click(Query.css("#listbox-option-durian"))
+ |> assert_has(@listbox |> Query.visible(true))
+ |> assert_has(@trigger_label |> Query.text("Banana"))
+ end
+
+ feature "keyboard navigation skips disabled options and wraps around", %{session: session} do
+ session
+ |> visit_fixture("/fixtures/listbox", "#listbox")
+ |> click(@button)
+ |> send_keys([:down_arrow])
+ |> assert_has(Query.css("#listbox-option-apple[data-focus]"))
+ # Up from the first enabled option wraps to the last *enabled* option (cherry, skipping durian)
+ |> send_keys([:up_arrow])
+ |> assert_has(Query.css("#listbox-option-cherry[data-focus]"))
+ |> assert_missing(Query.css("#listbox-option-durian[data-focus]"))
+ end
+
+ feature "aria-activedescendant is managed on the trigger button, not the listbox", %{
+ session: session
+ } do
+ session
+ |> visit_fixture("/fixtures/listbox", "#listbox")
+ |> click(@button)
+ |> assert_has(Query.css("#listbox [aria-haspopup=listbox]:not([aria-activedescendant])"))
+ |> send_keys([:down_arrow])
+ |> assert_has(
+ Query.css("#listbox [aria-haspopup=listbox][aria-activedescendant='listbox-option-apple']")
+ )
+ |> assert_has(Query.css("#listbox [role=listbox]:not([aria-activedescendant])"))
+ |> send_keys([:escape])
+ |> assert_has(Query.css("#listbox [aria-haspopup=listbox]:not([aria-activedescendant])"))
+ end
+
+ feature "Opening and closing listbox with keyboard (Enter, Space, Esc)", %{session: session} do
+ session
+ |> visit_fixture("/fixtures/listbox", "#listbox")
+ |> click(@button)
+ |> assert_has(@listbox |> Query.visible(true))
+ # Escape closes the listbox, but keeps focus on the
+ # trigger button, letting Enter/Space open it again.
+ |> send_keys([:escape])
+ |> assert_has(@listbox |> Query.visible(false))
+ |> send_keys([" "])
+ |> assert_has(@listbox |> Query.visible(true))
+ |> assert_has(Query.css("#listbox-option-banana[data-focus]"))
+ |> send_keys([:escape])
+ |> assert_has(@listbox |> Query.visible(false))
+ |> send_keys([:enter])
+ |> assert_has(@listbox |> Query.visible(true))
+ |> assert_has(Query.css("#listbox-option-banana[data-focus]"))
+ end
+
+ feature "aria-controls/aria-labelledby relationships are set from the given IDs", %{
+ session: session
+ } do
+ session
+ |> visit_fixture("/fixtures/listbox", "#listbox")
+ |> assert_has(
+ Query.css("#listbox-trigger[aria-haspopup=listbox][aria-controls='listbox-options']")
+ )
+ |> assert_has(
+ Query.css("#listbox-options[role=listbox][aria-labelledby='listbox-trigger']")
+ |> Query.visible(false)
+ )
+ end
+
+ feature "remains functional after LiveView reconnection", %{session: session} do
+ session
+ |> visit_fixture("/fixtures/listbox", "#listbox")
+ |> execute_script("window.liveSocket.disconnect()")
+ |> execute_script("window.liveSocket.connect()")
+ # Wait for reconnection by checking for the data attribute that gets set
+ |> assert_has(Query.css(".phx-connected[data-phx-main]"))
+ |> assert_has(@trigger_label |> Query.text("Banana"))
+ |> click(@button)
+ |> assert_has(@listbox |> Query.visible(true))
+ |> click(Query.css("#listbox-option-apple"))
+ |> assert_has(@trigger_label |> Query.text("Apple"))
+ end
+end
diff --git a/lib/prima/listbox.ex b/lib/prima/listbox.ex
new file mode 100644
index 0000000..1c396c2
--- /dev/null
+++ b/lib/prima/listbox.ex
@@ -0,0 +1,205 @@
+defmodule Prima.Listbox do
+ @moduledoc """
+ A single-select listbox component for use as a form input.
+
+ Unlike `Prima.Dropdown` (an action menu, `role="menu"`), `Listbox` is a value
+ picker (`role="listbox"`) — selecting an option updates a hidden form field
+ and the trigger's label, similar to a native ``.
+
+ ## Quick Start
+
+ <.listbox id="fruit-listbox" name="fruit" value={@selected_fruit}>
+ <.listbox_trigger id="fruit-listbox-trigger">
+ {@selected_fruit || "Select a fruit..."}
+
+
+ <.listbox_options id="fruit-listbox-options">
+ <.listbox_option id="fruit-option-apple" value="apple">Apple
+ <.listbox_option id="fruit-option-banana" value="banana">Banana
+
+
+
+ ## Form Integration
+
+ `Listbox` renders a hidden ` ` with the given `name`. Selecting an option
+ updates the input's value and dispatches a bubbling `input` event, so a parent
+ form's `phx-change` fires exactly like it would for a native form field:
+
+
+
+ def handle_event("form_changed", %{"fruit" => fruit}, socket) do
+ {:noreply, assign(socket, selected_fruit: fruit)}
+ end
+
+ ## Trigger Label
+
+ The trigger's label is rendered by the caller (so the initial page load is
+ always correct — no flash of placeholder text), and updated instantly on the
+ client when an option is picked, ahead of any server round-trip:
+
+ <.listbox_trigger id="fruit-listbox-trigger">
+ {@selected_fruit || "Select a fruit..."}
+
+ """
+
+ use Phoenix.Component
+ alias Phoenix.LiveView.JS
+
+ attr :id, :string, required: true
+ attr :name, :string, required: true
+ attr :value, :string, default: nil
+ attr :rest, :global
+ slot :inner_block, required: true
+
+ def listbox(assigns) do
+ ~H"""
+
+
+ {render_slot(@inner_block)}
+
+ """
+ end
+
+ attr :id, :string, required: true
+ attr :class, :string, default: ""
+ attr :rest, :global
+ slot :inner_block, required: true
+ slot :icon
+
+ @doc """
+ The trigger button for a listbox.
+
+ The `inner_block` slot is the label — render the currently selected value (or
+ a placeholder) there so the initial page load is correct; the JS hook rewrites
+ just this label on selection, leaving the `icon` slot untouched.
+
+ ## Examples
+
+ <.listbox_trigger id="fruit-listbox-trigger">
+ {@selected_fruit || "Select a fruit..."}
+ <:icon>
+ ...
+
+
+
+ ## Accessible Naming
+
+ The listbox is named after this trigger's accessible name (via
+ `aria-labelledby`). If the trigger only ever shows the *current value* (e.g.
+ a role picker whose trigger just says "Viewer", with no "Role" label
+ anywhere), the listbox gets announced by its value instead of its
+ purpose — the same problem as a native `` with no ``.
+
+ Fix it by adding an `aria-label` describing the field:
+
+ <.listbox_trigger id="role-listbox-trigger" aria-label="Role">
+ {@selected_role}
+
+ """
+ def listbox_trigger(assigns) do
+ ~H"""
+
+ {render_slot(@inner_block)}
+ {render_slot(@icon)}
+
+ """
+ end
+
+ attr :id, :string, required: true
+ attr :transition_enter, :any, default: nil
+ attr :transition_leave, :any, default: nil
+ attr :class, :string, default: ""
+ attr :rest, :global
+ slot :inner_block, required: true
+
+ # Positioning reference
+ attr :reference, :string, default: nil
+
+ # Floating UI positioning options
+ attr :placement, :string,
+ default: "bottom-start",
+ values:
+ ~w(top top-start top-end right right-start right-end bottom bottom-start bottom-end left left-start left-end)
+
+ attr :flip, :boolean, default: true
+ attr :offset, :integer, default: 4
+ attr :match_trigger_width, :boolean, default: true
+
+ # Two-div structure separates positioning from transitions, same as Dropdown's
+ # menu wrapper — see lib/prima/dropdown.ex for the rationale.
+ def listbox_options(assigns) do
+ ~H"""
+
+
+ {render_slot(@inner_block)}
+
+
+ """
+ end
+
+ attr :id, :string, required: true
+ attr :value, :string, required: true
+ attr :display, :string, default: nil
+ attr :class, :string, default: ""
+ attr :disabled, :boolean, default: false
+ attr :rest, :global
+ slot :inner_block, required: true
+
+ @doc """
+ An individual selectable option within a listbox.
+
+ ## Attributes
+
+ * `id` (required) - Unique identifier, required for ARIA relationships
+ * `value` (required) - The value submitted when this option is selected
+ * `display` - The text used for the trigger label when selected (defaults to `value`)
+ * `disabled` - Boolean to mark the option as unselectable (default: false)
+ """
+ def listbox_option(assigns) do
+ assigns = assign(assigns, :display_value, assigns.display || assigns.value)
+
+ ~H"""
+
+ {render_slot(@inner_block)}
+
+ """
+ end
+end