npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-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.

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(): void

Events

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. 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.

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 only

Adapter Contract

Adapters must:

  1. Create DOM elements and append the root to parent
  2. Apply behavior.presentation.cssClass to the root element (union semantics)
  3. Respect behavior.presentation.labelPosition ('top' | 'start' | 'hidden')
  4. Apply behavior.presentation.accessibility attributes (role, aria-description, aria-live)
  5. Call behavior.bind(refs) with references to created elements
  6. Register the dispose function via actx.onDispose(dispose)

Adapters must not:

  • Import @preact/signals-core or 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):

  1. Form-wide formPresentation hints in the definition
  2. Per-item presentation hints in the definition item
  3. Theme defaults
  4. Theme selectors (document order; later wins)
  5. 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

  1. Cleanup — dispose all previous signal effects and remove event listeners.
  2. Breakpoints — wire matchMedia listeners from componentDocument.breakpoints.
  3. Tokens — emit CSS custom properties onto .formspec-container.
  4. Screener gate — render the screener if one is defined and not yet completed.
  5. Plan — call planComponentTree() (from formspec-layout) to produce a layout node tree.
  6. Emit — walk the tree and dispatch each component to its plugin.
  7. 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