Render adapter library for formspec-webcomponent. Each adapter provides design-system-specific DOM for Formspec form components while reusing the shared behavior hooks that handle all reactive state, value sync, ARIA management, and validation display.
Formspec's webcomponent uses a headless behavior/adapter architecture (ADR 0046):
behavior hook → FieldBehavior contract ← render adapter
(signals, ARIA, values) (DOM structure, CSS classes)
A behavior hook extracts reactive state from the engine and returns a typed contract with a bind(refs) function. A render adapter builds DOM, then calls bind() with element references. The behavior wires all event listeners, signal effects, and ARIA updates onto those elements.
The default adapter (built into formspec-webcomponent) reproduces Formspec's standard DOM. Adapters in this package provide alternative DOM structures for specific design systems.
npm install formspec-adaptersPeer dependency: formspec-webcomponent.
import { globalRegistry } from 'formspec-webcomponent';
import { exampleAdapter } from 'formspec-adapters';
globalRegistry.registerAdapter(exampleAdapter);
globalRegistry.setAdapter('example');Per-form override:
const el = document.querySelector('formspec-render');
el.adapter = 'example';An adapter is a RenderAdapter object mapping component type strings to render functions:
import type { RenderAdapter, AdapterRenderFn, TextInputBehavior } from 'formspec-webcomponent';
import { el, applyCascadeClasses, applyCascadeAccessibility } from 'formspec-adapters/helpers';
const renderTextInput: AdapterRenderFn<TextInputBehavior> = (behavior, parent, actx) => {
const p = behavior.presentation;
// 1. Build your DOM structure
const root = el('div', { class: 'my-field', 'data-name': behavior.fieldPath });
applyCascadeClasses(root, p); // MUST: honor theme cssClass
applyCascadeAccessibility(root, p); // MUST: honor theme accessibility
const label = el('label', { for: behavior.id });
label.textContent = behavior.label;
if (p.labelPosition === 'hidden') label.classList.add('sr-only');
root.appendChild(label);
const input = el('input', {
id: behavior.id,
type: 'text',
name: behavior.fieldPath,
class: 'my-input',
});
root.appendChild(input);
const error = el('div', { role: 'alert', 'aria-live': 'polite' });
root.appendChild(error);
parent.appendChild(root);
// 2. bind() wires ALL reactive behavior — do NOT register your own event listeners
const dispose = behavior.bind({ root, label, control: input, error });
actx.onDispose(dispose);
};
export const myAdapter: RenderAdapter = {
name: 'my-design-system',
components: {
TextInput: renderTextInput,
// Missing entries fall back to the default adapter.
},
};Must:
| Obligation | Reason |
|---|---|
Apply behavior.presentation.cssClass to root element |
Theme spec guarantees union-merge across cascade levels |
Respect behavior.presentation.labelPosition (top / start / hidden) |
Semantic property from theme cascade |
Apply behavior.presentation.accessibility attributes |
Spec requires themes not reduce accessibility |
Call behavior.bind(refs) after building DOM |
Wires all reactive effects |
Register dispose via actx.onDispose(dispose) |
Cleanup on re-render |
Should:
| Obligation | Reason |
|---|---|
Apply behavior.presentation.style as inline styles |
Low specificity, easily overridden. Adapters using utility classes may skip. |
Read behavior.presentation.widgetConfig for semantic config |
{ searchable: true }, { direction: 'horizontal' }, { rows: 5 }, etc. |
Must not:
| Rule | Reason |
|---|---|
Import @preact/signals-core |
Adapters are pure DOM — no signal dependency |
| Register event listeners for value sync, change, or touch | bind() owns all event wiring |
Access formspec-engine directly |
All engine interaction goes through the behavior contract |
The refs object passed to bind() tells the behavior where to attach effects:
| Ref | Purpose |
|---|---|
root |
Outermost wrapper — receives relevance show/hide and readonly class |
label |
Label element — receives required indicator updates |
control |
Primary input control — receives aria-invalid, aria-required; bind() finds the deepest <input>/<select>/<textarea> inside for value sync |
hint |
(optional) Hint text element |
error |
(optional) Error display — receives validation message text |
optionControls |
(choice fields) Map of option value → <input> element for radio/checkbox groups |
rebuildOptions |
(optional) Callback for async option changes |
Each component type has a typed behavior interface that extends FieldBehavior with component-specific properties:
| Behavior | Key Properties |
|---|---|
TextInputBehavior |
placeholder, maxLines, prefix, suffix, resolvedInputType, extensionAttrs |
NumberInputBehavior |
min, max, step, dataType |
RadioGroupBehavior |
groupRole, inputName, orientation, options() |
CheckboxGroupBehavior |
groupRole, selectAll, columns, options(), setValue() |
SelectBehavior |
placeholder, clearable, dataType, options() |
ToggleBehavior |
onLabel, offLabel |
DatePickerBehavior |
inputType, minDate, maxDate |
MoneyInputBehavior |
min, max, step, placeholder, resolvedCurrency |
SliderBehavior |
min, max, step, showTicks, showValue |
RatingBehavior |
maxRating, icon, allowHalf, isInteger, setValue() |
FileUploadBehavior |
accept, multiple, dragDrop |
SignatureBehavior |
height, strokeColor |
WizardBehavior |
steps, activeStep(), goNext(), goPrev(), renderStep() |
TabsBehavior |
tabLabels, position, activeTab(), setActiveTab(), renderTab() |
import { el, applyCascadeClasses, applyCascadeAccessibility } from 'formspec-adapters/helpers';| Helper | Purpose |
|---|---|
el(tag, attrs?) |
Create an element with attributes in one call |
applyCascadeClasses(root, presentation) |
Apply theme-resolved cssClass with union-merge semantics |
applyCascadeAccessibility(root, presentation) |
Apply theme-resolved role, aria-description, aria-live |
Adapters are the integration point between Formspec and CSS frameworks like Tailwind, Bootstrap, or any utility-class / component-class system. The adapter owns the DOM — it decides what classes go on which elements.
Tailwind adapters emit utility classes directly in the markup. No bridge CSS or runtime class injection needed — the adapter IS the bridge.
import type { AdapterRenderFn, TextInputBehavior } from 'formspec-webcomponent';
import { el, applyCascadeClasses, applyCascadeAccessibility } from 'formspec-adapters/helpers';
const renderTextInput: AdapterRenderFn<TextInputBehavior> = (behavior, parent, actx) => {
const p = behavior.presentation;
const root = el('div', { class: 'max-w-md', 'data-name': behavior.fieldPath });
applyCascadeClasses(root, p);
applyCascadeAccessibility(root, p);
// Label — Tailwind typography utilities
const labelClasses = p.labelPosition === 'hidden'
? 'sr-only'
: 'block text-sm font-medium text-gray-700';
const label = el('label', { class: labelClasses, for: behavior.id });
label.textContent = behavior.label;
// Inline layout for 'start' labelPosition
if (p.labelPosition === 'start') root.classList.add('flex', 'items-center', 'gap-3');
root.appendChild(label);
// Hint
let hint: HTMLElement | undefined;
if (behavior.hint) {
hint = el('div', { class: 'mt-1 text-sm text-gray-500' });
hint.textContent = behavior.hint;
root.appendChild(hint);
}
// Input — Tailwind form utilities
const input = el('input', {
id: behavior.id,
type: behavior.resolvedInputType || 'text',
name: behavior.fieldPath,
class: 'mt-1 block w-full rounded-md border-gray-300 shadow-sm ' +
'focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm',
});
if (behavior.placeholder) input.setAttribute('placeholder', behavior.placeholder);
root.appendChild(input);
// Error — Tailwind color utilities
const error = el('div', {
class: 'mt-1 text-sm text-red-600',
role: 'alert',
'aria-live': 'polite',
});
root.appendChild(error);
parent.appendChild(root);
const dispose = behavior.bind({ root, label, control: input, hint, error });
actx.onDispose(dispose);
};Tailwind integration notes:
-
integrationCSSis omitted — nothing is injected into<head>. Styling is only utility classes on the emitted DOM, compiled by your Tailwind/Vite (or CDN) pipeline. In Tailwind v4, add@sourceforpackages/formspec-adapters/src/tailwind/**/*.tsso class names are discovered. -
Core plugin styling —
Card,SubmitButton, andValidationSummaryare not adapter-rendered; they still useformspec-*class hooks. Importformspec-adapters/tailwind-formspec-core.cssfor light-theme defaults (teal accent, white cards, validation summary). Rules are in@layer componentsso Tailwind utilities on those nodes (e.g.cssClassonSubmitButton) override the defaults. Override--formspec-tw-*on:rootfor token tweaks without utilities.
Customization:
- The adapter is intentionally neutral. Colors are kept minimal in
TWconstants. Override via:cssClassin your theme (union-merge supported)- CSS custom properties (
--accent-color,--error-color) tailwind-merge(recommended — see below)
behavior.presentation.stylecan be used for inline styles if needed.widgetConfigcan drive structural choices.
tailwind-merge support:
The adapter works well with tailwind-merge. Call setTailwindMerge(twMerge) from formspec-layout in your app to automatically resolve conflicting utilities from the theme cascade.
See ADR 0049 for details on cssClassReplace and classStrategy: "tailwind-merge".
Current design:
Field widgets use subtle zinc / currentColor in TW constants. Shared defaults for Card, submit, and validation summary live in src/tailwind/tailwind-formspec-core.css (package export formspec-adapters/tailwind-formspec-core.css).
Design systems like USWDS Tailwind v2 use <input class="sr-only peer"> with styled sibling elements and peer-checked: variants — a fundamentally different DOM structure from native radio buttons. This is the exact problem adapters solve.
const renderRadioGroup: AdapterRenderFn<RadioGroupBehavior> = (behavior, parent, actx) => {
const p = behavior.presentation;
const fieldset = el('fieldset', { class: 'space-y-2', role: behavior.groupRole });
applyCascadeClasses(fieldset, p);
const legend = el('legend', { class: 'text-sm font-bold mb-2' });
legend.textContent = behavior.label;
fieldset.appendChild(legend);
const optionControls = new Map<string, HTMLInputElement>();
const optionContainer = el('div', { class: 'space-y-2' });
for (const opt of behavior.options()) {
const optLabel = el('label', { class: 'flex items-center gap-3 cursor-pointer relative' });
// sr-only peer pattern — the input is invisible, siblings react to its state
const radio = document.createElement('input');
radio.type = 'radio';
radio.className = 'sr-only peer';
radio.name = behavior.inputName;
radio.value = opt.value;
optionControls.set(opt.value, radio);
// NOTE: no change listener — bind() owns all event wiring
// Styled indicator — uses peer-checked: variants
const indicator = el('div', {
class: 'flex items-center justify-center size-5 rounded-full ' +
'ring-2 ring-offset-0 ring-gray-400 ' +
'peer-checked:ring-indigo-600 ' +
'peer-checked:before:block peer-checked:before:size-2.5 ' +
'peer-checked:before:rounded-full peer-checked:before:bg-indigo-600 ' +
'peer-focus:outline-2 peer-focus:outline-indigo-500',
});
const text = el('div', { class: 'text-sm text-gray-900' });
text.textContent = opt.label;
optLabel.append(radio, indicator, text);
optionContainer.appendChild(optLabel);
}
fieldset.appendChild(optionContainer);
const error = el('div', {
class: 'mt-1 text-sm text-red-600',
role: 'alert',
'aria-live': 'polite',
});
fieldset.appendChild(error);
parent.appendChild(fieldset);
const dispose = behavior.bind({
root: fieldset,
label: legend,
control: fieldset,
error,
optionControls,
});
actx.onDispose(dispose);
};No bridge CSS. No x-classes workarounds. The adapter owns the DOM structure, and bind() wires checked state, validation, and ARIA onto whatever elements the adapter creates.
Bootstrap adapters use component classes (form-control, form-label, form-select, etc.):
const renderTextInput: AdapterRenderFn<TextInputBehavior> = (behavior, parent, actx) => {
const p = behavior.presentation;
const root = el('div', { class: 'mb-3' });
applyCascadeClasses(root, p);
applyCascadeAccessibility(root, p);
const label = el('label', { class: 'form-label', for: behavior.id });
label.textContent = behavior.label;
if (p.labelPosition === 'hidden') label.classList.add('visually-hidden');
root.appendChild(label);
const input = el('input', {
id: behavior.id,
type: behavior.resolvedInputType || 'text',
name: behavior.fieldPath,
class: 'form-control',
});
if (behavior.placeholder) input.setAttribute('placeholder', behavior.placeholder);
root.appendChild(input);
const error = el('div', { class: 'invalid-feedback', role: 'alert', 'aria-live': 'polite' });
root.appendChild(error);
parent.appendChild(root);
const dispose = behavior.bind({ root, label, control: input, error });
actx.onDispose(dispose);
};Bootstrap integration notes:
- Bootstrap's
is-invalidclass on the input drivesinvalid-feedbackvisibility. You can watcherror.textContentvia a MutationObserver to toggleis-invalidon the input, or use CSS:has()if browser support allows. form-floatinglabels (Bootstrap 5) require the<input>before the<label>— just reorder in the adapter.input-groupwith prepend/append maps naturally toTextInputBehavior.prefix/suffix.
For fully custom designs with no framework, adapters are just vanilla DOM:
const renderTextInput: AdapterRenderFn<TextInputBehavior> = (behavior, parent, actx) => {
const root = document.createElement('div');
const label = document.createElement('label');
label.textContent = behavior.label;
label.htmlFor = behavior.id;
const input = document.createElement('input');
input.id = behavior.id;
const error = document.createElement('div');
root.append(label, input, error);
parent.appendChild(root);
actx.onDispose(behavior.bind({ root, label, control: input, error }));
};Bring your own CSS. The behavior hook handles everything else.
Regardless of framework, every adapter follows the same flow:
- Read
behavior.presentationfor theme-cascade decisions (label position, accessibility, classes) - Build DOM with your framework's class vocabulary
- Call
behavior.bind(refs)— the behavior figures out the rest - Register dispose via
actx.onDispose()
The adapter never needs to know about signals, the engine, validation rules, or FEL expressions. It just builds markup.
src/
index.ts — barrel export for all adapters
helpers.ts — shared utilities (el, applyCascadeClasses, applyCascadeAccessibility)
<adapter-name>/ — one directory per design-system adapter
index.ts — exports the RenderAdapter object
text-input.ts — per-component render functions
radio-group.ts
...
npm run build # tsc
npm run test # vitest (happy-dom)
npm run test:watch # vitest watch mode