@gluonjs/json-forms
@gluonjs/json-forms turns a deliberately documented subset of JSON Schema and JSON Forms UI schema into one accessible, form-associated Web Component. It is an optional package: schema rendering and AJV validation do not enter @gluonjs/core.
@gluonjs/json-forms at a glance
Runtime: browser · Release: 1.13.0
Documentation guide · npm · Source
Public API: @gluonjs/json-forms
Install
npm install @gluonjs/json-formsQuick start
import { registerJsonForms } from '@gluonjs/json-forms';
registerJsonForms();Choose this package when
- Accessible form rendering from a documented JSON Schema subset.
- AJV-backed validation and form-associated custom elements.
- Optional schema-driven UI inside browser applications.
Choose another boundary when:
- Does not enter the core package runtime.
- Schema rendering is intentionally limited to the documented subset.
Related documentation
Stability notes
The package ships as part of the current 1.13.0 release line. Its documented schema and UI schema subset, host-owned lifecycle, and renderer-registry boundary are stable; broader JSON Schema features remain unsupported unless a later contract adds them.
import {
registerJsonForms,
resolveJsonSchema,
type JsonFormsElement,
type JsonSchema,
} from '@gluonjs/json-forms';
const schema = {
type: 'object',
title: 'Delivery preference',
properties: {
email: { $ref: '#/$defs/email' },
time: { type: 'string', enum: ['morning', 'afternoon'], enumNames: ['Morning', 'Afternoon'] },
giftWrap: { type: 'boolean', title: 'Add gift wrap', default: false },
},
required: ['email', 'time'],
$defs: {
email: { type: 'string', format: 'email', title: 'Email address' },
},
} satisfies JsonSchema;
registerJsonForms();
const form = document.querySelector<JsonFormsElement>('gluon-json-form')!;
form.schema = resolveJsonSchema(schema, { maxDepth: 8, maxNodes: 64 });
form.data = { email: 'hello@example.test' };
form.addEventListener('change', (event) => {
console.log(event.detail.data, event.detail.errors);
});<form>
<gluon-json-form name="delivery"></gluon-json-form>
<button>Save delivery preference</button>
</form>The element serializes its current JSON object to the outer form under name, uses ElementInternals for native validity, supports reset and state restore, and dispatches change with frozen { data, errors }. validation-change dispatches only when the validation result changes. The application owns the authoritative data: update .data after a change event when the surrounding state store accepts or transforms the edit.
Message provider
JSON Forms infrastructure copy is owned by a synchronous message provider. The package exports createJsonFormsMessageProvider() and typed provider interfaces so applications can swap locale-aware strings without importing @gluonjs/i18n or making network requests. The provider covers the root form label, array item numbering, add/remove controls, selection placeholders, validation diagnostics, and configuration diagnostics.
import {
createJsonFormsMessageProvider,
registerJsonForms,
type JsonFormsMessageProvider,
} from '@gluonjs/json-forms';
const messages: JsonFormsMessageProvider = createJsonFormsMessageProvider({
locale: 'de-DE',
messages: {
selectPlaceholder: (required, locale) =>
required ? `Bitte auswählen (${locale})` : `Keine Auswahl (${locale})`,
},
});
const form = document.querySelector('gluon-json-form')!;
form.messages = messages;createJsonFormsMessageProvider() always falls back to the built-in English defaults when a caller omits a specific override. Formatter overrides may return undefined or null to leave a key unresolved; the provider then uses the English fallback for that key. Validation formatters receive immutable AJV keyword params, including numeric limits and missing-property names. Locale- aware number formatting comes from Intl.NumberFormat, so item numbering and validation thresholds can follow the active locale without depending on the i18n package. The provider does not translate application-authored titles or descriptions. Object and array validation messages are associated with their own fieldset through aria-describedby, aria-errormessage, and aria-invalid, just as primitive fields associate their controls.
The maintained docs-site/examples/json-forms.html application installs the German provider in a real delivery-preferences form. Run npm run build:docs-examples && npm run check:json-forms-example-browser to verify localized validation and configuration diagnostics, long array-control labels, the custom lead-time renderer, host-owned form state, 390px overflow, and 44px controls in Chromium.
Renderer registry
createJsonFormsRendererRegistry() lets an application replace a supported field control without forking JsonFormsElement. Registrations are synchronous and request-free. A declarative selector may match field kind, JSON Schema schemaType or format, and an exact data path. Higher integer priorities win. Duplicate IDs, unsupported properties, invalid selectors, and overlapping selectors at the same priority throw an actionable TypeError when the registry is created, so selection never depends on registration timing.
import { html } from '@gluonjs/core';
import {
createJsonFormsRendererRegistry,
type JsonFormsElement,
} from '@gluonjs/json-forms';
const renderers = createJsonFormsRendererRegistry([{
id: 'quantity-stepper',
selector: { kind: 'number', path: ['quantity'] },
priority: 10,
render: (context) => {
const value = typeof context.value === 'number' ? context.value : 1;
return html`
<button
type="button"
aria-labelledby=${context.control.labelId}
?disabled=${context.disabled || context.readOnly}
@click=${() => context.control.commit(value + 1)}
>Increase</button>
<output
id=${context.control.id}
aria-labelledby=${context.control.labelId}
aria-describedby=${context.control.describedBy}
>${value}</output>
`;
},
}]);
document.querySelector<JsonFormsElement>('gluon-json-form')!.rendererRegistry = renderers;The frozen renderer context exposes the public field, resolved field schema, field and root UI-schema context, exact path, current data/value, validation errors, message provider, effective disabled/read-only state, and stable label, description, and error IDs. context.control.commit() is the only registry mutation boundary: the host still owns immutable data updates, AJV validation, native form value/validity, reset, restore, and exactly one change and validation-change lifecycle. Invalid non-JSON commits fail closed. Renderers must connect their native control to the provided label and description IDs; they must not dispatch replacement form events.
If no selector matches, the documented built-in renderer remains the fallback for text, number, boolean, select, object, and bounded-array fields. Custom controls are wrapped by stable part="field", part="control custom-control", data-gluon-json-renderer, validation, and ARIA state hooks. Applications may return a separately styled Gluon component or custom element when the generic native control styles are insufficient; the registry does not inject product themes, requests, authorization, persistence, or remote plugins.
Registry functions are deliberately not serialized. Deterministic SSR and hydration require the application to provide the same immutable registry while rendering and hydrating the element. Field selection depends only on the declarative selector, priority, and schema context, so the server and browser choose the same renderer.
Supported schema boundary
The renderer accepts a root schema with type: "object", primitive fields of type string, number, integer, or boolean, string/number enum, nested objects through properties, and arrays with one supported items schema. Nested arrays are rejected so item editing remains bounded and addressable. It supports title, description, default, required, minLength, maxLength, minimum, maximum, minItems, maxItems, format: "email", additionalProperties, field-level readOnly, and enumNames.
An optional JSON Forms VerticalLayout may order root or nested Control elements and supply their labels or options.enumNames. Object fields render as fieldsets. Array fields expose 44px Add/Remove controls and preserve the same immutable change event, native form value, validation, reset, and state restore contracts as direct fields. Native labels, keyboard controls, focus indicators, error association, disabled state, and reduced motion behavior are built into the element.
Local $ref accepts only fragment JSON Pointers whose first RFC 6901-decoded token is $defs; the legacy definitions token is also accepted explicitly for compatibility. Deeper own-property paths below either root container are supported, including escaped / (~1) and ~ (~0) tokens. URI/remote, anchor, inherited-property, and pointers outside those two containers are not resolved. Definition containers are lookup-only and are omitted from the normalized output.
References are resolved synchronously with a default maximum reference-chain depth of 16 and a maximum of 256 visited schema objects. Call resolveJsonSchema(schema, { maxDepth, maxNodes }) to use lower application limits. The function never mutates or retains caller-owned arrays or objects; its result is a null-prototype, deeply frozen clone. Keywords beside $ref form a shallow overlay and take precedence when the target defines the same keyword. Remote/URI references, malformed or missing pointers, cycles, and limit overflow produce stable configuration diagnostics (ref-remote, ref-pointer, ref-target, ref-cycle, ref-depth, or ref-budget). A resolved target still has exactly the supported field and validation boundary above; $ref does not enable composition or arbitrary JSON Schema.
Conditional/composition keywords, JSON Forms rules, async schemas, file widgets, nested arrays, and UI layouts other than VerticalLayout remain unsupported. An unsupported schema or UI schema renders an explicit configuration error rather than silently dropping fields, and that configuration copy also flows through the message provider.
Historical delivery decision
The direct-property component was the first usable package slice identified in issue #256 and delivered in #257. Nested object and bounded array support is delivered in #377; the remaining unsupported capabilities above still require separate contracts and browser evidence.