StatifierUI.Live.ExpressionInput (StatifierUI v0.7.0)

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.

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

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 %{}.
  • 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"