An expression field with completion: predicator's grammar and the host's declared datamodel paths, offered at the caret.
This is the affordance statifier_blocks ADR-0005 decision 15 defers here -
"Rich expression editing is statifier-ui's" - and it is written to drop
straight into that package's expression_component seam:
<StatifierBlocks.Editor.editor
...
expression_component={&StatifierUI.Live.ExpressionInput.expression_input/1}
path_candidates={StatifierBlocks.Datamodel.candidates(document, datamodel)}
/>Nothing here depends on statifier_blocks, and nothing here knows what a
block is. The seam hands this component a five-key map - :field, :id,
:name, :value, :candidates - and :field is the one key it
deliberately ignores: reading it would mean knowing that package's view
model, and ADR-0004's dependency arrow points the other way. Any host with
an expression to edit can call it the same way.
The assigns arrive as a bare map
The seam calls the override as a plain one-argument function rather than
through a HEEx component tag, so attr defaults never run. Every assign
outside those five keys is defaulted here, in normalize/1, which is what
makes the same function usable both ways - <.expression_input .../> in a
template and component.(%{...}) from the seam.
Edits round-trip through the host's own form
The rendered <input> carries the name it was handed and nothing else:
no phx-change, no phx-target, no event of its own. In the sb editor
that input sits inside <form phx-change="config-change">, so an edit is
already the host's event, arriving with every other field. The hook keeps
that true - after it writes a completion in, it dispatches a bubbling
input event, so a keyboard-driven completion and a typed character are
the same event to the host.
Two modes, and the source text is the only representation
The field has a picklist mode beside its text mode. On every render the
source is classified by StatifierUI.Expression.simple/2: inside the
picklist-renderable subset it draws one row of dropdowns per clause -
field, operator, value - with a connective toggle and an add-clause button;
outside it it draws the text input. Source that does not parse is a third
answer rather than the second: the text input plus the parse error's own
position, per ADR-0007's picklist amendment. It never refuses a source
string and never rewrites one.
Every control writes source text, and only source text. There is no
structured model held anywhere: each <option>'s value is the complete
expression source that choosing it produces, written by
Predicator.Simple.to_source/1 through StatifierUI.Expression.source/2,
and picking one copies that string into the same named <input> the text
mode edits. The two controls that have to compose rather than choose - a
free-text value and a multi-select list - are handed a source template
with the value's own spelling standing in it, plus the quoting, escaping
and list punctuation the writer uses, all measured off
StatifierUI.Expression.value_source/2 rather than written out in
JavaScript. Nothing in the browser knows how predicator spells anything.
That is the load-bearing property. A picklist that kept clause rows as its own state would be the second source of truth ADR-0007 rules out for the diagram, arrived at through a form instead of a canvas.
Switching modes
The switch to text is always offered; the switch to picklists is offered only while the current text is inside the subset - an author is never invited into a mode that cannot draw what they have written. The choice is the viewer's and lives in the hook, so a re-render from the host does not drag an author back out of the mode they picked. The server decides only the default, which is picklists when the source is inside the subset.
Picklist mode needs the StatifierUIExpressionPicklist hook, because a
control that writes source text has to write it into the input; a host
that registers no hook gets the text field alone, the same degradation the
completion popup makes.
Operator labels are display, the source is not
A dropdown shows in where the writer spells IN. The label is a display
string and display_label/1 is the only place one is made; the value
stored is always the writer's own spelling, untouched. When this package
re-pins to a predicator carrying Predicator.Vocabulary's human labels
(px-84i - "is one of", "is at least"), that function becomes a lookup and
nothing else moves.
Two affordances, and the second one is optional
Without JavaScript the field is a text input bound to a <datalist> of the
word-shaped completions - the same affordance sb's plain control gives for
paths, widened to the grammar. With assets/js/expression_input.js
registered as the StatifierUIExpressionInput hook (ADR-0009: the
JavaScript ships as source and the host's bundler compiles it), the hook
drops the datalist and offers a caret-aware list instead: the token under
the cursor is the prefix, arrow keys move, Enter or Tab inserts.
A host that registers no hook loses the popup and keeps the field. A
predicator without Predicator.Vocabulary loses the grammar entries and
keeps the declared paths. Both states are stamped on the element rather
than inferred - data-hook and data-vocabulary - so a page that offers
nothing says which of the two reasons it is.
What it stamps
Per ADR-0007's data-attribute contract, the rendered structure is the testable surface:
| Attribute | On | Meaning |
|---|---|---|
data-completions | the input | the full completion list as JSON, the hook's whole input |
data-completion-count | the input | how many were offered |
data-vocabulary | the input | whether the grammar half resolved |
data-candidates | the input | how many declared paths were supplied |
data-expression-source | the input | marks the one element holding the source |
data-mode | the wrapper | picklist or text, the mode rendered |
data-subset | the wrapper | inside, outside, or error |
data-error-position | the diagnostic | line:column of a parse failure |
data-clause-count | the wrapper | how many clause rows were drawn |
data-clause-index | a clause row | its position, from zero |
data-role | a picklist control | path, operator, value, connective |
data-action | a button | add-clause, remove-clause, switch-text, switch-picklist |
Summary
Functions
The display spelling of a source lexeme.
The field: the picklist rows, the text input, and the switch between them.
The hook name a host registers for the completion popup:
"StatifierUIExpressionInput".
The hook name a host registers for the picklist:
"StatifierUIExpressionPicklist".
Functions
The display spelling of a source lexeme.
The one place a label is cased. Predicator.Simple.to_source/1 writes
IN and CONTAINS because that is what the grammar's decompiler writes,
and that spelling is what gets stored; an author reading a dropdown is
better served by in. The two concerns never meet: this touches labels
only, and every value attribute in the rendered picklist is the writer's
own untouched output.
Examples
iex> StatifierUI.Live.ExpressionInput.display_label("IN")
"in"
iex> StatifierUI.Live.ExpressionInput.display_label(">=")
">="
@spec expression_input(map()) :: Phoenix.LiveView.Rendered.t()
The field: the picklist rows, the text input, and the switch between them.
See the module doc for the seam this is written against, for why every control writes source text, and for what the rendered element stamps.
Attributes
id(:string) (required) - DOM id of the input itself.name(:string) (required) - form field name; the host's form owns the event.value(:string) - the expression source. Defaults to"".candidates(:list) - declared datamodel paths, from the host. Offered ahead of the grammar. Defaults to[].value_candidates(:map) - values the host offers per clause path, as%{path => [candidate]}. Only the host knows its own value sets; a path with no entry gets a free-text control. Defaults to%{}.mode(:atom) - which mode to render first.:autopicks picklists when the source is inside the subset. Defaults to:auto. Must be one of:auto,:text, or:picklist.hook(:string) -phx-hookname;nilrenders the datalist-only field. Defaults to"StatifierUIExpressionInput".picklist_hook(:string) -phx-hookname for the picklist. Defaults to the shipped hook, or tonilwhen:hookisnil- a host registering no hooks has none of this package's JavaScript. Defaults tonil.placeholder(:string) - Defaults to"an expression".class(:string) - Defaults tonil.vocabulary_opts(:list) - passed toPredicator.Vocabulary.functions/1- a host's own providers. Defaults to[].field(:any) - accepted from the sb seam and never read. Defaults tonil.
@spec hook_name() :: String.t()
The hook name a host registers for the completion popup:
"StatifierUIExpressionInput".
It is the name assets/js/index.js exports and the name this component
renders as phx-hook, returned as a function so a host's app.js and a
test can name the same string without copying it.
Examples
iex> StatifierUI.Live.ExpressionInput.hook_name()
"StatifierUIExpressionInput"
@spec picklist_hook_name() :: String.t()
The hook name a host registers for the picklist:
"StatifierUIExpressionPicklist".
The second of the two hooks assets/js/index.js exports. It is what
copies a chosen option's source string into the field and dispatches the
input event the host's form is already listening for.
Examples
iex> StatifierUI.Live.ExpressionInput.picklist_hook_name()
"StatifierUIExpressionPicklist"