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)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 rows
Attributes
id(:string) (required)dropdown_id(:string) (required)target(:any) - Defaults tonil.mode(:string) - Defaults to"multi". Must be one of"multi", or"single".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 input-bordered 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".- Global attributes are accepted.