@formspec-org/webcomponent
v2.1.0
Published
Web component renderer (<formspec-render>) with pluggable component registry
Readme
formspec-webcomponent
<formspec-render> is a custom element that binds a FormEngine to the DOM. It ships 33 schema-defined built-in components, a plugin registry, a 5-level theme cascade, reactive ARIA attributes, and responsive breakpoint support.
Install
npm install formspec-webcomponentThe package is ESM-only. It requires formspec-engine and formspec-layout as peer dependencies. Runtime imports use formspec-engine/render and formspec-engine/init-formspec-engine so the custom element does not pull the full engine fel-api / tools JS glue graph.
Quick Start
import { FormspecRender } from 'formspec-webcomponent';
customElements.define('formspec-render', FormspecRender);
const el = document.createElement('formspec-render');
document.body.appendChild(el);
// Set registryDocuments BEFORE definition — the engine is created on `set definition`.
el.registryDocuments = myRegistryDoc;
el.responseActionsDocument = myResponseActionsDoc;
el.definition = myDefinition;
el.componentDocument = myComponentDoc;
el.themeDocument = myTheme;Do not import CSS. The element links every stylesheet it needs: structural formspec-layout.css (always, for every adapter), then the resolved adapter’s own stylesheets, then the theme’s stylesheets. Links go into the element’s own root — the document <head>, or the shadow root it is mounted in, so a shadow-isolated host is styled without hand-linking anything. Ref-counting is per root, so instances sharing a sheet in one root load it once and unlink when the last disconnects. The package still exports both CSS files because those are the files the element links.
For a flash-free first paint, pre-load them in <head> — a bundler import, a hashed asset, a hand-written <link>; the package exports are the files. Each stylesheet declares itself on .formspec-container with a marker (--formspec-layout, --formspec-default-skin, the adapter layers' own), which the renderer probes before linking, so a pre-loaded sheet is never linked twice. When it does have to link, the form stays visibility: hidden and aria-busy until those sheets answer (2s cap), rather than painting unstyled.
The element is exported but not auto-registered. Call customElements.define() with your preferred tag name.
Properties
| Property | Type | Description |
|---|---|---|
| definition | object | Formspec definition JSON. Creates a new FormEngine and schedules a render. |
| componentDocument | object | Component document JSON (layout tree, tokens, breakpoints). Schedules a render. |
| responseActionsDocument | object \| null | Response Actions document used to resolve ActionButton.actionRef. Unresolved action buttons are inert and emit formspec-action-finding. |
| themeDocument | ThemeDocument \| null | Theme document. Its adapter field names the render adapter the theme was written for; setting it relinks adapter + theme stylesheets and schedules a render. |
| adapter | string \| null | Host override of the render adapter for this element only. Outranks themeDocument.adapter. |
| resolvedAdapterName | string (read-only) | The adapter actually rendering: adapter → themeDocument.adapter → globalRegistry.setAdapter(…) → 'default'. |
| registryDocuments | object \| object[] | One or more extension registry documents. Builds an internal extension-name-to-entry map. Set this before definition — the engine reads registry entries at construction time. |
Setting any property schedules a coalesced re-render via microtask.
Methods
// Engine access
getEngine(): FormEngine | null
// Diagnostics
getDiagnosticsSnapshot(options?: { profile?: 'live' | 'on-submit' | 'on-demand' | 'off' }): object | null
// Replay
applyReplayEvent(event: object): { ok: boolean; event: object; error?: string }
replay(events: object[], options?: { stopOnError?: boolean }): { applied: number; results: object[]; errors: object[] }
// Runtime context (inject `now`, user metadata, etc.)
setRuntimeContext(context: object): void
// Validation and submission
touchAllFields(): void
submit(options?: { profile?: 'live' | 'on-submit' | 'on-demand' | 'off'; emitEvent?: boolean }): { response: object; validationReport: object | null } | null
resolveActionRef(actionRef: string, nodeId?: string): ActionResolution
invokeAction(actionRef: string, nodeId?: string): { response: object; validationReport: object | null } | null
resolveValidationTarget(resultOrPath: any): ValidationTargetMetadata
// Field focus
focusField(path: string): boolean
// Submit pending state
setSubmitPending(pending: boolean): void
isSubmitPending(): boolean
// Wizard navigation
goToWizardStep(index: number): boolean
// Screener
getScreenerState(): ScreenerStateSnapshot
getScreenerRoute(): ScreenerRoute | null
skipScreener(): void
restartScreener(): void
// Force synchronous re-render
render(): voidActionButton invocation emits host adapter events for non-renderer work:
formspec-action-precondition, formspec-action-idempotency-key,
formspec-action-effect, and formspec-action-result.
Events
All events bubble and are composed.
| Event | When | detail |
|---|---|---|
| formspec-submit | submit() called with emitEvent !== false, or an invoked Action declares a hostEvent effect with eventName: "formspec-submit" | { response, validationReport } |
| formspec-action-finding | ActionButton.actionRef is missing, unresolved, or no Response Actions document is loaded | { finding: { code: "COMP-REFERENTIAL-INTEGRITY", severity: "error", kind: "actionRef", ... } } |
| formspec-theme-finding | themeDocument.adapter names an adapter that is not registered (once per theme set; renders with the fallback) | { finding: { code: "THEME-ADAPTER-MISSING", severity: "error", adapter, message } } |
| formspec-submit-pending-change | Submit pending state toggles | { pending: boolean } |
| formspec-screener-state-change | Screener state changes (definition set, skip, restart, route selected) | { hasScreener, completed, routeType, route, reason } |
| formspec-screener-route | Screener evaluates a route | { route, answers, routeType, isInternal } |
| formspec-page-change | Wizard navigates to a step | { index, total, title } |
Declarative WebMCP (opt-in)
Every named control always carries toolparamdescription (the field hint, else its label — plain text, at most 150 characters, following the active Locale), so any DOM-reading agent can understand the form (Assist spec §8.2).
The tool itself is opt-in. With a tool-name attribute — <formspec-render tool-name> for the default formspec.form.fill, or tool-name="grants.apply" when a page renders more than one form — the render root becomes <form novalidate toolname tooldescription> (tooldescription is the Definition description, else title, as plain text of at most 500 characters; toolautosubmit is never set). Adding or removing the attribute after render swaps the root on the next render. A Formspec form never navigates: the root cancels native submission.
The submit-intent ActionButton (a Response Actions intent: 'submit') is the tool's native submit button — type="submit" — wherever it is actually actionable for the respondent: always outside a Wizard, only on a Wizard's last step otherwise (elsewhere it stays type="button", an override applied over whatever an adapter itself hardcodes — USWDS's type="button" included). A Wizard keeps every step's panel mounted once built (CSS-hidden, not removed), so this button — always the last step's — sits in the DOM the whole time; the type-toggle alone isn't the guard, though, because a form with no type="submit" button and at most one text-like field still submits directly on Enter (HTML implicit submission, event.submitter: null). The actual guard is in the form's own submit listener: a non-agent-invoked submission only runs the submit-intent Action when event.submitter is that native submit button — a submitter-less submission is the browser's own fallback, not a respondent choosing to submit, and does nothing. Every control whose bind is required also carries native required (the form is novalidate, so no browser validation bubble follows) — a declarative-tool synthesizer's only signal for the schema's required[] — except in a checkbox group, where required on one checkbox means "this one must be checked," not "pick at least one"; a required checkbox group's required[] entry is accepted as missing rather than mis-stated (WebMCP §8.2 MUST NOT degrade accessibility to add annotations, and "check every box" would).
A respondent submission — a click on that button, or Enter's implicit submission of it — runs the submit-intent Action, the same one the button's own click runs elsewhere. An agent-invoked submission (SubmitEvent.agentInvoked) runs it too, then is answered through respondWith() with the ValidationReport the submission produced (assist-spec §8.2 SHOULD) — the intent runs before the answer is built, never a stale or freshly-recomputed one.
Before opting in:
- The root lives in this element's light DOM. Do not wrap
<formspec-render tool-name>in a host<form>— the two nest, and the host form stops owning these controls. - A page running an Assist provider (
@formspec-org/assist) already exposes a fill tool; do not opt in there — WebMCP guidance is against overlapping tools. - On a Wizard, the declarative tool is invokable only while the respondent is on the last step: a WebMCP browser fills the controls, then looks for a submit button before any of the above runs, and only the last step ever has one. An agent that needs to fill a multi-step form uses the imperative provider instead (
formspec.field.bulkSet, from an active Assist provider). Tabs mode has the same last-page limitation but no live per-tab signal to gate the button on (unlike a Wizard'sformspec-page-change) — author a submit-intentActionButtonon the tab expected to be reached last, or outside the tabs entirely. @mcp-b/webmcp-polyfill(a polyfill, not this package) has an internal race: its document-level capture listener can queue a microtask that runs before the target-phase listener finishes, for a native (real click or Enter) submission specifically — soexecuteTool()can resolveundefinedeven thoughrespondWith()was correctly answered. Filed upstream; not a Formspec defect.
Assistant-written fields
A field written with setValue(value, { source: 'assist' }) — every Assist provider write — is marked and validated at once (Assist spec §8):
- Its root carries
data-formspec-agent-filleduntil the respondent edits it. Both shipped skins draw a left rule on it (the default skin also tints the surface); the attribute is the hook for any other adapter. - It counts as touched, so its validation shows immediately — nobody will blur it.
- A polite live region beside the render root says "{label} filled by your assistant", one message per frame (a bulk write reads as one sentence). The wording is the chrome string
$ui.assist.filledwhen a Locale authors it, else English.
Component Registry
All 33 built-in components register automatically on import. Add custom components by registering a plugin on the global registry singleton.
import { globalRegistry } from 'formspec-webcomponent';
globalRegistry.register({
type: 'MyWidget',
render(comp, parent, ctx) {
const div = document.createElement('div');
div.textContent = comp.props?.label ?? 'Hello';
parent.appendChild(div);
},
});Each plugin implements ComponentPlugin:
interface ComponentPlugin {
type: string;
render(comp: any, parent: HTMLElement, ctx: RenderContext): void;
}RenderContext provides engine access, path resolution, theme helpers, signal cleanup tracking, and recursive child rendering. See src/types.ts for the full interface.
Built-in Components
| Category | Components | |---|---| | Layout (9) | Section, Stack, Grid, Divider, Collapsible, Panel, Accordion, Modal, Popover | | Input (12) | TextInput, NumberInput, Select, Toggle, DatePicker, RadioGroup, CheckboxGroup, Slider, Rating, FileUpload, Signature, MoneyInput | | Display (8) | Heading, Text, Card, Alert, Badge, ProgressBar, Summary, ValidationSummary | | Interactive (2) | Tabs, ActionButton | | Special (2) | ConditionalGroup, DataTable |
Render Adapters
Input components use a headless behavior/adapter architecture (see ADR 0046). Each component is split into:
- Behavior hook — owns reactive signal wiring, value coercion, ARIA state management, touched tracking, and validation display. Never creates DOM.
- Render adapter — owns DOM structure and CSS class names. Never imports
@preact/signals-core. Callsbehavior.bind(refs)after building DOM to wire everything up.
The built-in default adapter reproduces the standard Formspec DOM. Design-system adapters can provide structurally different markup while reusing the same behavior hooks.
Beyond the built-in components, two chrome types route to the adapter as well: Group (a bound group — its live title, heading depth, and scoped children) and RepeatGroup (a repeatable group's rows plus Add and Remove). The default adapter renders .formspec-group / .formspec-repeat; USWDS renders fieldset.usa-fieldset with a legend.usa-legend and usa-button affordances.
An adapter owns its design system completely — markup and CSS. stylesheets lists absolute URLs of self-contained stylesheets (fonts and images inlined, no @import), typically new URL('./x.css', import.meta.url).href. The renderer links them; a host never imports adapter CSS. The default adapter’s stylesheet is the Formspec skin; structural formspec-layout.css is linked for every adapter, so an adapter sheet must not repeat it.
Registering a Custom Adapter
import { globalRegistry } from 'formspec-webcomponent';
globalRegistry.registerAdapter({
name: 'my-design-system',
stylesheets: [new URL('./my-design-system.css', import.meta.url).href],
components: {
TextInput: (behavior, parent, actx) => {
// Build your own DOM structure
const root = document.createElement('div');
root.className = 'my-field';
const label = document.createElement('label');
label.textContent = behavior.label;
root.appendChild(label);
const input = document.createElement('input');
input.id = behavior.id;
root.appendChild(input);
const error = document.createElement('div');
root.appendChild(error);
parent.appendChild(root);
// bind() wires ALL reactive behavior — adapter does NOT register event listeners
const dispose = behavior.bind({ root, label, control: input, error });
actx.onDispose(dispose);
},
// ... other components. Missing entries fall back to the default adapter.
},
});
// Host-level default for elements whose theme names no adapter
globalRegistry.setAdapter('my-design-system');Normally the theme picks the adapter, because a theme’s selectors, widgetConfig, and cssClass are written against one design system’s markup:
{ "$formspecTheme": "1.0", "version": "1.0.0", "adapter": "my-design-system" }Resolution is per element, so two <formspec-render> instances with different themes on one page get different adapters. Precedence, highest first:
el.adapter— host override for this instancethemeDocument.adapterglobalRegistry.setAdapter(…)— host-level default'default'
A theme naming an unregistered adapter emits formspec-theme-finding (THEME-ADAPTER-MISSING) once and renders with the fallback.
Adapter Contract
Adapters must:
- Create DOM elements and append the root to
parent - Apply
behavior.presentation.cssClassto the root element (union semantics) - Respect
behavior.presentation.labelPosition('top'|'start'|'hidden') - Apply
behavior.presentation.accessibilityattributes (role, aria-description, aria-live) - Call
behavior.bind(refs)with references to created elements - Register the dispose function via
actx.onDispose(dispose)
Adapters must not:
- Import
@preact/signals-coreor access the engine directly - Register event listeners for value sync, change detection, or touch tracking (
bind()owns all event wiring) - Ask the host to import their CSS, or inject
<style>into the document — declarestylesheetsinstead - Repeat the structural rules in
formspec-layout.css, which the renderer links for every adapter
Exported Types for Adapter Authors
import type {
RenderAdapter, AdapterRenderFn, AdapterContext,
FieldBehavior, FieldRefs, ResolvedPresentationBlock,
TextInputBehavior, NumberInputBehavior, RadioGroupBehavior,
CheckboxGroupBehavior, SelectBehavior, ToggleBehavior,
DatePickerBehavior, MoneyInputBehavior, SliderBehavior,
RatingBehavior, FileUploadBehavior, SignatureBehavior,
WizardBehavior, TabsBehavior,
} from 'formspec-webcomponent';Theme Cascade
The renderer resolves presentation through a 5-level cascade (lowest to highest priority):
- Form-wide
formPresentationhints in the definition - Per-item
presentationhints in the definition item - Theme
defaults - Theme
selectors(document order; later wins) - Theme
items[key]per-item overrides
Tokens ($token.spacing.lg) resolve from the component document and theme document, then emit as CSS custom properties (--formspec-spacing-lg) on the form container.
Theme documents may declare a stylesheets array of CSS URLs. The renderer links them last — after structural layout and the adapter’s own sheets — so theme CSS wins the cascade. Every link is ref-counted, so instances sharing a sheet load it once and it unloads when the last one disconnects.
Rendering Pipeline
- Cleanup — dispose all previous signal effects and remove event listeners.
- Breakpoints — wire
matchMedialisteners fromcomponentDocument.breakpoints. - Tokens — emit CSS custom properties onto
.formspec-container. - Screener gate — render the screener if one is defined and not yet completed.
- Plan — call
planComponentTree()(fromformspec-layout) to produce a layout node tree. - Emit — walk the tree and dispatch each component to its plugin.
- Orchestrate — each input plugin calls its behavior hook, resolves the active adapter, and invokes the adapter render function. The adapter builds DOM;
bind()wires all reactive effects.
Each input component receives a fully wired field wrapper with label, hint, error display, ARIA attributes, and touch tracking driven by signals from the engine.
Hydrating from saved or external data
Screener fields live in a standalone Screener document ($formspecScreener), not on the definition. Set element.screenerDocument = … before element.definition = … when you use the gate.
Use element.initialData = response.data (same shape as a Formspec response payload) before element.definition = …. On engine creation the element splits out screener keys, passes the rest as constructor responseData (hydrate-then-seed), and pre-fills or auto-skips the screener.
For hydration after the element already has a definition, call engine.loadResponseData(data).
Fine-grained helpers (both take the screener document as the first argument, not the definition):
extractScreenerSeedFromData(screenerDocument, data)— pick entries fromdatawhose keys match screener item keys.omitScreenerKeysFromData(screenerDocument, data)— shallow copy ofdatawithout those keys.
You can also assign element.screenerSeedAnswers directly when you already have a seed object.
Exports
// Element
export { FormspecRender } from './element';
// Registry
export { ComponentRegistry, globalRegistry } from './registry';
// Utilities
export { formatMoney } from './format';
export {
extractScreenerSeedFromData,
omitScreenerKeysFromData,
normalizeScreenerSeedForItem,
screenerAnswersSatisfyRequired,
buildInitialScreenerAnswers,
} from './rendering/screener';
// Re-exports from formspec-layout
export { resolvePresentation, resolveWidget, interpolateParams, resolveResponsiveProps, resolveToken, getDefaultComponent };
// Types
export type { RenderContext, ComponentPlugin, ValidationTargetMetadata, ScreenerRoute, ScreenerRouteType, ScreenerStateSnapshot };
export type { ThemeDocument, PresentationBlock, ItemDescriptor, AccessibilityBlock, ThemeSelector, SelectorMatch, Tier1Hints, FormspecDataType, Page, Region, LayoutHints, StyleHints };
// Default theme
import defaultThemeJson from '@formspec-org/layout/default-theme';
export { defaultThemeJson as defaultTheme };
// Headless adapter public API
export type { RenderAdapter, AdapterRenderFn, AdapterContext };
export type { FieldBehavior, FieldRefs, ResolvedPresentationBlock, BehaviorContext };
export type { TextInputBehavior, NumberInputBehavior, RadioGroupBehavior, CheckboxGroupBehavior, SelectBehavior, ToggleBehavior };
export type { DatePickerBehavior, MoneyInputBehavior, SliderBehavior, RatingBehavior, FileUploadBehavior, SignatureBehavior };
export type { WizardBehavior, WizardRefs, WizardSidenavItemRefs, WizardProgressItemRefs, TabsBehavior, TabsRefs };Development
npm run build # tsc + flatten the CSS entry points into dist
npm run test # vitest (happy-dom)
npm run test:watch # vitest watch mode