Skip to content

UI projection

Shared interpretation of engine state for apps, DOM enhancer, adapters, and tooling.

Previous: Entrypoints · Next: Validation

Import

@jayoncode/form-intelligence/ui

Playground

UI Projection lab → — toggle errorDisplay / disableSubmitWhen and watch showError, status, and canSubmit live.

Problem → solution

ProblemSolution
Every adapter reimplements “when to show errors”errorDisplay policy + field.ui.showError
Submit disable logic forked across React / DOMform.ui.canSubmit + explain("submit")
“Why is this field locked?” scattered in app codeexplain("disabled") / disabledReasons
CSS / a11y need one status tokenfield.ui.status (exactly one)

Quick start

ts
import { createForm } from "@jayoncode/form-intelligence";
import { ui } from "@jayoncode/form-intelligence/ui";

const form = createForm({
  initialValues: { email: "" },
  plugins: [
    ui({
      errorDisplay: "touched",
      disableSubmitWhen: ["submitting", "validating", "ruleDisabled"],
    }),
  ],
});

form.field("email").ui.showError;
form.field("email").ui.status; // "idle" | "validating" | "error" | "success"
form.ui.canSubmit;
form.ui.explain("submit");

Registering ui() opts the DOM enhancer into the same helpers. Without the plugin, DOM keeps legacy error && touched / submit behavior. form.ui / field.ui derived keys are still available with default policies.

Policies

PolicyDefaultRole
errorDisplay"touched"When showError is true (touched | submit | always | touchedOrSubmit)
disableSubmitWhen["submitting","validating","ruleDisabled"]Tokens that block canSubmit (invalid is opt-in)

Hard rule: formUi.submitDisabled always blocks submit, even if "ruleDisabled" is omitted from the array.

Field derived API

KeyMeaning
hasErrorValidation state — raw error present
showErrorUI state — whether to display the error under policy
errorMessageThin projection of the error string
statusExactly one of validating | error | success | idle
disabledReasonsAlias of explain("disabled").reasons
explain(topic)Primary explanation API

Presentation keys (visible, disabled, required, readOnly, busy) stay owned by Presentation — /ui never overwrites them. Static schema/required validators seed required into Presentation (ADR-018); workflow rules may override.

Status priority (exactly one)

PriorityStatusWhen
1validatingField isValidating or presentation busy
2errorshowError === true
3successTouched and no raw error
4idleDefault

validating and error never appear together.

Form derived API

KeyMeaning
canSubmitComposed submit gate
submitBlockedReasonsAlias of explain("submit").reasons
phaseThin projection of submitPhase
invalidFields / visibleFields / requiredFields / validatingFieldsRegistration-order snapshots

Vanilla (DOM)

ts
createForm({
  target: "#signup",
  plugins: [ui()],
  schema: { email: "email" },
  async onSubmit(values) {
    await api.signup(values);
  },
});

With ui() registered, the enhancer sets aria-invalid from showError and data-fi-status from field.ui.status.

React

tsx
import { useForm } from "@jayoncode/form-intelligence-react";
import { ui } from "@jayoncode/form-intelligence/ui";

const form = useForm({
  plugins: [ui()],
  schema: { email: "email" },
  async onSubmit(values) {
    await api.login(values);
  },
});

return (
  <form {...form.form()}>
    <input {...form.field("email")} />
    {/* data-fi-status + aria-invalid from projection */}
    <button {...form.submit()}>Login</button>
    {/* disabled via form.ui.canSubmit */}
  </form>
);

Prefer form.fieldController("email").ui / form.instance.ui for explain and collections.

Standalone createUiProjection

form.ui is a createUiProjection(form) instance created for you when you register the ui() plugin. Call it yourself for tooling or a custom wrapper:

ts
import { createForm } from "@jayoncode/form-intelligence";
import { createUiProjection, ui, setUiPolicies } from "@jayoncode/form-intelligence/ui";

const form = createForm({ plugins: [ui({ errorDisplay: "touched" })] });

const projection = createUiProjection(form); // same shape as form.ui
projection.canSubmit;
projection.explain("submit");

Free helpers / policy store (advanced)

/ui also exports the standalone functions and the policy registry that back form.ui / field.ui. Prefer the instance API (form.ui, field.ui, the ui() plugin) in app code — these are for headless composition or testing the projection logic in isolation.

ExportRole
shouldShowErrorPure errorDisplay policy → boolean, given error/touched/submitted
canSubmitPure submit-gate composition (hard guard + UX policy)
explainSubmit{ value, reasons, contributors } for the submit gate
projectFieldUiBuild one field's derived UI (showError, status, …)
snapshotUiProjectionSerializable snapshot of the whole projection (used by DevTools)
setUiPolicies / getUiPolicies / hasUiPoliciesRegistered / clearUiPoliciesPer-form policy registry backing the ui() plugin
resolvePoliciesForFormResolve the effective policies for a given form id

Ownership

/ui only projects, composes, or explains existing engine state. It does not own validation, presentation intents, or submission.

Hard guards vs UX policy

form.ui.canSubmit is presentation only. Enforcement lives on Submission:

ts
form.submissionGuard(); // { allowed, reasons } — hard eligibility
await form.submit(); // refuses when !submissionGuard().allowed
ConcernOwnerEffect
alreadySubmitting, ruleDisabledSubmission guardsBlocks submit() and forces form.ui.canSubmit === false
captchaLoading / Pending / Failed / Expired / Timeout / UnavailableSecurity StageAlways hard-blocks canSubmit (contributor security) — not disableSubmitWhen-gated
validating, optional invalid/ui disableSubmitWhenButton UX only — does not change engine behavior

Full walkthrough: Submission — Hard guards vs button UX.

DevTools

ts
import { enableFormDevTools, getFormDevTools } from "@jayoncode/form-intelligence/devtools";

enableFormDevTools(form);
const snap = getFormDevTools().getUiProjection(form.id);
// snap.submissionGuard — hard engine (allowed / reasons)
// snap.canSubmit + snap.submitExplain — button UX
// snap.policies, requiredFields, fields[].status / showError / required / explain

Playground /devtools renders these as structured explain panels (hard vs UX, policies, collections, per-field).

JOC — headless @jayoncode/* TypeScript libraries. Docs sync from package source via TypeDoc and sync scripts.