Flicker (Flicker v0.1.0)

Copy Markdown View Source

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:

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

palette(assigns)

@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 gives Flicker.select/1.
  • The open/on_close controlled pair — for a navbar button or any other host-driven trigger. open is 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 fires on_close so host state never drifts out of sync. on_close is sent as a bare message — send(self(), on_close) — exactly like on_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. Requires option_label. Defaults to nil.

  • source (:any) - Tier 2: a Flicker.Provider module, or {module, opts} — typically a federated provider. Defaults to nil.

  • search (:list) - Tier 1: attribute names search/2's text match runs over. Defaults to [].

  • option_label (:any) - Tier 1: an attribute name, or fun(record) :: String.t(). Defaults to nil.

  • option_sublabel (:any) - Tier 1: an attribute name, or fun(record) :: String.t() | nil. Defaults to nil.

  • read_action (:atom) - Tier 1: the read action to run. Defaults to :read. Defaults to nil.

  • sort (:any) - Tier 1: sort applied to search results. Defaults to nil.

  • filter (:any) - Tier 1: a base filter applied before the search-text match. Defaults to nil.

  • actor (:any) - The reading actor — passed through to the provider unmodified (ADR-004). Defaults to nil.

  • tenant (:any) - The tenant — passed through to the provider unmodified. Defaults to nil.

  • open (:boolean) - Controlled: whether the overlay is open. See moduledoc. Defaults to false.

  • on_close (:atom) - Optional: the host receives a bare message whenever the overlay closes. See moduledoc. Defaults to nil.

  • on_select (:atom) - Optional: the host receives {on_select, %Flicker.Result{}} on selection. See moduledoc. Defaults to nil.

  • limit (:integer) - Max results shown. Defaults to config :flicker, :default_limit (25). Defaults to nil.

  • min_chars (:integer) - Minimum typed characters before a search runs. Defaults to 0.

  • debounce (:integer) - Input debounce (ms). Defaults to config :flicker, :default_debounce (150). Defaults to nil.

  • dispatch (:atom) - When typing runs a search (Spec 020). :debounce (default) waits debounce ms after the last keystroke; :immediate searches on every keystroke; :enter searches 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) - A Flicker.Theme override — a full struct or a partial map. :backdrop, :panel, :palette_input, :group_header, and :footer are the overlay-specific parts. See Flicker.Theme. Defaults to nil.

  • messages (:atom) - A Flicker.Messages override module. See Flicker.Messages. Defaults to nil.

  • 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) — see Flicker.select/1. Defaults to [].

  • facet_trigger (:any) - A trigger character that opens the facet menu (Spec 024) — see Flicker.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 to nil.

  • paginate (:boolean) - Windowed infinite scroll instead of "keep typing to narrow" (Spec 010) — see Flicker.select/1. Defaults to false here too: a command palette isn't assumed to be browse-shaped just because it's a palette. Defaults to false.

  • max_windows (:integer) - paginate only: caps how many windows load before the narrow hint takes over. Defaults to 10. Defaults to nil.

Slots

  • option - Custom option rendering, given the Flicker.Result as the slot argument.

search(assigns)

@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)))}
end

facets 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 resource facets introspects. Defaults to nil.

  • source (:any) - Tier 2: a Flicker.Provider module, or {module, opts}. Defaults to nil.

  • facets (:list) - Facet keys/overrides (Tier 1), or a hand-built list of Flicker.Facet structs. Defaults to [].

  • actor (:any) - The reading actor — scopes nested relationship-facet searches (ADR-004). Defaults to nil.

  • tenant (:any) - The tenant — scopes nested relationship-facet searches. Defaults to nil.

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

  • debounce (:integer) - Input debounce (ms). Defaults to config :flicker, :default_debounce (150). Defaults to nil.

  • 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 the Recent group entirely; neither function is then ever called. Flicker.RecentValues.Ets is 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. :require withholds 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 — @stat completes to status:. 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. Set false to keep the pop-out behind an explicit second gesture.

    Defaults to true.

  • dispatch (:atom) - When typing emits on_change (Spec 020). For this component the host notification is the dispatch, so :enter withholds on_change until 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) - A Flicker.Theme override. See Flicker.Theme. Defaults to nil.

  • messages (:atom) - A Flicker.Messages override module. See Flicker.Messages. Defaults to nil.

