From 3f47fe2ba532d5db85ff00c112c82ab1973200d5 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Thu, 10 Sep 2026 13:10:14 +0100 Subject: [PATCH 01/12] feat: add Listbox component for single-select value pickers Dropdown's menu semantics (role="menu"/"menuitem") don't fit picking a value for a form - that's an ARIA listbox pattern instead. Listbox renders a hidden form field and instantly updates the trigger's label on selection, ahead of any phx-change round-trip, while keeping the same Floating UI positioning and keyboard navigation as Dropdown. Also adds a demo docs page with live examples, Wallaby coverage, and an Introduction page entry. --- assets/js/hooks/listbox.js | 413 ++++++++++++++++++ assets/js/prima.js | 3 +- demo/assets/js/app.js | 3 +- demo/lib/demo_web/components/code_example.ex | 4 +- demo/lib/demo_web/live/demo_live.html.heex | 4 + .../live/demo_live/introduction.html.heex | 12 + .../live/demo_live/listbox_form_demo.ex | 68 +++ .../live/demo_live/listbox_page.html.heex | 214 +++++++++ .../demo_web/live/demo_live/sidebar.html.heex | 11 + demo/lib/demo_web/live/fixtures_live.ex | 2 +- .../lib/demo_web/live/fixtures_live.html.heex | 8 + .../fixtures_live/listbox_fixture.html.heex | 42 ++ .../listbox_form_fixture.html.heex | 29 ++ demo/lib/demo_web/router.ex | 3 + .../code_examples/listbox/basic.html.heex | 38 ++ .../code_examples/listbox/disabled.html.heex | 50 +++ .../listbox/listbox_form_demo.ex | 68 +++ .../listbox_form_integration_test.exs | 76 ++++ demo/test/wallaby/demo_web/listbox_test.exs | 168 +++++++ lib/prima/listbox.ex | 205 +++++++++ 20 files changed, 1417 insertions(+), 4 deletions(-) create mode 100644 assets/js/hooks/listbox.js create mode 100644 demo/lib/demo_web/live/demo_live/listbox_form_demo.ex create mode 100644 demo/lib/demo_web/live/demo_live/listbox_page.html.heex create mode 100644 demo/lib/demo_web/live/fixtures_live/listbox_fixture.html.heex create mode 100644 demo/lib/demo_web/live/fixtures_live/listbox_form_fixture.html.heex create mode 100644 demo/priv/code_examples/listbox/basic.html.heex create mode 100644 demo/priv/code_examples/listbox/disabled.html.heex create mode 100644 demo/priv/code_examples/listbox/listbox_form_demo.ex create mode 100644 demo/test/wallaby/demo_web/listbox_form_integration_test.exs create mode 100644 demo/test/wallaby/demo_web/listbox_test.exs create mode 100644 lib/prima/listbox.ex diff --git a/assets/js/hooks/listbox.js b/assets/js/hooks/listbox.js new file mode 100644 index 0000000..4cfc169 --- /dev/null +++ b/assets/js/hooks/listbox.js @@ -0,0 +1,413 @@ +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) { + const focusedOption = this.el.querySelector(SELECTORS.FOCUSED_OPTION) + + 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) + } + }, + + handleShowStart() { + this.refs.button.setAttribute('aria-expanded', 'true') + + // Setup autoUpdate to reposition on scroll/resize + this.autoUpdateCleanup = autoUpdate(this.refs.referenceElement, this.refs.optionsWrapper, () => { + this.positionListbox() + }) + }, + + handleHideEnd() { + 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""" +
+
+ <.listbox id="demo-form-listbox" name="favorite_fruit" value={@selected_fruit}> + <.listbox_trigger + id="demo-form-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" + > + {@selected_fruit || "Select a fruit..."} + <:icon> + + + + + <.listbox_options + id="demo-form-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(@fruits)} + id={"demo-form-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} + + + +
+ +

+ 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/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 @@ +
+ <.listbox id="listbox-form" name="fruit" value={@selected_fruit}> + <.listbox_trigger + id="listbox-form-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" + > + {@selected_fruit || "Select a fruit..."} + + + <.listbox_options + id="listbox-form-options" + class="py-1 rounded-md bg-white shadow-xs ring-1 ring-gray-300" + > + <.listbox_option id="listbox-form-option-apple" value="Apple">Apple + <.listbox_option id="listbox-form-option-mango" value="Mango">Mango + <.listbox_option id="listbox-form-option-pineapple" value="Pineapple"> + Pineapple + + + + +
+ Selected: {@selected_fruit || "none"} +
+ +
+ Form changes: {@form_change_count} +
+
diff --git a/demo/lib/demo_web/router.ex b/demo/lib/demo_web/router.ex index 9970f0e..44ec86b 100644 --- a/demo/lib/demo_web/router.ex +++ b/demo/lib/demo_web/router.ex @@ -19,6 +19,7 @@ defmodule DemoWeb.Router do live "/modal", DemoLive, :modal live "/modal/history", DemoLive, :modal_history live "/combobox", DemoLive, :combobox + live "/listbox", DemoLive, :listbox if Mix.env() in [:dev, :test] do live "/fixtures/dropdown", FixturesLive, :dropdown @@ -45,6 +46,8 @@ defmodule DemoWeb.Router do live "/fixtures/display-value-combobox", FixturesLive, :display_value_combobox live "/fixtures/combobox-change", FixturesLive, :combobox_change live "/fixtures/async-combobox-form-change", FixturesLive, :async_combobox_form_change + live "/fixtures/listbox", FixturesLive, :listbox + live "/fixtures/listbox-form", FixturesLive, :listbox_form end end end diff --git a/demo/priv/code_examples/listbox/basic.html.heex b/demo/priv/code_examples/listbox/basic.html.heex new file mode 100644 index 0000000..b098005 --- /dev/null +++ b/demo/priv/code_examples/listbox/basic.html.heex @@ -0,0 +1,38 @@ +<.listbox id="basic-listbox" name="favorite_fruit" value="Cherry"> + <.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""" +
+
+ <.listbox id="demo-form-listbox" name="favorite_fruit" value={@selected_fruit}> + <.listbox_trigger + id="demo-form-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" + > + {@selected_fruit || "Select a fruit..."} + <:icon> + + + + + <.listbox_options + id="demo-form-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(@fruits)} + id={"demo-form-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} + + + +
+ +

+ 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/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 `` 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: + +
+ <.listbox id="fruit-listbox" name="fruit" value={@selected_fruit}> + ... + +
+ + 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 ``. + and the displayed value, similar to a native `