A searchable select: type to filter, arrow keys to move, Enter to choose.
The visible input is display-only chrome; the real form control is a
hidden native <select> kept in sync by the PetalComboBox hook. That
makes the component form-native: changesets, phx-change and LiveView
form recovery all work exactly as they do for a plain select - there is
no phx-update="ignore" island and no client-owned state to lose.
(Recovery follows the standard LiveView rules: the enclosing form needs
an id and a phx-change.)
Filtering happens client-side in the hook (zero dependencies), reusing the command palette's scoring: value prefix beats word-boundary prefix beats substring beats fuzzy subsequence. Options are hidden, never reordered - the server owns DOM order, which keeps the listbox safe under LiveView patches.
The markup follows the WAI-ARIA combobox pattern: the input carries
role="combobox" and aria-activedescendant, the panel is a
listbox, options are options. Keyboard focus never leaves the
input; the highlight is virtual (data-highlighted), and
aria-selected means the chosen value - the check-marked one - not
the highlight. Arrow keys wrap through an empty stop: Down past the
last option clears the highlight, Down again starts from the top.
<.form :let={f} for={@form} phx-change="validate">
<.combo_box field={f[:country]} options={["Australia", "Japan", "Portugal"]} />
</.form>Multiple
multiple turns the trigger into a chip row: each chosen option renders
as a removable token, the panel stays open while picking, Backspace in
an empty input removes the last chip, and the hidden select becomes a
real <select multiple> (the name gains [] so every choice survives
the form post). max_items caps how many can be chosen - at the cap,
unchosen options render inert until something is removed.
<.combo_box
field={f[:tags]}
multiple
max_items={5}
options={["elixir", "phoenix", "liveview"]}
/>Options
Options accept the shapes select users expect:
"Australia"- label and value are the string{"Australia", "au"}- label / value{"Australia", "au", disabled: true}- with per-option opts{"Oceania", [...]}- a group: heading plus its options
Summary
Functions
Attributes
id(:string) - unique id; the PetalComboBox hook mounts here. Derived from field or name when absent. Defaults tonil.field(Phoenix.HTML.FormField) - a form field, e.g. f[:country] - supplies name, value and id. Defaults tonil.name(:string) - input name, when not using field. Defaults tonil.value(:any) - current value - or list of values when multiple (overrides field). Defaults tonil.options(:list) - strings, {label, value} tuples, {label, value, opts} (opts: disabled: true), or {group_label, options} groups. Defaults to[].variant(:string) - input is the searchable field; trigger is a select-like button whose panel carries the search input - the picker anatomy, and the data table's filter editor. Defaults to"input". Must be one of"input", or"trigger".search_placeholder(:string) - trigger variant: placeholder for the search input inside the panel. Defaults to"Search…".count_label(:string) - trigger variant with multiple: the word after the count in the closed label. Defaults to"selected".multiple(:boolean) - chip-row selection: the hidden select becomes select multiple and the name gains []. Defaults tofalse.max_items(:integer) - multiple only: cap on chosen options; at the cap, unchosen options render inert. Defaults tonil.clearable(:boolean) - single select only: show a clear button in the control when a value is chosen. Defaults tofalse.free_text(:boolean) - typed text is a committable value: Enter with no highlighted option commits the query itself (the empty-stop keyboard grammar's payoff). The committed value is inserted into the hidden select as a dynamic option, so forms post it like any other choice - the server owns whether it persists (re-render it in options to keep it).Defaults to
false.create(:boolean) - free_text plus an explicit "create" row in the panel: when the query matches no option label exactly, a keyboard-reachable row offers to create it. Implies free_text's commit behavior.Defaults to
false.create_label(:string) - the create row's verb, localizable. Defaults to"Create".remote_options_event_name(:string) - use your LiveView as a remote data source: typing pushes this event (debounced) with the search term as the payload, exactly like the Tom Select-era contract. Handle it and reply with results:def handle_event("search", term, socket) do results = MyApp.search(term) |> Enum.map(&%{text: &1.name, value: &1.id}) {:reply, %{results: results}, socket} endThe hook renders the results as the option list (the listbox becomes hook-owned in remote mode); a chosen result is inserted into the hidden select so the form posts it. Requires a LiveView socket.
Defaults to
nil.remote_options_target(:any) - the event's target - pass @myself when the handler lives on a LiveComponent. Defaults tonil.loading_label(:string) - the remote loading row's text, localizable. Defaults to"Searching…".size(:string) - control density - follows the field-size family. Defaults to"md". Must be one of"sm","md", or"lg".placeholder(:string) - Defaults to"Select an option…".disabled(:boolean) - Defaults tofalse.required(:boolean) - renders on the hidden select, so native required validation guards the real control. Defaults tofalse.form_id(:string) - the form this control belongs to when rendered outside it (the select's form attribute). Defaults tonil.no_results_text(:string) - Defaults to"No results found".results_label(:string) - the live-region word after the count. Defaults to"results".clear_label(:string) - aria-label for the clear button. Defaults to"Clear selection".remove_label(:string) - aria-label prefix for chip remove buttons; the option label is appended. Defaults to"Remove".listbox_label(:string) - accessible name for the listbox. Defaults to"Options".class(:any) - extra classes for the wrapper. Defaults tonil.Global attributes are accepted.
Slots
option- custom option content, rendered inside each panel option in place of the plain label -:letreceives the normalized option map (label,value,disabled, andmeta: everything else from the option's keyword opts). Filtering, chips and the trigger label keep using the plain label, so rich content never affects search or the closed state.header- panel chrome rendered above the option list (below the trigger variant's search) - column captions, hints. Lives OUTSIDE the listbox, so keyboard navigation and filtering never touch it.footer- panel chrome rendered below the option list - counts, "manage" links. Lives OUTSIDE the listbox; pointer-interactive content works, keyboard focus stays with the options.selected- trigger variant only: custom closed-state content rendered inside the trigger in place of the plain label/count - avatars, colored dots, "+N" summaries.:letreceives the LIST of chosen normalized option maps (label,value,meta). Client-side picks show the plain optimistic text until the LiveView patch re-renders the slot - the server-wins reconciliation the trigger label already uses. The empty state always shows the placeholder.chip- multiple mode: custom chip content rendered in place of the plain label - avatars, dots.:letreceives the chosen normalized option map. The remove button stays appended. Client-side picks build plain-text chips optimistically until the LiveView patch re-renders the rich ones (server wins); server-rendered chips are left intact whenever they already match the selection.