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

@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

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

Usage

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 of xs | 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 — layout included — 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 layout size on a const / 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, and form as JS properties on the element (not HTML attributes)
  • Listen for change or update:model-value CustomEvents 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 build

License

MIT