# `StatifierBlocks.Editor.Field`
[🔗](https://github.com/riddler/statifier_blocks/blob/v0.44.1/lib/statifier_blocks/editor/field.ex#L2)

One config field, dispatching on the closed field-type set (ADR-0005
decision 9, ADR-0002 decision 7).

The set is closed precisely so this renderer can be total, and the
mapping is the record's rather than this module's:

| Field type | Rendering |
|---|---|
| `:string` | single-line text input; a `<select>` over a host's closed candidate list, or a `<datalist>` over an open one, when it supplied either |
| `:integer` | number input, step 1 |
| `:boolean` | checkbox |
| `{:select, choices}` | select, choices in declared order |
| `:expression` | statifier-ui's expression editor when that package is present, else a single-line source input; a `<datalist>` over a host's candidate list, either spelling, when it supplied one |
| `:duration` | one text control; duration strings the expression language reads, with on-screen examples |
| `{:list, t}` | repeatable rows of `t`'s renderer, with add and remove |
| `{:path, opts}` | single-line text input bound to a `<datalist>` of a host's candidate list, either spelling, when it supplied one, else of the declared datamodel paths; the plain input when there are neither |
| `{:type_expr, opts}` | per `opts.arms`: a text input bound to a `<datalist>` of the document's declared type names, or an inline member-list form; a toggle when the field admits both |

`:duration`'s row is decision 9 as amended 2026-08-29 and again
2026-09-05 (clause 9a, one grammar). One text control, not a value/unit
pair and not a pair with an escape hatch beside it: the compound control
could not spell a compound duration at all, and a control plus an escape
hatch is two ways to say one thing with a rule about which wins. The
field reads one grammar, the examples are on screen, and an empty field
omits the key.

What the typed text means is `StatifierBlocks.DurationInput`'s, not this
module's - it is a function of the text alone, so it is asserted with
LiveView absent. Where an omitted key is omitted is
`StatifierBlocks.Editor.ConfigForm`'s, which owns where a decoded value
is written. This module renders the control and shows the refusal.

The refusal shown beneath a `:duration` is the **inline** check, and it
is earlier than decision 9's gate rather than a second one: the gate
still decides what reaches the document, and the inline sentence tells
the author the text is not a duration while they are still typing it.
The sentence names what is accepted and nothing else, which is clause
9d. Nothing here is stored - the stored form is the author's string
verbatim, byte for byte.

A required field is marked with the **word**, not with an asterisk
(parity item 1.9). An asterisk is a convention that has to be learned
from a legend the editor does not have, it is read aloud as "star", and
it is one character wide next to a label it is easy to miss. `Required`
says the same thing to a reader and to a screen reader, and it is the
field record's own `required?` that decides - never a key name and never
a type.

Two control types carry a **placeholder**, and neither is chosen by key
or by type name: an `:expression` says what kind of thing belongs in it,
and a `:duration` shows the spelling it stores. A bare `:string` says
nothing, because there is nothing a type as wide as "string" can suggest -
which is the rule, and it is what makes the two that do say something the
editor's own presentation rather than a special case.

Whether a block type may declare a `placeholder` on its own fields is a
question this module deliberately does not answer: ADR-0002 decision 7
closes the field TYPE set, not the keys of a field record, so admitting
one is a widening of that record and belongs to whoever amends it.

`:expression` renders through the `expression_component` seam. Predicator
source is statifier-ui's subject (sui-ADR-0006), and decision 9
records a richer affordance as a deferral, so this component accepts an
override for exactly that seam - and, since 2026-09-04, fills the seam
itself when the package the deferral names is on the load path.

## Which control an `:expression` gets

Three answers, in this order, and the order is the whole rule:

  1. an `expression_component` the host passed - the host asked for its
     own control and gets it, whatever else is available;
  2. `StatifierUI.Live.ExpressionInput`, when `statifier_ui` resolves -
     value picklists over the subset predicator can round-trip, a text
     input over everything else, and the author's own source string
     either way;
  3. the plain source input this package has always rendered, with the
     `<datalist>` of declared paths described below.

`statifier_ui` is an **optional** dependency, resolved the way
`phoenix_live_view` is: absent, clause 3 is what an `:expression`
renders, nothing raises, and nothing warns at compile time. The
resolution is a runtime `Code.ensure_loaded?/1` against a module read
from `:statifier_blocks, :expression_component_module`, which is the
same indirection statifier-ui itself uses for `Predicator.Simple` - it
is what makes clause 3 assertable on a machine where clause 2 resolves.

Two properties of clause 2 are load-bearing and neither is this module's
to weaken. The component **never refuses a source string and never
rewrites one**: source it cannot draw as rows is drawn as text. And
every control it draws writes a *complete expression source string* into
the same named input the text mode edits, so the document still stores
the author's text and this package still holds no structured expression
model of its own.

## `value_candidates`

The values a host offers per datamodel path, `%{path => [candidate]}`,
where a candidate is `%{label: , value: }` or a bare string. It reaches
the `expression_component` beside `candidates` and is read by whatever
is behind the seam; nothing in this package interprets it, because only
a host knows which of its own paths have a value set at all. A path with
no entry gets a free-text value control, which is the same "suggests,
never constrains" posture the path `<datalist>` takes.

## `path_types`

The value kind the host's datamodel document declares per path,
`%{path => kind | {:list, kind} | {:one_of, values}}`, from
`StatifierBlocks.Datamodel.path_types/1`. It reaches the
`expression_component` beside `candidates` and `value_candidates`, and
like both of those, nothing in this package interprets it.

What is behind the seam reads a declared kind as *which operators the
row offers and which control it draws* - so a path the document declares
`integer` offers the numeric operators rather than the ones its current
source happens to imply. It is not a claim about the author's source: the
operator the source carries is still offered, the value in it is still
kept, and a disagreement renders as an advisory beside the clause. `%{}`,
which is what an editor with no datamodel supplies, is the behaviour
every `:expression` field had before the map existed.

## `candidates`

The values a host says belong in THIS field, supplied per
`{type_name, field_key}` through the editor's `field_candidates`
assign and handed down one field at a time. Two spellings, and the
difference between them is the whole feature:

  * `[{value, label}]` - a **closed** list. On a `:string` the control
    is a `<select>`, because a host that named the values is saying
    these are the values.
  * `{:open, [{value, label}]}` - an **open** list. The control is the
    text input with a `<datalist>`, on the terms every other
    suggestion list here is on: it suggests, it does not constrain,
    and free text stays valid.

`[]` is *no list supplied* and renders whatever the field type already
rendered, so a host that supplies nothing loses nothing.

Three field types read it, and the *closed* spelling means different
things to them:

| Field type | Closed list | Open list |
|---|---|---|
| `:string` | `<select>` over the offered values | text input bound to a `<datalist>` |
| `{:path, opts}` | text input bound to a `<datalist>` | the same |
| `:expression` | text input bound to a `<datalist>` | the same |

A `:path` and an `:expression` type their value - the first is a path
into the host's datamodel and the second is source the expression
language reads - so a host list can only ever suggest on them, and
drawing a closed one as a `<select>` would make it the authority on a
value the field's own type already answers for. On both of them the
host's list is read **ahead of** `path_candidates`, for the same reason
it is read ahead of the key-chosen lists: a list keyed on this field is
the narrower claim than the document's declarations. An `:expression`
served by an `expression_component` is the host's own control and is
still not decorated here, but the same precedence reaches it: the seam's
`:candidates` is the host's own offers ahead of `path_candidates`,
merged into one de-duplicated list. Drawing them is the override's job;
what it no longer has to do is re-derive a list from an assign it was
never handed.

Three properties, and none of them is this component's choice to make:

  * **It never decides validity.** `validate_config/1` is the
    authority (ADR-0002 decision 7), so a closed list is a control and
    not a rule. A stored value the list does not offer is drawn as its
    own selected option rather than silently rewritten to the first
    one, which is what a `<select>` would otherwise do to a document
    the moment its form was opened.
  * **It never changes what a field holds.** A `{:select, choices}`
    and a `:duration` already know what to draw and ignore it
    entirely, and on the two types that do read it beside `:string` it
    draws a suggestion and nothing else: the value stays typed by the
    control, and `:path`'s undeclared-path advisory (ADR-0005 clause
    11e) is produced from the value exactly as before.
  * **It is host state, not authoring state.** Which values exist is a
    property of the deployment the document runs in, so it arrives as
    an assign and is never stored in a block.

