# schema-form

<!--{ "parent": "Components" }-->

A form generated from a [JSON Schema](https://json-schema.org/), with validation.

```js
import { tosiSchemaForm } from 'tosijs-ui'

const schemaForm = tosiSchemaForm({
  schema: {
    type: 'object',
    properties: {
      name: { type: 'string', title: 'Full name' },
      email: { type: 'string', format: 'email' },
      age: { type: 'integer' },
      role: { type: 'string', enum: ['admin', 'editor', 'viewer'] },
      active: { type: 'boolean' },
    },
    required: ['name', 'email'],
  },
  value: { name: 'Ada', email: 'ada@example.com', role: 'admin', active: true },
})

const shown = document.createElement('pre')
schemaForm.addEventListener('change', () => {
  shown.textContent = JSON.stringify(schemaForm.value, null, 2)
})
shown.textContent = JSON.stringify(schemaForm.value, null, 2)

preview.append(schemaForm, shown)
```

## The model owns the data

`value` is the state and `change` fires when it changes — the same contract as every other
component here. The inputs are a **view** of `value`; they are never the source of truth.

That is not a stylistic preference. Reading data back out of rendered inputs, as most
schema-form libraries do, means a field your schema does not describe is **dropped on save** —
so editing one field can discard timestamps, provenance, or anything else added since the
schema was written. Here `value` is a plain object that is copied, never rebuilt:

```typescript
schemaForm.value = { name: 'Ada', _id: 'abc', updatedAt: '2020-01-01' }
// edit `name` in the UI …
schemaForm.value // → { name: 'Grace', _id: 'abc', updatedAt: '2020-01-01' }
```

Editing also never mutates the object you handed in — you get a new one, so
`diff(original, schemaForm.value)` is a usable dirty check.

## Validation

Errors come from [`tosijs-schema`](https://www.npmjs.com/package/tosijs-schema) and appear
under the field they belong to. `validate()` returns whether the current value conforms, and
`errors` is the list.

```js
import { tosiSchemaForm } from 'tosijs-ui'

const schemaForm = tosiSchemaForm({
  schema: {
    type: 'object',
    properties: {
      name: { type: 'string' },
      email: { type: 'string', format: 'email' },
    },
    required: ['name', 'email'],
  },
  value: { name: 'Ada', email: 'not-an-email' },
})

const report = document.createElement('pre')
const update = () => {
  report.textContent = schemaForm.validate()
    ? 'conforms ✅'
    : schemaForm.errors.map((e) => `${e.path || '(form)'}: ${e.message}`).join('\n')
}
schemaForm.addEventListener('change', update)
update()

preview.append(schemaForm, report)
```

### Give it a validator

The form does not import a validation library. It asks for two functions, and you supply
them once, anywhere before or after the form renders:

```typescript
import { setSchemaValidator } from 'tosijs-ui'
import { validate, inferSchema, unenforcedKeywords } from 'tosijs-schema' // ^1.8.0

setSchemaValidator({ validate, inferSchema, unenforcedKeywords })
```

Pass **all three**. `unenforcedKeywords` is what lets the form ask the validator *"which
keywords do you not check?"* — omit it and the form falls back to a built-in list frozen at
tosijs-schema 1.7.0, so every `oneOf` and `exclusiveMinimum` field is labelled "not validated"
while the validator is checking it. The note exists to stop the form lying; the two-argument
recipe makes it the thing lying.

**Already done for you** in the CDN `<script>` build and in a `tosijs-ui/site` doc site —
those are bundles we build, so they register it themselves. Only an ESM consumer writes the
line.

Without a validator **and with a `schema`**, the form still renders and edits; it simply
reports no errors, warns once in the console, and `validationAvailable` reads `false` — so a
Save handler can tell *"this conforms"* from *"nobody checked"*, which `validate() === true`
alone cannot.

Without a validator **and without a `schema`** there is nothing to infer fields from, so the
form has none. It says that on screen rather than rendering an empty box, and the console
warning names *that* problem rather than the validation one — a message about error reporting
is the wrong thing to read while looking at a blank form.

Why a seam rather than an import: a bare `import('tosijs-schema')` in shipped code is either
resolved by your bundler — which **fails the build** for anyone who did not install it,
including people using only `<tosi-table>` — or left external, which cannot resolve in a
browser and kills validation for everyone. There is no third option, so the component asks
for the functions instead of the package. The upside is that they are just functions: an Ajv
wrapper, a house validator or a test stub all work. `tosijs-schema` is the one we ship docs
for, not a requirement.

## Non-conforming data

The form is an **editor, not a gate**. Give it a value that violates its schema and it renders
that value, validates on load, and marks the offending fields. It does not coerce the data,
strip it, refuse it, or stop you saving it.

Enforcement is yours, deliberately. The form has no validator of its own, so a save gate built
in here would pass silently for every consumer who never registered one — a gate that opens
when nothing checked is worse than no gate, because it reads as enforcement. `validate()` and
`validationAvailable` are the seam instead, and they are three lines in your save handler:

```typescript
if (form.validationAvailable && !form.validate()) {
  // your call: refuse, warn, save a draft
}
```

**Known limitation, tracked as
[#162](https://github.com/tonioloewald/tosijs-ui/issues/162).** In three cases the offending
value is kept in `value` but cannot be seen on screen:

- **A value the typed control cannot hold shows as empty.** `age: "abc"` against
  `{ type: 'integer' }` renders an empty `number` input, because the browser discards a value
  it cannot parse; an enum value not in the list leaves the `select` blank. `value.age` is
  still `"abc"` — but the field looks unset, and typing into it overwrites something you were
  never shown.
- **A union value matching no branch renders no fields** for that subtree. It survives in
  `value` and is uneditable.
- **An error whose path has no rendered field is invisible** — an extra property, or an error
  against the object as a whole. It is in `errors`, so `validate()` returns `false` over a form
  that looks clean.

The fix being built for the first two is a panel listing what could not be displayed, with the
option to purge it. Until then, treat `errors` as the authority on what is wrong and the fields
as a partial view of it.

## No schema? It infers one

Give it a `value` and no `schema` and it derives one with `inferSchema` from
the registered validator's `inferSchema` — useful for editing a record whose shape you don't
have written down. With no validator there is nothing to infer from, and the form says so
rather than rendering an empty box.

```js
import { tosiSchemaForm } from 'tosijs-ui'

const inferred = tosiSchemaForm({
  value: {
    title: 'Dorothea',
    pages: 812,
    inPrint: true,
    author: { surname: 'Eliot' },
  },
})
const shown = document.createElement('pre')
const show = () => {
  shown.textContent = JSON.stringify(inferred.schema, null, 2)
}
inferred.addEventListener('change', show)
show()
preview.append(inferred, shown)
```
```test
const inferred = await waitFor('tosi-schema-form')

test('a schema is derived from the value, and it is the one you can read back', async () => {
  // Inference needs a registered validator that can do it. On this doc site the iife
  // registers one, so the fields arrive on a render rather than after a module load — there
  // is no lazy import any more, the seam replaced it.
  const pages = await waitFor('[data-path="pages"]', 5000)

  // Types come from the data: a whole number is an integer, a nested object is a section.
  expect(pages.type).toBe('number')
  expect(inferred.querySelector('[data-path="inPrint"]').type).toBe('checkbox')
  expect(inferred.querySelector('[data-path="author.surname"]')).toBeTruthy()

  // `$inferred` marks it as OBSERVED rather than authored, and nothing is required —
  // one sample is evidence of what was there, not of what must be.
  expect(inferred.schema.$inferred).toBe(true)
  expect(inferred.schema.required).toBeUndefined()

  // Inference happens ONCE. Editing must not re-derive the schema and rebuild the form
  // out from under whoever is typing.
  const built = inferred.schema
  pages.value = '900'
  pages.dispatchEvent(new Event('input', { bubbles: true }))
  expect(inferred.value.pages).toBe(900)
  expect(inferred.schema).toBe(built)
})
```

An inferred schema describes **the sample you gave it** — nothing more. It carries
`$inferred: true` so you can tell it from one you wrote, it is open
(`additionalProperties: true`) so `filter()` cannot strip a key that happened to be absent,
and it has **no `required`**: asserting that a field is mandatory because one example filled
it in is exactly the guess that makes inferred schemas reject valid future data.

It is inferred **once**, so typing never rebuilds the form. Read it back from `.schema`, edit
it, and set it again if you want something different — that round trip is the point.

## Nested objects

An object property becomes a `<details>` section, to any depth. Sections start **open** — a
form whose fields are hidden looks empty, and a user who does not know a section exists cannot
fill it in.

`required` is scoped to the object that declares it, which is what JSON Schema means: a
required `city` inside an optional `address` says *if you give an address, it needs a city*.

```js
import { tosiSchemaForm } from 'tosijs-ui'

preview.append(
  tosiSchemaForm({
    schema: {
      type: 'object',
      properties: {
        name: { type: 'string' },
        address: {
          type: 'object',
          title: 'Postal address',
          properties: {
            city: { type: 'string' },
            postcode: { type: 'string' },
            geo: {
              type: 'object',
              properties: {
                lat: { type: 'number' },
                lon: { type: 'number' },
              },
            },
          },
          required: ['city'],
        },
      },
      required: ['name'],
    },
    value: { name: 'Ada', address: { city: 'London', geo: { lat: 51.5 } } },
  })
)
```
```test
const nestedForm = await waitFor('tosi-schema-form')

test('nested objects render as sections, with fully-qualified paths', () => {
  expect(nestedForm.querySelectorAll('details.schema-group').length).toBe(2)
  expect(nestedForm.querySelector('[data-path="address.geo.lat"]')).toBeTruthy()
  // Sections start open, or the form reads as empty.
  expect([...nestedForm.querySelectorAll('details')].every((d) => d.open)).toBe(true)
})

test('editing a nested field writes the nested path and keeps its siblings', () => {
  const lat = nestedForm.querySelector('[data-path="address.geo.lat"]')
  lat.value = '48.9'
  lat.dispatchEvent(new Event('input', { bubbles: true }))
  expect(nestedForm.value.address.geo.lat).toBe(48.9)
  expect(nestedForm.value.address.city).toBe('London')
  expect(nestedForm.value.name).toBe('Ada')
})
```

## Arrays

An array property renders its elements with add, remove and reorder controls. Items can be
scalars or objects, and objects can contain further arrays.

Editing an array **splices the model**. That is worth stating because the usual approach —
rewriting the DOM path strings of every following element — is where these components
famously corrupt data: reindexing a nested array with an unanchored pattern rewrites the
*outer* index, so moving `items[2].variants[1]` puts its data on `items[0]`. There are no
path strings here, so there is nothing to rewrite wrongly. The indices are wherever the
elements now are.

```js
import { tosiSchemaForm } from 'tosijs-ui'

preview.append(
  tosiSchemaForm({
    schema: {
      type: 'object',
      properties: {
        tags: { type: 'array', title: 'Tag', items: { type: 'string' } },
        items: {
          type: 'array',
          title: 'Line item',
          items: {
            type: 'object',
            properties: {
              sku: { type: 'string' },
              qty: { type: 'integer' },
            },
            required: ['sku'],
          },
        },
      },
    },
    value: {
      tags: ['urgent', 'paid'],
      items: [
        { sku: 'WIDGET-1', qty: 2 },
        { sku: 'GASKET-9', qty: 5 },
      ],
    },
  })
)
```
```test
const arrayForm = await waitFor('tosi-schema-form')
const skus = () => arrayForm.value.items.map((i) => i.sku)

test('array elements render with their own paths', () => {
  expect(arrayForm.querySelector('[data-path="tags.0"]').value).toBe('urgent')
  expect(arrayForm.querySelector('[data-path="items.1.sku"]').value).toBe('GASKET-9')
})

test('add, reorder and remove all edit the model', () => {
  // One test: these steps share the form, and the live-example docs are explicit that
  // test() bodies run concurrently.
  const container = arrayForm.querySelector('[data-array="items"]')

  container.querySelector('.schema-add').click()
  expect(arrayForm.value.items.length).toBe(3)

  // Reorder the first two.
  const controls = container.querySelectorAll('.schema-item-controls')
  controls[1].querySelector('.schema-move-up').click()
  expect(skus().slice(0, 2)).toEqual(['GASKET-9', 'WIDGET-1'])

  // Remove the one we added.
  const after = arrayForm.querySelectorAll('[data-array="items"] .schema-item')
  after[2].querySelector('.schema-remove').click()
  expect(arrayForm.value.items.length).toBe(2)
  expect(skus()).toEqual(['GASKET-9', 'WIDGET-1'])
})

test('an array edit does not disturb the rest of the form', () => {
  // Only the edited array is rebuilt. A form that rebuilt everything would throw away focus,
  // scroll and every open section elsewhere on the page.
  const tag = arrayForm.querySelector('[data-path="tags.0"]')
  arrayForm.querySelector('[data-array="items"] .schema-add').click()
  expect(arrayForm.querySelector('[data-path="tags.0"]')).toBe(tag)
  expect(arrayForm.value.tags).toEqual(['urgent', 'paid'])
})
```

## Unions

`anyOf` and `oneOf` describe several shapes a value may take, and most of them are not
"pick a variant" at all. Each is rendered as what it actually is:

| the union | what you get |
| --- | --- |
| `[X, {type: 'null'}]` | just an X, not required — this is what an optional field looks like |
| all branches `const` | a `<select>`; a branch `title` is its label |
| all branches objects | a variant picker plus the fields of the matching branch |
| anything else | a placeholder naming the shapes, e.g. *a union of string \| object* |

A variant union finds its **discriminator** — the property every branch pins to a different
`const` — and uses it both to tell which branch the value matches and to label the choices.
An OpenAPI-style `discriminator: {propertyName}` is honoured when present, but plain JSON
Schema needs no extra ceremony. The discriminator is **not** rendered as a field of its own:
the picker is that control, and two controls for one value is an invitation to set them
differently.

Switching branch writes the new branch's `const` marks and **deletes nothing** — switch back
and your data is still there. `filter()` is what strips a value to a schema; a form is an
editor.

```js
import { tosiSchemaForm } from 'tosijs-ui'

const unionForm = tosiSchemaForm({
  schema: {
    type: 'object',
    properties: {
      nickname: { anyOf: [{ type: 'string' }, { type: 'null' }] },
      notify: {
        title: 'Notify',
        anyOf: [
          { const: 'all', title: 'Everything' },
          { const: 'mentions', title: 'Mentions only' },
          { const: 'none', title: 'Nothing' },
        ],
      },
      shape: {
        anyOf: [
          {
            type: 'object',
            title: 'Circle',
            properties: { kind: { const: 'circle' }, r: { type: 'number' } },
            required: ['kind', 'r'],
          },
          {
            type: 'object',
            title: 'Rectangle',
            properties: {
              kind: { const: 'rect' },
              w: { type: 'number' },
              h: { type: 'number' },
            },
            required: ['kind', 'w', 'h'],
          },
        ],
      },
    },
    required: ['nickname'],
  },
  value: { notify: 'mentions', shape: { kind: 'circle', r: 3 } },
})

const shown = document.createElement('pre')
const show = () => {
  shown.textContent = JSON.stringify(unionForm.value, null, 2)
}
unionForm.addEventListener('change', show)
show()

preview.append(unionForm, shown)
```
```test
const unionForm = await waitFor('tosi-schema-form')
const picker = unionForm.querySelector('[data-union="shape"] .schema-variant')

test('unions render as what they actually are', async () => {
  // ONE test: every step below shares this form, and test() bodies run concurrently.

  // A nullable union is just the field — and `required` in the schema does not make an
  // empty value invalid when null is a branch.
  expect(unionForm.querySelector('[data-path="nickname"]').type).toBe('text')

  // An all-const union is a select, labelled by branch titles.
  const notify = unionForm.querySelector('[data-path="notify"]')
  expect(notify.tagName).toBe('SELECT')
  // `notify` is not required, so the first option is the empty one — an optional field has
  // to offer a way back to "not set".
  expect([...notify.options].map((o) => o.textContent)).toEqual([
    '—',
    'Everything',
    'Mentions only',
    'Nothing',
  ])
  expect(notify.value).toBe('mentions')

  // The variant picker shows the matching branch's fields — and NOT the discriminator,
  // which the picker itself is.
  expect(picker.value).toBe('0')
  expect(unionForm.querySelector('[data-path="shape.r"]').value).toBe('3')
  expect(unionForm.querySelector('[data-path="shape.kind"]')).toBe(null)

  // Switching branch swaps the fields, writes the new mark, and keeps the old data.
  picker.value = '1'
  picker.dispatchEvent(new Event('change', { bubbles: true }))
  expect(unionForm.value.shape.kind).toBe('rect')
  expect(unionForm.value.shape.r).toBe(3)
  expect(unionForm.querySelector('[data-path="shape.w"]')).toBeTruthy()
  expect(unionForm.querySelector('[data-path="shape.r"]')).toBe(null)

  // Setting `value` to a different variant re-renders the branch, without a schema change.
  unionForm.value = { shape: { kind: 'circle', r: 9 } }
  await new Promise((r) => requestAnimationFrame(r))
  expect(picker.value).toBe('0')
  expect(unionForm.querySelector('[data-path="shape.r"]').value).toBe('9')

  // Through all of that the picker itself was never replaced — switching variant with the
  // keyboard must not take focus off the control you are operating.
  expect(unionForm.querySelector('[data-union="shape"] .schema-variant')).toBe(
    picker
  )
})
```

### When a keyword is not validated, the field says so

A validator enforces a subset of JSON Schema, and the parts outside it are the dangerous ones:
not an error, not a warning — a value the schema forbids, accepted, with `ok === true`. So any
field whose schema uses a keyword the **registered validator** does not check carries a note
saying which.

The form asks the validator itself, whenever it can answer (`unenforcedKeywords`, which
`tosijs-schema` exports from 1.8.0 — it was ask 3 of
[#8](https://github.com/tonioloewald/tosijs-schema/issues/8), filed from here). Only a
validator that cannot answer falls back to a local list, because the validator in use is the
only thing that actually knows.

That distinction is not academic: `tosijs-schema` **used to ignore `oneOf`** — `validate`
returned `true` for a value no branch accepted — and 1.8.0 enforces it. A hard-coded list
would now be claiming "oneOf is not validated" over a field being checked, which is the note
lying in the component whose whole point is that it does not.

> **Prefer `anyOf` for a discriminated union.** 1.8.0 validates `oneOf` by trying every branch
> with no short-circuit, where `anyOf` stops at the first match. Same result, more work.

```js
import { tosiSchemaForm } from 'tosijs-ui'

preview.append(
  tosiSchemaForm({
    schema: {
      type: 'object',
      properties: {
        score: { type: 'number', exclusiveMinimum: 0 },
        tags: { type: 'array', items: { type: 'string' }, uniqueItems: true },
        shape: {
          oneOf: [
            {
              type: 'object',
              title: 'Circle',
              properties: { kind: { const: 'circle' }, r: { type: 'number' } },
            },
            {
              type: 'object',
              title: 'Square',
              properties: { kind: { const: 'square' }, side: { type: 'number' } },
            },
          ],
        },
      },
    },
    value: { score: 0, tags: ['a', 'a'], shape: { kind: 'square', side: 2 } },
  })
)
```
```test
const oneOfForm = await waitFor('tosi-schema-form')

test('the note tracks the VALIDATOR, not a hard-coded list', () => {
  expect(oneOfForm.querySelector('[data-path="shape.side"]').value).toBe('2')

  const notes = [...oneOfForm.querySelectorAll('.schema-unvalidated')].map(
    (n) => n.textContent
  )
  // Against tosijs-schema >= 1.8.0 both `oneOf` and `exclusiveMinimum` ARE enforced, so
  // there is nothing to warn about and `score: 0` is correctly rejected. Under 1.7.0 this
  // same form rendered two notes and reported the value as conforming — the assertion that
  // matters is that the notes agree with the validator in use, whichever it is.
  // (Line comments: a block comment's `*` `/` closes the doc block early — see #70.)
  // `uniqueItems` is on an ARRAY and is still not enforced by 1.8.0, so its note is the one
  // that must appear — containers carried none until 1.11.0, which made the promise above
  // false for exactly the keywords a validator is most likely to skip.
  expect(notes).toEqual(['uniqueItems is not validated'])
  expect(oneOfForm.validate()).toBe(false)
  expect(oneOfForm.errors.some((e) => e.path === 'score')).toBe(true)
})
```

## Format plugins

`registerFieldPlugin(format, plugin)` claims a schema `format` and renders it yourself. The
plugin owns the **control**; the form still owns the label and the error slot, so a plugin
does not have to re-implement either and cannot drift from the rest of the form.

```typescript
registerFieldPlugin('percent', {
  // build the control element
  render({ value, set, elements }) { return elements.div() },
  // put a value back into it
  sync(element, value) {},
  // injected into its own stylesheet when the plugin registers
  styles: { '.my-widget': { display: 'flex' } },
})
```

`sync` is **required**. A control that cannot take a value back is not compatible with a form
whose model owns the data, and finding that out while writing the plugin beats finding it out
when a save silently reverts. The form calls it whenever `value` changes underneath — never by
replacing your element, which would ruin a slider mid-drag.

**Register whenever you like — there is no "too late".** Styles are injected when the plugin
registers, into their own stylesheet, and any form on screen that uses the format rebuilds.
(Forms that do not use it are left alone.) The component this design learned from merged
plugin styles into its style spec *once, when the component was defined*, so a plugin
registered a moment later rendered correctly and completely unstyled — with no error. Its
workaround was a bare `import './plugin'` placed above the component definition: load-bearing,
invisible, and unavailable to anyone registering from application code.

```js
import { tosiSchemaForm, registerFieldPlugin } from 'tosijs-ui'

const pluginForm = tosiSchemaForm({
  schema: {
    type: 'object',
    properties: {
      label: { type: 'string' },
      confidence: { type: 'number', format: 'percent', title: 'Confidence' },
    },
  },
  value: { label: 'match', confidence: 0.85 },
})
const shown = document.createElement('pre')
const show = () => {
  shown.textContent = JSON.stringify(pluginForm.value)
}
pluginForm.addEventListener('change', show)
show()
preview.append(pluginForm, shown)

// Let the form render FIRST, with no plugin for `percent` — otherwise this example would
// quietly be testing early registration, which was never the hard case.
await new Promise((r) => requestAnimationFrame(r))

// Registered AFTER the form is already on screen — which is the normal case, and works.
registerFieldPlugin('percent', {
  render({ value, set, elements: { div, input, span } }) {
    const readout = span({ class: 'percent-readout' })
    const slider = input({
      type: 'range',
      min: 0,
      max: 1,
      step: 0.01,
      onInput(event) {
        const next = Number(event.target.value)
        readout.textContent = `${Math.round(next * 100)}%`
        set(next)
      },
    })
    const control = div({ class: 'percent-control' }, slider, readout)
    slider.value = String(value ?? 0)
    readout.textContent = `${Math.round((value ?? 0) * 100)}%`
    return control
  },
  sync(element, value) {
    const slider = element.querySelector('input')
    slider.value = String(value ?? 0)
    element.querySelector('.percent-readout').textContent = `${Math.round(
      (value ?? 0) * 100
    )}%`
  },
  styles: {
    '.percent-control': { display: 'flex', gap: '8px', alignItems: 'center' },
    '.percent-readout': { minWidth: '3em', fontVariantNumeric: 'tabular-nums' },
  },
})
```
```test
const pluginForm = await waitFor('tosi-schema-form')

test('a plugin registered after the form renders takes effect, styles and all', async () => {
  // The rebuild is queued for a frame, and under load one rAF is not reliably that frame —
  // poll instead of racing it.
  const control = await waitFor('[data-plugin="confidence"]')
  expect(control.querySelector('input').type).toBe('range')
  expect(control.querySelector('.percent-readout').textContent).toBe('85%')

  // The styles went in on registration, not when the component was defined — the whole
  // point. A silently unstyled plugin is the defect this seam exists to make impossible.
  expect(getComputedStyle(control).display).toBe('flex')

  // Dragging the slider writes the MODEL, through the plugin's `set`.
  const slider = control.querySelector('input')
  slider.value = '0.4'
  slider.dispatchEvent(new Event('input', { bubbles: true }))
  expect(pluginForm.value.confidence).toBe(0.4)
  // …and the field the plugin does not own is untouched.
  expect(pluginForm.value.label).toBe('match')

  // Setting `value` syncs THROUGH the plugin rather than replacing its element.
  pluginForm.value = { label: 'match', confidence: 0.6 }
  await new Promise((r) => requestAnimationFrame(r))
  expect(pluginForm.querySelector('[data-plugin="confidence"]')).toBe(control)
  expect(control.querySelector('.percent-readout').textContent).toBe('60%')
})
```

## Curated forms

A schema-form shows every property the schema describes. That is right for an admin view and
wrong for a task-focused one: a panel with four fields from a two-hundred-property
configuration, an onboarding step, a review dialog.

Set **`fields`** to choose what is shown. It is a map from **paths** into the schema to how
each one should appear, and its order is the display order:

```typescript
form.fields = {
  'thresholds.default': { caption: 'Default threshold', help: 'Between 0 and 1.' },
  'routing.mode': 'How invoices are routed', // a string is just a caption
  'routing.priority': { editor: tosiRating }, // a custom editor
  owner: { readOnly: true }, // shown, not editable here
  notify: true, // exactly as the schema describes it
}
```

- Everything else still comes from the schema: the control, `enum` choices, `required`,
  number and format constraints, and the caption when you give none.
- A path runs through object properties (`a.b.c`). Naming an object, an array or a union
  shows that whole property, unless its entry has its own `fields`
  ([below](#inside-arrays-and-unions)).
- **`editor`** is a function that returns an element with a `value` property and a `change`
  event, which is what every element creator in this library is. Its `value` must be of the
  schema's type: a native `<input>` yields strings, so use one for string properties only. Pass a [field plugin](#format-plugins) object (`{ render, sync }`) for full control.
- **`readOnly`** on one field is a presentation choice, not a security boundary: the value is
  still writable through `form.value`.
- Name an object or one of its properties, not both: the first one listed is shown and the
  console warns about the other.
- A path the schema does not describe renders as a placeholder that says so, and the console
  warns. A field that silently fails to appear looks the same as one nobody asked for.

**The form still owns the whole value.** Properties you do not show keep their values, and
the whole value is validated. An error in a property that is not shown is listed above the
fields, so a curated form cannot quietly save a value that does not conform. `errors` and
`validate()` always describe the whole value.

Set `fields = null` to show everything again.

Tick **View only** below to turn the same curated form into a viewer, and **Every field** to
see what the schema describes when nothing is curated.

```js
import { tosiSchemaForm, tosiRating } from 'tosijs-ui'
import { elements } from 'tosijs'

const { div, label, input, pre } = elements

const configSchema = {
  type: 'object',
  properties: {
    owner: { type: 'string' },
    thresholds: {
      type: 'object',
      properties: {
        default: { type: 'number', minimum: 0, maximum: 1 },
        escalate: { type: 'number', minimum: 0, maximum: 1 },
      },
      required: ['default'],
    },
    routing: {
      type: 'object',
      properties: {
        mode: { type: 'string', enum: ['manual', 'assisted', 'automatic'] },
        priority: { type: 'integer', minimum: 0, maximum: 5 },
        retries: { type: 'integer', minimum: 0 },
      },
    },
    audit: { type: 'object', properties: { retainDays: { type: 'integer' } } },
  },
}

const fields = {
  'thresholds.default': {
    caption: 'Default threshold',
    help: 'Predictions below this go to a person. Between 0 and 1.',
  },
  'routing.mode': 'How invoices are routed',
  'routing.priority': { caption: 'Priority', editor: tosiRating },
  owner: { caption: 'Owned by', readOnly: true },
}

const curatedForm = tosiSchemaForm({
  schema: configSchema,
  value: {
    owner: 'finance-ops',
    thresholds: { default: 0.8, escalate: 0.95 },
    routing: { mode: 'assisted', priority: 3, retries: 2 },
    audit: { retainDays: 90 },
  },
  fields,
})

const curatedValue = pre({ class: 'curated-value' })
const showCurated = () => {
  curatedValue.textContent = JSON.stringify(curatedForm.value, null, 2)
}
curatedForm.addEventListener('change', showCurated)
showCurated()

preview.append(
  div(
    { class: 'curated-options' },
    label(
      input({
        type: 'checkbox',
        class: 'view-only',
        onChange(event) {
          curatedForm.readOnly = event.target.checked
        },
      }),
      ' View only'
    ),
    label(
      input({
        type: 'checkbox',
        class: 'every-field',
        onChange(event) {
          curatedForm.fields = event.target.checked ? null : fields
        },
      }),
      ' Every field'
    )
  ),
  div({ class: 'curated-demo' }, curatedForm, curatedValue)
)
```
```css
.preview .curated-options {
  display: flex;
  gap: 16px;
  padding: 0 0 12px;
}
.preview .curated-demo {
  display: flex;
  flex-wrap: wrap;
  gap: 24px;
  align-items: flex-start;
}
.preview .curated-demo tosi-schema-form {
  flex: 1 1 280px;
}
.preview .curated-value {
  flex: 0 1 240px;
  margin: 0;
  font-size: 12px;
}
```
```test
const curatedForm = await waitFor('tosi-schema-form')
await waitMs(100)

test('a curated form shows the chosen fields, in order, under the chosen captions', () => {
  const shown = [...curatedForm.querySelectorAll('[data-field]')]
  expect(shown.map((el) => el.dataset.field)).toEqual([
    'thresholds.default',
    'routing.mode',
    'routing.priority',
    'owner',
  ])
  expect(shown[0].querySelector('label').textContent).toBe('Default threshold')
  expect(shown[0].querySelector('.schema-help').textContent).toContain('Between 0 and 1')
  // the control still comes from the schema
  expect(curatedForm.querySelector('[data-path="thresholds.default"]').type).toBe('number')
  expect(curatedForm.querySelector('[data-path="routing.mode"]').tagName).toBe('SELECT')
  expect(curatedForm.querySelector('[data-path="owner"]').disabled).toBe(true)
  expect(curatedForm.querySelector('[data-path="routing.mode"]').disabled).toBe(false)
  // a conforming value shows no problems
  expect(curatedForm.querySelector('.schema-hidden-errors').hidden).toBe(true)
})

test('editing one field keeps everything that is not shown', () => {
  const mode = curatedForm.querySelector('[data-path="routing.mode"]')
  mode.value = 'automatic'
  mode.dispatchEvent(new Event('input', { bubbles: true }))
  expect(curatedForm.value.routing).toEqual({ mode: 'automatic', priority: 3, retries: 2 })
  expect(curatedForm.value.thresholds.escalate).toBe(0.95)
  expect(curatedForm.value.audit.retainDays).toBe(90)
  mode.value = 'assisted'
  mode.dispatchEvent(new Event('input', { bubbles: true }))
})

test('a custom editor reads and writes the value', () => {
  const rating = curatedForm.querySelector('[data-plugin="routing.priority"]')
  expect(rating.tagName).toBe('TOSI-RATING')
  expect(rating.value).toBe(3)
  let fired = 0
  const count = () => (fired += 1)
  curatedForm.addEventListener('change', count)
  rating.value = 5
  rating.dispatchEvent(new Event('change', { bubbles: true }))
  expect(curatedForm.value.routing.priority).toBe(5)
  expect(fired).toBe(1)
  rating.value = 3
  rating.dispatchEvent(new Event('change', { bubbles: true }))
  curatedForm.removeEventListener('change', count)
})

test('the same form is a viewer, and shows every field on request', async () => {
  const toggle = (selector, checked) => {
    const box = preview.querySelector(selector)
    box.checked = checked
    box.dispatchEvent(new Event('change', { bubbles: true }))
  }
  // the form re-renders on a frame, which a background page may delay
  const settled = async (check) => {
    for (let i = 0; i < 50 && !check(); i += 1) await waitMs(100)
    return check()
  }
  const disabled = (path) => curatedForm.querySelector(`[data-path="${path}"]`)?.disabled
  const shows = (path) => curatedForm.querySelector(`[data-path="${path}"]`) != null

  toggle('.view-only', true)
  expect(await settled(() => disabled('routing.mode') === true)).toBe(true)
  toggle('.view-only', false)
  expect(await settled(() => disabled('routing.mode') === false)).toBe(true)
  expect(disabled('owner')).toBe(true)

  toggle('.every-field', true)
  expect(await settled(() => shows('audit.retainDays'))).toBe(true)
  toggle('.every-field', false)
  expect(await settled(() => !shows('audit.retainDays'))).toBe(true)
  expect(curatedForm.querySelectorAll('[data-field]').length).toBe(4)
})
```

### A problem in a field that is not shown

This value arrives with an `audit.retainDays` that is not an integer. The form shows one
field, and still reports the problem:

```js
import { tosiSchemaForm } from 'tosijs-ui'

const hiddenErrorForm = tosiSchemaForm({
  schema: {
    type: 'object',
    properties: {
      owner: { type: 'string' },
      audit: { type: 'object', properties: { retainDays: { type: 'integer' } } },
    },
  },
  value: { owner: 'finance-ops', audit: { retainDays: 'ninety' } },
  fields: { owner: 'Owned by' },
})
hiddenErrorForm.validate()

preview.append(hiddenErrorForm)
```
```test
const hiddenErrorForm = await waitFor('tosi-schema-form')
await waitMs(100)

test('an error in a field that is not shown is listed, not hidden', () => {
  if (!hiddenErrorForm.validationAvailable) return
  expect(hiddenErrorForm.validate()).toBe(false)
  const summary = hiddenErrorForm.querySelector('.schema-hidden-errors')
  expect(summary.hidden).toBe(false)
  expect(summary.textContent).toContain('audit.retainDays')
})
```

### Inside arrays and unions

What is inside an array or a union depends on the value, so a path cannot run through one.
Give the property's entry its own **`fields`** instead. Its paths are relative: for an array,
to **each item**; for a union, to **the branch the value currently matches**.

```typescript
form.fields = {
  thresholds: {
    caption: 'Thresholds',
    fields: {
      value: 'Threshold', // thresholds[i].value, for every item
      'when.company': { caption: 'Company', readOnly: true },
    },
  },
  rule: {
    picker: false, // hide the variant picker
    fields: { amount: 'Amount', rate: 'Rate' }, // each branch shows the ones it has
  },
}
```

- Items keep the properties you do not show, and Add, remove and reorder work as before.
- An error in an item property that is not shown is listed above the fields by its real
  path (`thresholds.0.note`).
- A union's `fields` covers every branch. A path the active branch lacks is left out; a path
  **no** branch has renders the placeholder and warns.
- `fields` on an object entry works the same way, and keeps the object's panel.
- `collapsed: true` on an object or array entry starts its panel closed.

### Sections

An entry with a **`section`** title is a panel that groups some of the fields. Its key is an
id (the panel's `data-section`, for styling), not a path, and the paths in its `fields` are
the same paths you would use outside it.

```typescript
form.fields = {
  owner: 'Owned by',
  advanced: {
    section: 'Advanced',
    collapsed: true,
    fields: { 'routing.retries': 'Retries', 'audit.retainDays': 'Keep for (days)' },
  },
}
```

A section starts open unless you say `collapsed: true`. A section, and any entry with
`collapsed: true`, gets the class `-invalid` and a red title while a field inside it is in
error, so collapsing one cannot hide a problem. In TypeScript, `isSection(entry)` tells a
section from a field entry when you walk a curation yourself. Sections can sit inside an array's or a union's `fields` too.

```js
import { tosiSchemaForm } from 'tosijs-ui'
import { elements } from 'tosijs'

const { div, pre } = elements

const rulesForm = tosiSchemaForm({
  schema: {
    type: 'object',
    properties: {
      owner: { type: 'string' },
      thresholds: {
        type: 'array',
        items: {
          type: 'object',
          properties: {
            when: {
              type: 'object',
              properties: {
                company: { type: 'string' },
                account: { type: 'string' },
              },
            },
            value: { type: 'number', minimum: 0, maximum: 1 },
            note: { type: 'string' },
          },
        },
      },
      routing: {
        type: 'object',
        properties: { retries: { type: 'integer', minimum: 0 } },
      },
      audit: { type: 'object', properties: { retainDays: { type: 'integer' } } },
    },
  },
  value: {
    owner: 'finance-ops',
    thresholds: [
      { when: { company: 'Acme', account: '4000' }, value: 0.8, note: 'agreed in March' },
      { when: { company: 'Globex', account: '5000' }, value: 0.65, note: '' },
    ],
    routing: { retries: 2 },
    audit: { retainDays: 90 },
  },
  fields: {
    thresholds: {
      caption: 'Thresholds',
      fields: {
        'when.company': { caption: 'Company', readOnly: true },
        value: { caption: 'Threshold', help: 'Between 0 and 1.' },
      },
    },
    advanced: {
      section: 'Advanced',
      collapsed: true,
      fields: {
        'routing.retries': 'Retries',
        'audit.retainDays': 'Keep audit records for (days)',
      },
    },
  },
})

const rulesValue = pre({ class: 'rules-value' })
const showRules = () => {
  rulesValue.textContent = JSON.stringify(rulesForm.value, null, 2)
}
rulesForm.addEventListener('change', showRules)
showRules()

preview.append(div({ class: 'rules-demo' }, rulesForm, rulesValue))
```
```css
.preview .rules-demo {
  display: flex;
  flex-wrap: wrap;
  gap: 24px;
  align-items: flex-start;
}
.preview .rules-demo tosi-schema-form {
  flex: 1 1 280px;
}
.preview .rules-value {
  flex: 0 1 260px;
  margin: 0;
  font-size: 12px;
}
```
```test
const rulesForm = await waitFor('tosi-schema-form')
await waitMs(100)

test('each array item shows the curated fields, and a section groups the rest', () => {
  const shown = [...rulesForm.querySelectorAll('[data-field]')].map((el) => el.dataset.field)
  expect(shown).toEqual([
    'thresholds.0.when.company',
    'thresholds.0.value',
    'thresholds.1.when.company',
    'thresholds.1.value',
    'routing.retries',
    'audit.retainDays',
  ])
  expect(rulesForm.querySelector('[data-path="thresholds.0.when.company"]').disabled).toBe(true)
  const section = rulesForm.querySelector('details[data-section="advanced"]')
  expect(section.open).toBe(false)
  expect(section.querySelector('summary').textContent).toBe('Advanced')
  expect(section.querySelector('[data-field="routing.retries"]')).not.toBe(null)

  const threshold = rulesForm.querySelector('[data-path="thresholds.1.value"]')
  threshold.value = '0.7'
  threshold.dispatchEvent(new Event('input', { bubbles: true }))
  expect(rulesForm.value.thresholds[1]).toEqual({
    when: { company: 'Globex', account: '5000' },
    value: 0.7,
    note: '',
  })
  threshold.value = '0.65'
  threshold.dispatchEvent(new Event('input', { bubbles: true }))
})
```

## Proposals

A review screen shows what someone, or something, wants to change and lets a person decide.
Set **`proposals`** to a map from path to `{ value, reason?, source? }`, and each one appears
under its field with **Accept** and **Reject**:

```typescript
form.proposals = {
  'thresholds.default': {
    value: 0.9,
    reason: 'Three false escalations last week.',
    source: 'triage agent',
  },
}
```

- `form.value` stays the **current** value. Accepting writes the proposed value and fires
  `proposalaccept`, then `change`; rejecting fires `proposalreject` and leaves the value
  alone. Both carry `detail: { path, proposal }`, and both remove the entry from
  `form.proposals`.
- `acceptProposal(path)` and `rejectProposal(path)` do the same from code.
- Paths are value paths, so an array item is `thresholds.0.value`. A proposal can be for a
  whole array, object or union: it sits at the top of that panel, and accepting it replaces
  the lot. They work with or without a curation; a proposal for something that is not shown
  is named above the fields.
- An object item of an array can be proposed whole (`thresholds.0`).
- Accepting a proposal drops any others for things inside it, since they no longer refer to
  what is there. A proposal for an array item follows its item when items are moved, and is
  dropped when its item is removed. Neither fires an event: read `form.proposals` after a
  `change` if you keep your own list. One for a field inside a union stays at its path if the branch
  changes.
- `reason` and `source` are shown as text, and only when they are strings.
- A field that is `readOnly` in a curation can still have its proposal accepted: that is the
  review screen, where nobody types and somebody decides. A form with `readOnly` set shows
  the proposals with no buttons.
- Set **`proposalView`** to a function to render a proposal yourself. It receives
  `{ path, label, current, proposal, readOnly, accept, reject, elements }` and returns an
  element. While the form is read-only `accept` and `reject` do nothing, and `readOnly` is
  true so your view can leave its buttons out. `acceptProposal()` called from code is not
  affected by `readOnly`.
- Proposals are presentation, like the rest of this page: nothing stops code from writing
  `form.value`. Who may accept is for your application to enforce.

```js
import { tosiSchemaForm } from 'tosijs-ui'
import { elements } from 'tosijs'

const { div, pre } = elements

const reviewForm = tosiSchemaForm({
  schema: {
    type: 'object',
    properties: {
      thresholds: {
        type: 'object',
        properties: {
          default: { type: 'number', minimum: 0, maximum: 1 },
          escalate: { type: 'number', minimum: 0, maximum: 1 },
        },
      },
      routing: {
        type: 'object',
        properties: {
          mode: { type: 'string', enum: ['manual', 'assisted', 'automatic'] },
        },
      },
    },
  },
  value: {
    thresholds: { default: 0.8, escalate: 0.95 },
    routing: { mode: 'assisted' },
  },
  fields: {
    'thresholds.default': { caption: 'Default threshold', readOnly: true },
    'thresholds.escalate': { caption: 'Escalation threshold', readOnly: true },
    'routing.mode': { caption: 'How invoices are routed', readOnly: true },
  },
  proposals: {
    'thresholds.default': {
      value: 0.9,
      reason: 'Three false escalations last week.',
      source: 'triage agent',
    },
    'routing.mode': {
      value: 'automatic',
      reason: 'Assisted routing has matched a person for 30 days.',
      source: 'M. Okafor',
    },
  },
})

const reviewLog = pre({ class: 'review-log' }, 'Nothing decided yet.')
const decided = (verb) => (event) => {
  const { path, proposal } = event.detail
  reviewLog.textContent = `${verb} ${path} = ${JSON.stringify(proposal.value)}`
}
reviewForm.addEventListener('proposalaccept', decided('Accepted'))
reviewForm.addEventListener('proposalreject', decided('Rejected'))

preview.append(reviewForm, reviewLog)
```
```test
const reviewForm = await waitFor('tosi-schema-form')
await waitMs(100)

test('a proposal is shown under its field, and accepting it writes the value', async () => {
  const field = reviewForm.querySelector('[data-field="thresholds.default"]')
  for (let i = 0; i < 50 && !field.querySelector('.schema-proposal'); i += 1) await waitMs(100)
  expect(field.querySelector('.schema-proposal-value').textContent).toBe('Proposed: 0.9')
  expect(field.querySelector('.schema-proposal-source').textContent).toBe('triage agent')
  expect(reviewForm.value.thresholds.default).toBe(0.8)

  const events = []
  const record = (event) => events.push(event.type)
  for (const type of ['proposalaccept', 'proposalreject', 'change']) {
    reviewForm.addEventListener(type, record)
  }
  const proposals = reviewForm.proposals
  expect(reviewForm.acceptProposal('thresholds.default')).toBe(true)
  expect(reviewForm.value.thresholds.default).toBe(0.9)
  expect(events).toEqual(['proposalaccept', 'change'])
  expect(Object.keys(reviewForm.proposals)).toEqual(['routing.mode'])
  expect(reviewForm.acceptProposal('thresholds.default')).toBe(false)

  // put it back, so the example still has something to decide
  reviewForm.value = { ...reviewForm.value, thresholds: { default: 0.8, escalate: 0.95 } }
  reviewForm.proposals = proposals
  for (const type of ['proposalaccept', 'proposalreject', 'change']) {
    reviewForm.removeEventListener(type, record)
  }
})
```

## Read-only

`readOnly` disables the inputs and **hides** the add, remove and reorder controls rather than
grey them out. A dead row of buttons is a form advertising affordances it will not honour;
there is nothing to explain if they are not there.

## Styling

Four custom properties, each with a fallback so the component works unstyled:

| variable | default | what it colours |
| --- | --- | --- |
| `--tosi-error` | `#c00` | the invalid-field outline and the error text |
| `--tosi-border` | `#0002` | the border around a `<details>` section |
| `--tosi-border-radius` | `4px` | that section's corners |
| `--tosi-proposal-bg` | `#fc03` | the background of a proposal |

Spacing comes from the theme's `--tosi-spacing` / `--tosi-spacing-sm`, so a form matches
whatever scale the rest of your page is on.

## Localization

The form's own chrome — the *Add …* button and the reorder tooltips — goes through
[`localize`](/localize/), and the form **rebuilds when the locale changes**, so switching
language does not leave one form behind in the old one.

A curated form adds one more string, `Problems in fields not shown here`, and proposals add
`Proposed`, `Accept`, `Reject` and `Proposed changes to fields not shown here`.

**Labels from your schema are shown as written.** A property's `title`, a curation's
`caption` and `help`, and a section's title are never humanised or passed through
`localize`: they are your data, and a schema generated from data (one property per company,
titled with the company's name) shows those names untouched. Only a property with no `title`
gets a label derived from its key (`firstName` becomes `first name`).

The add button's key is the whole sentence, `Add {item}`, with the item name interpolated
afterwards. Building it as `'Add ' + label` would leave a translator with a dangling fragment
and no way to move the placeholder to where their language puts it.

Labels that come from your **schema** are not localized — a `title` is your string, and a
humanised property name is a data name rather than a UI string. Localize them in the schema if
you need to.

## What it renders today

Scalars and enums: `string` (with `format` picking the input type), `number`, `integer`,
`boolean`, `enum`, `const`, **nested objects**, **arrays** and **unions**. **Tuple arrays
(`prefixItems`) are not supported yet** — a property using one is shown as a placeholder
saying so, rather than being silently omitted. A field that vanishes is indistinguishable from
a schema that never mentioned it, which is how an editor loses data without anyone noticing.

## Properties, methods, events

- `schema` — the JSON Schema. Setting it rebuilds the fields.
- `value` — the data. Setting it updates the inputs; it does **not** fire `change`.
- `readOnly` — disables the inputs.
- `fields` — a curation: which properties to show, in order, with captions and editors.
- `errors` — `{ path, message }[]` for the current value.
- `validate()` — `true` when the value conforms.
- `change` — fires when the user edits a field.

```js
import { tosiSchemaForm } from 'tosijs-ui'

preview.append(
  tosiSchemaForm({
    schema: {
      type: 'object',
      properties: {
        name: { type: 'string' },
        email: { type: 'string', format: 'email' },
        age: { type: 'integer' },
        active: { type: 'boolean' },
      },
      required: ['name'],
    },
    value: { name: 'Ada' },
  })
)
```
```test
const schemaForm = await waitFor('tosi-schema-form')

test('renders one control per schema property', () => {
  expect(schemaForm.querySelectorAll('[data-path]').length).toBeGreaterThan(3)
})

test('editing keeps unknown keys, fires change, and does not rebuild under the user', async () => {
  // ONE test, because these steps share the form. Splitting them made the third read the
  // second's value — the concurrency hazard the live-example docs warn about, hit while
  // writing the component that documents it. (Line comments, not a block: a `*` `/` inside
  // a doc comment closes it early — see #70.)
  schemaForm.value = { name: 'Ada', _id: 'keep-me' }
  await new Promise((r) => requestAnimationFrame(r))
  let fired = 0
  schemaForm.addEventListener('change', () => fired++)

  const input = schemaForm.querySelector('[data-path="name"]')
  input.value = 'Grace'
  input.dispatchEvent(new Event('input', { bubbles: true }))

  // The model owns the data — and a key the schema never mentioned survives the edit,
  // which is the whole reason output is not rebuilt from the inputs.
  expect(schemaForm.value.name).toBe('Grace')
  expect(schemaForm.value._id).toBe('keep-me')
  expect(fired).toBe(1)

  // Now type into it while focused. A render must NOT replace the element or clobber what
  // is being typed — the failure mode of every "the DOM is the model" schema form.
  input.focus()
  input.value = 'Ada Lovelace'
  input.dispatchEvent(new Event('input', { bubbles: true }))
  await new Promise((r) => requestAnimationFrame(r))
  expect(schemaForm.querySelector('[data-path="name"]')).toBe(input)
  expect(input.value).toBe('Ada Lovelace')
  expect(schemaForm.value.name).toBe('Ada Lovelace')
})

```
