PetalComponents.ComboBox (petal_components v4.12.0)

Copy Markdown View Source

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

combo_box(assigns)

Attributes

  • id (:string) - unique id; the PetalComboBox hook mounts here. Derived from field or name when absent. Defaults to nil.

  • field (Phoenix.HTML.FormField) - a form field, e.g. f[:country] - supplies name, value and id. Defaults to nil.

  • name (:string) - input name, when not using field. Defaults to nil.

  • value (:any) - current value - or list of values when multiple (overrides field). Defaults to nil.

  • 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 to false.

  • max_items (:integer) - multiple only: cap on chosen options; at the cap, unchosen options render inert. Defaults to nil.

  • clearable (:boolean) - single select only: show a clear button in the control when a value is chosen. Defaults to false.

  • 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}
    end

    The 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 to nil.

  • 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 to false.

  • required (:boolean) - renders on the hidden select, so native required validation guards the real control. Defaults to false.

  • form_id (:string) - the form this control belongs to when rendered outside it (the select's form attribute). Defaults to nil.

  • 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 to nil.

  • Global attributes are accepted.

Slots

  • option - custom option content, rendered inside each panel option in place of the plain label - :let receives the normalized option map (label, value, disabled, and meta: 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. :let receives 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. :let receives 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.