# `Flicker.Facet`
[🔗](https://github.com/team-alembic/flicker/blob/v0.1.0/lib/flicker/facet.ex#L1)

A facet definition for faceted search ([Spec 003](https://github.com/team-alembic/flicker/blob/main/docs/specs/spec-003-faceted-search.md)).

A facet definition tells `Flicker.Query.parse/2` how to recognise a
`key:value` token in typed input: which key it answers to, what shape a
value must have to count as a match rather than degrading to free text,
and which comparison operators (`>=`, `<`, `!=`, ...) are legal for it.

## Fields

  * `:key` — the facet's identifier, matched against the token's key
    (e.g. `:status` matches `status:active`).
  * `:label` — user-facing facet name (used by later Spec 003 stages —
    autocomplete, the cursor state machine — not by the parser itself).
  * `:type` — how a raw value string is cast. One of `:string`
    (default, no casting), `:integer`, `:float`, `:boolean`, `:date`
    (ISO 8601, or a relative offset like `7d` / `2w` / `1m` / `1y`), or
    `:enum` (the raw value must match one of `:values`).
  * `:operators` — comparison operators this facet accepts when the user
    types them explicitly (`after>=7d`). Defaults to `[:eq]`. A value not
    built from this list degrades the whole token to free text.
  * `:default_op` — the operator used for the bare `:` form
    (`after:7d`). Defaults to `:eq`; a date facet meaning "after" would
    set this to `:gte`. Should be a member of `:operators`.
  * `:values` — for `:type: :enum`, the closed list of atoms a value may
    cast to. A value not in this list degrades to free text. Ignored for
    other types.
  * `:value_labels` — for `:type: :enum`, a map of value atom to its
    user-facing label (an `Ash.Type.Enum`'s own `label/1`, or a humanised
    fallback for a plain `one_of`-constrained attribute). `nil` for other
    types.
  * `:value_colors` — optional map of value atom to a host-supplied colour
    string (any CSS colour), shown as a dot on that value's facet pill
    (Spec 017). `nil` for no colours; unlisted values render uncoloured.
  * `:target` — the Ash filter path this facet resolves to: a list of
    atoms (an attribute name, an aggregate/calculation name, or a
    relationship path ending in an attribute, e.g. `[:worker,
    :full_name]`). `nil` means `[key]`. Filled in by
    `Flicker.Providers.AshResource.facets/1` (the facet registry,
    [Spec 003](https://github.com/team-alembic/flicker/blob/main/docs/specs/spec-003-faceted-search.md));
    hand-built facets can set it directly.
  * `:related` — for a `belongs_to`/`has_*` facet, `%{resource: module,
    search: [atom()], option_label: atom() | (struct() -> String.t())}`
    describing the related resource a nested search over this facet's
    values would run against (the same `Flicker.Providers.AshResource`
    `:search`/`:option_label` shape, so the nested search is a plain
    inner `Flicker.select`-style provider call) — `nil` for
    non-relationship facets.

Hand-build a `Flicker.Facet` directly, or — for an Ash resource — derive
it from `facets: [:status, :worker, ...]` via
`Flicker.Providers.AshResource.facets/1`, which introspects the
resource's own type system per the spec's type table and lets an
explicit `[type:, path:, attribute:, op:]` override any derived field.

# `bounds`

```elixir
@type bounds() :: %{
  optional(:min) =&gt; number(),
  optional(:max) =&gt; number(),
  optional(:step) =&gt; number()
}
```

Advisory numeric bounds for an editor, and a validation floor/ceiling.

# `counted`

```elixir
@type counted() :: boolean()
```

Whether to request a match count per value for this facet.

# `operator`

```elixir
@type operator() ::
  :eq | :neq | :gt | :gte | :lt | :lte | :contains | :between | :in | :not_in
```

A comparison operator a facet value can be compared with.

# `related`

```elixir
@type related() :: %{
  resource: module(),
  search: [atom()],
  option_label: atom() | (struct() -&gt; String.t())
}
```

The related resource a `belongs_to`/`has_*` facet searches over.

# `t`

```elixir
@type t() :: %Flicker.Facet{
  bounds: bounds() | nil,
  count: counted(),
  default_op: operator(),
  editor: module() | nil,
  key: atom(),
  label: String.t() | nil,
  multiple?: boolean(),
  operators: [operator()],
  presets: [Flicker.Facet.Preset.t()] | nil,
  related: related() | nil,
  scalar: type() | nil,
  suggested: [atom()] | nil,
  target: [atom()] | nil,
  type: type(),
  validate: validator() | nil,
  value_colors: %{required(atom()) =&gt; String.t()} | nil,
  value_labels: %{required(atom()) =&gt; String.t()} | nil,
  values: [atom()] | nil
}
```

A facet definition.

# `type`

```elixir
@type type() ::
  :string
  | :integer
  | :float
  | :boolean
  | :date
  | :datetime
  | :enum
  | :date_range
  | :datetime_range
  | :number_range
  | :duration
```

How a facet value is cast.

# `validator`

```elixir
@type validator() :: (term() -&gt;
                  :ok | {:error, {atom(), map()}} | {:error, String.t()})
```

An extra validator run after a successful cast, on the cast value.

# `cast_value`

```elixir
@spec cast_value(t(), String.t(), operator(), keyword()) ::
  {:ok, operator(), term()} | {:error, {atom(), map()}}
```

Casts `raw` for this facet — delegates to `Flicker.Facet.Cast.cast/4`.

Returns `{:ok, operator, value}` with the operator *resolved*: casting can
refine it, so `:` on a range facet comes back as `:between` and a
comma-separated list on a `multiple?: true` facet as `:in`.

## Examples

    iex> facet = Flicker.Facet.new(key: :created, type: :date_range)
    ...> Flicker.Facet.cast_value(facet, "2026-06-01..2026-06-30", :between)
    {:ok, :between, %Flicker.Facet.Range{from: ~D[2026-06-01], to: ~D[2026-06-30]}}

# `new`

```elixir
@spec new(keyword() | map()) :: t()
```

Builds a facet, filling in the defaults implied by its `:type` for any
field the caller didn't set.

Prefer this to a bare struct literal for the range types: a
`%Flicker.Facet{type: :date_range}` literal keeps the struct's own
`operators: [:eq]` default, which no range value can satisfy, so
`created:2026-06-01..2026-06-30` would degrade to free text. `new/1` gives
it `[:between]` instead.

Only *absent* keys are defaulted — an explicit `operators:` always wins,
including an explicit `[:eq]`.

## Examples

    iex> Flicker.Facet.new(key: :created, type: :date_range).operators
    [:between]

    iex> Flicker.Facet.new(key: :created, type: :date_range).scalar
    :date

    iex> Flicker.Facet.new(key: :price, type: :number_range).scalar
    :integer

    iex> Flicker.Facet.new(key: :status, type: :enum, operators: [:eq]).operators
    [:eq]

    iex> Flicker.Facet.new(key: :name).type
    :string

# `range?`

```elixir
@spec range?(t()) :: boolean()
```

Whether this facet's cast value is a `Flicker.Facet.Range`.

## Examples

    iex> Flicker.Facet.range?(%Flicker.Facet{key: :created, type: :date_range})
    true

    iex> Flicker.Facet.range?(%Flicker.Facet{key: :created, type: :date})
    false

# `scalar`

```elixir
@spec scalar(t()) :: type() | nil
```

The type each endpoint of a range facet casts as — `:scalar` when set,
otherwise the one implied by the range type. For a non-range facet this is
just its own type, so callers can use it unconditionally.

## Examples

    iex> Flicker.Facet.scalar(%Flicker.Facet{key: :created, type: :date_range})
    :date

    iex> Flicker.Facet.scalar(%Flicker.Facet{key: :price, type: :number_range, scalar: :float})
    :float

    iex> Flicker.Facet.scalar(%Flicker.Facet{key: :name, type: :string})
    :string

# `target`

```elixir
@spec target(t()) :: [atom()]
```

The Ash filter path this facet resolves to: `facet.target`, or `[facet.key]`
when `:target` is `nil`.

## Examples

    iex> Flicker.Facet.target(%Flicker.Facet{key: :status})
    [:status]

    iex> Flicker.Facet.target(%Flicker.Facet{key: :worker, target: [:worker, :full_name]})
    [:worker, :full_name]

# `validate_value`

```elixir
@spec validate_value(t(), String.t(), operator(), keyword()) ::
  :ok | {:error, {atom(), map()}}
```

Validates `raw` for this facet without keeping the value — `:ok`, or
`{:error, {reason, params}}` naming what was wrong
([ADR-012](https://github.com/team-alembic/flicker/blob/main/docs/adrs/adr-012-parse-reports-invalid-facet-tokens.md)).

## Examples

    iex> facet = Flicker.Facet.new(key: :status, type: :enum, values: [:active, :inactive])
    ...> Flicker.Facet.validate_value(facet, "activ", :eq)
    {:error, {:not_in_values, %{value: "activ", values: [:active, :inactive]}}}

    iex> facet = Flicker.Facet.new(key: :status, type: :enum, values: [:active])
    ...> Flicker.Facet.validate_value(facet, "active", :eq)
    :ok

# `value_label`

```elixir
@spec value_label(t(), term()) :: String.t()
```

A single value's display label — `:value_labels` when it has an entry,
otherwise the value as a string.

## Examples

    iex> facet = %Flicker.Facet{key: :status, value_labels: %{active: "Currently active"}}
    ...> Flicker.Facet.value_label(facet, :active)
    "Currently active"

    iex> Flicker.Facet.value_label(%Flicker.Facet{key: :status}, :active)
    "active"

# `value_source`

```elixir
@spec value_source(t()) :: {module(), keyword()} | nil
```

This facet's value candidates, as a `Flicker.Provider` source — or `nil` for
a facet whose values aren't drawn from a set.

Per [ADR-011](https://github.com/team-alembic/flicker/blob/main/docs/adrs/adr-011-facet-editors-are-modal-subcontexts.md),
a set-valued facet is edited by a nested `Flicker.select`, and the only thing
that differs between an enum facet and a relationship facet is the provider
behind it. This is the accessor that erases that difference: one editor
module, one counting path, one suggestion path.

`:enum` and `:boolean` resolve to a `Flicker.Providers.Static` over their own
closed set, with display labels from `:value_labels`, so an enum editor shows
`Established` rather than `established` and Spec 017's `:value_colors` key
off the same value atoms. A relationship facet resolves to the same
`Flicker.Providers.AshResource` source `Flicker.FacetSuggest.related_search/3`
already builds — that clause only exists when `ash` is present (ADR-006), so
an enum facet's `Static` source keeps working without it.

**This is about candidate values, not about controls.** Which editor opens
for a type is a separate mapping (Spec 019): `:boolean` appears here because
its `true`/`false` pair is genuinely a closed set worth suggesting, but it is
edited by a switch and never opens a select.

## Examples

    iex> facet =
    ...>   Flicker.Facet.new(
    ...>     key: :status,
    ...>     type: :enum,
    ...>     values: [:active],
    ...>     value_labels: %{active: "Active"}
    ...>   )
    ...>
    ...> {Flicker.Providers.Static, results: [result]} = Flicker.Facet.value_source(facet)
    ...> {result.value, result.label}
    {:active, "Active"}

    iex> facet = Flicker.Facet.new(key: :verified?, type: :boolean)
    ...> {Flicker.Providers.Static, results: results} = Flicker.Facet.value_source(facet)
    ...> Enum.map(results, & &1.value)
    [true, false]

    iex> Flicker.Facet.value_source(Flicker.Facet.new(key: :created, type: :date_range))
    nil

---

*Consult [api-reference.md](api-reference.md) for complete listing*