It is read ahead of the three key-chosen lists below, because a list
keyed on this field is the narrower claim.

## `event_candidates`

The completion events the blocks in a `core.on_event`'s enclosing body
raise, each `%{label: , value: }`, where the value is the generated
`done.outcome.<state id>.<outcome>` name and the label is the sibling
block's own label and that outcome's name. They are drawn as a
`<datalist>` on the `event` field, on exactly the terms the
`invoke_type` list is drawn on: the field stays a `:string`, a
free-typed name is validated as it always was, and an empty list draws
the plain input rather than an empty picker.

The list is derived by `StatifierBlocks.Editor` for a selected
`core.on_event` and is empty for every other selection, so the three
other core types that declare an `event` key are unaffected. Nothing in
this module tests for a block type to decide it.

## `outcome_candidates`

The outcomes a host says the chart a `core.subchart` names actually
finishes with, as plain names. They are drawn as a `<datalist>` on the
`outcomes` field on exactly the `invoke_type` list's terms: the field
stays a `:string`, a free-typed name is validated as it always was, and
an empty list draws the plain input rather than an empty picker.

The list is looked up by `StatifierBlocks.Editor` from its
`chart_outcomes` assign for a selected `core.subchart`, and is empty for
every other selection. Nothing in this module tests for a block type.

