Skip to content

Add combobox component - #2522

Draft
Flo0807 wants to merge 10 commits into
developfrom
claude/reusable-combobox-component-d7830d
Draft

Flo0807 wants to merge 10 commits into
developfrom
claude/reusable-combobox-component-d7830d

Conversation

@Flo0807

@Flo0807 Flo0807 commented Nov 13, 2026 •

Copy link
Copy Markdown
Collaborator

closes #984

Adds Backpex.HTML.Combobox.combobox/1, a select with search, multiple selection, groups, custom option markup and server search, and the BackpexCombobox hook that drives it. It is built to be reusable outside of a LiveResource (custom fields, filters, any LiveView) and with few ties to Backpex: one function component and one hook. The only new dependency is @floating-ui/dom (MIT), bundled into priv/static/js like imask.

The built-in selects and multi selects of fields and filters render the combobox (commit 6de672fe), and Backpex.HTML.Form.multi_select/1 is removed. That part is breaking, see "Fields and filters" below and guides/upgrading/v0.25.md.

What it does

  • A form control like a native select: takes field (or id, name, value) and the options formats of options_for_select/2, submits through the form's phx-change and submit, and needs no event handlers for local options.
  • multiple with max, clearable, groups, disabled options, sizes xs to xl, disabled and readonly.
  • Search in the browser (case and diacritics insensitive) or on the server with on_search (debounced, honors phx-target, loading state), with selected_options for the labels of values the search results do not contain.
  • select_all: a line in the popup with the number of selected values and buttons to select and deselect all options the search shows (multiple without max).
  • Slots :option, :header, :footer, :empty.
  • Commands via JS.dispatch/2: combobox:open, combobox:close, combobox:clear, combobox:select-all. The hook emits combobox:change.
  • Keyboard operation after the WAI-ARIA combobox and listbox patterns, including typeahead without a search input.

Fields and filters

  • Fields.Select and Fields.BelongsTo (form and index_editable), Fields.MultiSelect, Fields.HasMany, the select in the modal of Fields.HasManyThrough, Filters.Select and Filters.MultiSelect render the combobox. Their options and callbacks are unchanged and the forms submit the same params.
  • A selection changes in the browser and reaches the server with the form's phx-change. The events toggle-option and toggle-select-all are gone.
  • Fields.MultiSelect uses select_all. Fields.HasMany searches on the server (on_search), loads further pages with "Show more" in the :footer slot, and keeps its own "Select all" in the :header slot: it selects every option of the options_query on the server, also the ones on pages that are not loaded.
  • New option searchable_options (default false) for Select, BelongsTo and HasManyThrough adds a search input. The demo turns it on for the author of a post and the country of an address.
  • A Select or BelongsTo without a prompt still selects its first option while the item has no value, like the native select did. With a prompt it shows the prompt and a clear button.
  • Fields.MultiSelect leaves values of the form params that match no option out of the selection it renders, so they are not submitted again. develop made the removed toggle-option event ignore such ids; this keeps that guarantee after the merge.
  • c:Backpex.Filter.render_form/1 receives the label of the filter as @label; the select filters use it as the accessible name of their combobox.
  • Backpex.Testing.submit_form/3 and confirm_item_action/2 send the values of fields with multiple values with the event, as their inputs only exist in the browser.
  • Combobox changes this needed: phx-debounce/phx-throttle go to the anchor input (with "blur" the change is sent when focus leaves the combobox), a click on an option keeps the focus when the combobox sits in a focusable element (the menu of the filter dropdown closed the popup before the click), and the root isolates its stacking context (the clear button of an index editable select showed through the filter dropdown).

Breaking: Form.multi_select/1 and its events are removed, the markup and ids of these fields and filters changed (has-many-dropdown-<name>, multi-select-<id>), the "search" event of HasMany takes %{"query" => query}, PhoenixTest.select/3 and check/2 find no native control for them, and a filter that overrides render_form/1 and calls the one of Filters.Select or Filters.MultiSelect with assigns of its own has to pass label on.

Not covered: BelongsTo still loads all options (no server search), and a Select as a child field of InlineCRUD was not looked at in a browser.

Screenshots

Multiple selection at max, light theme:

Multiple selection with three badges and the open popup

:option slot, dark theme:

Open popup with custom option markup in the dark theme

Select all (select_all):

Open popup with the line "2 selected", "Select all" and "Deselect all" above the options

combobox-select-all.mp4

Fields.HasMany in the form of a post (server search, "Select all" for all options of the query):

Tags combobox of the post form with two selected tags and the open popup

Fields.BelongsTo with searchable_options:

Author combobox of the post form with a search for "a" and the matching users

Filters.MultiSelect inside the filter dropdown:

Users filter with two selected users and the open popup inside the filter dropdown

Design decisions

  • The server renders, the hook behaves. All markup and classes are in HEEx. The hook only toggles hidden, aria-* and data-*, sets input values and clones a server-rendered badge template; it contains no class names and no texts.
  • Popover API and Floating UI. The popup is a manual popover in the top layer, so modals, tables and the filter dropdown do not clip it. Floating UI keeps it next to the control: it opens above when it does not fit below, stays inside the viewport and follows the control on scroll, resize and layout shifts. There is no fallback for browsers without the Popover API. The dependency adds about 45 kB to the unminified backpex.esm.js (6.8 kB minified and gzipped).
  • Anchor input. LiveView treats a field whose inputs are all type="hidden" as used from the start, so its errors would show before the admin touched it. The combobox therefore renders one hidden text input with the plain name (the value for a single select, the empty fallback for multiple) and dispatches input/change on it.
  • Badges are plain elements, not function components. LiveView skips unchanged component roots in a patch and expects to find them in the DOM; the hook adds and removes badges, which left empty elements behind.
  • Select all acts on what the search shows. Both buttons leave values that the search hides as they are, and skip options the server disabled. They only know the rendered options, so with on_search they act on the last search result. The button that has nothing to do is aria-disabled, not disabled, so it does not drop the focus when it is used with the keyboard.
  • Server overrule behaves like a native input. A phx-change handler that renders another value shows that value. If the server renders the value it had before, the patch does not touch the field and the admin's choice stays, as with a native select.

