<formspec-render> is a custom element that binds a FormEngine to the DOM. It ships 37 built-in components, a plugin registry, a 5-level theme cascade, reactive ARIA attributes, and responsive breakpoint support.
npm install formspec-webcomponent
The 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.
import { FormspecRender } from 'formspec-webcomponent';
import 'formspec-webcomponent/formspec-default.css';
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.definition = myDefinition;
el.componentDocument = myComponentDoc;
el.themeDocument = myTheme;
Importing FormspecRender loads structural formspec-layout.css (grid, stack, wizard chrome). Import formspec-default.css when you use the built-in renderer’s field styling; omit it for custom adapters (for example Tailwind) so global input / label rules do not override utility classes.
The element is exported but not auto-registered. Call customElements.define() with your preferred tag name.
| 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. |
themeDocument |
ThemeDocument | null |
Theme document. Loads and unloads external stylesheets and schedules a render. |
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.
// Engine access
getEngine(): FormEngine | null
// Diagnostics
getDiagnosticsSnapshot(options?: { mode?: 'continuous' | 'submit' }): 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?: { mode?: 'continuous' | 'submit'; emitEvent?: boolean }): { response: object; validationReport: object } | 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(): void
All events bubble and are composed.
| Event | When | detail |
|---|---|---|
formspec-submit |
submit() called with emitEvent !== false |
{ response, validationReport } |
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 } |
All 37 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.
| Category | Components |
|---|---|
| Layout (10) | Page, Stack, Grid, Divider, Collapsible, Columns, Panel, Accordion, Modal, Popover |
| Input (13) | TextInput, NumberInput, Select, Toggle, Checkbox, DatePicker, RadioGroup, CheckboxGroup, Slider, Rating, FileUpload, Signature, MoneyInput |
| Display (9) | Heading, Text, Card, Spacer, Alert, Badge, ProgressBar, Summary, ValidationSummary |
| Interactive (3) | Wizard, Tabs, SubmitButton |
| Special (2) | ConditionalGroup, DataTable |
Input components use a headless behavior/adapter architecture (see ADR 0046). Each component is split into:
@preact/signals-core. Calls behavior.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.
import { globalRegistry } from 'formspec-webcomponent';
globalRegistry.registerAdapter({
name: 'my-design-system',
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.
},
});
// Activate globally
globalRegistry.setAdapter('my-design-system');
Per-form override is also available:
const el = document.querySelector('formspec-render');
el.adapter = 'my-design-system'; // Override for this instance only
Adapters must:
parentbehavior.presentation.cssClass to the root element (union semantics)behavior.presentation.labelPosition ('top' | 'start' | 'hidden')behavior.presentation.accessibility attributes (role, aria-description, aria-live)behavior.bind(refs) with references to created elementsactx.onDispose(dispose)Adapters must not:
@preact/signals-core or access the engine directlybind() owns all event wiring)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';
The renderer resolves presentation through a 5-level cascade (lowest to highest priority):
formPresentation hints in the definitionpresentation hints in the definition itemdefaultsselectors (document order; later wins)items[key] per-item overridesTokens ($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 injects <link> elements with ref-counting so multiple <formspec-render> instances sharing a theme do not duplicate loads.
matchMedia listeners from componentDocument.breakpoints..formspec-container.planComponentTree() (from formspec-layout) to produce a layout node tree.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.
dataUse element.initialData = response.data (same shape as a Formspec response payload) before element.definition = …. On engine creation the element splits out screener keys, applies the rest with applyResponseDataToEngine, and pre-fills or auto-skips the screener—one assignment, same as the old “walk data + setValue” flow, without separate screener plumbing.
For hydration after the element already has a definition, call applyResponseDataToEngine(engine, data) from this package. Optional: extractScreenerSeedFromData / omitScreenerKeysFromData / element.screenerSeedAnswers only if you need fine-grained control.
// Element
export { FormspecRender } from './element';
// Registry
export { ComponentRegistry, globalRegistry } from './registry';
// Utilities
export { formatMoney } from './format';
export { applyResponseDataToEngine } from './hydrate-response-data';
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 './default-theme.json';
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 };
npm run build # tsc + copy base CSS
npm run test # vitest (happy-dom)
npm run test:watch # vitest watch mode