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""" +
+
+ <.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/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 @@ +
+ <.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/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">
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">