## The fixture hint

`fixture_hint` is `StatifierBlocks.Shell.fixture_hint/3`'s answer for
this field, and it is drawn as a sibling element after the control: the
exemplar the selected block's first fixture row in declaration order
binds to the path the source names, with every distinct value that path
takes across the block's rows on the element's `title`. ADR-0005's
2026-09-05 note records it, and three of its properties are the reason
it is here rather than anywhere else.

  * **It is a hint, not a `placeholder`.** The rule above - exactly two
    control types carry a placeholder, and neither is chosen by key or
    by type name - is untouched. The hint is a third element with its
    own text, not a third placeholder source.
  * **It is never an option.** Nothing about it reaches a picker, it is
    not merged with `one_of` or with a host's `value_candidates`, and it
    cannot be selected. A fixture value is an *example*, and an example
    promoted into a dropdown becomes a declaration the author never made.
  * **It adds no assign to the rendering package.** The hint is not
    passed through the `expression_component` seam and that component
    gains no key; this package draws it out of the `fixtures` the editor
    already holds. Widening another package's API to draw this package's
    own decoration is what the seam's shape exists to prevent.

`nil` - a block with no fixture rows, or no fixtures source at all -
draws no element, so such a block renders exactly as it did before. That
is silence rather than an empty affordance, which is the same thing the
empty `<datalist>` cases below do.

## The `:expression` path suggestions

That plain input gains a `<datalist>` of the declared datamodel paths
when `path_candidates` is non-empty, on exactly the `invoke_type` terms
below: it suggests and does not constrain, free text stays valid, an
undeclared path stays the `:info` advisory `StatifierBlocks.Datamodel`
already produced rather than becoming a refusal, and an empty list
renders the input the package has always rendered. The same list reaches
the `expression_component` override as `:candidates`, so a host that
fills the seam is handed the paths rather than re-deriving them.

