PhoenixKitWeb.Components.Core.SearchPicker (phoenix_kit v2.52.2)

Copy Markdown View Source

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 with push_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 to hero-user);
  • a pick event (pick_event, default "picker_pick") receiving %{"kind" =>, "uuid" =>, "label" =>} — stage your chip, then confirm with push_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

search_picker(assigns)

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 — when true, 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 raw data-search-on-focus rest 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 mode
  • search_event / results_event / pick_event / text_event / staged_event — event-name overrides (defaults in moduledoc; text_event nil = no free-text row)
  • placeholder, class — input presentation
  • searching_label etc. — translated strings for the client-rendered rows
  • close_on_pick — multi mode: after a pick the list stays closed until the user types again (by default it clears the input and, with search_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 to nil.
  • mode (:string) - Defaults to "multi". Must be one of "multi", or "single".
  • search_on_focus (:boolean) - Defaults to false.
  • direction (:string) - Defaults to "down". Must be one of "down", or "up".
  • name (:string) - Defaults to nil.
  • value (:string) - Defaults to nil.
  • 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 to nil.
  • staged_event (:string) - Defaults to "picker_staged".
  • placeholder (:string) - Defaults to nil.
  • 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 to false.
  • 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"].