Flicker is an Ash-native searchable select / combobox / faceted-search library for Phoenix LiveView.
It reads directly off Ash resources — no options plumbing — authorises via
actor: and Ash policies, and derives facet behaviour from the Ash type
system. Where Cinder is for tables, Flicker is for searching, filtering,
and selecting records.
The building blocks:
Flicker.select/1— the function component: a type-to-search, pick-one combobox over an Ash resource (or anyFlicker.Provider).Flicker.palette/1— a ⌘K command-palette overlay wrapping the same core asFlicker.select/1: federated search across resources, grouped results, navigate-on-select (Spec 008).Flicker.Provider— the behaviour every data source implements (search/2,fetch/2), and the internal invocation boundary (run_search/3,run_fetch/3) core code calls through.Flicker.Result— the display struct providers return.Flicker.Query— the search request struct passed tosearch/2; alsoFlicker.Query.parse/2, the faceted-search query parser, andFlicker.Query.to_filter/1, its Ash filter builder.Flicker.Facet— a faceted-search facet definition, consumed byFlicker.Query.parse/2.Flicker.Providers.Static— an in-memory reference provider, useful as a test double or for small fixed option lists.Flicker.Providers.AshResource— the built-in provider Tier 1 declarative component config compiles to.Flicker.Theme— the class-per-part styling map (ADR-002).Flicker.Messages— the overridable user-facing text behaviour (ADR-009).
See the guides for how the component layer (built on top of this contract) is configured.
Summary
Functions
Renders a ⌘K command-palette overlay: backdrop, centred panel, large search input, grouped result list, footer kbd hints (↑↓ navigate · ↵ select · esc close).
Renders a standalone faceted search / filter bar — "which subset?" (see the component-surface table).
Renders a type-to-search, pick-one combobox.
Functions
@spec palette(map()) :: Phoenix.LiveView.Rendered.t()
Renders a ⌘K command-palette overlay: backdrop, centred panel, large search input, grouped result list, footer kbd hints (↑↓ navigate · ↵ select · esc close).
Wraps the exact same core Flicker.select/1 runs on top of — same
provider tiers, same Flicker.Result/Flicker.Provider contract — as a
modal overlay instead of an inline combobox. Nothing here is special
machinery: it's activate_with_keyboard (Spec 006) + a federated
provider (ADR-001) + theme parts (ADR-002) arranged into one component.
<Flicker.palette
id="cmdk"
source={MyApp.Search.Global}
actor={@current_user}
open={@palette_open}
on_close={:palette_closed}
on_select={:palette_selected}
/>
def handle_info(:palette_closed, socket), do: {:noreply, assign(socket, :palette_open, false)}
def handle_info({:palette_selected, result}, socket), do: {:noreply, assign(socket, :palette_open, false)}Opening
Two independent ways to open it, usable together:
activate_with_keyboard(default"mod+k") — self-contained, needs no host state: the chord opens/closes the overlay entirely client-side-triggered/server-confirmed, the same activation pattern Spec 006 givesFlicker.select/1.- The
open/on_closecontrolled pair — for a navbar button or any other host-driven trigger.openis adopted whenever it changes between renders (a rising edge opens it, a falling edge closes it); in between, the component is free to open/close itself (chord,Escape, backdrop click) and always fireson_closeso host state never drifts out of sync.on_closeis sent as a bare message —send(self(), on_close)— exactly likeon_select's convention, just with no payload.
Grouped results and navigate-on-select
A provider whose Flicker.Results carry :group gets contiguous group
headers for free (Spec 008) — a federated provider searching several Ash
resources typically tags each with its resource name
(group: "Artists", group: "Albums", ...). A result whose meta.href
is set additionally navigates (push_navigate/2) on selection; the raw
on_select message still fires either way, so a host doing something
other than navigating (closing the palette, logging, a custom action)
always can.
Modes
Controlled mode only — there is no form-field mode, a modal ⌘K overlay
isn't a form control. on_select is required.
Attributes
id(:string) (required) - DOM id for the component.resource(:atom) - Tier 1: the Ash resource to read. Requiresoption_label. Defaults tonil.source(:any) - Tier 2: aFlicker.Providermodule, or{module, opts}— typically a federated provider. Defaults tonil.search(:list) - Tier 1: attribute namessearch/2's text match runs over. Defaults to[].option_label(:any) - Tier 1: an attribute name, orfun(record) :: String.t(). Defaults tonil.option_sublabel(:any) - Tier 1: an attribute name, orfun(record) :: String.t() | nil. Defaults tonil.read_action(:atom) - Tier 1: the read action to run. Defaults to:read. Defaults tonil.sort(:any) - Tier 1: sort applied to search results. Defaults tonil.filter(:any) - Tier 1: a base filter applied before the search-text match. Defaults tonil.actor(:any) - The reading actor — passed through to the provider unmodified (ADR-004). Defaults tonil.tenant(:any) - The tenant — passed through to the provider unmodified. Defaults tonil.open(:boolean) - Controlled: whether the overlay is open. See moduledoc. Defaults tofalse.on_close(:atom) - Optional: the host receives a bare message whenever the overlay closes. See moduledoc. Defaults tonil.on_select(:atom) - Optional: the host receives{on_select, %Flicker.Result{}}on selection. See moduledoc. Defaults tonil.limit(:integer) - Max results shown. Defaults toconfig :flicker, :default_limit(25). Defaults tonil.min_chars(:integer) - Minimum typed characters before a search runs. Defaults to0.debounce(:integer) - Input debounce (ms). Defaults toconfig :flicker, :default_debounce(150). Defaults tonil.dispatch(:atom) - When typing runs a search (Spec 020).:debounce(default) waitsdebouncems after the last keystroke;:immediatesearches on every keystroke;:entersearches only when the user presses Enter. Facet commits and the initial listing always dispatch, under every policy.Defaults to
:debounce. Must be one of:debounce,:immediate, or:enter.theme(:any) - AFlicker.Themeoverride — a full struct or a partial map.:backdrop,:panel,:palette_input,:group_header, and:footerare the overlay-specific parts. SeeFlicker.Theme. Defaults tonil.messages(:atom) - AFlicker.Messagesoverride module. SeeFlicker.Messages. Defaults tonil.activate_with_keyboard(:string) - A chord string that opens/closes the overlay from anywhere on the page. See moduledoc. Defaults to"mod+k".facets(:list) - Faceted key/value autocomplete over the search input (Spec 003) — seeFlicker.select/1. Defaults to[].facet_trigger(:any) - A trigger character that opens the facet menu (Spec 024) — seeFlicker.select/1. A palette is the surface this matters most on: it is a global search box, so leaving facet suggestions always-on means every query the user types competes with them. Defaults tonil.paginate(:boolean) - Windowed infinite scroll instead of "keep typing to narrow" (Spec 010) — seeFlicker.select/1. Defaults tofalsehere too: a command palette isn't assumed to be browse-shaped just because it's a palette. Defaults tofalse.max_windows(:integer) -paginateonly: caps how many windows load before the narrow hint takes over. Defaults to 10. Defaults tonil.
Slots
option- Custom option rendering, given theFlicker.Resultas the slot argument.
@spec search(map()) :: Phoenix.LiveView.Rendered.t()
Renders a standalone faceted search / filter bar — "which subset?" (see the component-surface table).
No selection semantics: Flicker.search/1 never lists or fetches
records itself. It emits the parsed %Flicker.Query{} and its composed
Ash filter map (Flicker.Query.to_filter/2) via on_change — the host's
handle_info/2 receives {on_change, %Flicker.Query{}, filter} on every
keystroke, and feeds filter to its own Cinder table, Ash.read/2 call,
or list.
<Flicker.search
id="artist-search"
resource={Dev.Music.Artist}
actor={@current_user}
facets={[:status, :tier, :genre, after: [attribute: :formed_on, op: :>=]]}
on_change={:artist_query_changed}
/>
def handle_info({:artist_query_changed, _query, filter}, socket) do
{:noreply, assign(socket, artists: Ash.read!(Dev.Music.Artist |> Ash.Query.filter_input(filter)))}
endfacets derives value autocomplete straight from the Ash type system
(Spec 003's type table): an enum attribute gets a value picklist; a
belongs_to/has_* facet opens a nested, actor-scoped record search
over the related resource, inserting key:<id> (displayed, while
choosing, as the related record's own label) rather than a raw id typed
by hand.
resource (Tier 1) derives facets via Flicker.Providers.AshResource.facets/1;
source (Tier 2, a Flicker.Provider implementing the optional
facets/0 callback) or a hand-built list of Flicker.Facet structs
passed directly as facets both work without any Ash resource at all.
Attributes
id(:string) (required) - DOM id for the component.resource(:atom) - Tier 1: the Ash resourcefacetsintrospects. Defaults tonil.source(:any) - Tier 2: aFlicker.Providermodule, or{module, opts}. Defaults tonil.facets(:list) - Facet keys/overrides (Tier 1), or a hand-built list ofFlicker.Facetstructs. Defaults to[].actor(:any) - The reading actor — scopes nested relationship-facet searches (ADR-004). Defaults tonil.tenant(:any) - The tenant — scopes nested relationship-facet searches. Defaults tonil.on_change(:atom) (required) - The host receives{on_change, %Flicker.Query{}, filter}on every keystroke.text(:string) - Initial search text — e.g. restored from a URL param (Spec 009 Level 2'sFlicker.Integrations.Cinder). Adopted once, on this component instance's first mount only; the component owns all updates to it afterward (typing, clearing), so changing this attr on a later render of an already-mounted instance has no effect — it is not a controlled value.Defaults to
nil.limit(:integer) - Max nested relationship-facet search results shown. Defaults tonil.debounce(:integer) - Input debounce (ms). Defaults toconfig :flicker, :default_debounce(150). Defaults tonil.recent_values(:any) - Host-supplied storage for recently-used facet values (Spec 022) —%{load: fun/2, record: fun/3}. Flicker owns no persistence: recency is user data, and where it lives and how long it's kept are the host's decisions. Omit to disable theRecentgroup entirely; neither function is then ever called.Flicker.RecentValues.Etsis a dev/test implementation.Defaults to
nil.on_invalid(:atom) - What to do when a facet value fails to cast (Spec 023).:drop(default) emits the query without the broken facet — every other facet and the free text still apply.:requirewithholds the query entirely until it's fixed, for hosts where a partially-applied filter is worse than no update. Under both, an unvalidated value is never part of the query; they differ only in whether the rest of it proceeds.Defaults to
:drop. Must be one of:drop, or:require.facet_trigger(:any) - A trigger character that opens the facet menu (Spec 024) —"@", or%{"@" => [:worker], "#" => [:tag]}to scope which facets each trigger offers.Without it (the default) every bare word offers facet-key suggestions, which is right for a dedicated filter bar and in the way for a search box that is mostly used for free text. With it, plain words search records and only a token opening with the trigger lists facets.
The trigger is input sugar and never grammar: it reaches no token, pill, query or URL —
@statcompletes tostatus:. A facet typed out in full or restored from a URL is still recognised, so sharing a search link keeps working.Defaults to
nil.open_editor_on_pick(:boolean) - Whether choosing a facet key opens that facet's editor (Spec 019) immediately, rather than leaving the user in value position with an inline picklist. Setfalseto keep the pop-out behind an explicit second gesture.Defaults to
true.dispatch(:atom) - When typing emitson_change(Spec 020). For this component the host notification is the dispatch, so:enterwithholdson_changeuntil the user presses Enter — useful when the host drives an expensive query off it. Committing or removing a facet always emits, under every policy.Defaults to
:debounce. Must be one of:debounce,:immediate, or:enter.theme(:any) - AFlicker.Themeoverride. SeeFlicker.Theme. Defaults tonil.messages(:atom) - AFlicker.Messagesoverride module. SeeFlicker.Messages. Defaults tonil.
@spec select(map()) :: Phoenix.LiveView.Rendered.t()
Renders a type-to-search, pick-one combobox.
Tier 1: declarative resource config
The common case — no provider module of your own:
<Flicker.select
id="client-select"
field={f[:client_id]}
resource={MyApp.Client}
actor={@current_user}
search={[:first_name, :last_name, :uci_number]}
option_label={:full_name}
option_sublabel={fn client -> "#{client.uci_number} #{client.city}" end}
read_action={:search}
limit={20}
/>resource, search, option_label, option_sublabel, read_action,
limit, sort, and filter compile at mount to
{Flicker.Providers.AshResource, opts} (ADR-001).
Tier 2: a custom provider
Pass source instead of resource — a module or {module, opts}
implementing Flicker.Provider:
<Flicker.select id="global-search" source={MyApp.Search.Global} actor={@current_user} />Modes
Passing field (a Phoenix.HTML.FormField, e.g. f[:client_id]) selects
form-field mode: the component owns its hidden input and the
_unused_<field> recovery marker (ADR-005); required-field errors don't
fire until the field is engaged, and the value survives a LiveSocket
reconnect.
Omitting field and passing on_select (an atom) selects controlled
mode: no form inputs are rendered; instead the host's handle_info/2
receives {on_select, %Flicker.Result{} | nil} (nil on clear).
In form-field mode, on_select is optional. The component still owns the
hidden input, and also sends the same notification so the host can refresh
dependent fields immediately after a selection changes.
Multi-select
Passing multiple switches the value model to a list — the same
component is the multi-select surface, there is no separate
Flicker.multi_select (Spec 002). Selections render as removable chips;
form-field mode emits name[] array hidden inputs; controlled mode's
on_select carries the full selection list on every change. fetch/2
resolves every preselected value in one call regardless of how many are
set (ADR-003).
<Flicker.select
id="worker-select"
field={f[:worker_ids]}
multiple
resource={MyApp.Worker}
actor={@current_user}
search={[:name]}
option_label={:name}
max_selections={5}
/>Keyboard activation
activate_with_keyboard="mod+k" focuses and opens this search from
anywhere on the page — a global Cmd/Ctrl+K supersearch shortcut. See
Spec 006 for the chord syntax and behaviour.
Windowed search (infinite scroll)
paginate (Spec 010,
default false) turns "keep typing to narrow" into windowed infinite
scroll — scrolling to (or pressing ArrowDown at) the last option loads
the next limit-sized window and appends it, up to max_windows:
<Flicker.select
id="artist-select"
field={f[:artist_id]}
resource={MyApp.Artist}
actor={@current_user}
search={[:name]}
option_label={:name}
paginate
max_windows={20}
/>This is deliberately opt-in — the default stays "narrowing is the interaction" for a typeahead; see the spec for when browsing beats narrowing.
Attributes
id(:string) (required) - DOM id for the component; also the assign namespace for its internal state.resource(:atom) - Tier 1: the Ash resource to read. Requiresoption_label. Defaults tonil.source(:any) - Tier 2: aFlicker.Providermodule, or{module, opts}. Defaults tonil.search(:list) - Tier 1: attribute namessearch/2's text match runs over. Defaults to[].option_label(:any) - Tier 1: an attribute name, orfun(record) :: String.t(). Defaults tonil.option_sublabel(:any) - Tier 1: an attribute name, orfun(record) :: String.t() | nil. Defaults tonil.read_action(:atom) - Tier 1: the read action to run. Defaults to:read. Defaults tonil.read_action_args(:list) - Tier 1: read-action arguments. Use:queryas the typed-text placeholder. Defaults to[].fetch_action(:atom) - Tier 1: read action used to resolve selected values. Defaults to:read. Defaults tonil.load(:list) - Tier 1: Ash loads needed by option label, sublabel, or slot rendering. Defaults to[].sort(:any) - Tier 1: sort applied to search results. Defaults tonil.filter(:any) - Tier 1: a base filter applied before the search-text match. Defaults tonil.actor(:any) - The reading actor — passed through to the provider unmodified (ADR-004). Defaults tonil.tenant(:any) - The tenant — passed through to the provider unmodified. Defaults tonil.field(Phoenix.HTML.FormField) - Selects form-field mode. See moduledoc. Defaults tonil.required(:boolean) - Form-field mode: marks the hidden inputrequired. Defaults tofalse.on_select(:atom) - Required in controlled mode; optional form-mode notification tag. See moduledoc. Defaults tonil.multiple(:boolean) - Switches the value model to a list — chips,name[]array inputs. See moduledoc. Defaults tofalse.max_selections(:integer) - Multi-select only: caps the number of selected values; further picking is disabled at the cap. Defaults tonil.max_visible(:integer) - Multi-select with a:selectedslot: shows at most this many selected items, collapsing the rest into a "+N" overflow token. Defaults to showing all. Defaults tonil.limit(:integer) - Max results shown. Defaults toconfig :flicker, :default_limit(25). Defaults tonil.min_chars(:integer) - Minimum typed characters before a search runs. Defaults to0.placeholder(:string) - Input placeholder. Defaults to the:search_placeholdermessage. Defaults tonil.aria_labelledby(:string) - The id of a host element that names the combobox input, such as the form field's own<label>. When set, Flicker renders no visually-hidden label of its own, so the name is not read twice, and the listbox is labelled by the same element.Defaults to
nil.aria_describedby(:string) - Space-separated ids of host elements that describe the combobox input, such as the form field's help text and error. Appended after Flicker's own hint ids on the input'saria-describedby.Defaults to
nil.aria_invalid(:boolean) - Rendersaria-invalid="true"on the combobox input whentrue. Defaults tofalse.debounce(:integer) - Input debounce (ms). Defaults toconfig :flicker, :default_debounce(150). Defaults tonil.dispatch(:atom) - When typing runs a search (Spec 020).:debounce(default) waitsdebouncems after the last keystroke;:immediatesearches on every keystroke (for an in-memory provider, where the debounce is pure latency);:entersearches only when the user presses Enter (for an expensive or metered backend). Facet commits and the initial listing always dispatch, under every policy.Defaults to
:debounce. Must be one of:debounce,:immediate, or:enter.loading_delay(:integer) - How long (ms) a search must be in flight before any loading affordance appears (Spec 020). A 40ms query that flashes a spinner reads as slower than one that simply updates, so the row is held hidden until this elapses and never appears at all for a fast response.0shows it immediately.Defaults to
200.theme(:any) - AFlicker.Themeoverride — a full struct or a partial map. SeeFlicker.Theme. Defaults tonil.messages(:atom) - AFlicker.Messagesoverride module. SeeFlicker.Messages. Defaults tonil.activate_with_keyboard(:string) - A chord string (e.g."mod+k") that focuses and opens this search from anywhere on the page.modresolves to Cmd on macOS, Ctrl elsewhere. See Spec 006. Defaults tonil.facets(:list) - Faceted key/value autocomplete over the search input (Spec 003) — Tier 1: a list of bare facet keys /{key, overrides}pairs, expanded viaFlicker.Providers.AshResource.facets/1; or a hand-built list ofFlicker.Facetstructs. Narrows the free-text portion sent to the provider; see the moduledoc for what's in and out of scope for facets-in-select in v1. Defaults to[].facet_trigger(:any) - A trigger character that opens the facet menu (Spec 024) —"@", or a map scoping which facets each trigger offers. Matters more here than inFlicker.search/1: this component's primary job is finding a record, so having every typed word volunteer facet keys competes with the thing the user came for. SeeFlicker.search/1for the full semantics.Defaults to
nil.paginate(:boolean) - Windowed infinite scroll instead of "keep typing to narrow" (Spec 010). See moduledoc. Defaults tofalse.max_windows(:integer) -paginateonly: caps how many windows load before the narrow hint takes over. Defaults to 10. Defaults tonil.
Slots
option- Custom option rendering, given theFlicker.Resultas the slot argument.selected- Custom rendering of a selected item (given itsFlicker.Result), e.g. an avatar. Multi-select: each chip's visual, defaulting to text chips; pairs withmax_visible. Single-select: the collapsed selection display, defaulting to the:optionrendering.