select(assigns)

@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. Requires option_label. Defaults to nil.

  • source (:any) - Tier 2: a Flicker.Provider module, or {module, opts}. Defaults to nil.

  • search (:list) - Tier 1: attribute names search/2's text match runs over. Defaults to [].

  • option_label (:any) - Tier 1: an attribute name, or fun(record) :: String.t(). Defaults to nil.

  • option_sublabel (:any) - Tier 1: an attribute name, or fun(record) :: String.t() | nil. Defaults to nil.

  • read_action (:atom) - Tier 1: the read action to run. Defaults to :read. Defaults to nil.

  • read_action_args (:list) - Tier 1: read-action arguments. Use :query as the typed-text placeholder. Defaults to [].

  • fetch_action (:atom) - Tier 1: read action used to resolve selected values. Defaults to :read. Defaults to nil.

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

  • filter (:any) - Tier 1: a base filter applied before the search-text match. Defaults to nil.

  • actor (:any) - The reading actor — passed through to the provider unmodified (ADR-004). Defaults to nil.

  • tenant (:any) - The tenant — passed through to the provider unmodified. Defaults to nil.

  • field (Phoenix.HTML.FormField) - Selects form-field mode. See moduledoc. Defaults to nil.

  • required (:boolean) - Form-field mode: marks the hidden input required. Defaults to false.

  • on_select (:atom) - Required in controlled mode; optional form-mode notification tag. See moduledoc. Defaults to nil.

  • multiple (:boolean) - Switches the value model to a list — chips, name[] array inputs. See moduledoc. Defaults to false.

  • max_selections (:integer) - Multi-select only: caps the number of selected values; further picking is disabled at the cap. Defaults to nil.

  • max_visible (:integer) - Multi-select with a :selected slot: shows at most this many selected items, collapsing the rest into a "+N" overflow token. Defaults to showing all. Defaults to nil.

  • limit (:integer) - Max results shown. Defaults to config :flicker, :default_limit (25). Defaults to nil.

  • min_chars (:integer) - Minimum typed characters before a search runs. Defaults to 0.

  • placeholder (:string) - Input placeholder. Defaults to the :search_placeholder message. Defaults to nil.

  • 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's aria-describedby.

    Defaults to nil.

  • aria_invalid (:boolean) - Renders aria-invalid="true" on the combobox input when true. Defaults to false.

  • debounce (:integer) - Input debounce (ms). Defaults to config :flicker, :default_debounce (150). Defaults to nil.

  • dispatch (:atom) - When typing runs a search (Spec 020). :debounce (default) waits debounce ms after the last keystroke; :immediate searches on every keystroke (for an in-memory provider, where the debounce is pure latency); :enter searches 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. 0 shows it immediately.

    Defaults to 200.

  • theme (:any) - A Flicker.Theme override — a full struct or a partial map. See Flicker.Theme. Defaults to nil.

  • messages (:atom) - A Flicker.Messages override module. See Flicker.Messages. Defaults to nil.

  • activate_with_keyboard (:string) - A chord string (e.g. "mod+k") that focuses and opens this search from anywhere on the page. mod resolves to Cmd on macOS, Ctrl elsewhere. See Spec 006. Defaults to nil.

  • facets (:list) - Faceted key/value autocomplete over the search input (Spec 003) — Tier 1: a list of bare facet keys / {key, overrides} pairs, expanded via Flicker.Providers.AshResource.facets/1; or a hand-built list of Flicker.Facet structs. 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 in Flicker.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. See Flicker.search/1 for the full semantics.

    Defaults to nil.

  • paginate (:boolean) - Windowed infinite scroll instead of "keep typing to narrow" (Spec 010). See moduledoc. Defaults to false.

  • max_windows (:integer) - paginate only: caps how many windows load before the narrow hint takes over. Defaults to 10. Defaults to nil.

Slots

  • option - Custom option rendering, given the Flicker.Result as the slot argument.
  • selected - Custom rendering of a selected item (given its Flicker.Result), e.g. an avatar. Multi-select: each chip's visual, defaulting to text chips; pairs with max_visible. Single-select: the collapsed selection display, defaulting to the :option rendering.