A generic instant typeahead for "search a source, pick an entry" fields —
extracted from phoenix_kit_crm's involved-parties picker.
The dropdown opens, spins, and renders ENTIRELY client-side (the
SearchPicker hook in phoenix_kit.js); the server only executes the
actual search and returns rows via push_event. Nothing visual waits on
a round-trip.
Contract
The consumer (LiveView or LiveComponent) implements:
- a search event (
search_event, default"picker_search") receiving%{"q" => query, "limit" => limit}and answering withpush_event(socket, results_event, %{q: query, results: rows, has_more: bool})where each row is%{kind:, uuid:, label:, sublabel?, icon?}(icon = a heroicon class, defaults tohero-user); - a pick event (
pick_event, default"picker_pick") receiving%{"kind" =>, "uuid" =>, "label" =>}— stage your chip, then confirm withpush_event(socket, staged_event, %{})so the hook clears the input; - optionally a free-text event (
text_event) receiving%{"name" => text}— the "Add … as text" row renders only when this attr is set.
Multiple pickers in one view
push_event replies broadcast to EVERY hook listening on the event
name, so two pickers sharing names would cross-populate (and a staged
confirm would clear both). Give each picker distinct event names — or
keep shared names and echo the id the hook sends with every push
(%{"id" => input_id}) back in the results/staged payloads; when
present, each instance drops payloads addressed to another id.
Single-select mode
mode="single" turns the picker into a suggestion box for ONE value: a
pick sets the input's value client-side and fires a synthetic input event
so a surrounding form's phx-change (and any server-side value→id
mapping) sees it. No pick/staged events, no chips — give the input a form
name and it submits like any field.
Example (multi, inside a LiveComponent)
<.search_picker
id="party-search"
dropdown_id="party-dropdown"
text_event="stage_text"
placeholder={gettext("Type a name…")}
/>Events auto-route to the closest LiveComponent/LiveView containing the input — no target needed when the picker renders inside the component that handles its events.
The chips themselves stay consumer-rendered — their look and remove semantics differ per feature.
Summary
Functions
Renders the picker input + its client-rendered dropdown container.
Functions
Renders the picker input + its client-rendered dropdown container.
Attributes
id— input DOM id (required; must be page-unique)dropdown_id— dropdown DOM id (required)target— CSS selector of an element inside the LiveComponent that should receive the pushed events. ONLY needed when the picker renders OUTSIDE that component's DOM tree; omitted, events auto-route to the closest LiveComponent/LiveView containing the input (the usual case). The selector must actually match an element or every push is dropped with a console error.mode—"multi"(default) or"single"(see moduledoc)search_on_focus— whentrue, clicking/focusing the EMPTY input already opens the dropdown in browse mode (the hook searches with an empty query, so the server should answer it with a first page — the workspace picker rule: offer options before any typing). Off by default; without it the dropdown only opens while typing, which also means a multi-mode picker won't reopen after a pick clears the input. (Previously only reachable as a rawdata-search-on-focusrest attr — still honored for existing call sites.)direction—"down"(default) or"up". Use"up"when the input sits near the BOTTOM of a scrollable container (e.g. the last field of a modal): a downward dropdown there extends the container's scroll area instead of floating, so the suggestions end up below the fold — users read that as "no suggestions".name/value— form field wiring for single modesearch_event/results_event/pick_event/text_event/staged_event— event-name overrides (defaults in moduledoc;text_eventnil = no free-text row)placeholder,class— input presentationsearching_labeletc. — translated strings for the client-rendered rowsclose_on_pick— multi mode: after a pick the list stays closed until the user types again (by default it clears the input and, withsearch_on_focus, reopens straight away)row_layout—"inline"(default: label and sublabel on one line, the label truncated) or"stacked"(label on top, up to two lines, sublabel underneath)- anything else in
rest(form,maxlength,required,inputmode,enterkeyhint,autocapitalize…) lands on the input
Where the list is drawn
The dropdown is a popover="manual" element: it opens in the top layer,
positioned from the input, so an overflow ancestor — a modal's body, a
scrolling panel — can no longer cut it off. It stays a DOM child of the
picker, so inside a <dialog> it is not inert. It opens downward unless
direction="up" or there is no room below (the phone keyboard counts),
follows the input when anything scrolls, and closes with the dialog.
Browsers without the Popover API get the old absolute positioning.
On touch screens a row is picked when the finger lifts (and has not moved to scroll), so the first tap counts even with the keyboard up.
Attributes
id(:string) (required)dropdown_id(:string) (required)target(:any) - Defaults tonil.mode(:string) - Defaults to"multi". Must be one of"multi", or"single".search_on_focus(:boolean) - Defaults tofalse.direction(:string) - Defaults to"down". Must be one of"down", or"up".name(:string) - Defaults tonil.value(:string) - Defaults tonil.search_event(:string) - Defaults to"picker_search".results_event(:string) - Defaults to"picker_results".pick_event(:string) - Defaults to"picker_pick".text_event(:string) - Defaults tonil.staged_event(:string) - Defaults to"picker_staged".placeholder(:string) - Defaults tonil.class(:any) - Defaults to"input w-full".searching_label(:string) - Defaults to"Searching…".add_prefix_label(:string) - Defaults to"Add".add_suffix_label(:string) - Defaults to"as text".adding_label(:string) - Defaults to"Adding…".more_label(:string) - Defaults to"Load more".loading_more_label(:string) - Defaults to"Loading…".no_matches_label(:string) - Defaults to"No matches".close_on_pick(:boolean) - Defaults tofalse.row_layout(:string) - Defaults to"inline". Must be one of"inline", or"stacked".- Global attributes are accepted. Supports all globals plus:
["form", "maxlength", "minlength", "required", "disabled", "readonly", "autofocus", "inputmode", "enterkeyhint", "autocapitalize", "spellcheck", "pattern"].