The contract every rich facet editor implements (Spec 019).
Per ADR-011
an editor is a modal sub-context whose only output is token text. It holds
no committed state, it never queries on its own, and it commits exactly once —
when its value is complete. Everything it can express, a user can type;
everything a user can type, it can be opened on. That symmetry is what keeps
Flicker.Query.input the single source of truth.
The callbacks
serialise/2— the value's canonical token text, without thekey:prefix or a trailing space (the component adds both). Locale- invariant, per ADR-013.parse/2— the inverse: an editor's own value from a token's value text, for reopening a committed facet.complete?/2— whether this value may commit yet. A half-made range is a draft, and no route (Enter, click, Done) may commit one.render/1— the editor's markup.draft_label/3— optional one-line summary of an incomplete value, for the footer ("Jun 18, 2026 → pick an end date").modal?/0— optional;falsefor an editor whose value is complete in one interaction and so needs no pop-out. The switch is the only one.
serialise/2 and parse/2 are a round-trip pair, and the property that pair
must satisfy is the load-bearing test of this spec:
serialise(parse(serialise(value))) == serialise(value)Note that this is idempotence of serialise/2, not structural identity of the
value — deliberately. A preset serialises to its id (last-30-days) and parses
back resolved (a %Range{} with real endpoints and the preset id), which
is the whole point of presets staying relative. The two forms mean the same
thing and serialise identically; they are not the same struct. For every
non-preset value the property does collapse to plain identity.
The second half matters just as much: the text an editor emits must re-parse to
a real filter through Flicker.Query.parse/2. An editor that drifts from the
grammar fails that, which is what makes token text a safe sole output.
Summary
Callbacks
Whether value is complete enough to commit.
A one-line summary of an incomplete value, or nil.
Whether this editor needs a modal pop-out. Defaults to true.
An editor value from a token's value text, for reopening a committed facet.
The editor's markup.
Canonical token text for value — no key: prefix, no trailing space.
Functions
Whether facet has a rich editor at all.
The editor for facet — its own :editor when set, otherwise the default for
its type, or nil for a type that has none (a plain :string facet, or an
unbounded numeric one, where typing straight through beats any control).
Whether opening facet's editor should take over the surface with a pop-out.
The full token an editor's value commits as — key:value — or nil when the
value isn't complete.
Types
@type value() :: term()
An editor's own working value. Its shape is the editor's business.
Callbacks
@callback complete?(value(), Flicker.Facet.t()) :: boolean()
Whether value is complete enough to commit.
@callback draft_label(value(), Flicker.Facet.t(), keyword()) :: String.t() | nil
A one-line summary of an incomplete value, or nil.
@callback modal?() :: boolean()
Whether this editor needs a modal pop-out. Defaults to true.
@callback parse(String.t(), Flicker.Facet.t()) :: {:ok, value()} | :error
An editor value from a token's value text, for reopening a committed facet.
@callback render(assigns :: map()) :: Phoenix.LiveView.Rendered.t()
The editor's markup.
@callback serialise(value(), Flicker.Facet.t()) :: String.t()
Canonical token text for value — no key: prefix, no trailing space.
Functions
@spec editable?(Flicker.Facet.t()) :: boolean()
Whether facet has a rich editor at all.
Examples
iex> Flicker.FacetEditor.editable?(Flicker.Facet.new(key: :created, type: :date_range))
true
iex> Flicker.FacetEditor.editable?(Flicker.Facet.new(key: :name, type: :string))
false
@spec for_facet(Flicker.Facet.t()) :: module() | nil
The editor for facet — its own :editor when set, otherwise the default for
its type, or nil for a type that has none (a plain :string facet, or an
unbounded numeric one, where typing straight through beats any control).
Examples
iex> Flicker.FacetEditor.for_facet(Flicker.Facet.new(key: :created, type: :date_range))
Flicker.FacetEditor.Calendar
iex> Flicker.FacetEditor.for_facet(Flicker.Facet.new(key: :verified?, type: :boolean))
Flicker.FacetEditor.Switch
iex> Flicker.FacetEditor.for_facet(Flicker.Facet.new(key: :status, type: :enum, values: [:a]))
Flicker.FacetEditor.Set
iex> Flicker.FacetEditor.for_facet(Flicker.Facet.new(key: :name, type: :string))
nil
iex> Flicker.FacetEditor.for_facet(Flicker.Facet.new(key: :n, type: :integer))
nil
iex> Flicker.FacetEditor.for_facet(
...> Flicker.Facet.new(key: :n, type: :integer, bounds: %{min: 0, max: 9})
...> )
Flicker.FacetEditor.Dial
@spec modal?(Flicker.Facet.t()) :: boolean()
Whether opening facet's editor should take over the surface with a pop-out.
false for an editor whose value is complete in one interaction — the switch.
Atomic commit is mandatory either way; a pop-out is only what multi-step
values need.
Examples
iex> Flicker.FacetEditor.modal?(Flicker.Facet.new(key: :created, type: :date_range))
true
iex> Flicker.FacetEditor.modal?(Flicker.Facet.new(key: :verified?, type: :boolean))
false
iex> Flicker.FacetEditor.modal?(Flicker.Facet.new(key: :name, type: :string))
false
@spec to_token(module(), value(), Flicker.Facet.t()) :: String.t() | nil
The full token an editor's value commits as — key:value — or nil when the
value isn't complete.
The trailing space is deliberate: it matches what every other suggestion
inserts, so Flicker.FacetSuggest.replace_current_token/3 leaves the caret
ready for the next token.
Examples
iex> facet = Flicker.Facet.new(key: :verified?, type: :boolean)
...> Flicker.FacetEditor.to_token(Flicker.FacetEditor.Switch, true, facet)
"verified?:true "
iex> facet = Flicker.Facet.new(key: :created, type: :date_range)
...>
...> Flicker.FacetEditor.to_token(
...> Flicker.FacetEditor.Calendar,
...> %Flicker.Facet.Range{from: ~D[2026-07-01]},
...> facet
...> )
"created:2026-07-01.. "
iex> facet = Flicker.Facet.new(key: :created, type: :date_range)
...>
...> Flicker.FacetEditor.to_token(
...> Flicker.FacetEditor.Calendar,
...> %Flicker.Facet.Range{},
...> facet
...> )
nil