@formspec-org/webcomponent
v1.0.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 37 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';
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.
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. |
| 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.
Methods
// 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(): voidEvents
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 } |
Component Registry
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.
Built-in Components
| 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 |
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.
Registering a Custom Adapter
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 onlyAdapter 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)
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 injects <link> elements with ref-counting so multiple <formspec-render> instances sharing a theme do not duplicate loads.
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
Use 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.
Exports
// 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 };Development
npm run build # tsc + copy base CSS
npm run test # vitest (happy-dom)
npm run test:watch # vitest watch mode