@structured-field/widget-editor
v1.5.0
Published
A lightweight JSON Schema form builder with support for relation fields (ForeignKey, QuerySet) and autocomplete search.
Downloads
636
Maintainers
Readme
@structured-field/widget-editor
A Vue-powered JSON Schema form builder with first-class support for relation fields (ForeignKey, QuerySet) and autocomplete search. Ships as both Vue components and a Web Component custom element.
Built for the django-structured-json-field admin widget, but usable standalone with any JSON Schema + REST search endpoint.
Features
- JSON Schema driven — renders forms from standard JSON Schema (
$ref,$defs,anyOf,oneOf,discriminator) - Relation fields — ForeignKey (single) and QuerySet (multi) with AJAX autocomplete from a search endpoint
- Discriminated unions — type selector that swaps sub-forms based on a discriminator property
- Nullable fields — togglable Add/Remove for optional nested objects
- Array fields — ordered list with add, remove, and reorder controls
- Built with Vue 3 — internally uses Vue SFC components, exported as a Web Component custom element
- Theme integration — styles via CSS custom properties (ships with Django admin compatibility)
Installation
npm install @structured-field/widget-editor
# or
pnpm add @structured-field/widget-editorUsage
Web Component (custom element)
Register the <schema-form> custom element, then use it in any HTML page or framework:
import { registerCustomElement } from '@structured-field/widget-editor';
import '@structured-field/widget-editor/css';
registerCustomElement(); // registers <schema-form>Programmatic initialization (recommended)
const el = document.createElement('schema-form');
el.schema = {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'integer' },
},
};
el.initialData = { name: 'Ada', age: 36 };
el.addEventListener('change', (e) => {
console.log('Form value:', e.detail);
});
document.getElementById('my-form').appendChild(el);
// Get current value at any time
const value = el.getValue();Declarative HTML
<schema-form
schema='{"type":"object","properties":{"name":{"type":"string"}}}'
initial-data='{"name":"Ada"}'
></schema-form>
<script>
document.querySelector('schema-form').addEventListener('change', (e) => {
console.log(e.detail);
});
</script>As a Vue component
Import SchemaForm directly for use inside Vue applications:
<template>
<SchemaForm :schema="schema" :initial-data="data" @change="onFormChange" />
</template>
<script setup>
import { SchemaForm } from '@structured-field/widget-editor';
import '@structured-field/widget-editor/css';
const schema = { /* JSON Schema */ };
const data = { /* initial form data */ };
function onFormChange(value) {
console.log('Form value:', value);
}
</script>As an IIFE (script tag)
All-in-one bundle (recommended)
A single <script> tag that includes both JS and CSS — no separate stylesheet needed:
<!-- Latest version -->
<script src="https://bnznamco.github.io/structured-widget-editor/latest/structured-widget-editor.iife.js"></script>
<!-- Pinned version -->
<script src="https://bnznamco.github.io/structured-widget-editor/v1.0.0/structured-widget-editor.iife.js"></script>Separate JS + CSS
<!-- Latest version -->
<link rel="stylesheet" href="https://bnznamco.github.io/structured-widget-editor/latest/structured-widget-editor.css">
<script src="https://bnznamco.github.io/structured-widget-editor/latest/structured-widget-editor.js"></script>
<!-- Pinned version -->
<link rel="stylesheet" href="https://bnznamco.github.io/structured-widget-editor/v1.0.0/structured-widget-editor.css">
<script src="https://bnznamco.github.io/structured-widget-editor/v1.0.0/structured-widget-editor.js"></script>Via jsDelivr (from npm)
<script src="https://cdn.jsdelivr.net/npm/@structured-field/widget-editor@latest/dist/structured-widget-editor.iife.js"></script>
<!-- or pinned -->
<script src="https://cdn.jsdelivr.net/npm/@structured-field/[email protected]/dist/structured-widget-editor.iife.js"></script>Usage
<script src="https://bnznamco.github.io/structured-widget-editor/latest/structured-widget-editor.iife.js"></script>
<script>
StructuredWidgetEditor.registerCustomElement();
const el = document.createElement('schema-form');
el.schema = { /* JSON Schema */ };
el.initialData = { /* data */ };
el.addEventListener('change', (e) => console.log(e.detail));
document.getElementById('my-form').appendChild(el);
</script>Relation Fields
The editor supports a custom type: "relation" schema extension for ForeignKey and QuerySet fields:
{
"type": "relation",
"format": "select2",
"model": "app.ModelName",
"multiple": false,
"options": {
"select2": {
"placeholder": "Search...",
"allowClear": true,
"ajax": {
"url": "/api/search/app.ModelName/"
}
}
}
}The search endpoint should return:
{
"items": [
{ "id": 1, "name": "Item 1", "model": "app.modelname" },
{ "id": 2, "name": "Item 2", "model": "app.modelname" }
],
"more": false
}Query parameters sent: _q (search term), page (pagination).
For styling, the editor root exposes state classes: sf-relation-multiple when the field accepts multiple values, and sf-relation-open while the autocomplete dropdown is visible.
Editors
| Schema type | Editor | Description |
|---|---|---|
| string | StringEditor | Text input or textarea |
| integer / number | NumberEditor | Number input with step |
| boolean | BooleanEditor | Checkbox |
| enum | SelectEditor | Dropdown select |
| const | HiddenEditor | Hidden (not rendered) |
| object | ObjectEditor | Nested fieldset |
| array | ArrayEditor | List with add/remove/reorder |
| anyOf [T, null] | NullableEditor | Togglable wrapper |
| oneOf + discriminator | UnionEditor | Type selector |
| relation | RelationEditor | Autocomplete with search |
Multicolumn layout
Object fields flow into a responsive multicolumn layout: every field gets an intrinsic size token from its schema, and columns emerge naturally from the available width at each nesting level (no media queries — the layout adapts to the actual container, so it works in narrow admin inlines and nested fieldsets alike). Visual order always equals schema/DOM order.
| Token | flex-basis | Assigned to |
|---|---|---|
| xs | 8rem | boolean, integer, number |
| sm | 12rem | date, compact enums/choices (≤8 options, short labels), strings with maxLength ≤ 40 |
| md | 18rem | plain strings, date-time, single relations, wide enums |
| lg | 26rem | multiple relations |
| full | 100% | objects, arrays, unions, JSON, textareas (format: 'textarea' or maxLength > 255), custom editors |
Nullable scalars are bumped one tier (xs → sm, sm → md) to make room for the inline null-clear button. const / discriminator fields render as hidden cells and leave no gap.
Per-field overrides
Schema authors can override the heuristic with a layout keyword on any property — from pydantic, pass it via json_schema_extra:
class Book(BaseModel):
isbn: str = Field(json_schema_extra={"layout": "sm"})
summary: str = Field(json_schema_extra={"layout": {"size": "full", "break": "before"}})layout: '<token>'— shorthand for{ size: '<token>' }.layout.size— one ofxs | sm | md | lg | full.layout.break—'before' | 'after' | 'both': force a row break before and/or after the field.'after'breaks before the next visible field (hidden const fields are skipped).- Invalid values are silently ignored and the heuristic applies.
- Caveat: on a multi-branch union without a discriminator (e.g.
Union[int, str]), the widget collapses to the first branch and outer keywords —layoutincluded — are dropped; put the hint inside the first branch or add a discriminator.Optional[T]fields are unaffected (sibling keys are carried through the nullable collapse). - A
layoutsize on aconst/ hidden field is ignored — hidden fields never occupy a cell.
Custom-editor matches default to full (their rendering is unpredictable); override with the schema layout hint or a layout key on the customEditors entry itself.
Theming the layout
The basis tokens and the checkbox row height are read from CSS custom properties, so embedders can retune density without touching the bundle:
.structured-field-editor {
--sf-basis-xs: 7rem;
--sf-basis-sm: 11rem;
--sf-basis-md: 16rem;
--sf-basis-lg: 24rem;
--sf-control-height: 30px;
}Browsers without flex gap support (pre-2021) automatically keep the original single-column layout.
Custom Editors
You can override the editor used for any field by passing a customEditors array to SchemaForm. Each entry defines a match condition and the component to render when that condition is true. Overrides are evaluated before the built-in resolution logic, in order — the first match wins.
Vue component
<template>
<SchemaForm :schema="schema" :custom-editors="customEditors" />
</template>
<script setup>
import { SchemaForm } from '@structured-field/widget-editor';
import MyDatePicker from './MyDatePicker.vue';
import MyColorPicker from './MyColorPicker.vue';
const customEditors = [
// Match by schema format
{ match: (schema) => schema.format === 'date', component: MyDatePicker },
// Match by field name (last segment of the path)
{ match: (schema, path) => path.at(-1) === 'color', component: MyColorPicker },
// Match by a custom schema property
{ match: (schema) => schema['x-widget'] === 'rich-text', component: MyRichText },
];
</script>Web Component (custom element)
const el = document.querySelector('schema-form');
el.customEditors = [
// Vue component — pass the component object
{ match: (schema) => schema.format === 'date', component: MyDatePicker },
// Web Component — pass the tag name as a string
{ match: (schema, path) => path.at(-1) === 'color', component: 'my-color-picker' },
];When component is a string containing a hyphen, it is treated as a web component tag name. The editor wrapper will:
- Set
schema,modelValue,path, andformas JS properties on the element (not HTML attributes) - Listen for
changeorupdate:model-valueCustomEvents to receive the new value
Custom editor API
Vue component
A custom editor Vue component must accept the following props and emit update:modelValue to write values back. Never mutate modelValue directly.
| Prop | Type | Description |
|---|---|---|
| schema | Object | The resolved JSON Schema for this field |
| modelValue | any | Current field value — read-only |
| path | string[] | Path segments from the root (['address', 'city']) |
| form | Object | Form API: getSchemaAtPath(path), getErrorsForPath(path), resolveSchema(schema) |
| Emit | Payload | Description |
|---|---|---|
| update:modelValue | new value | The only way to write a value back to the form |
Web Component
A custom editor web component receives the same data as JS properties and dispatches a change CustomEvent with the new value as detail:
| Property | Type | Description |
|---|---|---|
| schema | Object | The resolved JSON Schema for this field |
| modelValue | any | Current field value — read-only |
| path | string[] | Path segments from the root |
| form | Object | Form API: getSchemaAtPath(path), getErrorsForPath(path), resolveSchema(schema) |
| Event | Detail | Description |
|---|---|---|
| change | new value | Dispatched to write a value back to the form |
Starter templates
Vue component
Copy and adapt this component as a starting point:
<template>
<div class="sf-field" :class="{ errors: fieldErrors.length }">
<span class="sf-label" :class="{ required: isRequired }">{{ title }}</span>
<!-- Replace this with your custom input -->
<input
type="text"
class="sf-input"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
<ul v-if="fieldErrors.length" class="errorlist">
<li v-for="(err, i) in fieldErrors" :key="i">{{ err }}</li>
</ul>
</div>
</template>
<script>
export default {
name: 'MyCustomEditor',
props: {
schema: { type: Object, required: true },
modelValue: { default: undefined },
path: { type: Array, default: () => [] },
form: { type: Object, default: null },
},
emits: ['update:modelValue'],
computed: {
title() {
return this.schema.title || this.humanize(this.path.at(-1)) || '';
},
isRequired() {
if (this.path.length < 2 || !this.form) return false;
const parentSchema = this.form.getSchemaAtPath(this.path.slice(0, -1));
return Array.isArray(parentSchema?.required) && parentSchema.required.includes(this.path.at(-1));
},
fieldErrors() {
return this.form?.getErrorsForPath?.(this.path) ?? [];
},
},
methods: {
humanize(str) {
if (!str) return '';
return str.replace(/_/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, s => s.toUpperCase());
},
},
};
</script>Web Component (using BaseEditorElement)
The BaseEditorElement base class handles the property contract for you. Override render() to build the initial DOM and update() to react to property changes. Use this.emitChange(value) to send values back and this.getErrors() to read validation errors.
ESM:
import { BaseEditorElement } from '@structured-field/widget-editor';
class MyColorPicker extends BaseEditorElement {
render() {
const wrapper = document.createElement('div');
wrapper.className = 'sf-field';
const label = document.createElement('label');
label.className = 'sf-label';
label.textContent = this.schema?.title || 'Color';
this._label = label;
const input = document.createElement('input');
input.type = 'color';
input.className = 'sf-input';
input.value = this.modelValue || '#000000';
input.addEventListener('input', () => this.emitChange(input.value));
this._input = input;
wrapper.appendChild(label);
wrapper.appendChild(input);
this.appendChild(wrapper);
}
update() {
if (this._label) this._label.textContent = this.schema?.title || 'Color';
if (this._input) this._input.value = this.modelValue || '#000000';
}
}
customElements.define('my-color-picker', MyColorPicker);IIFE:
<script src="https://bnznamco.github.io/structured-widget-editor/latest/structured-widget-editor.iife.js"></script>
<script>
var BaseEditorElement = StructuredWidgetEditor.BaseEditorElement;
class MyColorPicker extends BaseEditorElement {
render() {
var input = document.createElement('input');
input.type = 'color';
input.value = this.modelValue || '#000000';
input.addEventListener('input', () => this.emitChange(input.value));
this._input = input;
this.appendChild(input);
}
update() {
if (this._input) this._input.value = this.modelValue || '#000000';
}
}
customElements.define('my-color-picker', MyColorPicker);
</script>BaseEditorElement API
| Property / Method | Description |
|---|---|
| this.schema | The resolved JSON Schema for this field |
| this.modelValue | Current field value (read-only) |
| this.path | Path segments from the root |
| this.form | Form API object |
| this.emitChange(value) | Dispatch the new value back to the form |
| this.getErrors() | Returns string[] of validation errors for this field |
| render() | Override. Called once when connected — build the DOM here |
| update() | Override. Called on every property change after render() |
Conditional fields
The form renderer evaluates standard JSON Schema conditional keywords on every change and updates the visible/required fields accordingly. No custom keywords — anything you put in the schema is also enforced server-side by Pydantic v2.
Supported keywords on object schemas:
| Keyword | Use case |
|---|---|
| if / then / else | "If status == 'archived', require archive_reason" |
| allOf: [{ if, then }, ...] | Multiple independent rules on the same object |
| dependentSchemas | "If field publisher is present, also show edition" |
| dependentRequired | Lighter version: only toggles required |
Example schema fragment:
{
"type": "object",
"properties": {
"status": { "enum": ["draft", "archived"] }
},
"allOf": [
{
"if": { "properties": { "status": { "const": "archived" } }, "required": ["status"] },
"then": {
"properties": { "archive_reason": { "type": "string" } },
"required": ["archive_reason"]
}
}
],
"dependentSchemas": {
"publisher": { "properties": { "edition": { "type": "string" } } }
}
}Fields that disappear when a rule stops matching are pruned from the form value, so the emitted JSON stays clean.
Declaring conditionals on a Pydantic model
django-structured-field ships matching helpers in structured.pydantic.conditionals that compile down to the same standard keywords:
from typing import Literal, Optional
from pydantic import ConfigDict
from structured.pydantic.models import BaseModel
from structured.pydantic.conditionals import (
When, conditional_schema, dependent_schemas,
)
class Book(BaseModel):
status: Literal["draft", "review", "published", "archived"] = "draft"
archive_reason: Optional[str] = None
published_at: Optional[str] = None
publisher: Optional[str] = None
edition: Optional[str] = None
model_config = ConfigDict(json_schema_extra=conditional_schema(
When("status", equals="archived",
then={"required": ["archive_reason"]}),
When("status", equals="published",
then={"required": ["published_at"]}),
dependent_schemas(publisher={
"properties": {"edition": {"type": "string"}}
}),
))When(field, equals=..., in_=..., not_equals=..., then=..., else_=...) builds a single if/then/else clause; conditional_schema(...) groups them into allOf and merges any dependent_schemas / dependent_required fragments.
Theming
All styles use CSS custom properties with sensible defaults. Override them to match your design system:
:root {
--body-bg: #fff;
--body-fg: #333;
--border-color: #ccc;
--primary: #79aec8;
--error-fg: #ba2121;
--darkened-bg: #f0f0f0;
--object-tools-bg: #888;
--object-tools-fg: #fff;
/* ... */
}Development
pnpm install
pnpm run dev # watch mode
pnpm run build # production buildLicense
MIT