Tests

  • test/js/combobox_test.js: matching, navigation and typeahead as pure functions; debounced search push, phx-target, stale replies and cleanup with a fake root.
  • test/html/combobox_test.exs: rendering (options formats, selection, form controls and submitted params, field errors, disabled/readonly, ARIA wiring, slots, texts).
  • integration_test/e2e/combobox_test.exs: mouse and keyboard operation, local and server search, multiple, commands, server patches while open, server overrule, Escape in a <dialog> and in the Backpex modal, flipping, following the control on a layout shift, select all with mouse and keyboard, control height per size, a mobile viewport.
  • integration_test/e2e/a11y_test.exs: axe on the fixture closed and open, in light and dark.
  • integration_test/e2e/select_fields_test.exs: the fields and filters in the browser (select all, search, "Show more", saving, index edit, the modal of HasManyThrough, the filters inside the filter dropdown). Unit and integration tests of the fields and filters drive the new markup.
  • The test app gets the plain LiveView Backpex.Test.ComboboxLive at /admin/combobox; harness_test.exs now only treats Index, Show and Form views as LiveResource views.

Docs

Moduledoc of Backpex.HTML.Combobox, a section in the custom fields guide, usage-rules.md and the create-field skill. For the fields and filters: their moduledocs, the readonly guide, the create-filter skill and the upgrade guide v0.25.

Demo

/admin/combobox shows one section per capability with the value the server holds (second commit; drop it if the showcase should not be part of the demo).

Known limits

  • A form reset does not update the display.
  • The bundle in priv/static/js carries no license notice for its bundled MIT dependencies (imask already, now also @floating-ui/dom).
  • Checked in Chromium only (browser tests and by hand); Firefox and Safari are untested.
  • In the cupcake theme axe flags the contrast of the daisyUI label class, which Form.input/1 uses as well.

Backpex.HTML.Combobox.combobox/1 is a select with search, multiple
selection, groups, custom option markup and server search, driven by
the new BackpexCombobox hook. It is a form control like a native
select: the server renders all markup and state, the hook only toggles
attributes, so it can be used in custom fields, filters and LiveViews
outside of a LiveResource.

Existing fields and filters keep their current inputs.
A page at /admin/combobox with one section per capability of the
combobox, each showing the value the server holds.
Floating UI replaces the hook's own geometry and its scroll and resize
listeners. Besides scrolling and resizing, it repositions the popup
when the control or the popup change their size or place, which the
hook did not notice before.

@floating-ui/dom (MIT) is bundled into priv/static/js like imask.
@Flo0807 Flo0807 added the feature New feature label Nov 13, 2026
@codecov

codecov Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.45799% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
lib/backpex/fields/select.ex 95.00% 1 Missing ⚠️
lib/backpex/html/combobox.ex 99.60% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

The formatter of Elixir 1.16 removes the parentheses around the
condition in describedby/2, the one of later versions keeps them, so
the formatting check failed on 1.16. Name the condition instead.
The rendering tests left the badge and clear button classes of the
sizes, a group inside a group, the {:hr, nil} separator and a combobox
without a name unchecked.
@Flo0807 Flo0807 self-assigned this Nov 13, 2026
@Flo0807
Flo0807 marked this pull request as draft November 13, 2026 07:59
With select_all, the popup of a multiple combobox without a maximum
shows the number of selected values and buttons to select and deselect
all options the search shows. Values the search hides stay as they
are.
The forms of Select, BelongsTo, MultiSelect and HasMany, the select of
HasManyThrough and the Select and MultiSelect filters render
Backpex.HTML.Combobox.combobox/1 instead of a native select or a
dropdown with checkboxes. An admin changes the selection in the
browser and it reaches the server with the phx-change of the form, so
the multi select needs no event per option. HasMany searches its
options on the server through on_search and loads further pages with
"Show more".

Select, BelongsTo and HasManyThrough take the new option
searchable_options, which adds a search input. A select without a
prompt still selects its first option, like a native select.

Backpex.HTML.Form.multi_select/1 is removed. Backpex.Testing sends
the values of fields with multiple values with the event, as their
inputs only exist in the browser.

The combobox passes phx-debounce and phx-throttle to its anchor input
and blurs it when focus leaves, keeps the focus when it sits in a
focusable element like the filter dropdown, and isolates its stacking
context, so its clear button stays below overlays.
@Flo0807 Flo0807 added breaking-change A breaking change and removed feature New feature labels Nov 13, 2026
develop made the multi select's toggle-option event ignore an id that
matches no option. The field no longer has that event: its combobox
submits the selection with the form. It now leaves values that match no
option out of the selection it renders, so they are not submitted
again.
Backpex.Testing parsed the form with LazyHTML to find the fields with
multiple values. LazyHTML is a test-only dependency, so compiling
Backpex in dev failed with warnings as errors, and an app that uses
Backpex got warnings for the undefined module. Ask LiveViewTest for the
anchor input of each param instead.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking-change A breaking change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Create Combobox component

1 participant