StatifierUI.Live.ExpressionInput (StatifierUI v0.9.1)

Copy Markdown View Source

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.

The two switches sit in a statifier-ui-expression-modes container, and that container is the one element in this package that carries a style of its own: an inline display: inline-flex; gap: 0.5rem. HEEx drops the whitespace between two adjacent elements, so on a host page with no stylesheet the two buttons abutted and read as one run-together phrase (sui-aln). The default is layout only - no colour, no font, no token to reconcile with a host's palette, so the theming contract in docs/ops-embedding.md still holds. A host restyles the container through the class as usual; because the default is an attribute it wins on specificity, so a rule that replaces display or gap rather than adding to them needs !important.

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 is one of where the writer spells IN. The label is a display string; the value stored is always the writer's own spelling, untouched. Since the re-pin to a predicator carrying Predicator.Vocabulary's human labels (px-84i), operator labels are the grammar's own phrases, delivered by StatifierUI.Expression.operators/1 as the :label beside the writer's :lexeme - so this module no longer makes them, and no longer cases one either: every operator option it renders carries the grammar's :label verbatim, so one spelling of a display phrase exists in the system and it is the vocabulary's. The display_label/1 that used to lowercase a word-shaped lexeme is gone with the job it did (sui-ne0). Its one caller was op_options/5 here; the grammar's phrases are already display-cased, and every one of them is either multi-word or already lowercase, so it returned every label it could be handed unchanged.

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:

AttributeOnMeaning
data-completionsthe inputthe full completion list as JSON, the hook's whole input
data-completion-countthe inputhow many were offered
data-vocabularythe inputwhether the grammar half resolved
data-candidatesthe inputhow many declared paths were supplied
data-expression-sourcethe inputmarks the one element holding the source
data-modethe wrapperpicklist or text, the mode rendered
data-subsetthe wrapperinside, outside, or error
data-error-positionthe diagnosticline:column of a parse failure
data-clause-countthe wrapperhow many clause rows were drawn
data-clause-indexa clause rowits position, from zero
data-rolea picklist controlpath, operator, value, connective
data-actiona buttonadd-clause, remove-clause, switch-text, switch-picklist
data-declared-kinda clause rowthe kind the host declared for its path (integer, list:string, one-of, ...), absent when none
data-advisoryan advisory rowvalue-kind or operator, why it is shown
data-severityan advisory rowinfo - an advisory never blocks

What a declaration does and does not do

A :path_types entry decides three things: which operators the row offers (asked of StatifierUI.Expression.operators/1, the same grammar call an observed kind goes through), which control is drawn, when the value's own shape agrees with the declaration, and what literal a new row is seeded with. It never rewrites the author's source: the operator the source carries is always offered even when the declared kind's list would drop it, the value in the source is always kept and selected, and a scalar value is never handed a list control (or the reverse). When the declaration and the source disagree, an advisory row renders beside the clause and nothing about the source changes.

Seeding is the one place a declaration writes rather than describes, and it writes only where there was nothing: the row the "add clause" button creates. A row seeded with '' on a path declared a number would arrive carrying an advisory about itself, which is a declaration telling an author off for the shape it chose (sui-loj). So a declared number seeds 0, a boolean true, a date today, a datetime midnight today, a duration 1d, a {:one_of, _} its first value, and a list declaration seeds a CONTAINS clause holding one member. A path with no declaration - and a {:list, nil} that names no member kind - keeps the empty string this component always seeded.

Two ways to declare, and the map wins

A host that has a datamodel document may hand it over as :document instead of projecting it itself: the component asks StatifierDatamodel.Index.path_types/1 for the same path -> kind map that record defines, and uses it wherever a :path_types map was not supplied. A non-empty :path_types wins whole, because a host that supplies both has said the more specific thing.

The projection's vocabulary is this component's :path_types vocabulary, so nothing is translated between them - which is why StatifierUI.Expression.declared_kind/0 admits :number, the tag that projection answers for a document's integer and decimal alike.

Summary

Functions

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

expression_input(assigns)

@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 %{}.
  • path_types (:map) - kinds the host declares per clause path, as %{path => kind | {:list, kind} | {:one_of, values}}. A declared kind decides the operator list and the value control; it never rewrites the author's source. Defaults to %{}.

  • document (:map) - a decoded datamodel document. Its StatifierDatamodel.Index.path_types/1 projection supplies :path_types when the host declares none directly; a non-empty :path_types wins over it. Defaults to nil.
  • mode (:atom) - which mode to render first. :auto picks picklists when the source is inside the subset. Defaults to :auto. Must be one of :auto, :text, or :picklist.
  • hook (:string) - phx-hook name; nil renders the datalist-only field. Defaults to "StatifierUIExpressionInput".
  • picklist_hook (:string) - phx-hook name for the picklist. Defaults to the shipped hook, or to nil when :hook is nil - a host registering no hooks has none of this package's JavaScript. Defaults to nil.
  • placeholder (:string) - Defaults to "an expression".
  • class (:string) - Defaults to nil.
  • vocabulary_opts (:list) - passed to Predicator.Vocabulary.functions/1 - a host's own providers. Defaults to [].
  • field (:any) - accepted from the sb seam and never read. Defaults to nil.

hook_name()

@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"

picklist_hook_name()

@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"