**This is the data, not the feature.** Decision 9's deferral of rich
expression editing to statifier-ui is untouched, and one property of a
`<datalist>` is why that matters rather than being a formality: the
browser matches options against the input's **whole value**, so the list
is live while the author is typing the leading path and goes quiet the
moment the expression grows an operator. That is genuinely useful for
the bare-path condition and for the first token of any other, and it is
not completion. Mid-expression completion needs to know where the caret
is inside the source, which needs either a hook this package may not add
(decision 7's two-hook limit) or the richer component decision 9 defers -
and it needs predicator's operator vocabulary, which px-15q tracks.

Ordering follows the same reasoning as the clause order below: the
override wins over the datalist, because a host that supplied a
component asked for its own control and getting the package's suggestion
markup stapled beside it would be the package overriding the override.

This module is a renderer, not a gate. Nothing here decides whether a
value is acceptable: `validate_config/1` does, through
`StatifierBlocks.Edit.check_config/3`, which is why an unparseable
integer reaches the draft config as the string the author typed rather
than being silently coerced or dropped.

## The `{:type_expr, opts}` control

ADR-0005 decision 9's Note of 2026-09-06. Two arms and a toggle, and
the arm a field admits is `opts.arms` - a field declaring one arm
draws that arm and no toggle.

**The name arm is the `invoke_type` control's shape over a different
feed.** A single-line text input with a `<datalist>` beside it, free
text still valid, the plain input when the list is empty. The names are
the datamodel document's **declarations** - the list
`StatifierBlocks.Datamodel.declared_types/1` computes and the Datamodel
tab already draws - arriving here as `type_candidates`. It is a
different feed from `path_candidates`, and deliberately disjoint: the
`types` key contributes no path, so the declared paths a
`{:path, opts}` field suggests and the declared type names this one
suggests share no member and are never merged. It suggests and never
constrains.

**The inline arm draws an ordered member list**, each row carrying the
member's name, its type and whether the shape promises it. Adding and
removing a row is the affordance a `{:list, t}`'s rows already have,
on the same two events. A member's *type* control is this same control
recursing, so a member may itself hold an inline shape and the form
nests as a `{:list, t}` of a `{:list, t}` nests. The order the author
writes is preserved, because that is the order an unmet-member reason
renders in, and nothing here presents reordering as though it changed
the value.

**Switching arms replaces the value; it never translates it.** A name
and a member list are not two spellings of one value, so there is
nothing to carry across: the new arm opens empty and the edit reaches
the document through `:update_config` exactly as every other field
edit does.

**A value the control cannot read renders raw** - in the name arm's
text input, showing the bytes exactly as stored, with the field's own
`:config` finding beneath it. Raw rather than blank, for decision 9's
reason that a control showing nothing invites an author to save over a
value they never saw.

## The `{:path, opts}` control

Decision 7's eighth field type holds a path into the host's datamodel,
and it reaches its control by **type**, which is the whole reason the
type exists beside the `datamodel_path?: true` key: a host that never
learned the key wrote `type: :string` and got neither a candidate list
nor an advisory, and nothing in its declaration said anything was
missing.

The control is the same `<datalist>` of `path_candidates` the
`:expression` input carries, on the same terms - it suggests and does
not constrain, free text stays valid, and an empty list renders the
plain text input a `:string` rendered, which is what "no datamodel
supplied" looks like on screen. It is not a `{:select, choices}`,
because the declared paths are what a host happens to have declared and
not the set of values a field may hold: `validate_config/1` is still
the only authority on that, and an undeclared path is still ADR-0005
clause 11e's `:info` advisory anchored on this field's `key`, never a
refusal.

A host that named this field's values in `candidates` gets those in the
datalist instead of the declared paths, on identical terms and for the
reason the `## candidates` section above gives; the advisory is
produced from the stored value either way.

`opts` is read by nothing here, and its two keys are why that is worth
saying rather than obvious. `expects` and `writes` (ADR-0011 decision
2) are claims about the document's data flow at the path, read by
`StatifierBlocks.Environment` and reported by
`StatifierBlocks.Assignability` as a finding on this field's own key.
They are not claims about the bytes this control edits, so the control
is the same one either way and an author sees the difference in the
findings beneath it. A `{:path, opts}` inside a `{:list, t}` is a list
of paths: each row is a text input bound to one `<datalist>` of the
declared datamodel paths (`path_candidates`), drawn once for the
field. A host's per-field `candidates` do not reach list rows; rows
read `path_candidates` only. Each row's value is checked for the same
`:info` advisory, anchored on the list field's `key`. A row of any
other type draws no suggestion markup.

## The `invoke_type` suggestion list

`invoke_types` is the one control this module chooses **by key**, and
the exception is deliberate rather than an oversight of the rule above.
A host that knows which invoke types it has registered can pass them as
an editor assign, and an `invoke_type` field then renders as a text
input bound to a `<datalist>` of those strings. With the assign absent
or empty the same field renders as the plain text input it has always
been, so the suggestion list is additive and a host that supplies
nothing loses nothing.

Three properties make this a suggestion rather than a vocabulary, and
each of them is ADR-0004 decision 8 rather than a choice made here:

  * **Free text stays valid.** A `<datalist>` suggests; it does not
    constrain, which is exactly why it is the control used and a
    `{:select, choices}` is not. An author can type a type that is not
    on the list and the editor stores it verbatim.
  * **An unknown type stays a lint.** The two-registry check is the
    compiler's opt-in `:known_invoke_types` lint, and it reports; it
    never refuses. Nothing here changes what compiles.
  * **The handler set is deployment state, not authoring state.** Which
    types a host can actually run is a property of the deployment the
    document is run in, not of the document, so it arrives as an assign
    the host fills in and never as anything stored in the block.

The assign shares its name with the compiler's `invoke_types` surface -
`StatifierBlocks.Compiled`'s field and the compiler's
`:known_invoke_types` option - and the two are separate surfaces that
happen to describe the same vocabulary from opposite ends. The
compiler's is *derived from a document*: the sorted set of types that
document actually emits. This one is *supplied by a host*: the types it
is prepared to answer. Neither reads the other.

Keying a control on a field's key is a narrower thing than the
`placeholder` question above, which is why it does not reopen it: a
block type declaring an `invoke_type` field of some other type keeps
that type's control, because this clause is reached only after every
typed clause has had its turn.

# `decode`

```elixir
@spec decode(StatifierBlocks.BlockType.field_type(), term()) ::
  StatifierBlocks.Block.json()
```

Decodes one field's slice of a form's params back into a config value,
dispatching on the same closed type set the renderer does.

Total, and deliberately non-coercing at the edges: an integer field
whose input does not parse yields the string the author typed, so
`validate_config/1` reports it and decision 9's gate keeps it out of
the document. Coercing to zero here would silently discard the author's
intent and commit a value they never asked for.

# `field`

One field: its label, its control, and its own findings (decision 11).

Two flags on the declaration change what is drawn (ADR-0002 decision 7,
amended 2026-09-07). A `hidden?: true` field renders **nothing at all** -
no row, no label, no control. A `readonly?: true` field renders its row
and its label, with its **value** where a control would sit; it is not a
disabled input and carries no form control, so it posts nothing. Both
flags leave the field's findings visible where a row is drawn at all.
`hidden?` wins when both are set, because a field that is not rendered
has nothing to render as a value.

The `variant` attribute chooses how much chrome the row wears and
nothing else; the two flags above win over it, and `:inline` posts what
`:block` posts. See the attribute's own documentation.

## Attributes

* `field` (`StatifierBlocks.ViewModel.Field`) (required)
* `target` (`:any`) (required)
* `class` (`:string`) - Defaults to `nil`.
* `block_id` (`:string`) - The id of the block this field belongs to, sent as `block-id` on the
  list gestures (`field-list-add`, `field-list-remove`) the way
  `config-change` and `discard-draft` already carry it. `nil` sends no
  attribute at all, so a caller that supplies none renders exactly what
  it rendered before.

  Defaults to `nil`.
* `expression_component` (`:any`) - Override for `:expression`, per ADR-0005 decision 9's seam. Receives the same assigns. Defaults to `nil`.
* `invoke_types` (`:list`) - The invoke types the host is prepared to answer. Suggestions for an
  `invoke_type` field, never a constraint on it; empty is *no list
  supplied* and renders the plain input.

  Defaults to `[]`.
* `path_candidates` (`:list`) - The declared datamodel paths, from `StatifierBlocks.Datamodel.candidates/3`.
  Suggestions on an `:expression` field, on a `{:path, opts}` one and on
  each row of a `{:list, {:path, opts}}` one, and passed to
  `expression_component` as `:candidates`; empty renders the plain input.

  Defaults to `[]`.
* `value_candidates` (`:map`) - The values a host offers per datamodel path, `%{path => [candidate]}`.
  Passed to `expression_component` as `:value_candidates` and read only
  there; `%{}` offers none and a path with no entry gets free text.

  Defaults to `%{}`.
* `type_candidates` (`:list`) - The document's declared type **names**, sorted, drawn as the
  `<datalist>` of a `{:type_expr, opts}` field's name arm and of every
  member type control inside its inline arm. `[]` renders the plain
  input, on the same "an empty list is markup that suggests nothing"
  terms every other feed here is under.

  Defaults to `[]`.
* `path_types` (`:map`) - The kinds the host's datamodel declares per path, from
  `StatifierBlocks.Datamodel.path_types/1`. Passed to
  `expression_component` as `:path_types` and read only there; `%{}`
  declares none and a path with no entry renders as it always did.

  Defaults to `%{}`.
* `event_candidates` (`:list`) - The completion events the enclosing body's blocks raise, each
  `%{label: , value: }`. Suggestions for a `core.on_event` `event` field,
  never a constraint on it; empty is *no list supplied* and renders the
  plain input.

  Defaults to `[]`.
* `outcome_candidates` (`:list`) - The outcomes the host says the referenced chart finishes with, as plain
  names. Suggestions for a `core.subchart` `outcomes` field, never a
  constraint on it; empty is *no list supplied* and renders the plain
  input.

  Defaults to `[]`.
* `candidates` (`:any`) - The values a host offers for this field, `[{value, label}]` for a
  closed list or `{:open, [{value, label}]}` for an open one. `[]` is
  *no list supplied* and renders the control the field type already
  rendered. Read by `:string`, `{:path, opts}` and `:expression`; on
  the last two, both spellings draw a `<datalist>` and the value stays
  typed by the control.

  Defaults to `[]`.
* `fixture_hint` (`:any`) - `StatifierBlocks.Shell.fixture_hint/3`'s answer for this field, or
  `nil`. Drawn as an element beside the control, never passed through the
  `expression_component` seam and never an option.

  Defaults to `nil`.
* `debounce` (`:any`) - What `phx-debounce` this field's controls carry, or `nil` for none.
  Written verbatim onto every form control this component renders, so
  the accepted values are LiveView's own: milliseconds as an integer
  or a string, or `:blur` for "post when the control loses focus".

  `nil` renders no attribute at all - byte for byte what this
  component rendered before the attr existed, and the behaviour
  LiveView gives a control with no `phx-debounce`, which is to post
  every change event as it happens. There is deliberately no non-`nil`
  default: a package that debounced on its own would change the event
  stream of every host already mounted on it, and how often a document
  is written is the host's decision rather than this component's.

  Every control is every control: the hidden inputs that pair a
  checkbox and stand in for an empty list carry it too. The attribute
  is inert on an input that fires no input event, and the rule a
  reader can check against the markup - no control in this form is
  missing it - is worth more than trimming an attribute that does
  nothing.

  A host's own `expression_component` override is handed this value as
  the seam map's `debounce` key, and what it does with it is the
  override's decision rather than this component's - the override
  renders its own markup, so nothing here can write the attribute onto
  it. An override that ignores the key rate-limits the way it always
  did; one that writes it through - `StatifierUI.Live.ExpressionInput`
  does, from 0.10.1 - debounces the way every other control in the same
  form does.

  Defaults to `nil`.
* `variant` (`:atom`) - How much chrome this one field wears. An attribute on this component,
  never a mode on the editor: a host that wants a field inside a
  sentence of its own writing says so per field, and nothing about the
  package's own forms changes.

  `:block` is the row this component has always drawn - a labelled
  block with its control beneath it - and it is byte for byte what the
  component rendered before the attribute existed.

  `:inline` draws the same control, in an inline box, with the label
  moved out of sight but not out of the accessibility tree: the
  `<label>` is still there and still bound to the control by `for`, so
  a screen reader still announces the field's name and a click on the
  label still focuses the control. It is `sb-field__label--inline` that
  takes it off the screen, which is a stylesheet rule rather than an
  omission, so a host restyling the editor can put the label back
  without the markup changing under it.

  What is drawn is all that differs. The control is the same control,
  chosen by the same field type; it posts the same event under the same
  param name, so `StatifierBlocks.Editor.ConfigForm.decode/3` reads an
  inline field exactly as it reads a block one and the config a form
  produces cannot depend on how its fields were dressed.

  The declaration's own flags still win. A `hidden?: true` field
  renders nothing in either variant, and a `readonly?: true` field
  renders its readonly row, because those flags are statements about
  the field itself while this is a statement about one placement of it.

  Defaults to `:block`. Must be one of `:block`, or `:inline`.

# `input_id`

```elixir
@spec input_id(StatifierBlocks.ViewModel.Field.t()) :: String.t()
```

The DOM id for a field's control. Part of decision 7's DOM contract.

# `input_name`

```elixir
@spec input_name(StatifierBlocks.ViewModel.Field.t()) :: String.t()
```

The form param name a field's control posts under.

---

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