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

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-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';

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

ActionButton 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's formspec-page-change) — author a submit-intent ActionButton on 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 — so executeTool() can resolve undefined even though respondWith() 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-filled until 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.filled when 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. 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.

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:

  1. el.adapter — host override for this instance
  2. themeDocument.adapter
  3. globalRegistry.setAdapter(…) — host-level default
  4. 'default'

A theme naming an unregistered adapter emits formspec-theme-finding (THEME-ADAPTER-MISSING) once and renders with the fallback.

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)
  • Ask the host to import their CSS, or inject <style> into the document — declare stylesheets instead
  • 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):

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

  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

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 from data whose keys match screener item keys.
  • omitScreenerKeysFromData(screenerDocument, data) — shallow copy of data without 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