Components

Object editor

A record as labelled fields, each with the control its kind calls for, built from the record's schema.

Markdown

An object editor shows a record as labelled fields and lets people change them: the form that edits a customer, the inspector beside a canvas, the detail of a row. It is built from the record's schema, so every field has the control that suits its kind.

A product

Every control here comes from the schema. Clear the name and leave the field; type 1in as a width; add a tag in capitals. Save shows every remaining problem at once.

Size
Tag

Lowercase letters, digits and hyphens.

{
  "name": "Teapot",
  "price": 48,
  "status": "draft",
  "released": null,
  "featured": false,
  "size": {
    "width": 180,
    "height": 120
  },
  "tags": [
    "kitchen"
  ]
}
Code
"use client";

import { ObjectEditor } from "@publictheta/ui/components/object-editor/object-editor.tsx";
import type { ObjectEditorHandle } from "@publictheta/ui/components/object-editor/object-editor.tsx";
import { schema, validate } from "@publictheta/ui/primitives/schema.ts";
import { useRef, useState } from "react";

const product = schema({
  kind: "object",
  fields: [
    { key: "name", schema: { kind: "text", label: "Name", maxLength: 30 } },
    {
      key: "price",
      schema: {
        kind: "number",
        label: "Price",
        minimum: 0,
        precision: 0,
        format: "currency",
        currency: "USD",
      },
    },
    {
      key: "status",
      schema: {
        kind: "choice",
        label: "Status",
        options: [
          { value: "draft", label: "Draft" },
          { value: "review", label: "In review" },
          { value: "live", label: "Live" },
        ],
      },
    },
    { key: "released", schema: { kind: "date", label: "Released", optional: true } },
    { key: "featured", schema: { kind: "boolean", label: "Featured" } },
    {
      key: "size",
      schema: {
        kind: "object",
        label: "Size",
        fields: [
          {
            key: "width",
            schema: {
              kind: "number",
              label: "Width",
              minimum: 0,
              precision: 1,
              dimension: "length",
              unit: "mm",
            },
          },
          {
            key: "height",
            schema: {
              kind: "number",
              label: "Height",
              minimum: 0,
              precision: 1,
              dimension: "length",
              unit: "mm",
            },
          },
        ],
      },
    },
    {
      key: "tags",
      schema: {
        kind: "list",
        label: "Tag",
        description: "Lowercase letters, digits and hyphens.",
        maxItems: 3,
        item: { kind: "text", pattern: "[a-z0-9-]+" },
      },
    },
  ],
});

export default function ProductEditor() {
  const [value, setValue] = useState<unknown>({
    name: "Teapot",
    price: 48,
    status: "draft",
    released: null,
    featured: false,
    size: { width: 180, height: 120 },
    tags: ["kitchen"],
  });
  const [attempted, setAttempted] = useState(false);
  const editor = useRef<ObjectEditorHandle>(null);
  const problems = validate(product, value).length;

  return (
    <div className="grid w-full gap-6 lg:grid-cols-2">
      <form
        className="flex flex-col gap-4"
        onSubmit={(event) => {
          event.preventDefault();
          setAttempted(true);
          editor.current?.focusFirstIssue();
        }}
      >
        <ObjectEditor
          ref={editor}
          schema={product}
          locale="en-US"
          value={value}
          onValueChange={setValue}
          showAllIssues={attempted}
        />
        <div className="flex items-center gap-3">
          <button
            type="submit"
            className="h-8 rounded-md bg-selected px-3 text-sm text-on-selected outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 pointer-coarse:h-11"
          >
            Save
          </button>
          <p aria-live="polite" className="text-sm text-fg-muted">
            {!attempted
              ? ""
              : problems === 0
                ? "Saved."
                : `${problems} ${problems === 1 ? "problem" : "problems"} to fix.`}
          </p>
        </div>
      </form>
      <pre className="self-start overflow-x-auto rounded-lg bg-surface p-3 font-mono text-[12px] leading-relaxed text-fg ring-1 ring-border-muted">
        {JSON.stringify(value, null, 2)}
      </pre>
    </div>
  );
}

An order, computed

Amounts, the subtotal, the total and the due date are computed from the fields their descriptions name. Change a quantity and they follow. Type over the total and it is frozen there. Open the total's menu — a secondary click, Shift+F10, or touch and hold — and hold it; then change a price: the change is refused, and the price says why. Unlock the total and press Enter in the price to make it after all. Undo takes back each step, a lock with what came with it.

Line
Line 1

Computed as price * quantity.

Line 2

Computed as price * quantity.

Computed as sum(lines.amount).

Computed as round(subtotal * (1 - coalesce(discount, 0)), 2).

Computed as today() + 30.

Total
$151.20
Locks
None
The record
{
  "customer": "Tea House",
  "lines": [
    {
      "item": "Teapot",
      "price": 48,
      "quantity": 2,
      "amount": 96
    },
    {
      "item": "Cup",
      "price": 12,
      "quantity": 6,
      "amount": 72
    }
  ],
  "discount": 0.1,
  "due": "2026-11-13",
  "subtotal": 168,
  "total": 151.2
}
Code
"use client";

import { ObjectEditor } from "@publictheta/ui/components/object-editor/object-editor.tsx";
import { Button } from "@publictheta/ui/components/button/button.tsx";
import { deriveNested, deriveRecord, derivationsOf } from "@publictheta/ui/primitives/derived.ts";
import type { Lock } from "@publictheta/ui/primitives/derived.ts";
import { createHistory } from "@publictheta/ui/primitives/history.ts";
import { schema } from "@publictheta/ui/primitives/schema.ts";
import type { ObjectSchema } from "@publictheta/ui/primitives/schema.ts";
import { useRef, useState } from "react";

const money = { kind: "number", format: "currency", currency: "USD", minimum: 0 } as const;

const order: ObjectSchema = schema({
  kind: "object",
  fields: [
    { key: "customer", schema: { kind: "text", label: "Customer" } },
    {
      key: "lines",
      schema: {
        kind: "list",
        label: "Line",
        item: {
          kind: "object",
          fields: [
            { key: "item", schema: { kind: "text", label: "Item" } },
            { key: "price", schema: { ...money, label: "Price" } },
            { key: "quantity", schema: { kind: "number", label: "Quantity", minimum: 0, precision: 0 } },
            { key: "amount", schema: { ...money, label: "Amount", formula: "price * quantity" } },
          ],
        },
      },
    },
    { key: "subtotal", schema: { ...money, label: "Subtotal", formula: "sum(lines.amount)" } },
    {
      key: "discount",
      schema: {
        kind: "number",
        label: "Discount",
        format: "percent",
        optional: true,
        minimum: 0,
        maximum: 1,
      },
    },
    {
      key: "total",
      schema: { ...money, label: "Total", formula: "round(subtotal * (1 - coalesce(discount, 0)), 2)" },
    },
    { key: "due", schema: { kind: "date", label: "Due", formula: "today() + 30" } },
  ],
});

/** The order as it is first shown, with every computed value made right. */
function first(): Record<string, unknown> {
  const entered = {
    customer: "Tea House",
    lines: [
      { item: "Teapot", price: 48, quantity: 2 },
      { item: "Cup", price: 12, quantity: 6 },
    ],
    discount: 0.1,
  };
  return deriveRecord(derivationsOf(order).plan, deriveNested(order, undefined, entered));
}

interface State {
  readonly value: unknown;
  readonly locks: ReadonlyMap<string, Lock>;
}

/** What one action did, to take back and to do again: a lock and the change it came with are one. */
interface Step {
  readonly before: State;
  readonly after: State;
}

const formatter = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });

export default function OrderEditor() {
  const [state, setState] = useState<State>(() => ({ value: first(), locks: new Map() }));
  const [history] = useState(() => createHistory<Step>());
  // Whether a step is being written: what arrives within the same event belongs to it.
  const writing = useRef(false);
  const [, setSteps] = useState(0);

  function record(next: State): void {
    const step = { before: state, after: next };
    if (writing.current) history.merge(step, (last) => ({ before: last.before, after: next }));
    else history.push(step);
    writing.current = true;
    queueMicrotask(() => {
      writing.current = false;
    });
    setSteps((count) => count + 1);
  }

  // Each callback sees the state the last one left, within one event as across them.
  const latest = useRef(state);
  function apply(next: State): void {
    record(next);
    latest.current = next;
    setState(next);
  }

  function travel(direction: "undo" | "redo"): void {
    const step = direction === "undo" ? history.undo() : history.redo();
    if (step === undefined) return;
    const next = direction === "undo" ? step.before : step.after;
    latest.current = next;
    setState(next);
    setSteps((count) => count + 1);
  }

  const total = (state.value as { total?: unknown }).total;
  const locked = [...state.locks].map(([key, lock]) => `${key} ${lock}`).join(", ");

  return (
    <div className="grid w-full gap-6 @container">
      <div className="flex min-w-0 flex-col gap-4">
        <ObjectEditor
          aria-label="Order"
          schema={order}
          locale="en-US"
          value={state.value}
          locks={state.locks}
          onLocksChange={(locks) => apply({ ...latest.current, locks })}
          onValueChange={(value) => apply({ ...latest.current, value })}
        />
        <div className="flex flex-wrap items-center gap-2">
          <Button focusableWhenDisabled disabled={!history.canUndo()} onClick={() => travel("undo")}>
            Undo
          </Button>
          <Button focusableWhenDisabled disabled={!history.canRedo()} onClick={() => travel("redo")}>
            Redo
          </Button>
        </div>
      </div>
      <dl className="grid content-start gap-x-4 gap-y-1 self-start rounded-lg bg-surface p-3 text-sm ring-1 ring-border-muted [grid-template-columns:auto_minmax(0,1fr)]">
        <dt className="text-fg-muted">Total</dt>
        <dd className="text-fg-strong tabular-nums">
          {typeof total === "number" ? formatter.format(total) : "—"}
        </dd>
        <dt className="text-fg-muted">Locks</dt>
        <dd className="text-fg-strong">{locked === "" ? "None" : locked}</dd>
        <dt className="col-span-2 mt-2 text-fg-muted">The record</dt>
        <dd className="col-span-2">
          <pre className="overflow-x-auto font-mono text-[12px] leading-relaxed text-fg">
            {JSON.stringify(state.value, null, 2)}
          </pre>
        </dd>
      </dl>
    </div>
  );
}

A schema, edited

The same editor, given the schema of schemas: pick a field and choose its kind, and the settings of that kind follow. Pick dimensions for a record within the record, each of whose fields is of a kind again. The form on the other side is built from what is edited here.

Fields

Select all
Key
Kind
Label
Optional
Formula
1
Text
Name
2
price
Number
Price
3
taxed
Number
With tax
round(price * 1.1, 2)
4
status
Choice
Status
5
released
Date
Released
6
tags
List
Tag
7
dimensions
Record
Dimensions
+

The form and the records follow every change.

Field

Field

The name its value is held under.

What people call this value.

How it is computed from the record's other fields, as price * quantity.

In characters as people count them.

In characters as people count them.

A regular expression the whole text must match.

What the text is, for its keyboard and for filling it in.

A sentence of help: what the value is for, or the form it takes.

May be left without a value.

Shown, but not for changing.

A record

Computed as round(price * 1.1, 2).

Tag

Lowercase letters, digits and hyphens.

Dimensions

Records

Select all
Name
Price
With tax
Status
Released
1
$48.00
$52.80
Live
Mar 2, 2026
2
Tea caddy
$22.50
$24.75
In review
3
Tray
$36.00
$39.60
Draft
+

3 records. A list or a nested record has no column: it is edited in the form. With tax follows the price; type over it to freeze it, or hold a cell from the menu, and a change to the schema that would move it is refused.

Code
"use client";

import { DataGrid } from "@publictheta/ui/components/data-grid/data-grid.tsx";
import type { CellChange, Run } from "@publictheta/ui/components/data-grid/data-grid.tsx";
import { ObjectEditor } from "@publictheta/ui/components/object-editor/object-editor.tsx";
import type { ObjectEditorHandle } from "@publictheta/ui/components/object-editor/object-editor.tsx";
import { Select, SelectItem } from "@publictheta/ui/components/select/select.tsx";
import { Button } from "@publictheta/ui/components/button/button.tsx";
import {
  checkSchema,
  fieldSchema,
  fromSchema,
  schemaSchema,
  toSchema,
} from "@publictheta/ui/primitives/schema-schema.ts";
import {
  blank,
  conform,
  conforms,
  schema,
  tagSchema,
  validate,
  withValueAt,
  withVariant,
} from "@publictheta/ui/primitives/schema.ts";
import type { Issue, ObjectSchema } from "@publictheta/ui/primitives/schema.ts";
import {
  conformRecords,
  deriveNested,
  deriveRecord,
  derivationsOf,
  lockKey,
} from "@publictheta/ui/primitives/derived.ts";
import type { Lock } from "@publictheta/ui/primitives/derived.ts";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";

const product = schema({
  kind: "object",
  fields: [
    { key: "name", schema: { kind: "text", label: "Name", maxLength: 40 } },
    {
      key: "price",
      schema: { kind: "number", label: "Price", minimum: 0, format: "currency", currency: "USD" },
    },
    {
      key: "taxed",
      schema: {
        kind: "number",
        label: "With tax",
        format: "currency",
        currency: "USD",
        formula: "round(price * 1.1, 2)",
      },
    },
    {
      key: "status",
      schema: {
        kind: "choice",
        label: "Status",
        default: "draft",
        options: [
          { value: "draft", label: "Draft" },
          { value: "review", label: "In review" },
          { value: "live", label: "Live" },
        ],
      },
    },
    { key: "released", schema: { kind: "date", label: "Released", optional: true } },
    {
      key: "tags",
      schema: {
        kind: "list",
        label: "Tag",
        description: "Lowercase letters, digits and hyphens.",
        maxItems: 3,
        item: { kind: "text", pattern: "[a-z0-9-]+" },
      },
    },
    {
      key: "dimensions",
      schema: {
        kind: "object",
        label: "Dimensions",
        fields: [
          {
            key: "width",
            schema: { kind: "number", label: "Width", minimum: 0, dimension: "length", unit: "mm" },
          },
          {
            key: "height",
            schema: { kind: "number", label: "Height", minimum: 0, dimension: "length", unit: "mm" },
          },
        ],
      },
    },
  ],
});

type Row = Readonly<Record<string, unknown>>;

const PRODUCTS: readonly Row[] = [
  {
    name: "Teapot",
    price: 48,
    status: "live",
    released: "2026-03-02",
    tags: ["kitchen", "stoneware"],
    dimensions: { width: 180, height: 120 },
  },
  {
    name: "Tea caddy",
    price: 22.5,
    status: "review",
    released: null,
    tags: ["kitchen"],
    dimensions: { width: 80, height: 110 },
  },
  {
    name: "Tray",
    price: 36,
    status: "draft",
    released: null,
    tags: [],
    dimensions: { width: 320, height: 20 },
  },
];

/** One field in a record's description, whole or not: its `key`, and its `schema`'s description. */
type Field = Readonly<Record<string, unknown>>;

/** The description of the record's schema as it is being edited, whole or not. */
interface Description {
  readonly kind: "object";
  readonly fields: readonly Field[];
  readonly [setting: string]: unknown;
}

/** A field as the table of fields shows it: the settings worth a column of their own. */
const fieldRow = schema({
  kind: "object",
  fields: [
    { key: "key", schema: { kind: "text", label: "Key" } },
    // The kinds a schema can be, as the schema of schemas lists them.
    { key: "kind", schema: tagSchema(schemaSchema) },
    { key: "label", schema: { kind: "text", label: "Label", optional: true } },
    { key: "optional", schema: { kind: "boolean", label: "Optional" } },
    { key: "formula", schema: { kind: "text", label: "Formula", optional: true } },
  ],
});

const isRecord = (value: unknown): value is Record<string, unknown> =>
  typeof value === "object" && value !== null && !Array.isArray(value);

// The same row for the same field, so the table sees a change only where there was one.
const rows = new WeakMap<Field, Row>();

function rowOf(field: Field): Row {
  let row = rows.get(field);
  if (row === undefined) {
    const of = isRecord(field.schema) ? field.schema : {};
    row = {
      key: field.key,
      kind: of.kind ?? null,
      label: of.label ?? null,
      optional: of.optional === true,
      formula: of.formula ?? null,
    };
    rows.set(field, row);
  }
  return row;
}

/** A field with one cell of its row changed. Another kind keeps the settings the two kinds share. */
function withCell(field: Field, key: string, value: unknown): Field {
  if (key === "key") return { ...field, key: value };
  if (key === "kind") {
    return {
      ...field,
      schema: withVariant(schemaSchema, field.schema, typeof value === "string" ? value : null),
    };
  }
  if (!isRecord(field.schema)) return field;
  return { ...field, schema: { ...field.schema, [key]: key === "optional" ? value === true : value } };
}

/** The field a new row of the table stands for. */
function fieldOf(row: Row): Field {
  let field: Field = blank(fieldSchema);
  for (const key of ["kind", "key", "label", "optional", "formula"]) {
    if (row[key] !== undefined && row[key] !== null) field = withCell(field, key, row[key]);
  }
  return field;
}

/**
 * What is wrong with a description: what the schema of schemas says of it, and
 * once that is nothing, what it cannot say — a minimum above its maximum, a key
 * used twice.
 */
function problemsOf(description: Description): Issue[] {
  if (!conforms(schemaSchema, description)) return validate(schemaSchema, description);
  return checkSchema(toSchema(description));
}

interface State {
  /** The schema as it is being edited, whole or not. */
  readonly description: Description;
  /** The last description with nothing wrong, as a schema: what the form and the records are built from. */
  readonly schema: ObjectSchema;
  readonly records: readonly Row[];
  /** The record in the form. */
  readonly draft: unknown;
  /** The locks on the form's fields. */
  readonly locks: ReadonlyMap<string, Lock>;
  /** The locks on the records' cells, each under the record's place and the field's key. */
  readonly recordLocks: ReadonlyMap<string, Lock>;
  /** Why the description as it stands is not the schema, though nothing is wrong with it. */
  readonly refused: string | null;
}

/** A record with every value it computes made right, keeping what is frozen. */
function computed(definition: ObjectSchema, record: unknown, frozen?: (key: string) => boolean): Row {
  const fitted = isRecord(record) ? record : {};
  const whole = deriveNested(definition, undefined, fitted);
  return deriveRecord(derivationsOf(definition).plan, whole, frozen === undefined ? {} : { frozen });
}

/** The same record when it already computes as it should, so the table sees a change only where there was one. */
function recomputed(definition: ObjectSchema, record: Row, frozen?: (key: string) => boolean): Row {
  const next = computed(definition, record, frozen);
  return Object.keys(next).every((key) => Object.is(next[key], record[key])) ? record : next;
}

/**
 * The held values a schema change would take or move — in the form's record, and
 * in the records — by name, with the values; `null` when there are none.
 */
function heldTaken(state: State, next: ObjectSchema, draft: Row, records: readonly Row[]): string | null {
  const named: string[] = [];
  const formLock = (key: string) => state.locks.get(key);
  const taken = conformRecords(next, state.schema, [state.draft as Row], (_row, key) =>
    formLock(key),
  ).blocked.map((cell) => cell.key);
  // A formula changed under a held value would move it as surely.
  for (const key of state.locks.keys()) {
    if (formLock(key) === "held" && !taken.includes(key) && Object.hasOwn(draft, key)) {
      if (!Object.is(draft[key], (state.draft as Row)[key])) taken.push(key);
    }
  }
  for (const key of taken)
    named.push(`${key} (${JSON.stringify((state.draft as Row)[key] ?? null)}) in the form`);
  const recordLock = (row: number, key: string) => state.recordLocks.get(lockKey(String(row), key));
  const blocked = conformRecords(next, state.schema, state.records, recordLock).blocked;
  const moved = new Set(blocked.map((cell) => `${cell.row} ${cell.key}`));
  for (const [row, record] of state.records.entries()) {
    for (const key of Object.keys(record)) {
      const now = records[row];
      if (recordLock(row, key) === "held" && now !== undefined && Object.hasOwn(now, key)) {
        if (!Object.is(now[key], record[key])) moved.add(`${row} ${key}`);
      }
    }
  }
  for (const at of moved) {
    const [row = "0", key = ""] = at.split(" ");
    const value = state.records[Number(row)]?.[key] ?? null;
    named.push(`${key} (${JSON.stringify(value)}) in record ${Number(row) + 1}`);
  }
  if (named.length === 0) return null;
  return `Not applied: it would change ${named.length === 1 ? "a held value" : "held values"} — ${named.join(", ")}. Unlock ${named.length === 1 ? "it" : "them"} first.`;
}

/** The records' locks kept under the names their fields now have. */
function renamedRecordLocks(
  locks: ReadonlyMap<string, Lock>,
  before: ObjectSchema,
  after: ObjectSchema,
): ReadonlyMap<string, Lock> {
  if (locks.size === 0) return locks;
  const byRecord = new Map<string, Map<string, Lock>>();
  for (const [key, lock] of locks) {
    const [row = "", field = ""] = key.split("\u001f");
    const fields = byRecord.get(row) ?? new Map<string, Lock>();
    fields.set(field, lock);
    byRecord.set(row, fields);
  }
  const next = new Map<string, Lock>();
  for (const [row, fields] of byRecord) {
    for (const [field, lock] of renamedLocks(fields, before, after)) next.set(lockKey(row, field), lock);
  }
  return next;
}

/** Locks kept under the names their fields now have. */
function renamedLocks(
  locks: ReadonlyMap<string, Lock>,
  before: ObjectSchema,
  after: ObjectSchema,
): ReadonlyMap<string, Lock> {
  if (locks.size === 0) return locks;
  const next = new Map<string, Lock>();
  const now = new Set(after.fields.map((field) => field.key));
  const was = new Set(before.fields.map((field) => field.key));
  for (const [key, lock] of locks) {
    if (now.has(key)) next.set(key, lock);
    else {
      // Renamed in place: the field now where it stood has a name it did not have before.
      const at = before.fields.findIndex((field) => field.key === key);
      const there = after.fields[at]?.key;
      if (there !== undefined && !was.has(there)) next.set(there, lock);
    }
  }
  return next;
}

/**
 * The state after an edit of the description. A whole description becomes the
 * schema, and every record is made to fit it: what still fits is kept, a
 * renamed field keeps its value, the rest starts blank, and what is computed
 * is computed. A change that would take a held value from the form is not made.
 */
function described(state: State, description: Description): State {
  if (!conforms(schemaSchema, description) || problemsOf(description).length > 0) {
    return { ...state, description, refused: null };
  }
  const next = toSchema(description) as ObjectSchema;
  const locks = renamedLocks(state.locks, state.schema, next);
  const draft = computed(
    next,
    conform(next, state.draft, state.schema),
    (key) => locks.get(key) === "frozen",
  );
  const recordLocks = renamedRecordLocks(state.recordLocks, state.schema, next);
  // What each record computes is computed again, but for what is frozen in it.
  const records = state.records.map((record, row) =>
    recomputed(
      next,
      conform(next, record, state.schema),
      (key) => recordLocks.get(lockKey(String(row), key)) === "frozen",
    ),
  );
  const refused = heldTaken(state, next, draft, records);
  if (refused !== null) return { ...state, description, refused };
  return {
    description,
    schema: next,
    records: records.every((record, index) => record === state.records[index]) ? state.records : records,
    draft,
    locks,
    recordLocks,
    refused: null,
  };
}

function Section({ title, children }: { title: string; children: ReactNode }) {
  return (
    <section className="flex min-w-0 flex-col gap-3">
      <h4 className="text-sm font-semibold text-fg-strong">{title}</h4>
      {children}
    </section>
  );
}

export default function SchemaEditor() {
  const [state, setState] = useState<State>(() => ({
    description: fromSchema(product) as Description & { readonly kind: "object" },
    schema: product,
    records: PRODUCTS.map((row) => computed(product, row)),
    draft: computed(product, blank(product)),
    locks: new Map(),
    recordLocks: new Map(),
    refused: null,
  }));
  const { description, records, draft } = state;
  const { fields } = description;
  const change = (next: Description) => setState((current) => described(current, next));
  const setFields = (next: readonly Field[]) => change({ ...description, fields: next });

  // Which field the inspector shows.
  const [picked, setPicked] = useState(0);
  const at = Math.min(picked, fields.length - 1);
  const field = fields[at];
  const fieldLabelId = useId();
  const name = (index: number) => {
    const key = fields[index]?.key;
    return typeof key === "string" && key !== "" ? key : `Field ${index + 1}`;
  };
  function move(by: 1 | -1) {
    const to = at + by;
    if (field === undefined || to < 0 || to >= fields.length) return;
    setFields(fields.toSpliced(at, 1).toSpliced(to, 0, field));
    setPicked(to);
  }

  const problems = useMemo(() => problemsOf(description), [description]);
  // Those of the field in the inspector, as paths within it.
  const fieldProblems = useMemo(
    () =>
      problems
        .filter((issue) => issue.path[0] === "fields" && issue.path[1] === at)
        .map((issue) => ({ ...issue, path: issue.path.slice(2) })),
    [problems, at],
  );
  // Those the table has a cell for, each by its field's place and the column it is in.
  const cellProblems = useMemo(
    () =>
      problems.flatMap((issue): Issue[] => {
        const [list, index, part, setting] = issue.path;
        if (list !== "fields" || typeof index !== "number") return [];
        const column = part === "schema" ? setting : part;
        return typeof column === "string" ? [{ ...issue, path: [index, column] }] : [];
      }),
    [problems],
  );
  const inspector = useRef<ObjectEditorHandle>(null);
  const [shown, setShown] = useState(false);
  // How many times the first problem has been asked for: each time, focus goes to it.
  const [sought, setSought] = useState(0);
  useEffect(() => {
    if (sought > 0) inspector.current?.focusFirstIssue();
  }, [sought]);
  const fieldRows = useMemo(() => fields.map(rowOf), [fields]);

  const form = useRef<ObjectEditorHandle>(null);
  const [attempted, setAttempted] = useState(false);
  const formatter = new Intl.NumberFormat("en-US");

  return (
    <div className="@container w-full">
      <div className="grid gap-x-6 gap-y-10 @2xl:grid-cols-2">
        <div className="flex min-w-0 flex-col gap-6">
          <Section title="Fields">
            <DataGrid
              aria-label="Fields"
              schema={fieldRow}
              locale="en-US"
              rows={fieldRows}
              onCellsChange={(changes: readonly CellChange[]) => {
                const next = fields.slice();
                for (const { row, key, value } of changes) {
                  if (next[row] !== undefined) next[row] = withCell(next[row], key, value);
                }
                setFields(next);
              }}
              onRowsInsert={(index, added) => {
                setFields(fields.toSpliced(index, 0, ...added.map(fieldOf)));
                setPicked(index);
              }}
              onRowsRemove={(runs: ReadonlyArray<Run<Row>>) => {
                const gone = new Set(runs.flatMap((run) => run.rows.map((_, index) => run.at + index)));
                setFields(fields.filter((_, index) => !gone.has(index)));
              }}
              issues={cellProblems}
              // The inspector shows the field whose row the active cell is in.
              onActiveCellChange={(cell) => {
                if (cell !== null && cell.row < fields.length) setPicked(cell.row);
              }}
              columnWidth={116}
              style={{ height: 264 }}
            />
            <p aria-live="polite" className="flex min-h-8 flex-wrap items-center gap-x-3 gap-y-1 text-sm">
              {problems.length === 0 && state.refused !== null ? (
                <span className="text-danger">{state.refused}</span>
              ) : problems.length === 0 ? (
                <span className="text-fg-muted">The form and the records follow every change.</span>
              ) : (
                <>
                  <span className="text-danger">
                    {formatter.format(problems.length)} {problems.length === 1 ? "problem" : "problems"}: the
                    form and the records keep the last schema that had none.
                  </span>
                  <Button
                    onClick={() => {
                      const first = problems.find((issue) => issue.path[0] === "fields");
                      if (typeof first?.path[1] === "number") setPicked(first.path[1]);
                      setShown(true);
                      setSought(sought + 1);
                    }}
                  >
                    Show the first
                  </Button>
                </>
              )}
            </p>
          </Section>

          <Section title="Field">
            {field === undefined ? (
              <p className="text-sm text-fg-muted">Type a key into the table above to add a field.</p>
            ) : (
              <>
                <div className="flex flex-wrap items-center gap-2">
                  <span id={fieldLabelId} className="sr-only">
                    Field
                  </span>
                  <Select
                    aria-labelledby={fieldLabelId}
                    className="min-w-0 flex-1"
                    value={String(at)}
                    onValueChange={(next) => setPicked(Number(next))}
                  >
                    {fields.map((_, index) => (
                      // Fields have no identity but their place: a key may be missing, or used twice.
                      // oxlint-disable-next-line react/no-array-index-key
                      <SelectItem key={index} value={String(index)}>
                        {name(index)}
                      </SelectItem>
                    ))}
                  </Select>
                  <Button disabled={at === 0} onClick={() => move(-1)}>
                    Move up
                  </Button>
                  <Button disabled={at === fields.length - 1} onClick={() => move(1)}>
                    Move down
                  </Button>
                </div>
                <ObjectEditor
                  ref={inspector}
                  aria-label={name(at)}
                  schema={fieldSchema}
                  locale="en-US"
                  value={field}
                  onValueChange={(next) => change(withValueAt(description, ["fields", at], next))}
                  issues={fieldProblems}
                  showAllIssues={shown}
                />
              </>
            )}
          </Section>
        </div>

        <div className="flex min-w-0 flex-col gap-6">
          <Section title="A record">
            <form
              className="flex flex-col gap-4"
              onSubmit={(event) => {
                event.preventDefault();
                if (validate(state.schema, draft).length > 0) {
                  setAttempted(true);
                  form.current?.focusFirstIssue();
                  return;
                }
                setAttempted(false);
                // The record goes into the table with its locks.
                const recordLocks = new Map(state.recordLocks);
                for (const [key, lock] of state.locks)
                  recordLocks.set(lockKey(String(records.length), key), lock);
                setState({
                  ...state,
                  records: [...records, draft as Row],
                  draft: computed(state.schema, blank(state.schema)),
                  locks: new Map(),
                  recordLocks,
                });
              }}
            >
              <ObjectEditor
                ref={form}
                schema={state.schema}
                locale="en-US"
                value={draft}
                onValueChange={(next) => setState((current) => ({ ...current, draft: next }))}
                locks={state.locks}
                onLocksChange={(locks) => setState((current) => ({ ...current, locks }))}
                showAllIssues={attempted}
              />
              <Button type="submit" className="self-start">
                Add to the records
              </Button>
            </form>
          </Section>

          <Section title="Records">
            <DataGrid
              aria-label="Records"
              schema={state.schema}
              locale="en-US"
              rows={records}
              // What a row computes comes with what was typed into it, from the table itself.
              onRowsChange={(next) => setState((current) => ({ ...current, records: next }))}
              locks={state.recordLocks}
              onLocksChange={(recordLocks) => setState((current) => ({ ...current, recordLocks }))}
              columnWidth={132}
              style={{ height: 220 }}
            />
            <p className="text-sm text-fg-muted">
              {formatter.format(records.length)} {records.length === 1 ? "record" : "records"}. A list or a
              nested record has no column: it is edited in the form. With tax follows the price; type over it
              to freeze it, or hold a cell from the menu, and a change to the schema that would move it is
              refused.
            </p>
          </Section>
        </div>
      </div>
    </div>
  );
}

When to use

Use an object editor where people work on one record at a time and need all of its fields. For many records with the same fields, a table shows more at once.

Best practices

Give each kind of value its own control. Text in a text field, a number where numbers are read and stepped, a date where dates are understood, a choice as a select. People should never type what they could choose. Where a text holds an email address, a telephone number or a name, say so: the right keyboard appears, and the browser can fill it in.

Ask a yes-or-no with a checkbox only when it must be answered. Unticked is then an answer, so the checkbox is never called required. A yes-or-no that may be left unanswered is a choice of yes, no and not set: a checkbox cannot be put back to neither.

Label every field, with the words people use for it, beside the field where the editor has room and above it where the editor is narrow or under a finger. A long label wraps; it does not take the width from its control. Beside a control of several lines, the label sits on the first.

Describe what a label cannot. A sentence of help belongs under the control, always visible, and is read with it.

Show structure. A record within a record is a group with its own heading. A list shows its items, each with its own control and named by its place; an item that is itself a record is a group of its own fields. A list has a way to add an item and each item a way to remove it. An optional record can be removed altogether, and none of its fields is then asked for.

Let people choose among kinds of record. Where a value is one of several kinds of record, show the choice of which first, named by the field's label, and the chosen kind's fields after it, as a group in the same columns. Before a kind is chosen there is only the choice. Changing it keeps what the two kinds share and starts the rest blank, as one change; nothing typed into the old kind's fields, or reported of them, carries over. A choice that may be left unmade can be put back to none. In a list, each item says which kind it is beside its name.

Show a record that contains itself only as far as it goes. A category with a parent category, a folder of folders: draw what the value holds and, where it holds nothing yet, a button that adds it. Never draw fields for a record that is not there.

Show problems the schema cannot state where they belong. A key used twice, a start after an end, a name the server has taken: given with the path to the field at fault, such a problem appears beside it like any other, and is found with the rest when people try to finish.

Keep focus where the work is. Adding an item moves focus to it. Removing one moves focus to the next item, else the one before, else the button that adds one; focus is never lost. In a list of single-line texts, Enter in the last item adds another and Backspace in an empty one removes it, as in any list being typed.

Let items be put in order without dragging. Each item of a list of several has a handle. Pressing it lifts the item; the arrow keys, or the two buttons that appear beside it, move it; pressing it again drops it, and Escape puts it back where it was. Every move is announced with the item's new place.

An item is itself, not its place. What has been typed into an item, and any problem shown for it, stay with that item when another is removed or it is moved.

Show what is not yet given as empty. A field without a value is blank, and a yes-or-no without one is neither; do not show a zero or a "no" that nobody entered. A new item starts from the default its schema states, if it states one.

Report a problem beside its field, once the field has been left. People who have not reached a field have not got it wrong yet. Say what the limit is — "At least 3 characters", "No earlier than Oct 1, 2026" — written as the reader's locale writes it. Do not let the report move the page under a pointer that is still pressed. When people try to finish, show every remaining problem at once and take them to the first.

Say what is required to assistive technology as well as to the eye. Mark only the fewer: the optional fields where most are required, the required ones where most are optional.

Show what cannot be changed as it is. A read-only field keeps its value in view, can be reached and read, and does not respond to editing; a read-only editor offers nothing to add, remove or reorder.

Keep what is computed right, and show that it is. A field whose formula computes it follows what it reads: an edit comes with every value it moves, as one change, which is undone as one. It is marked as computed and says what it is computed as; a formula that cannot be used is a problem of its field from the start, with the part it is about marked. Within the records of a list, computed values follow and are not typed over.

Let values be locked, and say which are. Typing over a computed field freezes it at what was typed. A computed field has a menu — and, where the application works with locks, every field of the record does: Hold value; Freeze for a computed one; Unlock, or Unfreeze for a frozen computed one, which computes it again. Elsewhere a field keeps the browser's own menu, with its cut, copy, paste and spelling. A held field is shown but not changed, and says why. A change that would move a held value is not made: the field it was made in keeps what was typed, marked, with the reason — "Total is held; this would change it." — announced; Escape puts it aside, and Enter asks for it again once the lock is off.

Change one thing at a time. Editing a field changes that field and what it computes; the rest of the record is untouched, and an edit that changes nothing reports nothing.

Start afresh with another record. What was visited, typed or reported for one record says nothing about the next.

Say everything in the reader's language. Every phrase the editor shows or announces can be replaced, and a phrase with parts — "Remove Tag 2" — is made from its parts, so each language can order them its own way.

Platform considerations

Keyboard. Tab moves through the fields in order; each field follows its own component's keys. On an item's handle, Space or Enter lifts and drops, the up and down arrows move the lifted item, and Escape returns it. A field's menu opens with the Menu key or Shift+F10.

Pointer. Pressing a handle lifts the item and shows buttons to move it up and down, which keep it lifted while they are used; pressing the handle again drops it. Dragging is not yet offered. A secondary click opens a field's menu.

Touch. Labels sit above their fields, and every control is at least 44 CSS pixels tall. Touching and holding a field opens its menu.

Assistive technology. Each control is named by its label and described by its lock, what it is computed as, its help and its problem; a problem marks it invalid, and is announced politely when it appears, with the name of its field. A nested record and a list are named groups. A handle says whether its item is lifted and how it is used; lifting, moving, dropping and removing are each announced, and so is a change a lock refused.

Behaviour

Semantics

Every field has the control for its kind, named by its label
const screen = await render(<Editor />);
await expect.element(text(screen, "Name")).toHaveValue("Teapot");
await expect.element(number(screen, "Price")).toHaveValue("4,800");
await expect.element(screen.getByRole("combobox", { name: "Status" })).toHaveTextContent("Draft");
await expect.element(text(screen, "Released")).toHaveValue("");
await expect.element(screen.getByRole("checkbox", { name: "Featured" })).not.toBeChecked();
A record within the record is a named group of its own fields
const screen = await render(<Editor />);
const size = screen.getByRole("group", { name: "Size" });
await expect.element(size.getByText("Size", { exact: true })).toBeVisible();
await expect.element(size.getByRole("spinbutton", { name: "Width" })).toHaveValue("180 mm");
A field that must be given says it is required; an optional one does not
const screen = await render(<Editor />);
await expect.element(text(screen, "Name")).toBeRequired();
await expect.element(text(screen, "Released")).not.toBeRequired();
Where most fields are required the optional ones are marked for the eye, and where most are optional the required ones
const screen = await render(<Editor />);
const marks = () =>
  [...document.querySelectorAll("[data-mark]")].map((mark) => mark.parentElement!.textContent);
await expect.element(screen.getByText("optional", { exact: true })).toBeVisible();
expect(marks()).toEqual(["Releasedoptional"]);
// The mark is for the eye: the control already says whether it is required.
await expect.element(text(screen, "Released")).toHaveAccessibleName("Released");
await screen.unmount();

const mostlyOptional = schema({
  kind: "object",
  fields: [
    { key: "name", schema: { kind: "text", label: "Name" } },
    { key: "nickname", schema: { kind: "text", label: "Nickname", optional: true } },
    { key: "note", schema: { kind: "text", label: "Note", optional: true } },
    // A yes-or-no that must be answered is never marked, and is counted as neither.
    { key: "agreed", schema: { kind: "boolean", label: "Agreed" } },
    { key: "subscribed", schema: { kind: "boolean", label: "Subscribed" } },
  ],
});
await render(<Editor schema={mostlyOptional} initial={blank(mostlyOptional)} />);
expect(marks()).toEqual(["Namerequired"]);
A yes-or-no that must be answered is a checkbox, and is not said to be required: unticked is an answer
const screen = await render(<Editor />);
const featured = screen.getByRole("checkbox", { name: "Featured" });
await expect.element(featured).not.toBeRequired();
await expect.element(featured).not.toHaveAttribute("aria-required");
An optional yes-or-no is a choice of yes, no and not set, so it can go back to unanswered
const order = schema({
  kind: "object",
  fields: [{ key: "gift", schema: { kind: "boolean", label: "Gift", optional: true } }],
});
const onChange = vi.fn();
const screen = await render(<Editor schema={order} initial={{ gift: null }} onChange={onChange} />);
const gift = screen.getByRole("combobox", { name: "Gift" });
await expect.element(gift).toHaveTextContent("Not set");
await userEvent.click(gift);
await userEvent.click(screen.getByRole("option", { name: "Yes" }));
expect(onChange.mock.lastCall![0]).toEqual({ gift: true });
await userEvent.click(gift);
await userEvent.click(screen.getByRole("option", { name: "Not set" }));
expect(onChange.mock.lastCall![0]).toEqual({ gift: null });
A value not yet given shows as empty, and a yes-or-no as neither
const screen = await render(<Editor initial={blank(product)} />);
await expect.element(text(screen, "Name")).toHaveValue("");
await expect.element(number(screen, "Price")).toHaveValue("");
await expect.element(screen.getByRole("checkbox", { name: "Featured" })).toBePartiallyChecked();
A field's description is shown with it, and described by it along with any problem
const account = schema({
  kind: "object",
  fields: [
    {
      key: "handle",
      schema: { kind: "text", label: "Handle", description: "Shown beside your posts." },
    },
  ],
});
const screen = await render(<Editor schema={account} initial={{ handle: "ada" }} />);
await expect.element(screen.getByText("Shown beside your posts.")).toBeVisible();
await expect.element(text(screen, "Handle")).toHaveAccessibleDescription("Shown beside your posts.");
await commit(text(screen, "Handle"), "");
await userEvent.keyboard("{Tab}");
await expect
  .element(text(screen, "Handle"))
  .toHaveAccessibleDescription("Shown beside your posts. Required.");
What a text is said to hold chooses its keyboard and what the browser may fill in
const contact = schema({
  kind: "object",
  fields: [
    { key: "email", schema: { kind: "text", label: "Email", format: "email" } },
    { key: "phone", schema: { kind: "text", label: "Phone", format: "tel" } },
    { key: "name", schema: { kind: "text", label: "Name", format: "name" } },
  ],
});
const screen = await render(<Editor schema={contact} initial={blank(contact)} />);
const email = text(screen, "Email");
await expect.element(email).toHaveAttribute("inputmode", "email");
await expect.element(email).toHaveAttribute("autocomplete", "email");
await expect.element(email).toHaveAttribute("autocapitalize", "none");
await expect.element(text(screen, "Phone")).toHaveAttribute("inputmode", "tel");
await expect.element(text(screen, "Name")).toHaveAttribute("autocomplete", "name");
await expect.element(text(screen, "Name")).toHaveAttribute("autocapitalize", "words");
A read-only field shows its value and cannot be changed
const record = schema({
  kind: "object",
  fields: [
    { key: "id", schema: { kind: "text", label: "ID", readOnly: true } },
    { key: "name", schema: { kind: "text", label: "Name" } },
  ],
});
const onChange = vi.fn();
const screen = await render(
  <Editor schema={record} initial={{ id: "p-1", name: "Teapot" }} onChange={onChange} />,
);
await expect.element(text(screen, "ID")).toHaveAttribute("readonly");
await expect.element(text(screen, "Name")).not.toHaveAttribute("readonly");
await userEvent.click(text(screen, "ID"));
await userEvent.keyboard("x{Enter}");
await expect.element(text(screen, "ID")).toHaveValue("p-1");
expect(onChange).not.toHaveBeenCalled();
An editor that is read-only is so throughout: nothing steps, ticks, opens, or is added or removed
const onChange = vi.fn();
const screen = await render(<Editor readOnly onChange={onChange} />);
await userEvent.click(number(screen, "Price"));
await userEvent.keyboard("{ArrowUp}");
await expect.element(number(screen, "Price")).toHaveValue("4,800");
await userEvent.click(screen.getByRole("checkbox", { name: "Featured" }));
await expect.element(screen.getByRole("checkbox", { name: "Featured" })).not.toBeChecked();
const status = screen.getByRole("combobox", { name: "Status" });
await expect.element(status).toHaveAttribute("aria-readonly", "true");
await userEvent.click(status);
await expect.element(status).toHaveAttribute("aria-expanded", "false");
// No button that can be pressed: a date's calendar is there, and unavailable.
expect(
  screen
    .getByRole("button")
    .elements()
    .filter((control) => !(control as HTMLButtonElement).disabled),
).toHaveLength(0);
expect(onChange).not.toHaveBeenCalled();
Has no accessibility violations, with and without problems shown
const screen = await render(<Editor />);
await expectAccessible(screen.container);
const blankScreen = await render(<Editor initial={blank(product)} showAllIssues />);
await expectAccessible(blankScreen.container);
Has no accessibility violations with lists of records, an item lifted, and optional parts
const order = schema({
  kind: "object",
  fields: [
    {
      key: "gift",
      schema: { kind: "boolean", label: "Gift", optional: true, description: "Wrapped." },
    },
    {
      key: "shipping",
      schema: {
        kind: "object",
        label: "Shipping",
        optional: true,
        fields: [{ key: "city", schema: { kind: "text", label: "City" } }],
      },
    },
    {
      key: "lines",
      schema: {
        kind: "list",
        label: "Line",
        item: {
          kind: "object",
          fields: [
            { key: "item", schema: { kind: "text", label: "Item" } },
            { key: "quantity", schema: { kind: "number", label: "Quantity", optional: true } },
          ],
        },
      },
    },
  ],
});
const screen = await render(
  <Editor
    schema={order}
    initial={{
      gift: null,
      shipping: { city: null },
      lines: [{ item: "Tea", quantity: 2 }, blank(order).lines],
    }}
    showAllIssues
  />,
);
await userEvent.click(button(screen, "Reorder Line 1"));
await expectAccessible(screen.container);

Editing

Changing a field changes that field of the record, and nothing else
const onChange = vi.fn();
const screen = await render(<Editor onChange={onChange} />);
await commit(number(screen, "Price"), "5200");
const next = onChange.mock.lastCall![0] as typeof teapot;
expect(next).toEqual({ ...teapot, price: 5200 });
expect(next.size).toBe(teapot.size);
expect(next.tags).toBe(teapot.tags);
Each kind is edited through its own control
const onChange = vi.fn();
const screen = await render(<Editor onChange={onChange} />);
await commit(text(screen, "Name"), "Kettle");
await commit(text(screen, "Released"), "2026-10-20");
await userEvent.click(screen.getByRole("checkbox", { name: "Featured" }));
await userEvent.click(screen.getByRole("combobox", { name: "Status" }));
await userEvent.click(screen.getByRole("option", { name: "Live" }));
expect(onChange.mock.lastCall![0]).toEqual({
  ...teapot,
  name: "Kettle",
  released: "2026-10-20",
  featured: true,
  status: "live",
});
A field of a record within the record changes that inner record only
const onChange = vi.fn();
const screen = await render(<Editor onChange={onChange} />);
await commit(number(screen, "Width"), "1in");
const next = onChange.mock.lastCall![0] as typeof teapot;
expect(next.size).toEqual({ width: 25.4, height: 120 });
expect(next.tags).toBe(teapot.tags);
Clearing an optional field leaves it absent
const onChange = vi.fn();
const screen = await render(
  <Editor initial={{ ...teapot, released: "2026-10-20" }} onChange={onChange} />,
);
await commit(text(screen, "Released"), "");
expect((onChange.mock.lastCall![0] as typeof teapot).released).toBeNull();
Typing into an empty text and erasing it again changes nothing
const onChange = vi.fn();
const screen = await render(<Editor initial={{ ...teapot, name: "" }} onChange={onChange} />);
await userEvent.fill(text(screen, "Name"), "T");
await commit(text(screen, "Name"), "");
await userEvent.click(number(screen, "Price"));
expect(onChange).not.toHaveBeenCalled();
A value of the wrong shape is edited as an empty record, and none of it leaks into the fields
const measured = schema({
  kind: "object",
  fields: [{ key: "length", schema: { kind: "number", label: "Length" } }],
});
const onChange = vi.fn();
const screen = await render(<Editor schema={measured} initial="four" onChange={onChange} />);
await expect.element(number(screen, "Length")).toHaveValue("");
await commit(number(screen, "Length"), "3");
expect(onChange.mock.lastCall![0]).toEqual({ length: 3 });
A field's key may be any text: one with a dot or a slash in it is not taken for a part of another field
const flat = schema({
  kind: "object",
  fields: [
    { key: "tags.0", schema: { kind: "text", label: "With a dot" } },
    { key: "tags/0", schema: { kind: "text", label: "With a slash" } },
    { key: "tags", schema: { kind: "list", label: "Tag", item: { kind: "text" } } },
  ],
});
const screen = await render(
  <Editor schema={flat} initial={{ "tags.0": null, "tags/0": null, tags: [null] }} />,
);
await userEvent.click(text(screen, "With a dot"));
await userEvent.keyboard("{Tab}{Tab}");
await expect.element(text(screen, "With a dot")).toHaveAttribute("aria-invalid", "true");
await expect.element(text(screen, "With a slash")).toHaveAttribute("aria-invalid", "true");
await expect.element(text(screen, "Tag 1")).toHaveFocus();
await expect.element(text(screen, "Tag 1")).not.toHaveAttribute("aria-invalid");
An optional record can be removed, which asks for none of its fields, and added again as new
const order = schema({
  kind: "object",
  fields: [
    {
      key: "shipping",
      schema: {
        kind: "object",
        label: "Shipping",
        optional: true,
        fields: [{ key: "city", schema: { kind: "text", label: "City" } }],
      },
    },
  ],
});
const onChange = vi.fn();
const screen = await render(
  <Editor schema={order} initial={{ shipping: { city: null } }} onChange={onChange} />,
);
await userEvent.click(text(screen, "City"));
await userEvent.keyboard("{Tab}");
await expect.element(text(screen, "City")).toHaveAttribute("aria-invalid", "true");
await userEvent.click(button(screen, "Remove Shipping"));
expect(onChange.mock.lastCall![0]).toEqual({ shipping: null });
expect(text(screen, "City").elements()).toHaveLength(0);
await expect.element(button(screen, "Add Shipping")).toHaveFocus();
await userEvent.click(button(screen, "Add Shipping"));
expect(onChange.mock.lastCall![0]).toEqual({ shipping: { city: null } });
await expect.element(text(screen, "City")).toHaveFocus();
// Nothing is remembered of the record that was removed.
await expect.element(text(screen, "City")).not.toHaveAttribute("aria-invalid");
Choosing what is already chosen changes nothing
const onChange = vi.fn();
const screen = await render(<Editor onChange={onChange} />);
await userEvent.click(screen.getByRole("combobox", { name: "Status" }));
await userEvent.click(screen.getByRole("option", { name: "Draft" }));
await expect
  .element(screen.getByRole("combobox", { name: "Status" }))
  .toHaveAttribute("aria-expanded", "false");
expect(onChange).not.toHaveBeenCalled();

Lists

A list shows a control for each item, named by its place
const screen = await render(<Editor />);
await expect.element(text(screen, "Tag 1")).toHaveValue("kitchen");
An item is added empty, and removed by its own button
const onChange = vi.fn();
const screen = await render(<Editor onChange={onChange} />);
await userEvent.click(screen.getByRole("button", { name: "Add Tag" }));
await commit(text(screen, "Tag 2"), "gift");
expect((onChange.mock.lastCall![0] as typeof teapot).tags).toEqual(["kitchen", "gift"]);
await userEvent.click(screen.getByRole("button", { name: "Remove Tag 1" }));
expect((onChange.mock.lastCall![0] as typeof teapot).tags).toEqual(["gift"]);
An added item starts from the default its schema states
const order = schema({
  kind: "object",
  fields: [
    { key: "counts", schema: { kind: "list", label: "Count", item: { kind: "number", default: 1 } } },
  ],
});
const onChange = vi.fn();
const screen = await render(<Editor schema={order} initial={{ counts: [] }} onChange={onChange} />);
await userEvent.click(button(screen, "Add Count"));
expect(onChange.mock.lastCall![0]).toEqual({ counts: [1] });
await expect.element(number(screen, "Count 1")).toHaveValue("1");
A list that is full cannot be added to
const screen = await render(<Editor initial={{ ...teapot, tags: ["a", "b"] }} />);
await expect.element(screen.getByRole("button", { name: "Add Tag" })).toBeDisabled();
Adding an item moves focus to it
const screen = await render(<Editor />);
await userEvent.click(button(screen, "Add Tag"));
await expect.element(text(screen, "Tag 2")).toHaveFocus();
Removing an item moves focus to the next item, else the one before, else the button that adds one
const screen = await render(<Editor schema={tagged} initial={{ tags: ["a", "b", "c"] }} />);
await userEvent.click(button(screen, "Remove Tag 2"));
await expect.element(text(screen, "Tag 2")).toHaveValue("c");
await expect.element(text(screen, "Tag 2")).toHaveFocus();
await userEvent.click(button(screen, "Remove Tag 2"));
await expect.element(text(screen, "Tag 1")).toHaveFocus();
await userEvent.click(button(screen, "Remove Tag 1"));
await expect.element(button(screen, "Add Tag")).toHaveFocus();
A removal is announced
const screen = await render(<Editor schema={tagged} initial={{ tags: ["a", "b"] }} />);
await userEvent.click(button(screen, "Remove Tag 2"));
await expect.element(said(screen)).toHaveTextContent("Tag 2 removed.");
What was typed into an item, and its problem, stay with that item when another is removed
const screen = await render(<Editor schema={counted} initial={{ counts: [1, 2, 3] }} />);
await commit(number(screen, "Count 2"), "lots");
await expect.element(number(screen, "Count 2")).toHaveAttribute("aria-invalid", "true");
await userEvent.click(button(screen, "Remove Count 1"));
await expect.element(number(screen, "Count 1")).toHaveValue("lots");
await expect.element(number(screen, "Count 1")).toHaveAccessibleDescription("Not a valid value.");
await expect.element(number(screen, "Count 2")).toHaveValue("3");
await expect.element(number(screen, "Count 2")).not.toHaveAttribute("aria-invalid");
Enter in the last item of a list of single-line texts adds another, keeping what was typed
const onChange = vi.fn();
const screen = await render(
  <Editor schema={tagged} initial={{ tags: ["a", "b"] }} onChange={onChange} />,
);
await commit(text(screen, "Tag 2"), "gift");
expect(onChange.mock.lastCall![0]).toEqual({ tags: ["a", "gift", null] });
await expect.element(text(screen, "Tag 3")).toHaveFocus();
// Nothing is added from an empty item, or from one that is not the last.
await userEvent.keyboard("{Enter}");
await commit(text(screen, "Tag 1"), "first");
expect(onChange.mock.lastCall![0]).toEqual({ tags: ["first", "gift", null] });
Backspace in an empty item of such a list removes it and moves focus to the one before
const onChange = vi.fn();
const screen = await render(
  <Editor schema={tagged} initial={{ tags: ["kitchen", null] }} onChange={onChange} />,
);
// Wherever the caret was left in the item before.
await userEvent.click(text(screen, "Tag 1"));
(text(screen, "Tag 1").element() as HTMLInputElement).setSelectionRange(0, 0);
await userEvent.click(text(screen, "Tag 2"));
await userEvent.keyboard("{Backspace}");
expect(onChange.mock.lastCall![0]).toEqual({ tags: ["kitchen"] });
await expect.element(text(screen, "Tag 1")).toHaveFocus();
// The press that removed the item erases nothing from the one before.
await expect.element(text(screen, "Tag 1")).toHaveValue("kitchen");
// And typing goes on from its end.
await userEvent.keyboard("s");
await expect.element(text(screen, "Tag 1")).toHaveValue("kitchens");
While an input method is composing, Enter and Backspace are its own: nothing is added or removed
const onChange = vi.fn();
const screen = await render(
  <Editor schema={tagged} initial={{ tags: ["a", "b"] }} onChange={onChange} />,
);
await userEvent.fill(text(screen, "Tag 2"), "にほんご");
text(screen, "Tag 2")
  .element()
  .dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", isComposing: true, bubbles: true }));
await userEvent.fill(text(screen, "Tag 2"), "");
text(screen, "Tag 2")
  .element()
  .dispatchEvent(new KeyboardEvent("keydown", { key: "Backspace", isComposing: true, bubbles: true }));
await expect.element(text(screen, "Tag 2")).toHaveFocus();
expect(onChange).not.toHaveBeenCalled();
Enter that adds an item does not also submit a form the editor is in
const onSubmit = vi.fn((event: { preventDefault: () => void }) => event.preventDefault());
const screen = await render(
  <form onSubmit={onSubmit}>
    <Editor schema={tagged} initial={{ tags: ["a"] }} />
    <button type="submit">Save</button>
  </form>,
);
await commit(text(screen, "Tag 1"), "gift");
await expect.element(text(screen, "Tag 2")).toHaveFocus();
expect(onSubmit).not.toHaveBeenCalled();
An item added after another was removed starts with nothing remembered of that one
const screen = await render(<Editor schema={counted} initial={{ counts: [1, 2] }} />);
await commit(number(screen, "Count 2"), "lots");
await expect.element(number(screen, "Count 2")).toHaveAttribute("aria-invalid", "true");
await userEvent.click(button(screen, "Remove Count 2"));
await userEvent.click(button(screen, "Add Count"));
await expect.element(number(screen, "Count 2")).toHaveValue("");
await expect.element(number(screen, "Count 2")).not.toHaveAttribute("aria-invalid");
A list's own problem is shown once the list has been added to or removed from
const party = schema({
  kind: "object",
  fields: [
    { key: "guests", schema: { kind: "list", label: "Guest", minItems: 2, item: { kind: "text" } } },
  ],
});
const guests = (screen: RenderResult) => screen.getByRole("group", { name: "Guest", exact: true });
const empty = await render(<Editor schema={party} initial={{ guests: [] }} />);
await expect.element(guests(empty)).not.toHaveAccessibleDescription();
await userEvent.click(button(empty, "Add Guest"));
await expect.element(guests(empty)).toHaveAccessibleDescription("At least 2 items.");
await empty.unmount();

const enough = await render(<Editor schema={party} initial={{ guests: ["Ada", "Grace"] }} />);
await userEvent.click(button(enough, "Remove Guest 2"));
await expect.element(guests(enough)).toHaveAccessibleDescription("At least 2 items.");
In a list of anything else, Enter and Backspace add and remove nothing
const onChange = vi.fn();
const screen = await render(
  <Editor schema={counted} initial={{ counts: [1, null] }} onChange={onChange} />,
);
await userEvent.click(number(screen, "Count 2"));
await userEvent.keyboard("{Backspace}");
await commit(number(screen, "Count 2"), "2");
expect(onChange.mock.lastCall![0]).toEqual({ counts: [1, 2] });
expect(onChange).toHaveBeenCalledTimes(1);
A list of records shows each as a group of its own fields, named by its place
const order = schema({
  kind: "object",
  fields: [
    {
      key: "lines",
      schema: {
        kind: "list",
        label: "Line",
        item: {
          kind: "object",
          fields: [
            { key: "item", schema: { kind: "text", label: "Item" } },
            { key: "quantity", schema: { kind: "number", label: "Quantity", default: 1 } },
          ],
        },
      },
    },
  ],
});
const onChange = vi.fn();
const screen = await render(
  <Editor schema={order} initial={{ lines: [{ item: "Tea", quantity: 2 }] }} onChange={onChange} />,
);
const first = screen.getByRole("group", { name: "Line 1", exact: true });
await expect.element(first.getByText("Line 1", { exact: true })).toBeVisible();
await expect.element(first.getByRole("textbox", { name: "Item" })).toHaveValue("Tea");
await commit(first.getByRole("spinbutton", { name: "Quantity" }), "3");
expect(onChange.mock.lastCall![0]).toEqual({ lines: [{ item: "Tea", quantity: 3 }] });
await userEvent.click(button(screen, "Add Line"));
expect(onChange.mock.lastCall![0]).toEqual({
  lines: [
    { item: "Tea", quantity: 3 },
    { item: null, quantity: 1 },
  ],
});
const second = screen.getByRole("group", { name: "Line 2", exact: true });
await expect.element(second.getByRole("textbox", { name: "Item" })).toHaveFocus();
A list of lists shows each inner list as a group of its own items
const table = schema({
  kind: "object",
  fields: [
    {
      key: "rows",
      schema: { kind: "list", label: "Row", item: { kind: "list", item: { kind: "number" } } },
    },
  ],
});
const onChange = vi.fn();
const screen = await render(<Editor schema={table} initial={{ rows: [[1, 2]] }} onChange={onChange} />);
const row = screen.getByRole("group", { name: "Row 1", exact: true });
await expect.element(row.getByRole("spinbutton", { name: "Row 1 2", exact: true })).toHaveValue("2");
await userEvent.click(row.getByRole("button", { name: "Add Row 1", exact: true }));
expect(onChange.mock.lastCall![0]).toEqual({ rows: [[1, 2, null]] });
await expect.element(row.getByRole("spinbutton", { name: "Row 1 3", exact: true })).toHaveFocus();
// A list added empty has no item to focus: its own button to add one is.
await userEvent.click(button(screen, "Add Row"));
await expect.element(button(screen, "Add Row 2")).toHaveFocus();

Reordering

Each item of a list of several has a handle: Space lifts the item, the arrow keys move it, and Space drops it
const onChange = vi.fn();
const screen = await render(
  <Editor schema={tagged} initial={{ tags: ["a", "b", "c"] }} onChange={onChange} />,
);
await userEvent.click(text(screen, "Tag 2"));
focus(button(screen, "Reorder Tag 2"));
await userEvent.keyboard(" ");
await expect.element(button(screen, "Reorder Tag 2")).toHaveAttribute("aria-pressed", "true");
await userEvent.keyboard("{ArrowUp}");
expect(onChange.mock.lastCall![0]).toEqual({ tags: ["b", "a", "c"] });
// Focus goes with the item, which is still lifted.
await expect.element(button(screen, "Reorder Tag 1")).toHaveFocus();
await expect.element(button(screen, "Reorder Tag 1")).toHaveAttribute("aria-pressed", "true");
await userEvent.keyboard("{ArrowDown}{ArrowDown}");
expect(onChange.mock.lastCall![0]).toEqual({ tags: ["a", "c", "b"] });
await userEvent.keyboard(" ");
await expect.element(button(screen, "Reorder Tag 3")).toHaveAttribute("aria-pressed", "false");
await expect.element(button(screen, "Reorder Tag 3")).toHaveFocus();
The handle says how it is used
const screen = await render(<Editor schema={tagged} initial={{ tags: ["a", "b"] }} />);
await expect
  .element(button(screen, "Reorder Tag 1"))
  .toHaveAccessibleDescription(
    "Press Space to lift, the arrow keys to move, Space to drop, Escape to put back.",
  );
The arrow keys do not move an item that has not been lifted, or past either end
const onChange = vi.fn();
const screen = await render(
  <Editor schema={tagged} initial={{ tags: ["a", "b"] }} onChange={onChange} />,
);
await userEvent.click(text(screen, "Tag 1"));
focus(button(screen, "Reorder Tag 1"));
await userEvent.keyboard("{ArrowDown}");
await userEvent.keyboard(" ");
await userEvent.keyboard("{ArrowUp}");
expect(onChange).not.toHaveBeenCalled();
Escape puts a lifted item back where it was, and goes no further
const onChange = vi.fn();
const onEscape = vi.fn();
const screen = await render(
  // A stand-in for a dialog, which would close on an Escape that reached it.
  // oxlint-disable-next-line jsx-a11y/no-static-element-interactions
  <div onKeyDown={(event) => event.key === "Escape" && onEscape()}>
    <Editor schema={tagged} initial={{ tags: ["a", "b", "c"] }} onChange={onChange} />
  </div>,
);
await userEvent.click(text(screen, "Tag 1"));
focus(button(screen, "Reorder Tag 1"));
await userEvent.keyboard(" {ArrowDown}{ArrowDown}");
expect(onChange.mock.lastCall![0]).toEqual({ tags: ["b", "c", "a"] });
await userEvent.keyboard("{Escape}");
expect(onEscape).not.toHaveBeenCalled();
expect(onChange.mock.lastCall![0]).toEqual({ tags: ["a", "b", "c"] });
await expect.element(button(screen, "Reorder Tag 1")).toHaveAttribute("aria-pressed", "false");
await expect.element(button(screen, "Reorder Tag 1")).toHaveFocus();
await expect.element(said(screen)).toHaveTextContent("Tag 1 put back at position 1 of 3.");
Lifting, each move and the drop are announced, with the item's place among how many
const screen = await render(<Editor schema={tagged} initial={{ tags: ["a", "b", "c"] }} />);
await userEvent.click(text(screen, "Tag 2"));
focus(button(screen, "Reorder Tag 2"));
await userEvent.keyboard(" ");
await expect.element(said(screen)).toHaveTextContent("Tag 2 lifted, at position 2 of 3.");
await userEvent.keyboard("{ArrowUp}");
await expect.element(said(screen)).toHaveTextContent("Tag 2 moved to position 1 of 3.");
await userEvent.keyboard(" ");
await expect.element(said(screen)).toHaveTextContent("Tag 1 dropped at position 1 of 3.");
A lifted item is dropped where it is when focus leaves its handle
const onChange = vi.fn();
const screen = await render(
  <Editor schema={tagged} initial={{ tags: ["a", "b"] }} onChange={onChange} />,
);
await userEvent.click(text(screen, "Tag 1"));
focus(button(screen, "Reorder Tag 1"));
await userEvent.keyboard(" {ArrowDown}");
await userEvent.click(text(screen, "Tag 1"));
await expect.element(button(screen, "Reorder Tag 2")).toHaveAttribute("aria-pressed", "false");
expect(onChange.mock.lastCall![0]).toEqual({ tags: ["b", "a"] });
A lifted item shows buttons that move it up and down, for a pointer, and stays lifted as they are used
const onChange = vi.fn();
const screen = await render(
  <Editor schema={tagged} initial={{ tags: ["a", "b", "c"] }} onChange={onChange} />,
);
expect(button(screen, "Move Tag 2 up").elements()).toHaveLength(0);
await userEvent.click(button(screen, "Reorder Tag 2"));
// Where its keys are read, whichever of the two is used.
await expect.element(button(screen, "Reorder Tag 2")).toHaveFocus();
await userEvent.click(button(screen, "Move Tag 2 up"));
expect(onChange.mock.lastCall![0]).toEqual({ tags: ["b", "a", "c"] });
await expect.element(button(screen, "Reorder Tag 1")).toHaveAttribute("aria-pressed", "true");
await expect.element(button(screen, "Move Tag 1 up")).toBeDisabled();
await userEvent.click(button(screen, "Move Tag 1 down"));
expect(onChange.mock.lastCall![0]).toEqual({ tags: ["a", "b", "c"] });
await userEvent.click(button(screen, "Reorder Tag 2"));
expect(button(screen, "Move Tag 2 up").elements()).toHaveLength(0);
What was typed into an item moves with it
const screen = await render(<Editor schema={counted} initial={{ counts: [1, 2, 3] }} />);
await commit(number(screen, "Count 1"), "lots");
focus(button(screen, "Reorder Count 1"));
await userEvent.keyboard(" {ArrowDown}");
await expect.element(number(screen, "Count 2")).toHaveValue("lots");
await expect.element(number(screen, "Count 2")).toHaveAttribute("aria-invalid", "true");
await expect.element(number(screen, "Count 1")).toHaveValue("2");
await expect.element(number(screen, "Count 1")).not.toHaveAttribute("aria-invalid");
A list of one item has no handle
const screen = await render(<Editor schema={tagged} initial={{ tags: ["a"] }} />);
expect(button(screen, "Reorder Tag 1").elements()).toHaveLength(0);
Items are reordered by dragging

Not yet written.

Variants

A variant is a choice of which, named by its label, followed by the chosen variant's fields as a group
const screen = await render(<Editor schema={customer} initial={ada} />);
const reach = screen.getByRole("combobox", { name: "Reach by" });
await expect.element(reach).toHaveTextContent("Email");
const fields = screen.getByRole("group", { name: "Reach by" });
await expect.element(fields.getByRole("textbox", { name: "Address" })).toHaveValue("ada@example.com");
await expect.element(fields.getByRole("textbox", { name: "Note" })).toHaveValue("Evenings");
// Only the chosen variant's fields are there, and the choice is not one of them.
expect(text(screen, "Number").elements()).toHaveLength(0);
expect(fields.getByRole("combobox").elements()).toHaveLength(0);
Until one is chosen a variant shows the choice alone, which says it is required
const screen = await render(<Editor schema={customer} initial={blank(customer)} />);
const reach = screen.getByRole("combobox", { name: "Reach by" });
await expect.element(reach).toHaveAttribute("aria-required", "true");
await expect.element(reach).toHaveTextContent("");
expect(screen.getByRole("group").elements()).toHaveLength(0);
expect(screen.getByRole("textbox").elements()).toHaveLength(1);
Choosing another variant shows its fields, keeps the values of the fields the two share, and starts the rest blank: one change
const onChange = vi.fn();
const screen = await render(<Editor schema={customer} initial={ada} onChange={onChange} />);
await userEvent.click(screen.getByRole("combobox", { name: "Reach by" }));
await userEvent.click(screen.getByRole("option", { name: "Phone" }));
expect(onChange).toHaveBeenCalledTimes(1);
// The note is a text in both; a phone has no address, and its hour starts at its default.
expect(onChange.mock.lastCall![0]).toEqual({
  name: "Ada",
  reach: { by: "phone", number: null, note: "Evenings", after: 9 },
});
await expect.element(text(screen, "Number")).toHaveValue("");
await expect.element(text(screen, "Note")).toHaveValue("Evenings");
expect(text(screen, "Address").elements()).toHaveLength(0);
A variant with no fields of its own shows the choice alone
const onChange = vi.fn();
const screen = await render(<Editor schema={customer} initial={ada} onChange={onChange} />);
await userEvent.click(screen.getByRole("combobox", { name: "Reach by" }));
await userEvent.click(screen.getByRole("option", { name: "Do not" }));
expect(onChange.mock.lastCall![0]).toEqual({ name: "Ada", reach: { by: "none" } });
expect(screen.getByRole("group").elements()).toHaveLength(0);
A field of the chosen variant is edited like any other, and only it changes
const onChange = vi.fn();
const screen = await render(<Editor schema={customer} initial={ada} onChange={onChange} />);
await commit(text(screen, "Address"), "ada@example.org");
expect(onChange.mock.lastCall![0]).toEqual({
  name: "Ada",
  reach: { by: "email", address: "ada@example.org", note: "Evenings" },
});
An optional variant can be put back to none chosen; one that must be chosen offers no way back
const onChange = vi.fn();
const screen = await render(<Editor schema={customer} initial={ada} onChange={onChange} />);
await userEvent.click(screen.getByRole("combobox", { name: "Reach by" }));
// The list of variants is named as its button is.
await expect.element(screen.getByRole("listbox", { name: "Reach by" })).toBeVisible();
await expect.element(screen.getByRole("option", { name: "Email" })).toBeVisible();
expect(screen.getByRole("option", { name: "Not set" }).elements()).toHaveLength(0);
await userEvent.keyboard("{Escape}");
await screen.unmount();

const optional = schema({
  kind: "object",
  fields: [{ key: "reach", schema: { ...reachBy, optional: true } }],
});
const other = await render(
  <Editor schema={optional} initial={{ reach: ada.reach }} onChange={onChange} />,
);
const reach = other.getByRole("combobox", { name: "Reach by" });
await expect.element(reach).not.toHaveAttribute("aria-required");
await userEvent.click(reach);
await userEvent.click(other.getByRole("option", { name: "Not set" }));
expect(onChange.mock.lastCall![0]).toEqual({ reach: null });
await expect.element(reach).toHaveTextContent("Not set");
expect(other.getByRole("textbox").elements()).toHaveLength(0);
A variant's description is shown with its choice, and the fewer of its fields are marked
const screen = await render(<Editor schema={customer} initial={ada} />);
await expect
  .element(screen.getByRole("combobox", { name: "Reach by" }))
  .toHaveAccessibleDescription("How to get in touch.");
// Of an email's two fields one is optional; beside it, Name and the choice must be given.
const marks = [...document.querySelectorAll("[data-mark]")].map(
  (mark) => mark.parentElement!.textContent,
);
expect(marks).toEqual(["Noteoptional"]);
await screen.unmount();

// The choice is itself asked for: with it, one field here must be given and one need not.
const nicknamed = schema({
  kind: "object",
  fields: [
    { key: "nickname", schema: { kind: "text", label: "Nickname", optional: true } },
    { key: "reach", schema: reachBy },
  ],
});
await render(<Editor schema={nicknamed} initial={blank(nicknamed)} />);
expect(document.querySelector("[data-mark]")?.parentElement?.textContent).toBe("Nicknameoptional");
A problem with which variant it is shows beside the choice, once the choice has been left
const screen = await render(<Editor schema={customer} initial={blank(customer)} />);
const reach = screen.getByRole("combobox", { name: "Reach by" });
await userEvent.click(reach);
await userEvent.keyboard("{Escape}");
await expect.element(reach).not.toHaveAttribute("aria-invalid");
await userEvent.click(text(screen, "Name"));
await expect.element(reach).toHaveAttribute("aria-invalid", "true");
await expect.element(reach).toHaveAccessibleDescription("How to get in touch. Required.");
await expect.element(said(screen)).toHaveTextContent("Reach by: Required.");
await screen.unmount();

// A value that says it is a variant there is none of.
const fax = await render(
  <Editor schema={customer} initial={{ name: "Ada", reach: { by: "fax" } }} showAllIssues />,
);
await expect
  .element(fax.getByRole("combobox", { name: "Reach by" }))
  .toHaveAccessibleDescription("How to get in touch. Not one of the options.");
A problem in a variant's field shows beside that field, and is announced with its name
const screen = await render(<Editor schema={customer} initial={ada} />);
await commit(text(screen, "Address"), "ada at example");
await userEvent.click(text(screen, "Name"));
await expect.element(text(screen, "Address")).toHaveAccessibleDescription("Not in the expected form.");
await expect.element(said(screen)).toHaveTextContent("Address: Not in the expected form.");
await expect
  .element(screen.getByRole("combobox", { name: "Reach by" }))
  .not.toHaveAttribute("aria-invalid");
What was typed into one variant's field, and its problem, are not another's, nor its own when it is chosen again
const screen = await render(<Editor schema={customer} initial={{ ...ada, reach: blank(phone) }} />);
await commit(number(screen, "Not before"), "noon");
await expect.element(number(screen, "Not before")).toHaveAttribute("aria-invalid", "true");
const reach = screen.getByRole("combobox", { name: "Reach by" });
await userEvent.click(reach);
await userEvent.click(screen.getByRole("option", { name: "Email" }));
await expect.element(text(screen, "Address")).not.toHaveAttribute("aria-invalid");
await userEvent.click(reach);
await userEvent.click(screen.getByRole("option", { name: "Phone" }));
await expect.element(number(screen, "Not before")).toHaveValue("9");
await expect.element(number(screen, "Not before")).not.toHaveAttribute("aria-invalid");
A read-only variant shows which it is and its fields, and neither can be changed
const onChange = vi.fn();
const locked = schema({
  kind: "object",
  fields: [{ key: "reach", schema: { ...reachBy, readOnly: true } }],
});
const screen = await render(
  <Editor schema={locked} initial={{ reach: ada.reach }} onChange={onChange} />,
);
const reach = screen.getByRole("combobox", { name: "Reach by" });
await expect.element(reach).toHaveAttribute("aria-readonly", "true");
await userEvent.click(reach);
await expect.element(reach).toHaveAttribute("aria-expanded", "false");
await expect.element(text(screen, "Address")).toHaveAttribute("readonly");
expect(onChange).not.toHaveBeenCalled();
In a list of variants each item shows which it is beside its name, and its own fields
const screen = await render(<Editor schema={blocks} initial={article} />);
const first = screen.getByRole("group", { name: "Block 1", exact: true });
await expect.element(first.getByRole("combobox", { name: "Block 1" })).toHaveTextContent("Heading");
await expect.element(first.getByRole("textbox", { name: "Text" })).toHaveValue("Tea");
await expect.element(first.getByRole("spinbutton", { name: "Level" })).toHaveValue("1");
const second = screen.getByRole("group", { name: "Block 2", exact: true });
await expect.element(second.getByRole("combobox", { name: "Block 2" })).toHaveTextContent("Paragraph");
expect(second.getByRole("spinbutton").elements()).toHaveLength(0);
const third = screen.getByRole("group", { name: "Block 3", exact: true });
await expect.element(third.getByRole("combobox", { name: "Block 3" })).toHaveTextContent("Rule");
expect(third.getByRole("textbox").elements()).toHaveLength(0);
An item added to a list of variants starts as the variant its schema states, or as none chosen, with focus on its choice
const onChange = vi.fn();
const screen = await render(<Editor schema={blocks} initial={{ blocks: [] }} onChange={onChange} />);
await userEvent.click(button(screen, "Add Block"));
expect(onChange.mock.lastCall![0]).toEqual({ blocks: [{ type: "paragraph", text: null }] });
await expect.element(screen.getByRole("combobox", { name: "Block 1" })).toHaveFocus();
await screen.unmount();

const undecided = schema({
  kind: "object",
  fields: [
    { key: "blocks", schema: { kind: "list", label: "Block", item: { ...block, default: "?" } } },
  ],
});
const other = await render(<Editor schema={undecided} initial={{ blocks: [] }} onChange={onChange} />);
await userEvent.click(button(other, "Add Block"));
expect(onChange.mock.lastCall![0]).toEqual({ blocks: [null] });
await expect.element(other.getByRole("combobox", { name: "Block 1" })).toHaveFocus();
An item of a list of variants changes which it is without disturbing the others
const onChange = vi.fn();
const screen = await render(<Editor schema={blocks} initial={article} onChange={onChange} />);
await userEvent.click(screen.getByRole("combobox", { name: "Block 2" }));
await userEvent.click(screen.getByRole("option", { name: "Heading" }));
expect(onChange).toHaveBeenCalledTimes(1);
const next = onChange.mock.lastCall![0] as typeof article;
expect(next.blocks[1]).toEqual({ type: "heading", text: "Leaves, water, time.", level: 2 });
expect(next.blocks[0]).toBe(article.blocks[0]);
expect(next.blocks[2]).toBe(article.blocks[2]);
Items of a list of variants are reordered and removed like any others, each keeping which it is and what it holds
const onChange = vi.fn();
const screen = await render(<Editor schema={blocks} initial={article} onChange={onChange} />);
await userEvent.click(text(screen, "Text").first());
focus(button(screen, "Reorder Block 1"));
await userEvent.keyboard(" {ArrowDown} ");
expect(onChange.mock.lastCall![0]).toEqual({
  blocks: [article.blocks[1], article.blocks[0], article.blocks[2]],
});
await expect.element(screen.getByRole("combobox", { name: "Block 2" })).toHaveTextContent("Heading");
await expect.element(said(screen)).toHaveTextContent("Block 2 dropped at position 2 of 3.");
await userEvent.click(button(screen, "Remove Block 1"));
expect(onChange.mock.lastCall![0]).toEqual({ blocks: [article.blocks[0], article.blocks[2]] });
await expect.element(screen.getByRole("combobox", { name: "Block 1" })).toHaveTextContent("Heading");
await expect.element(screen.getByRole("combobox", { name: "Block 1" })).toHaveFocus();
Has no accessibility violations with a variant, a list of variants, and their problems shown
const screen = await render(
  <Editor
    schema={customer}
    initial={{ name: null, reach: { by: "phone", number: null } }}
    showAllIssues
  />,
);
await expectAccessible(screen.container);
await screen.unmount();
const unchosen = await render(<Editor schema={customer} initial={blank(customer)} showAllIssues />);
await expectAccessible(unchosen.container);
await unchosen.unmount();
const listed = await render(
  <Editor
    schema={blocks}
    initial={{ blocks: [...article.blocks, null, { type: "heading" }] }}
    showAllIssues
  />,
);
await expectAccessible(listed.container);

References

A field whose schema is given by reference is edited as what it refers to, under the reference's own label
const amount = schema({ kind: "number", label: "Amount", minimum: 0, precision: 0 });
const order = schema({
  kind: "object",
  fields: [
    { key: "paid", schema: { kind: "lazy", schema: () => amount } },
    { key: "owed", schema: { kind: "lazy", label: "Owed", optional: true, schema: () => amount } },
  ],
});
const onChange = vi.fn();
const screen = await render(
  <Editor schema={order} initial={{ paid: 10, owed: null }} onChange={onChange} />,
);
await expect.element(number(screen, "Amount")).toHaveValue("10");
await expect.element(number(screen, "Amount")).toBeRequired();
await expect.element(number(screen, "Owed")).not.toBeRequired();
await commit(number(screen, "Owed"), "-5");
expect(onChange.mock.lastCall![0]).toEqual({ paid: 10, owed: 0 });
A record that contains itself is shown as far as its value goes: where it has none, a button adds it
const onChange = vi.fn();
const screen = await render(<Editor schema={category} initial={blank(category)} onChange={onChange} />);
expect(screen.getByRole("textbox").elements()).toHaveLength(1);
await userEvent.click(button(screen, "Add Parent"));
expect(onChange.mock.lastCall![0]).toEqual({ name: null, parent: { name: null, parent: null } });
// Focus goes into what was added, which in turn has a parent to add.
const parent = screen.getByRole("group", { name: "Parent" }).first();
await expect.element(parent.getByRole("textbox", { name: "Name" }).first()).toHaveFocus();
expect(screen.getByRole("textbox").elements()).toHaveLength(2);
await expect.element(button(screen, "Add Parent")).toBeVisible();
await userEvent.click(button(screen, "Add Parent"));
expect(screen.getByRole("textbox").elements()).toHaveLength(3);
A record that must hold itself and does not is reported as missing, with nothing to remove; an optional one can be removed again
const screen = await render(<Editor schema={category} initial={blank(category)} showAllIssues />);
const parent = screen.getByRole("group", { name: "Parent" });
await expect.element(parent.getByText("Required.", { exact: true })).toBeVisible();
await userEvent.click(button(screen, "Add Parent"));
expect(button(screen, "Remove Parent").elements()).toHaveLength(0);
await screen.unmount();

const optional = schema({
  kind: "object",
  fields: [
    { key: "name", schema: { kind: "text", label: "Name" } },
    { key: "parent", schema: lazy((): Schema => optional, { label: "Parent", optional: true }) },
  ],
});
const onChange = vi.fn();
const other = await render(
  <Editor
    schema={optional}
    initial={{ name: "Tea", parent: { name: "Drinks", parent: null } }}
    onChange={onChange}
  />,
);
await userEvent.click(button(other, "Remove Parent"));
expect(onChange.mock.lastCall![0]).toEqual({ name: "Tea", parent: null });
await expect.element(button(other, "Add Parent")).toHaveFocus();
A list of records of the list's own kind nests as deep as its value, and each level is added to on its own
const onChange = vi.fn();
const screen = await render(
  <Editor
    schema={folder}
    initial={{ name: "Home", folders: [{ name: "Work", folders: [] }] }}
    onChange={onChange}
  />,
);
const work = screen.getByRole("group", { name: "Folder 1", exact: true });
await expect.element(work.getByRole("textbox", { name: "Name" })).toHaveValue("Work");
await userEvent.click(work.getByRole("button", { name: "Add Folder" }));
expect(onChange.mock.lastCall![0]).toEqual({
  name: "Home",
  folders: [{ name: "Work", folders: [{ name: null, folders: [] }] }],
});
expect(screen.getByRole("textbox", { name: "Name" }).elements()).toHaveLength(3);
expect(screen.getByRole("button", { name: "Add Folder" }).elements()).toHaveLength(3);
Has no accessibility violations with a record within itself, added and not
const screen = await render(
  <Editor
    schema={category}
    initial={{ name: "Tea", parent: { name: null, parent: null } }}
    showAllIssues
  />,
);
await expectAccessible(screen.container);

Problems

A problem is not shown while its field has not been visited
const screen = await render(<Editor initial={blank(product)} />);
expect(screen.getByText("Required.").elements()).toHaveLength(0);
A problem is shown beside its field once the field is left, and belongs to it
const screen = await render(<Editor initial={blank(product)} />);
await userEvent.click(text(screen, "Name"));
await userEvent.click(number(screen, "Price"));
await expect.element(screen.getByText("Required.", { exact: true })).toBeVisible();
await expect.element(text(screen, "Name")).toHaveAttribute("aria-invalid", "true");
await expect.element(text(screen, "Name")).toHaveAccessibleDescription("Required.");
A problem is announced as it appears, with the field it belongs to
const screen = await render(<Editor initial={blank(product)} />);
await userEvent.click(text(screen, "Name"));
await userEvent.click(number(screen, "Price"));
await expect.element(said(screen)).toHaveTextContent("Name: Required.");
await commit(number(screen, "Price"), "a lot");
await expect.element(said(screen)).toHaveTextContent("Price: Not a valid value.");
A problem appears when the pointer is released, not while it is pressed, so nothing moves under it
const screen = await render(<Editor initial={blank(product)} />);
await userEvent.click(text(screen, "Name"));
const price = number(screen, "Price");
let whilePressed: unknown;
window.addEventListener(
  "pointerup",
  () => {
    whilePressed = {
      focusMoved: document.activeElement === price.element(),
      shown: screen.getByText("Required.", { exact: true }).elements().length,
    };
  },
  { capture: true, once: true },
);
await commands.pressDragRelease(price.selector, price.selector);
expect(whilePressed).toEqual({ focusMoved: true, shown: 0 });
await expect.element(screen.getByText("Required.", { exact: true })).toBeVisible();
Moving into a choice's own list is not leaving the field
const screen = await render(<Editor initial={blank(product)} />);
await userEvent.click(screen.getByRole("combobox", { name: "Status" }));
await expect.element(screen.getByRole("option", { name: "Draft" })).toBeVisible();
await userEvent.keyboard("{ArrowDown}");
expect(screen.getByText("Required.", { exact: true }).elements()).toHaveLength(0);
await userEvent.keyboard("{Escape}{Tab}");
await expect.element(screen.getByText("Required.", { exact: true })).toBeVisible();
Clearing a field that must be given is reported as required, and the record holds no value for it
const onChange = vi.fn();
const screen = await render(<Editor onChange={onChange} />);
await commit(text(screen, "Name"), "");
expect((onChange.mock.lastCall![0] as typeof teapot).name).toBeNull();
await userEvent.click(number(screen, "Price"));
await expect.element(text(screen, "Name")).toHaveAccessibleDescription("Required.");
A value that breaks a limit is reported in words, not only marked
const screen = await render(<Editor />);
await commit(text(screen, "Name"), "A name that runs well past twenty characters");
await userEvent.click(number(screen, "Price"));
await expect.element(text(screen, "Name")).toHaveAccessibleDescription("At most 20 characters.");
await commit(text(screen, "Tag 1"), "Not OK");
await userEvent.click(number(screen, "Price"));
await expect.element(text(screen, "Tag 1")).toHaveAccessibleDescription("Not in the expected form.");
The words name the limit, written as the editor's locale writes it
const event = schema({
  kind: "object",
  fields: [
    // Of two problems with one value, the first is the one shown.
    { key: "code", schema: { kind: "text", label: "Code", minLength: 3, pattern: "[A-Z]+" } },
    { key: "from", schema: { kind: "date", label: "From", minimum: "2026-10-01" } },
    {
      key: "fee",
      schema: { kind: "number", label: "Fee", maximum: 1500, format: "currency", currency: "EUR" },
    },
    { key: "guests", schema: { kind: "list", label: "Guest", minItems: 1, item: { kind: "text" } } },
  ],
});
const early = { code: "ab", from: "2026-09-01", fee: 2000, guests: [] };
const screen = await render(<Editor schema={event} initial={early} showAllIssues />);
await expect.element(text(screen, "Code")).toHaveAccessibleDescription("At least 3 characters.");
await expect.element(text(screen, "From")).toHaveAccessibleDescription("No earlier than Oct 1, 2026.");
await expect.element(number(screen, "Fee")).toHaveAccessibleDescription("No more than €1,500.00.");
await expect
  .element(screen.getByRole("group", { name: "Guest" }))
  .toHaveAccessibleDescription("At least 1 item.");
await screen.unmount();

const german = await render(<Editor schema={event} initial={early} locale="de-DE" showAllIssues />);
await expect.element(text(german, "From")).toHaveAccessibleDescription("No earlier than 01.10.2026.");
await expect.element(number(german, "Fee")).toHaveAccessibleDescription("No more than 1.500,00 €.");
Text that cannot be read as a value of the field's kind is reported, and the record is unchanged
const onChange = vi.fn();
const screen = await render(<Editor onChange={onChange} />);
await commit(number(screen, "Price"), "a lot");
await expect.element(number(screen, "Price")).toHaveAccessibleDescription("Not a valid value.");
expect(onChange).not.toHaveBeenCalled();
await commit(number(screen, "Price"), "52");
await expect.element(number(screen, "Price")).not.toHaveAttribute("aria-invalid");
await expect.element(number(screen, "Price")).not.toHaveAccessibleDescription();
Correcting the value removes the problem
const screen = await render(<Editor initial={{ ...teapot, tags: ["Not OK"] }} showAllIssues />);
await expect.element(text(screen, "Tag 1")).toHaveAttribute("aria-invalid", "true");
await commit(text(screen, "Tag 1"), "now-ok");
await expect.element(text(screen, "Tag 1")).not.toHaveAttribute("aria-invalid");
Every problem can be shown at once, as after an attempt to submit, and then none is announced
const screen = await render(<Editor initial={blank(product)} showAllIssues />);
expect(screen.getByText("Required.", { exact: true }).elements().length).toBeGreaterThanOrEqual(5);
await expect.element(said(screen)).toHaveTextContent("");

Computed and locked values

A value computed from others follows them: an edit comes with every value it computes, as one change
const onChange = vi.fn();
const screen = await render(<Order onChange={onChange} />);
await commit(number(screen, "Quantity"), "4");
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange.mock.lastCall![0]).toEqual({ ...placed, quantity: 4, total: 40, doubled: 80 });
await expect.element(number(screen, "Total")).toHaveValue("40");
await expect.element(number(screen, "Doubled")).toHaveValue("80");
A computed value is marked, and says what it is computed from, which assistive technology hears with it
const screen = await render(<Order />);
const total = number(screen, "Total");
await expect.element(total).toHaveAccessibleDescription("Computed as price * quantity.");
expect(fieldOf(total).hasAttribute("data-computed")).toBe(true);
expect(fieldOf(number(screen, "Price")).hasAttribute("data-computed")).toBe(false);
expect(lockMarks()).toEqual(["Total computed", "Doubled computed"]);
Typing over a computed value freezes it at what was typed: it says so, and no longer follows
const onChange = vi.fn();
const screen = await render(<Order onChange={onChange} />);
const total = number(screen, "Total");
await commit(total, "100");
expect(onChange.mock.lastCall![0]).toEqual({ ...placed, total: 100, doubled: 200 });
expect(fieldOf(total).getAttribute("data-lock")).toBe("frozen");
await expect
  .element(total)
  .toHaveAccessibleDescription("Frozen: kept as it is. Computed as price * quantity.");
expect(lockMarks()).toContain("Total frozen");
await commit(number(screen, "Quantity"), "5");
await expect.element(total).toHaveValue("100");
Where the owner works with locks, each value has a menu, asked for by the keyboard: Hold value for any, and Freeze too for one that is computed
const screen = await render(<Order onLocksChange={() => {}} />);
await askMenu(screen, number(screen, "Price"));
await expect.element(screen.getByRole("menu", { name: "Price" })).toBeVisible();
expect(items(screen)).toEqual(["Hold value"]);
await userEvent.keyboard("{Escape}");
await expect.element(number(screen, "Price")).toHaveFocus();
await expect.poll(() => screen.getByRole("menu").elements()).toEqual([]);
await askMenu(screen, number(screen, "Total"));
await expect.element(screen.getByRole("menuitem", { name: "Hold value" })).toHaveFocus();
expect(items(screen)).toEqual(["Hold value", "Freeze"]);
Where the owner does not, only a computed value has the menu: the rest keep the browser's own, with its cut, copy, paste and spelling
const screen = await render(<Order />);
const asked = new MouseEvent("contextmenu", { bubbles: true, cancelable: true, button: 2 });
number(screen, "Price").element().dispatchEvent(asked);
expect(asked.defaultPrevented).toBe(false);
await expect.poll(() => screen.getByRole("menu").elements()).toEqual([]);
await askMenu(screen, number(screen, "Total"));
await expect.element(screen.getByRole("menu", { name: "Total" })).toBeVisible();
expect(items(screen)).toEqual(["Hold value", "Freeze"]);
A secondary click on a value opens its menuChromium, Firefox, WebKit

Not yet written.

A finger that rests on a value opens its menuChromium · touch, WebKit · touch

Not yet written.

A held value cannot be changed, is marked, and says why
const onLocksChange = vi.fn();
const screen = await render(<Order onLocksChange={onLocksChange} />);
const price = number(screen, "Price");
await choose(screen, price, "Hold value");
expect(onLocksChange.mock.lastCall![0]).toEqual(new Map([["price", "held"]]));
await expect.element(price).toHaveAttribute("readonly");
await expect.element(price).toHaveAccessibleDescription("Held: it may not change.");
expect(fieldOf(price).getAttribute("data-lock")).toBe("held");
expect(lockMarks()).toContain("Price held");
A change that would move a held computed value is not made: the field keeps what was typed, marked, with why, said aloud
const onChange = vi.fn();
const screen = await render(<Order onChange={onChange} />);
await choose(screen, number(screen, "Total"), "Hold value");
const quantity = number(screen, "Quantity");
await commit(quantity, "4");
expect(onChange).not.toHaveBeenCalled();
await expect.element(quantity).toHaveValue("4");
await expect.element(quantity).toHaveAttribute("aria-invalid", "true");
await expect.element(quantity).toHaveAccessibleDescription("Total is held; this would change it.");
await expect.element(said(screen)).toHaveTextContent("Quantity: Total is held; this would change it.");
await expect.element(number(screen, "Total")).toHaveValue("30");
A change that leaves a held value as it was is made; and a frozen value between stops the change on its way
const onChange = vi.fn();
const screen = await render(<Order onChange={onChange} defaultLocks={new Map([["total", "held"]])} />);
await commit(text(screen, "Note"), "Fragile");
expect(onChange.mock.lastCall![0]).toEqual({ ...placed, note: "Fragile" });
await screen.unmount();
const between = await render(
  <Order
    onChange={onChange}
    defaultLocks={
      new Map([
        ["total", "frozen"],
        ["doubled", "held"],
      ])
    }
  />,
);
await commit(number(between, "Quantity"), "4");
expect(onChange.mock.lastCall![0]).toEqual({ ...placed, quantity: 4 });
Escape puts a refused change aside and shows the value again; Enter asks for it again, which a lock taken off since lets through
const onChange = vi.fn();
const screen = await render(<Order onChange={onChange} />);
const quantity = number(screen, "Quantity");
await choose(screen, number(screen, "Total"), "Hold value");
await commit(quantity, "4");
await userEvent.keyboard("{Escape}");
await expect.element(quantity).toHaveValue("3");
await expect.element(quantity).not.toHaveAttribute("aria-invalid");

await commit(quantity, "4");
await choose(screen, number(screen, "Total"), "Unlock");
expect(onChange).not.toHaveBeenCalled();
await userEvent.click(quantity);
await userEvent.keyboard("{Enter}");
expect(onChange.mock.lastCall![0]).toEqual({ ...placed, quantity: 4, total: 40, doubled: 80 });
await expect.element(quantity).not.toHaveAttribute("aria-invalid");
Freeze keeps a computed value as it is; Unfreeze computes it again, as one change
const onChange = vi.fn();
const screen = await render(<Order onChange={onChange} />);
const total = number(screen, "Total");
await choose(screen, total, "Freeze");
expect(onChange).not.toHaveBeenCalled();
await commit(number(screen, "Quantity"), "4");
expect(onChange.mock.lastCall![0]).toEqual({ ...placed, quantity: 4 });
await askMenu(screen, total);
expect(items(screen)).toEqual(["Hold value", "Unfreeze"]);
await userEvent.click(screen.getByRole("menuitem", { name: "Unfreeze" }));
expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange.mock.lastCall![0]).toEqual({ ...placed, quantity: 4, total: 40, doubled: 80 });
expect(fieldOf(total).hasAttribute("data-lock")).toBe(false);
Unfreezing that would move a held value is refused, and says so
const onChange = vi.fn();
const screen = await render(
  <Order
    onChange={onChange}
    initial={{ ...placed, total: 100, doubled: 200 }}
    defaultLocks={
      new Map([
        ["total", "frozen"],
        ["doubled", "held"],
      ])
    }
  />,
);
await choose(screen, number(screen, "Total"), "Unfreeze");
expect(onChange).not.toHaveBeenCalled();
await expect
  .element(number(screen, "Total"))
  .toHaveAccessibleDescription(
    "Frozen: kept as it is. Computed as price * quantity. Doubled is held; this would change it.",
  );
await expect.element(said(screen)).toHaveTextContent("Total: Doubled is held; this would change it.");
A formula that cannot be used is a problem of its field from the start, with the part it is about marked
const broken = schema({
  kind: "object",
  fields: [
    { key: "price", schema: { kind: "number", label: "Price" } },
    { key: "total", schema: { kind: "number", label: "Total", formula: "price * cost" } },
  ],
});
const screen = await render(<Editor schema={broken} initial={{ price: 1, total: 2 }} />);
const total = number(screen, "Total");
await expect.element(total).toHaveAccessibleDescription("There is no field “cost”.");
await expect.element(total).toHaveAttribute("aria-invalid", "true");
const marked = document.querySelector("[data-formula-problem] span")!;
expect(marked.textContent).toBe("cost");
// It is not computed, and is typed in like any other value.
expect(fieldOf(total).hasAttribute("data-computed")).toBe(false);
Values computed within the records of a list follow them too, and are not typed over
const onChange = vi.fn();
const lined = schema({
  kind: "object",
  fields: [
    {
      key: "lines",
      schema: {
        kind: "list",
        label: "Line",
        item: {
          kind: "object",
          fields: [
            { key: "price", schema: { kind: "number", label: "Price" } },
            { key: "amount", schema: { kind: "number", label: "Amount", formula: "price * 2" } },
          ],
        },
      },
    },
    { key: "sum", schema: { kind: "number", label: "Sum", formula: "sum(lines.amount)" } },
  ],
});
const screen = await render(
  <Editor schema={lined} initial={{ lines: [{ price: 1, amount: 2 }], sum: 2 }} onChange={onChange} />,
);
await commit(number(screen, "Price"), "5");
expect(onChange.mock.lastCall![0]).toEqual({ lines: [{ price: 5, amount: 10 }], sum: 10 });
await expect.element(number(screen, "Amount")).toHaveAttribute("readonly");
await expect.element(number(screen, "Amount")).toHaveAccessibleDescription("Computed as price * 2.");
An editor that is read-only offers no menu, and leaves the browser's own
const screen = await render(<Order readOnly />);
const asked = new MouseEvent("contextmenu", { bubbles: true, cancelable: true });
number(screen, "Total").element().dispatchEvent(asked);
expect(asked.defaultPrevented).toBe(false);
A value within a record of the record, or a list, can be locked

Not yet written.

Has no accessibility violations with computed, frozen, held and refused values
const screen = await render(
  <Order
    defaultLocks={
      new Map([
        ["price", "held"],
        ["total", "frozen"],
        ["doubled", "held"],
      ])
    }
  />,
);
await commit(number(screen, "Total"), "7");
await expect.element(number(screen, "Total")).toHaveAttribute("aria-invalid", "true");
await expectAccessible(screen.container);

Language

The words for a problem can be given in another language
const screen = await render(
  <Editor initial={blank(product)} showAllIssues messages={{ required: "必須項目です。" }} />,
);
await expect.element(text(screen, "Name")).toHaveAccessibleDescription("必須項目です。");
Words that name a limit are made from it, in the order the language wants
const screen = await render(
  <Editor
    initial={{ ...teapot, name: "A name that runs well past twenty characters" }}
    showAllIssues
    messages={{
      maxLength: (_, field) =>
        `${field.kind === "text" ? field.maxLength : ""}文字以内で入力してください。`,
    }}
  />,
);
await expect
  .element(text(screen, "Name"))
  .toHaveAccessibleDescription("20文字以内で入力してください。");
Everything else the editor says can be too, with its parts in that language's order
const tags = schema({
  kind: "object",
  fields: [{ key: "tags", schema: { kind: "list", label: "タグ", item: { kind: "text" } } }],
});
const screen = await render(
  <Editor
    schema={tags}
    initial={{ tags: ["a", "b", "c"] }}
    locale="ja-JP"
    messages={{
      item: (label, position) => `${position}番目の${label}`,
      add: (label) => `${label}を追加`,
      remove: (name) => `${name}を削除`,
      removed: (name) => `${name}を削除しました。`,
      reorder: (name) => `${name}を並べ替え`,
      moved: (name, position, count) => `${name}を${count}件中${position}番目に移動しました。`,
    }}
  />,
);
await expect.element(text(screen, "2番目のタグ")).toHaveValue("b");
await expect.element(button(screen, "タグを追加")).toBeVisible();
await userEvent.click(text(screen, "2番目のタグ"));
focus(button(screen, "2番目のタグを並べ替え"));
await userEvent.keyboard(" {ArrowUp} ");
await expect.element(text(screen, "1番目のタグ")).toHaveValue("b");
await userEvent.click(button(screen, "3番目のタグを削除"));
await expect.element(said(screen)).toHaveTextContent("3番目のタグを削除しました。");
The words for computed and locked values, and for a refusal, can be given in another language
const screen = await render(
  <Order
    locale="ja-JP"
    messages={{
      hold: "値を固定",
      computed: (formula) => `${formula} で計算します。`,
      held: "固定されています。",
      refused: (label) => `${label}は固定されているため変更できません。`,
      problem: (label, problem) => `${label}:${problem}`,
    }}
  />,
);
await expect
  .element(number(screen, "Total"))
  .toHaveAccessibleDescription("price * quantity で計算します。");
await choose(screen, number(screen, "Total"), "値を固定");
await expect
  .element(number(screen, "Total"))
  .toHaveAccessibleDescription("固定されています。 price * quantity で計算します。");
await commit(number(screen, "Price"), "11");
await expect
  .element(said(screen))
  .toHaveTextContent("Price:Totalは固定されているため変更できません。");
A move is announced in the language's own order
const screen = await render(
  <Editor
    schema={tagged}
    initial={{ tags: ["a", "b", "c"] }}
    messages={{
      moved: (name, position, count) => `${name}を${count}件中${position}番目に移動しました。`,
    }}
  />,
);
await userEvent.click(text(screen, "Tag 2"));
focus(button(screen, "Reorder Tag 2"));
await userEvent.keyboard(" {ArrowUp}");
await expect.element(said(screen)).toHaveTextContent("Tag 2を3件中1番目に移動しました。");

Layout

Where the editor is wide, each label sits beside its controlChromium, Firefox, WebKit

Not yet written.

Where the editor itself is narrow, each label sits above its control, however wide the screenChromium, Firefox, WebKit

Not yet written.

On a narrow screen, each label sits above its control
const screen = await render(<Editor />);
const label = screen.getByText("Name", { exact: true }).element().getBoundingClientRect();
const control = text(screen, "Name").element().getBoundingClientRect();
expect(label.bottom).toBeLessThanOrEqual(control.top);
Above its control, a label stands further from the field before it than from its own
const screen = await render(
  <div style={{ width: 320 }}>
    <Editor />
  </div>,
);
const label = screen.container.querySelectorAll("label")[1]!;
const before = label.previousElementSibling!.getBoundingClientRect();
const own = document.getElementById(label.htmlFor)!.getBoundingClientRect();
const { top, bottom } = label.getBoundingClientRect();
expect(top - before.bottom).toBeGreaterThan(own.top - bottom);
Under a finger, each label sits above its control, however wide the editorChromium · touch, WebKit · touch

Not yet written.

A long label wraps, and leaves its control most of the widthChromium, Firefox, WebKit

Not yet written.

Beside a control of several lines, the label sits on its first lineChromium, Firefox, WebKit

Not yet written.

API

ref gives focusFirstIssue(), which moves focus to the first field with a problem and says whether there was one
const handle = createRef<ObjectEditorHandle>();
const screen = await render(
  <Editor ref={handle} initial={{ ...teapot, price: null, tags: ["ok", "Not OK"] }} />,
);
await userEvent.click(text(screen, "Name"));
expect(handle.current!.focusFirstIssue()).toBe(true);
await expect.element(number(screen, "Price")).toHaveFocus();
await commit(number(screen, "Price"), "12");
expect(handle.current!.focusFirstIssue()).toBe(true);
await expect.element(text(screen, "Tag 2")).toHaveFocus();
await commit(text(screen, "Tag 2"), "fine");
expect(handle.current!.focusFirstIssue()).toBe(false);
focusFirstIssue() counts text that cannot be read as a value, which the record does not hold
const handle = createRef<ObjectEditorHandle>();
const screen = await render(<Editor ref={handle} />);
await commit(number(screen, "Price"), "a lot");
await userEvent.click(text(screen, "Name"));
expect(handle.current!.focusFirstIssue()).toBe(true);
await expect.element(number(screen, "Price")).toHaveFocus();
A value the editor did not ask for through onValueChange is another record: what was visited and typed is forgotten
function Records() {
  const [value, setValue] = useState<unknown>(blank(product));
  return (
    <>
      <ObjectEditor schema={product} locale="en-US" value={value} onValueChange={setValue} />
      <button type="button" onClick={() => setValue({ ...teapot, name: null, price: null })}>
        Next record
      </button>
    </>
  );
}
const screen = await render(<Records />);
await userEvent.click(text(screen, "Name"));
await userEvent.keyboard("{Tab}");
await expect.element(text(screen, "Name")).toHaveAttribute("aria-invalid", "true");
await commit(number(screen, "Price"), "a lot");
// Price is left by the very click that brings the next record, which has no price either.
await userEvent.click(button(screen, "Next record"));
await expect.element(number(screen, "Price")).toHaveValue("");
await expect.element(text(screen, "Name")).not.toHaveAttribute("aria-invalid");
await userEvent.click(text(screen, "Released"));
await expect.element(number(screen, "Price")).not.toHaveAttribute("aria-invalid");
expect(screen.getByText("Required.", { exact: true }).elements()).toHaveLength(0);
A value that is the one last given to onValueChange is the same record: what was visited stays so
const screen = await render(<Editor initial={blank(product)} />);
await userEvent.click(text(screen, "Name"));
await commit(number(screen, "Price"), "12");
await expect.element(text(screen, "Name")).toHaveAttribute("aria-invalid", "true");
When another record arrives while a field has focus, focus stays on that field
const screen = await render(
  <ObjectEditor schema={product} locale="en-US" value={teapot} onValueChange={() => {}} />,
);
await userEvent.click(number(screen, "Price"));
await screen.rerender(
  <ObjectEditor
    schema={product}
    locale="en-US"
    value={{ ...teapot, price: 12 }}
    onValueChange={() => {}}
  />,
);
await expect.element(number(screen, "Price")).toHaveValue("12");
await expect.element(number(screen, "Price")).toHaveFocus();
focusFirstIssue() goes to a variant's choice when which it is is the problem, and into its fields when one of them is
const handle = createRef<ObjectEditorHandle>();
const screen = await render(
  <Editor ref={handle} schema={customer} initial={{ name: "Ada", reach: { by: "fax" } }} />,
);
await userEvent.click(text(screen, "Name"));
expect(handle.current!.focusFirstIssue()).toBe(true);
const reach = screen.getByRole("combobox", { name: "Reach by" });
await expect.element(reach).toHaveFocus();
await userEvent.click(reach);
await userEvent.click(screen.getByRole("option", { name: "Phone" }));
expect(handle.current!.focusFirstIssue()).toBe(true);
await expect.element(text(screen, "Number")).toHaveFocus();
await commit(text(screen, "Number"), "555 0100");
expect(handle.current!.focusFirstIssue()).toBe(false);
issues adds problems the schema cannot state, shown at their paths as the editor's own are, and found by focusFirstIssue()
const handle = createRef<ObjectEditorHandle>();
const taken: Issue[] = [
  { path: ["name"], code: "unique" },
  { path: ["price"], code: "maximum" },
];
const screen = await render(<Editor ref={handle} issues={taken} />);
// Like the editor's own, not before its field has been left.
await expect.element(text(screen, "Name")).not.toHaveAttribute("aria-invalid");
await userEvent.click(text(screen, "Name"));
await userEvent.click(number(screen, "Price"));
await expect.element(text(screen, "Name")).toHaveAccessibleDescription("Already used.");
await expect.element(said(screen)).toHaveTextContent("Name: Already used.");
expect(handle.current!.focusFirstIssue()).toBe(true);
await expect.element(text(screen, "Name")).toHaveFocus();
await userEvent.click(text(screen, "Released"));
await expect.element(number(screen, "Price")).toHaveAccessibleDescription("Above the maximum.");
A formula problem in issues is shown at once, in its own words, with the part of the formula it is about marked in the value
const setting = schema({
  kind: "object",
  fields: [{ key: "formula", schema: { kind: "text", label: "Formula", optional: true } }],
});
const screen = await render(
  <Editor
    schema={setting}
    initial={{ formula: "price * cost" }}
    issues={[
      { path: ["formula"], code: "formula", message: "There is no field “cost”.", at: 8, length: 4 },
    ]}
  />,
);
await expect.element(text(screen, "Formula")).toHaveAccessibleDescription("There is no field “cost”.");
expect(document.querySelector("[data-formula-problem] span")!.textContent).toBe("cost");
Where issues and the schema both find a problem with a part, the schema's is the one shown
const screen = await render(
  <Editor
    initial={{ ...teapot, name: null }}
    issues={[{ path: ["name"], code: "unique" }]}
    showAllIssues
  />,
);
await expect.element(text(screen, "Name")).toHaveAccessibleDescription("Required.");
A reference is not read until a value reaches it
const read = vi.fn(() => schema({ kind: "text", label: "City" }));
const order = schema({
  kind: "object",
  fields: [
    {
      key: "shipping",
      schema: {
        kind: "object",
        label: "Shipping",
        optional: true,
        fields: [{ key: "city", schema: { kind: "lazy", schema: read } }],
      },
    },
  ],
});
const screen = await render(<Editor schema={order} initial={{ shipping: null }} />);
await expect.element(button(screen, "Add Shipping")).toBeVisible();
expect(read).not.toHaveBeenCalled();
await userEvent.click(button(screen, "Add Shipping"));
await expect.element(text(screen, "City")).toHaveFocus();
expect(read).toHaveBeenCalledTimes(1);
locks makes the locks the owner's: the editor shows them, and asks through onLocksChange for a change
const onLocksChange = vi.fn();
const screen = await render(
  <Order locks={new Map([["price", "held"]])} onLocksChange={onLocksChange} />,
);
await expect.element(number(screen, "Price")).toHaveAttribute("readonly");
await choose(screen, number(screen, "Price"), "Unlock");
expect(onLocksChange).toHaveBeenCalledWith(new Map());
// Not given back, it is not taken off.
await expect.element(number(screen, "Price")).toHaveAttribute("readonly");
A lock and the change it came with are heard in one event, the lock first, so they can be undone as one
const heard: string[] = [];
const screen = await render(
  <Order
    onLocksChange={(locks) => heard.push(`locks ${[...locks].join()}`)}
    onChange={(value) => heard.push(`value ${(value as typeof placed).total}`)}
  />,
);
await commit(number(screen, "Total"), "100");
expect(heard).toEqual(["locks total,frozen", "value 100"]);
messages takes any part of what the editor says and leaves the rest as it was
const screen = await render(<Editor messages={{ add: (label) => `New ${label.toLowerCase()}` }} />);
await expect.element(button(screen, "New tag")).toBeVisible();
await expect.element(button(screen, "Remove Tag 1")).toBeVisible();

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/object-editor.json
Source

src/components/object-editor/object-editor.tsx

"use client";

import {
  createContext,
  use,
  useCallback,
  useEffect,
  useId,
  useImperativeHandle,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import type { ComponentProps, FocusEvent, KeyboardEvent, ReactElement, ReactNode, Ref } from "react";
import { useDocumentLocale } from "../../lib/locale.ts";
import { buttonClassName } from "../../lib/button-class.ts";
import { focusRingClassName } from "../../lib/focus-class.ts";
import { cn } from "../../lib/utils.ts";
import {
  blank,
  resolve,
  tagSchema,
  validate,
  variantOf,
  withValueAt,
  withVariant,
} from "../../primitives/schema.ts";
import type {
  Issue,
  ListSchema,
  ObjectSchema,
  Path,
  ResolvedSchema,
  Schema,
  TextFormat,
  VariantSchema,
} from "../../primitives/schema.ts";
import { angle, length } from "../../primitives/units.ts";
import {
  derivationsOf,
  deriveNested,
  editRecord,
  LOCK_MESSAGES,
  lockActions,
  unlockRecord,
} from "../../primitives/derived.ts";
import type { Lock, LockMessages } from "../../primitives/derived.ts";
import { useAnnouncer } from "../../primitives/announcements.ts";
import { useControllableState } from "../../primitives/use-controllable-state.ts";
import { Checkbox } from "../checkbox/checkbox.tsx";
import { DateField } from "../date-field/date-field.tsx";
import { ContextMenu, ContextMenuTrigger, MenuContent, MenuItem } from "../menu/menu.tsx";
import { NumberField } from "../number-field/number-field.tsx";
import { Select, SelectItem } from "../select/select.tsx";
import { TextField } from "../text-field/text-field.tsx";

const DIMENSIONS = { length, angle };

/** The words for a problem: fixed, or made from the issue, its schema and the editor's locale. */
export type IssueMessage = string | ((issue: Issue, schema: Schema, locale: string) => string);

function limit(schema: Schema, bound: "minimum" | "maximum", locale: string): string | undefined {
  if (schema.kind === "date") {
    const day = schema[bound];
    if (day === undefined) return undefined;
    return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone: "UTC" }).format(
      new Date(`${day}T00:00:00Z`),
    );
  }
  if (schema.kind !== "number") return undefined;
  const value = schema[bound];
  if (value === undefined) return undefined;
  const text = new Intl.NumberFormat(
    locale,
    schema.format === "percent"
      ? { style: "percent", maximumFractionDigits: 20 }
      : schema.format === "currency" && schema.currency !== undefined
        ? { style: "currency", currency: schema.currency }
        : {},
  ).format(value);
  return schema.unit === undefined ? text : `${text} ${schema.unit}`;
}

function counted(count: number | undefined, one: string, many: string, locale: string): string | undefined {
  if (count === undefined) return undefined;
  return `${new Intl.NumberFormat(locale).format(count)} ${count === 1 ? one : many}`;
}

/** What to say about each kind of problem; pass `messages` to say it in another language. */
export const ISSUE_MESSAGES: Readonly<Record<Issue["code"], IssueMessage>> = {
  required: "Required.",
  type: "Not a valid value.",
  minimum(_, schema, locale) {
    const bound = limit(schema, "minimum", locale);
    if (bound === undefined) return "Below the minimum.";
    return schema.kind === "date" ? `No earlier than ${bound}.` : `No less than ${bound}.`;
  },
  maximum(_, schema, locale) {
    const bound = limit(schema, "maximum", locale);
    if (bound === undefined) return "Above the maximum.";
    return schema.kind === "date" ? `No later than ${bound}.` : `No more than ${bound}.`;
  },
  minLength(_, schema, locale) {
    const size = counted(
      schema.kind === "text" ? schema.minLength : undefined,
      "character",
      "characters",
      locale,
    );
    return size === undefined ? "Too short." : `At least ${size}.`;
  },
  maxLength(_, schema, locale) {
    const size = counted(
      schema.kind === "text" ? schema.maxLength : undefined,
      "character",
      "characters",
      locale,
    );
    return size === undefined ? "Too long." : `At most ${size}.`;
  },
  pattern: "Not in the expected form.",
  option: "Not one of the options.",
  minItems(_, schema, locale) {
    const size = counted(schema.kind === "list" ? schema.minItems : undefined, "item", "items", locale);
    return size === undefined ? "Too few items." : `At least ${size}.`;
  },
  maxItems(_, schema, locale) {
    const size = counted(schema.kind === "list" ? schema.maxItems : undefined, "item", "items", locale);
    return size === undefined ? "Too many items." : `At most ${size}.`;
  },
  unique: "Already used.",
  formula: (issue) => issue.message ?? "This formula cannot be used.",
};

/**
 * Everything the editor says. Each phrase with a part that varies is a function
 * of its parts, so a language can put them in its own order.
 */
export interface ObjectEditorMessages extends Record<Issue["code"], IssueMessage>, LockMessages {
  /** A list's item by its place: "Tag 2". */
  item: (label: string, position: number) => string;
  /** The button that adds an item to a list, or an optional record that is absent. */
  add: (label: string) => string;
  /** The button that removes what `name` names: a list's item, or an optional record. */
  remove: (name: string) => string;
  removed: (name: string) => string;
  /** The handle that lifts an item to move it. */
  reorder: (name: string) => string;
  /** How the handle is used, as its description. */
  reorderHint: string;
  moveUp: (name: string) => string;
  moveDown: (name: string) => string;
  lifted: (name: string, position: number, count: number) => string;
  /** `name` is what the item was called before it moved. */
  moved: (name: string, position: number, count: number) => string;
  dropped: (name: string, position: number, count: number) => string;
  /** Said when a lifted item is put back where it was. */
  returned: (name: string, position: number, count: number) => string;
  /** Shown beside the optional fields of a group where most are required. */
  optionalMark: string;
  /** Shown beside the required fields of a group where most are optional. */
  requiredMark: string;
  yes: string;
  no: string;
  /** A yes-or-no that has not been answered. */
  unset: string;
  /** A problem as it is announced, where its field cannot be seen beside it. */
  problem: (label: string, problem: string) => string;
}

export const EDITOR_MESSAGES: Readonly<ObjectEditorMessages> = {
  ...ISSUE_MESSAGES,
  ...LOCK_MESSAGES,
  item: (label, position) => `${label} ${position}`,
  add: (label) => `Add ${label}`,
  remove: (name) => `Remove ${name}`,
  removed: (name) => `${name} removed.`,
  reorder: (name) => `Reorder ${name}`,
  reorderHint: "Press Space to lift, the arrow keys to move, Space to drop, Escape to put back.",
  moveUp: (name) => `Move ${name} up`,
  moveDown: (name) => `Move ${name} down`,
  lifted: (name, position, count) => `${name} lifted, at position ${position} of ${count}.`,
  moved: (name, position, count) => `${name} moved to position ${position} of ${count}.`,
  dropped: (name, position, count) => `${name} dropped at position ${position} of ${count}.`,
  returned: (name, position, count) => `${name} put back at position ${position} of ${count}.`,
  optionalMark: "optional",
  requiredMark: "required",
  yes: "Yes",
  no: "No",
  unset: "Not set",
  problem: (label, problem) => `${label}: ${problem}`,
};

/** How each kind of text is entered: its keyboard, what the browser may fill in, what it corrects. */
const TEXT_FORMATS: Readonly<
  Record<
    TextFormat,
    Pick<ComponentProps<"input">, "inputMode" | "autoComplete" | "autoCapitalize" | "spellCheck">
  >
> = {
  email: { inputMode: "email", autoComplete: "email", autoCapitalize: "none", spellCheck: false },
  url: { inputMode: "url", autoComplete: "url", autoCapitalize: "none", spellCheck: false },
  tel: { inputMode: "tel", autoComplete: "tel" },
  name: { autoComplete: "name", autoCapitalize: "words", spellCheck: false },
  "given-name": { autoComplete: "given-name", autoCapitalize: "words", spellCheck: false },
  "family-name": { autoComplete: "family-name", autoCapitalize: "words", spellCheck: false },
  organization: { autoComplete: "organization", autoCapitalize: "words" },
  "street-address": { autoComplete: "street-address", autoCapitalize: "words" },
  "postal-code": { autoComplete: "postal-code", autoCapitalize: "characters", spellCheck: false },
  username: { autoComplete: "username", autoCapitalize: "none", spellCheck: false },
};

const YES = "yes";
const NO = "no";
const UNSET = "unset";
// Before a variant's own value in its list, so that none can be mistaken for "unset".
const CHOSEN = "=";

const isRecord = (value: unknown): value is Record<string, unknown> =>
  typeof value === "object" && value !== null && !Array.isArray(value);

/** The variant a value is, if it is one of them. */
const chosenOf = (schema: VariantSchema, value: unknown) =>
  isRecord(value) ? variantOf(schema, value[schema.tag]) : undefined;

export interface ValueEditorProps {
  schema: Schema;
  value: unknown;
  onValueChange: (value: unknown) => void;
  locale?: string | undefined;
  /** The control's `id`, for a label to point at. */
  id?: string | undefined;
  "aria-labelledby"?: string | undefined;
  "aria-describedby"?: string | undefined;
  /** Whether there is a problem with the value to mark the control for. */
  invalid?: boolean | undefined;
  /** Whether the value is shown but cannot be changed. */
  readOnly?: boolean | undefined;
  /**
   * The words for yes, no and unanswered. Given them, an optional yes-or-no is a
   * choice of the three, so it can go back to unanswered; without, a checkbox.
   * An optional variant offers the word for unanswered too, to go back to none.
   */
  booleanLabels?: { yes: string; no: string; unset: string } | undefined;
  /** Hears when what was typed cannot be read as a value of this kind, and when it can again. */
  onUnreadableChange?: ((unreadable: boolean) => void) | undefined;
}

/**
 * The control for one scalar value, chosen by the kind its schema gives it.
 * A form uses it for a field and a grid for a cell. For a variant it is the
 * choice of which: choosing another changes the whole value to that variant.
 */
export function ValueEditor({
  schema: stated,
  value,
  onValueChange,
  locale,
  invalid = false,
  readOnly = false,
  booleanLabels,
  onUnreadableChange,
  ...labelling
}: ValueEditorProps): ReactElement | null {
  const schema = resolve(stated);
  const change = (next: unknown) => {
    if (!readOnly) onValueChange(next);
  };
  // Marked only when there is a problem, so a control's own marking is not undone.
  const marking = { ...labelling, ...(invalid && { "aria-invalid": true as const, "data-invalid": "" }) };
  // Whether a value is given and within its limits is the schema's to judge, so a
  // cleared field commits as absent and the same check reports every problem.
  // The control is told only that the value is required, for it to announce.
  const common = { ...marking, "aria-required": schema.optional === true ? undefined : true };
  const onInvalidChange = (reason: string | null) => onUnreadableChange?.(reason !== null);
  const still = { readOnly };

  switch (schema.kind) {
    case "number":
      return (
        <NumberField
          {...common}
          readOnly={readOnly}
          onInvalidChange={onInvalidChange}
          locale={locale}
          value={typeof value === "number" ? value : null}
          onValueChange={change}
          minimum={schema.minimum}
          maximum={schema.maximum}
          step={schema.step}
          precision={schema.precision}
          kind={schema.format}
          currency={schema.currency}
          units={schema.dimension === undefined ? undefined : DIMENSIONS[schema.dimension]}
          unit={schema.unit}
        />
      );
    case "date":
      return (
        <DateField
          {...common}
          readOnly={readOnly}
          onInvalidChange={onInvalidChange}
          locale={locale}
          value={typeof value === "string" ? value : null}
          onValueChange={change}
          minimum={schema.minimum}
          maximum={schema.maximum}
        />
      );
    case "text":
      return (
        <TextField
          {...common}
          // The type stays `text`: the browser's own check of an address would
          // compete with the schema's, and stop a form before it is reported.
          {...(schema.format !== undefined && TEXT_FORMATS[schema.format])}
          readOnly={readOnly}
          multiline={schema.multiline === true}
          value={typeof value === "string" ? value : null}
          onValueChange={change}
        />
      );
    case "boolean":
      if (schema.optional === true && booleanLabels !== undefined) {
        return (
          <Select
            {...marking}
            {...still}
            value={value === true ? YES : value === false ? NO : UNSET}
            onValueChange={(next) => change(next === YES ? true : next === NO ? false : null)}
          >
            <SelectItem value={YES}>{booleanLabels.yes}</SelectItem>
            <SelectItem value={NO}>{booleanLabels.no}</SelectItem>
            <SelectItem value={UNSET}>{booleanLabels.unset}</SelectItem>
          </Select>
        );
      }
      // Never said to be required: unticked is an answer, not a missing one.
      return (
        <Checkbox
          {...marking}
          aria-readonly={readOnly || undefined}
          checked={typeof value === "boolean" ? value : null}
          onCheckedChange={change}
        />
      );
    case "choice":
      return (
        <Select
          {...common}
          {...still}
          value={typeof value === "string" ? value : null}
          onValueChange={change}
        >
          {schema.options.map((option) => (
            <SelectItem key={option.value} value={option.value}>
              {option.label}
            </SelectItem>
          ))}
        </Select>
      );
    case "variant": {
      const chosen = chosenOf(schema, value);
      // What can be left unchosen can be put back to it.
      const unset = schema.optional === true && booleanLabels !== undefined;
      return (
        <Select
          {...common}
          {...still}
          value={chosen === undefined ? (unset ? UNSET : null) : `${CHOSEN}${chosen.value}`}
          onValueChange={(next) =>
            change(withVariant(schema, value, next === UNSET ? null : next.slice(CHOSEN.length)))
          }
        >
          {schema.variants.map((variant) => (
            <SelectItem key={variant.value} value={`${CHOSEN}${variant.value}`}>
              {variant.label}
            </SelectItem>
          ))}
          {unset && <SelectItem value={UNSET}>{booleanLabels.unset}</SelectItem>}
        </Select>
      );
    }
    default:
      return null;
  }
}

/** The handle an editor gives through `ref`. */
export interface ObjectEditorHandle {
  /** Moves focus to the first field with a problem, and says whether there was one. */
  focusFirstIssue: () => boolean;
}

interface Editor {
  readonly locale: string;
  readonly messages: Readonly<ObjectEditorMessages>;
  /** The problem to show for the part at `path`, which the editor knows by `address`. */
  readonly problemAt: (path: Path, address: string) => Issue | undefined;
  /** Whether the part has been left, or holds text that is not a value: its problem is news. */
  readonly isEarned: (address: string) => boolean;
  readonly leave: (address: string) => void;
  readonly setUnreadable: (address: string, unreadable: boolean) => void;
  readonly announce: (text: string) => void;
  /** The lock on a field of the record, by its key. */
  readonly lockOf: (key: string) => Lock | undefined;
  /**
   * Whether the owner works with locks: then every field offers its menu. Else
   * only a computed one does, and the rest keep the browser's own — cut, copy,
   * paste and spelling are worth more than locks no one asked for.
   */
  readonly offersLocks: boolean;
  readonly lock: (key: string, lock: Lock) => void;
  /** Takes a field's lock off; a computed one is computed again, unless a held value refuses it. */
  readonly unlock: (key: string, address: string, label: string) => void;
  /** Says which control the change about to arrive was made in, and what it asked for. */
  readonly from: (address: string, label: string, value: unknown) => void;
  /** A change made at `address` that locks refused: what was asked for, and why it was not made. */
  readonly refusalAt: (address: string) => Refusal | undefined;
  /** The same, as it is at this moment, between renders: for a key's handlers. */
  readonly refusalNow: (address: string) => Refusal | undefined;
  readonly forget: (address: string) => void;
}

interface Refusal {
  readonly value: unknown;
  readonly text: string;
}

const EditorContext = createContext<Editor | null>(null);

function useEditor(): Editor {
  const editor = use(EditorContext);
  if (editor === null) throw new Error("A field of an object editor is rendered outside one.");
  return editor;
}

const NOTHING: ReadonlySet<string> = new Set();
const NO_ITEMS: readonly unknown[] = [];
const NO_FIELDS: Readonly<Record<string, unknown>> = {};

/** A path as one string, for the DOM and for looking issues up. */
const keyOf = (path: Path): string => JSON.stringify(path);

/**
 * Where a part is, as the editor remembers it: like its path, but with a list's
 * item known by its identity rather than its place, so what is remembered about
 * an item stays with it when others are removed or it is moved. A field's key is
 * quoted, so no key can be mistaken for a longer path.
 */
const within = (address: string, part: string | number): string =>
  `${address}/${typeof part === "number" ? part : JSON.stringify(part)}`;

/** The ids a control is described by, or none. */
const described = (...ids: Array<string | false>): string | undefined =>
  ids.filter(Boolean).join(" ") || undefined;

const FOCUSABLE =
  'input:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])';

function focusWithin(container: Element | null | undefined, caret?: "end"): boolean {
  if (container === null || container === undefined) return false;
  const target = container.matches(FOCUSABLE) ? container : container.querySelector(FOCUSABLE);
  if (!(target instanceof HTMLElement)) return false;
  target.focus();
  if (caret === "end" && target instanceof HTMLInputElement && target.type === "text") {
    target.setSelectionRange(target.value.length, target.value.length);
  }
  return true;
}

/** Focuses the control of the part at a path, or the first control within it. */
function focusPart(root: Element | null, key: string): boolean {
  if (root === null) return false;
  const name = CSS.escape(key);
  if (focusWithin(root.querySelector(`[data-field="${name}"]`))) return true;
  const group = root.querySelector(`[data-group="${name}"]`);
  return focusWithin(group?.querySelector("[data-field]")) || focusWithin(group);
}

// Two columns where the editor itself has room for them and the pointer is fine;
// one, with each label above its control, when it is narrow or under a finger.
const GRID =
  "grid grid-cols-1 items-start gap-x-4 gap-y-1 @md:[@media_not_(pointer:coarse)]:grid-cols-[fit-content(40%)_minmax(0,1fr)] @md:[@media_not_(pointer:coarse)]:gap-y-2";
// A group within the grid that keeps to its columns, so its fields line up with those around it.
const SUBGRID =
  "col-span-full grid grid-cols-subgrid items-start gap-y-1 @md:[@media_not_(pointer:coarse)]:gap-y-2";
// Beside a control, a label sits on its first line, in a column at least 6rem wide
// (`fit-content()` cannot be the maximum of a `minmax()`, so the label holds the minimum).
// Above a control, it stands further from the field before than from its own.
const LABEL =
  "min-w-0 text-sm break-words text-fg-muted not-first:mt-2 @md:[@media_not_(pointer:coarse)]:mt-0 @md:[@media_not_(pointer:coarse)]:min-w-24 @md:[@media_not_(pointer:coarse)]:pt-1.5";
const ICON_BUTTON = cn(
  "grid size-8 shrink-0 place-items-center rounded-md text-fg-muted [-webkit-tap-highlight-color:transparent]",
  "enabled:hover:bg-surface-muted enabled:hover:text-fg-strong enabled:active:bg-surface-strong disabled:text-fg-disabled",
  "aria-pressed:bg-selected aria-pressed:text-on-selected aria-pressed:hover:bg-selected aria-pressed:hover:text-on-selected",
  focusRingClassName,
  "pointer-coarse:size-11",
);
const TEXT_BUTTON = cn(buttonClassName, "self-start");

function Icon({ d, filled = false }: { d: string; filled?: boolean }) {
  return (
    <svg
      aria-hidden
      viewBox="0 0 16 16"
      className="size-3.5"
      fill={filled ? "currentColor" : "none"}
      stroke={filled ? "none" : "currentColor"}
      strokeWidth="1.75"
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d={d} />
    </svg>
  );
}

const CROSS = "M4 4l8 8M12 4l-8 8";
const GRIP =
  "M6 3.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0m6 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0M6 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0m6 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-6 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0m6 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0";
const UP = "m4.5 10 3.5-3.5 3.5 3.5";
const DOWN = "m4.5 6 3.5 3.5L11.5 6";

/** Where a variant says which it is: a problem with that is shown with the choice. */
const tagPathOf = (schema: Schema, path: Path): Path | undefined =>
  schema.kind === "variant" ? [...path, schema.tag] : undefined;

function Problem({
  id,
  path,
  address,
  label,
  schema,
  formula,
  value,
}: {
  id: string;
  path: Path;
  address: string;
  label: string;
  schema: ResolvedSchema;
  /** A problem with how the value is computed, which is shown whether or not the field was left. */
  formula?: Issue | undefined;
  value?: unknown;
}) {
  const { problemAt, isEarned, announce, messages, locale, refusalAt } = useEditor();
  const tagPath = tagPathOf(schema, path);
  const refusal = refusalAt(address);
  const issue = problemAt(path, address) ?? (tagPath && problemAt(tagPath, address)) ?? formula;
  let text: string | undefined = refusal?.text;
  if (text === undefined && issue !== undefined) {
    const message = messages[issue.code];
    // Where the words name a limit, a variant's is its tag's: a choice among the variants.
    const of = schema.kind === "variant" ? tagSchema(schema) : schema;
    text = typeof message === "function" ? message(issue, of, locale) : message;
  }
  // A problem its field earned is news; one only shown with all the others is not. A
  // refusal was said as it was made, each time it was.
  const spoken =
    text !== undefined && refusal === undefined && isEarned(address)
      ? messages.problem(label, text)
      : undefined;
  useEffect(() => {
    if (spoken !== undefined) announce(spoken);
  }, [spoken, announce]);

  if (text === undefined) return null;
  // The part of a formula a problem is about, marked where it was written: in the
  // schema's formula, or in the value where the value is a formula.
  const written =
    refusal === undefined && issue?.code === "formula" && issue.at !== undefined
      ? (schema.formula ?? (typeof value === "string" ? value : undefined))
      : undefined;
  return (
    <>
      <p id={id} className="text-xs text-danger">
        {text}
      </p>
      {written !== undefined && issue?.at !== undefined && (
        <p aria-hidden data-formula-problem="" className="font-mono text-xs break-all text-fg-muted">
          {written.slice(0, issue.at)}
          <span className="text-danger underline decoration-danger decoration-wavy underline-offset-2">
            {/* Something missing at the end is marked where it is missing. */}
            {written.slice(issue.at, issue.at + (issue.length ?? 0)) || "‸"}
          </span>
          {written.slice(issue.at + (issue.length ?? 0))}
        </p>
      )}
    </>
  );
}

/**
 * One scalar value's control with what is said about it: its description and its
 * problem. A variant's is the choice of which it is.
 */
function Control({
  id,
  path,
  address,
  label,
  schema,
  value,
  onValueChange,
  readOnly,
  labelledBy,
  className,
  computed = false,
  lock,
  lockable = false,
  formula,
}: {
  id: string;
  path: Path;
  address: string;
  label: string;
  schema: ResolvedSchema;
  value: unknown;
  onValueChange: (value: unknown) => void;
  readOnly: boolean;
  labelledBy: string;
  className?: string;
  /** Whether the value is computed by its schema's formula. */
  computed?: boolean;
  lock?: Lock | undefined;
  /** Whether the value has a menu for its lock: it is a field of the record itself, and can be changed. */
  lockable?: boolean;
  /** A problem with the schema's formula for the value. */
  formula?: Issue | undefined;
}) {
  const editor = useEditor();
  const { messages } = editor;
  const statusId = `${id}-status`;
  const descriptionId = `${id}-description`;
  const problemId = `${id}-problem`;
  const tagPath = tagPathOf(schema, path);
  const refusal = editor.refusalAt(address);
  const problem =
    refusal !== undefined ||
    formula !== undefined ||
    (editor.problemAt(path, address) ?? (tagPath && editor.problemAt(tagPath, address))) !== undefined;
  const key = path[path.length - 1];
  // What it is, said with it: locked, and computed from what.
  const status = [
    lock === "held" ? messages.held : lock === "frozen" ? messages.frozen : undefined,
    computed && schema.formula !== undefined ? messages.computed(schema.formula) : undefined,
  ]
    .filter(Boolean)
    .join(" ");
  const chosen =
    schema.kind === "choice" ||
    schema.kind === "variant" ||
    (schema.kind === "boolean" && schema.optional === true);
  // The refused change is still in the control, marked, for nothing typed to be lost.
  const change = (next: unknown) => {
    editor.from(address, label, next);
    onValueChange(next);
  };
  const control = (
    <ValueEditor
      id={id}
      schema={schema}
      value={refusal === undefined ? value : refusal.value}
      locale={editor.locale}
      readOnly={readOnly}
      booleanLabels={editor.messages}
      // A select names its list as well as its button, so it takes the label's id.
      aria-labelledby={chosen ? labelledBy : undefined}
      aria-describedby={described(
        status !== "" && statusId,
        schema.description !== undefined && descriptionId,
        problem && problemId,
      )}
      invalid={problem}
      onUnreadableChange={(unreadable) => editor.setUnreadable(address, unreadable)}
      onValueChange={(next) => {
        // Nothing was given and nothing is: an empty text is not turned into none.
        if (next === null && (value === undefined || value === "")) return;
        change(next);
      }}
    />
  );
  // The refusal there was as a key went down: one the key's own commit replaced is not tried again.
  const pressedOn = useRef<Refusal | undefined>(undefined);
  const fieldProps = {
    "data-field": keyOf(path),
    "data-tag": tagPath && keyOf(tagPath),
    "data-computed": computed ? "" : undefined,
    "data-lock": lock,
    "data-refused": refusal === undefined ? undefined : "",
    className: cn(
      "flex min-w-0 flex-col gap-1",
      // Computed and following: drawn apart from a field to type into.
      "[&[data-computed]:not([data-lock])_:is(input,textarea,button[aria-haspopup])]:border-dashed",
      className,
    ),
    // Leaving a field is when its problem, if it has one, is first shown.
    onBlur: (event: FocusEvent<HTMLDivElement>) => {
      if (!event.currentTarget.contains(event.relatedTarget)) editor.leave(address);
    },
    onKeyDownCapture: () => {
      pressedOn.current = editor.refusalNow(address);
    },
    onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => {
      const held = pressedOn.current;
      pressedOn.current = undefined;
      if (held === undefined || event.defaultPrevented) return;
      if (event.target instanceof Element && event.target.closest('[role="menu"]') !== null) return;
      if (event.key === "Escape") {
        // The refused change is put aside, and the value shown again.
        event.preventDefault();
        event.stopPropagation();
        editor.forget(address);
      } else if (event.key === "Enter" && editor.refusalNow(address) === held) {
        // Asked for again, as it stands: a lock taken off since may now allow it.
        change(held.value);
      }
    },
  };
  const body = (
    <>
      {/* A checkbox is as tall as a line of text; it sits where a field's text would. */}
      {schema.kind === "boolean" && !chosen ? (
        <span className="flex min-h-8 items-center">{control}</span>
      ) : (
        control
      )}
      {status !== "" && (
        <p id={statusId} data-status="" className="text-xs text-fg-muted">
          {status}
        </p>
      )}
      {schema.description !== undefined && (
        <p id={descriptionId} className="text-xs text-fg-muted">
          {schema.description}
        </p>
      )}
      <Problem
        id={problemId}
        path={path}
        address={address}
        label={label}
        schema={schema}
        formula={formula}
        value={value}
      />
    </>
  );
  if (!lockable || typeof key !== "string" || !(computed || editor.offersLocks)) {
    return <div {...fieldProps}>{body}</div>;
  }
  const unlocking = lock === "frozen" && computed ? messages.unfreeze : messages.unlock;
  return (
    <ContextMenu>
      <ContextMenuTrigger {...fieldProps}>
        {body}
        <MenuContent aria-label={label}>
          {lockActions(computed, lock).map((action) => (
            <MenuItem
              key={action}
              onSelect={() => {
                if (action === "unlock") editor.unlock(key, address, label);
                else editor.lock(key, action === "hold" ? "held" : "frozen");
              }}
            >
              {action === "hold" ? messages.hold : action === "freeze" ? messages.freeze : unlocking}
            </MenuItem>
          ))}
        </MenuContent>
      </ContextMenuTrigger>
    </ContextMenu>
  );
}

interface ItemIds {
  readonly items: readonly unknown[];
  readonly ids: readonly number[];
  /** An id no item of the list has had. */
  readonly unused: number;
  /** The ids for the list this editor has asked for, if the record comes to hold it. */
  readonly planned: { readonly items: readonly unknown[]; readonly ids: readonly number[] } | null;
}

/**
 * An identity for each item of a list, which the list itself does not give: a
 * number no other item of the list has had, so nothing said of a removed item
 * can land on a later one. An edit made here says what the ids become; an edit
 * within an item leaves the list as long as it was, and its ids as they were.
 */
function useItemIds(
  items: readonly unknown[],
): [
  ids: readonly number[],
  plan: (items: readonly unknown[], ids: readonly number[]) => void,
  unused: number,
] {
  const [state, setState] = useState<ItemIds>(() => ({
    items,
    ids: items.map((_, index) => index),
    unused: items.length,
    planned: null,
  }));
  let { ids, unused } = state;
  if (state.items !== items) {
    if (state.planned?.items === items) ids = state.planned.ids;
    // Not an edit of this editor's: the items that are still in place keep their ids.
    else if (state.items.length !== items.length) ids = items.map((_, index) => state.ids[index] ?? unused++);
    setState({ items, ids, unused, planned: null });
  }
  const plan = useCallback(
    (next: readonly unknown[], nextIds: readonly number[]) =>
      setState((held) => ({
        ...held,
        unused: Math.max(held.unused, ...nextIds.map((id) => id + 1)),
        planned: { items: next, ids: nextIds },
      })),
    [],
  );
  return [ids, plan, unused];
}

/**
 * How many times which variant a value is has been chosen here. The fields of
 * each choice are remembered apart, as another record's are: what was typed into
 * one variant's field, or reported of it, is not the next one's, nor its own if it
 * is chosen again.
 */
const useChoices = () => useState(0);

function moved<T>(list: readonly T[], from: number, to: number): T[] {
  const next = list.slice();
  next.splice(to, 0, ...next.splice(from, 1));
  return next;
}

/** What a list does once the record has caught up with the edit just made. */
type Pending =
  | { readonly kind: "add" }
  | { readonly kind: "focus-add" }
  | { readonly kind: "focus"; readonly id: number; readonly part: "control" | "control-end" | "grip" };

function List({
  labelId,
  label,
  path,
  address,
  schema,
  value,
  onValueChange,
  readOnly,
}: {
  /** The element that names the list. */
  labelId: string;
  /** What one item is called. */
  label: string;
  path: Path;
  address: string;
  schema: ListSchema;
  value: unknown;
  onValueChange: (value: unknown) => void;
  readOnly: boolean;
}) {
  const editor = useEditor();
  const { messages, announce } = editor;
  const id = useId();
  const hintId = `${id}-hint`;
  const descriptionId = `${id}-description`;
  const problemId = `${id}-problem`;
  const items = Array.isArray(value) ? (value as readonly unknown[]) : NO_ITEMS;
  const [ids, plan, unused] = useItemIds(items);
  const count = items.length;
  const full = schema.maxItems !== undefined && count >= schema.maxItems;
  const problem = editor.problemAt(path, address) !== undefined;
  const name = (index: number) => messages.item(label, index + 1);
  // A single line of text is typed into like a line of a list: Enter and Backspace make and unmake lines.
  const of = resolve(schema.item);
  const lines = of.kind === "text" && of.multiline !== true && !readOnly;

  const container = useRef<HTMLDivElement>(null);
  const addButton = useRef<HTMLButtonElement>(null);
  const [lifted, setLifted] = useState<{ readonly id: number; readonly origin: number } | null>(null);
  const pending = useRef<Pending | null>(null);
  const [, setTick] = useState(0);

  function change(next: readonly unknown[], nextIds: readonly number[], then: Pending): void {
    plan(next, nextIds);
    pending.current = then;
    onValueChange(next);
  }

  function add(): void {
    if (full || readOnly) return;
    change([...items, blank(schema.item)], [...ids, unused], { kind: "focus", id: unused, part: "control" });
    editor.leave(address);
  }

  function remove(index: number, towards: "next" | "previous"): void {
    const neighbour =
      towards === "next" ? (ids[index + 1] ?? ids[index - 1]) : (ids[index - 1] ?? ids[index + 1]);
    change(
      items.filter((_, at) => at !== index),
      ids.filter((_, at) => at !== index),
      neighbour === undefined
        ? { kind: "focus-add" }
        : { kind: "focus", id: neighbour, part: towards === "previous" ? "control-end" : "control" },
    );
    editor.leave(address);
    setLifted(null);
    announce(messages.removed(name(index)));
  }

  function move(from: number, to: number): boolean {
    if (to < 0 || to >= count || to === from) return false;
    change(moved(items, from, to), moved(ids, from, to), { kind: "focus", id: ids[from]!, part: "grip" });
    return true;
  }

  function step(index: number, by: 1 | -1): void {
    if (move(index, index + by)) announce(messages.moved(name(index), index + by + 1, count));
  }

  function drop(index: number): void {
    setLifted(null);
    announce(messages.dropped(name(index), index + 1, count));
  }

  // Runs after every render: what is pending waits for the render that can do it.
  useLayoutEffect(() => {
    const request = pending.current;
    if (request === null) return;
    if (request.kind === "add") {
      pending.current = null;
      add();
      return;
    }
    if (request.kind === "focus-add") {
      if (addButton.current === null || addButton.current.disabled) return;
      addButton.current.focus();
    } else {
      // Its own item: a list within an item numbers its items from nothing too.
      const item = container.current?.querySelector(`:scope > [data-item="${request.id}"]`);
      if (item === null || item === undefined) return;
      if (request.part === "grip") item.querySelector<HTMLElement>("[data-grip]")?.focus();
      else {
        const caret = request.part === "control-end" ? "end" : undefined;
        const targets = [item.querySelector("[data-field]"), item.querySelector("[data-add]"), item];
        targets.some((target) => focusWithin(target, caret));
      }
    }
    pending.current = null;
  });

  return (
    <div
      ref={container}
      role="group"
      aria-labelledby={labelId}
      aria-describedby={described(schema.description !== undefined && descriptionId, problem && problemId)}
      data-group={keyOf(path)}
      className="flex min-w-0 flex-col gap-1.5"
    >
      {schema.description !== undefined && (
        <p id={descriptionId} className="text-xs text-fg-muted">
          {schema.description}
        </p>
      )}
      {count > 1 && !readOnly && (
        <span id={hintId} hidden>
          {messages.reorderHint}
        </span>
      )}
      {items.map((item, index) => {
        const itemId = ids[index]!;
        const isLifted = lifted?.id === itemId;
        const itemName = name(index);
        return (
          <Item
            key={itemId}
            itemId={itemId}
            name={itemName}
            path={[...path, index]}
            address={within(address, itemId)}
            schema={of}
            value={item}
            onValueChange={(next) => onValueChange(withValueAt(items, [index], next))}
            readOnly={readOnly}
            lifted={isLifted}
            onKeyDown={
              lines
                ? (event) => {
                    // Keys pressed during a composition are the input method's.
                    if (event.nativeEvent.isComposing || event.keyCode === 229) return;
                    if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey) return;
                    if (!(event.target instanceof HTMLInputElement)) return;
                    if (event.key === "Enter" && index === count - 1 && !full && event.target.value !== "") {
                      // Not a form's Enter. The field commits what was typed in this
                      // same press, so the item is added once the record holds it.
                      event.preventDefault();
                      pending.current = { kind: "add" };
                      setTick((tick) => tick + 1);
                    } else if (event.key === "Backspace" && !event.repeat && event.target.value === "") {
                      // Or the press would go on to erase from the item focus moves to.
                      event.preventDefault();
                      remove(index, "previous");
                    }
                  }
                : undefined
            }
            before={
              count > 1 &&
              !readOnly && (
                <>
                  <button
                    type="button"
                    data-grip=""
                    aria-pressed={isLifted}
                    aria-label={messages.reorder(itemName)}
                    aria-describedby={hintId}
                    className={ICON_BUTTON}
                    // WebKit takes focus from a button as it is pressed: a lifted item
                    // would be dropped by that, then lifted again by the click.
                    onMouseDown={(event) => event.preventDefault()}
                    onClick={(event) => {
                      // Not every browser focuses a button it clicks, and the keys are read here.
                      event.currentTarget.focus();
                      if (isLifted) drop(index);
                      else {
                        setLifted({ id: itemId, origin: index });
                        announce(messages.lifted(itemName, index + 1, count));
                      }
                    }}
                    onKeyDown={(event) => {
                      if (lifted?.id !== itemId) return;
                      if (event.key === "ArrowUp" || event.key === "ArrowDown") {
                        event.preventDefault();
                        step(index, event.key === "ArrowUp" ? -1 : 1);
                      } else if (event.key === "Escape") {
                        // The Escape is used here; what contains the editor does not close for it.
                        event.preventDefault();
                        event.stopPropagation();
                        setLifted(null);
                        move(index, lifted.origin);
                        announce(messages.returned(name(lifted.origin), lifted.origin + 1, count));
                      }
                    }}
                    onBlur={() => {
                      if (isLifted) drop(index);
                    }}
                  >
                    <Icon d={GRIP} filled />
                  </button>
                  {/* For a pointer, which has no arrow keys. Focus stays on the handle. */}
                  {isLifted && (
                    <>
                      <button
                        type="button"
                        tabIndex={-1}
                        aria-label={messages.moveUp(itemName)}
                        disabled={index === 0}
                        className={ICON_BUTTON}
                        onMouseDown={(event) => event.preventDefault()}
                        onClick={() => step(index, -1)}
                      >
                        <Icon d={UP} />
                      </button>
                      <button
                        type="button"
                        tabIndex={-1}
                        aria-label={messages.moveDown(itemName)}
                        disabled={index === count - 1}
                        className={ICON_BUTTON}
                        onMouseDown={(event) => event.preventDefault()}
                        onClick={() => step(index, 1)}
                      >
                        <Icon d={DOWN} />
                      </button>
                    </>
                  )}
                </>
              )
            }
            after={
              !readOnly && (
                <button
                  type="button"
                  aria-label={messages.remove(itemName)}
                  className={ICON_BUTTON}
                  onClick={() => remove(index, "next")}
                >
                  <Icon d={CROSS} />
                </button>
              )
            }
          />
        );
      })}
      {!readOnly && (
        <button
          ref={addButton}
          type="button"
          data-add=""
          disabled={full}
          onClick={add}
          className={TEXT_BUTTON}
        >
          {messages.add(label)}
        </button>
      )}
      <Problem id={problemId} path={path} address={address} label={label} schema={schema} />
    </div>
  );
}

function Item({
  itemId,
  name,
  path,
  address,
  schema,
  value,
  onValueChange,
  readOnly,
  lifted,
  onKeyDown,
  before,
  after,
}: {
  itemId: number;
  /** The item by its place: "Tag 2". */
  name: string;
  path: Path;
  address: string;
  schema: ResolvedSchema;
  value: unknown;
  onValueChange: (value: unknown) => void;
  readOnly: boolean;
  lifted: boolean;
  onKeyDown: ((event: KeyboardEvent<HTMLDivElement>) => void) | undefined;
  /** The handle that moves the item, and the button that removes it. */
  before: ReactNode;
  after: ReactNode;
}) {
  const id = useId();
  const nameId = `${id}-name`;
  const locked = readOnly || schema.readOnly === true;
  const marks = { "data-item": itemId, "data-lifted": lifted ? "" : undefined };
  const [choices, setChoices] = useChoices();

  if (schema.kind === "variant") {
    const chosen = chosenOf(schema, value);
    return (
      <div
        {...marks}
        role="group"
        aria-labelledby={nameId}
        className="@container min-w-0 rounded-md border border-border-muted p-2 data-lifted:bg-surface-muted"
      >
        {/* Which it is sits with its name: the item's own line says what kind of item it is. */}
        <div className="flex items-start gap-1.5">
          {before}
          <label
            id={nameId}
            htmlFor={id}
            className="flex min-h-8 shrink-0 items-center text-sm font-medium text-fg-strong pointer-coarse:min-h-11"
          >
            {name}
          </label>
          <Control
            id={id}
            path={path}
            address={address}
            label={name}
            schema={schema}
            value={value}
            onValueChange={(next) => {
              setChoices(choices + 1);
              onValueChange(next);
            }}
            readOnly={locked}
            labelledBy={nameId}
            className="flex-1"
          />
          {after}
        </div>
        {chosen !== undefined && chosen.schema.fields.length > 0 && (
          <div className="mt-2">
            <Fields
              key={choices}
              schema={chosen.schema}
              value={value}
              path={path}
              address={within(within(address, schema.tag), choices)}
              onValueChange={onValueChange}
              readOnly={locked}
            />
          </div>
        )}
      </div>
    );
  }

  if (schema.kind === "object" || schema.kind === "list") {
    return (
      <div
        {...marks}
        {...(schema.kind === "object" && {
          role: "group",
          "aria-labelledby": nameId,
          "data-group": keyOf(path),
        })}
        className="@container min-w-0 rounded-md border border-border-muted p-2 data-lifted:bg-surface-muted"
      >
        <div className="mb-1 flex items-center gap-1.5">
          {before}
          <span id={nameId} className="min-w-0 flex-1 text-sm font-medium text-fg-strong">
            {name}
          </span>
          {after}
        </div>
        {schema.kind === "object" ? (
          <>
            <Fields
              schema={schema}
              value={value}
              path={path}
              address={address}
              onValueChange={onValueChange}
              readOnly={locked}
            />
            <Problem id={`${id}-problem`} path={path} address={address} label={name} schema={schema} />
          </>
        ) : (
          <List
            labelId={nameId}
            label={name}
            path={path}
            address={address}
            schema={schema}
            value={value}
            onValueChange={onValueChange}
            readOnly={locked}
          />
        )}
      </div>
    );
  }

  return (
    // The keys are the text field's own, heard here on their way up.
    // oxlint-disable-next-line jsx-a11y/no-static-element-interactions
    <div
      {...marks}
      onKeyDown={onKeyDown}
      className="flex items-start gap-1.5 rounded-md data-lifted:bg-surface-muted"
    >
      {before}
      <Control
        id={id}
        path={path}
        address={address}
        label={name}
        schema={schema}
        value={value}
        onValueChange={onValueChange}
        readOnly={locked}
        labelledBy={nameId}
        className="flex-1"
      />
      <label id={nameId} htmlFor={id} className="sr-only">
        {name}
      </label>
      {after}
    </div>
  );
}

/** A record within the record: a group named by a heading, over its own fields. */
function Group({
  label,
  path,
  address,
  schema,
  value,
  onValueChange,
  readOnly,
  referred,
}: {
  label: string;
  path: Path;
  address: string;
  schema: ObjectSchema;
  value: unknown;
  onValueChange: (value: unknown) => void;
  readOnly: boolean;
  /** Whether the record is reached by a reference, and so is shown only once it is there. */
  referred: boolean;
}) {
  const editor = useEditor();
  const { messages } = editor;
  const id = useId();
  const labelId = `${id}-label`;
  const descriptionId = `${id}-description`;
  // An optional record can be absent altogether, and then none of its fields is asked for.
  const optional = schema.optional === true;
  // One reached by a reference may be the schema within itself: its fields are
  // shown when it is added, or a record that contains itself would never end.
  const absent = (optional || referred) && (value === null || value === undefined);
  const group = useRef<HTMLDivElement>(null);
  const pending = useRef<"first" | "add" | null>(null);
  // How many times the record has been removed: one added again is another record,
  // and nothing said of the last — even late, as focus leaves it — is said of this.
  const [removals, setRemovals] = useState(0);

  useLayoutEffect(() => {
    const request = pending.current;
    pending.current = null;
    if (request === "first") focusWithin(group.current?.querySelector("[data-field]"));
    else if (request === "add") focusWithin(group.current);
  });

  return (
    <div
      ref={group}
      role="group"
      aria-labelledby={labelId}
      aria-describedby={schema.description === undefined ? undefined : descriptionId}
      data-group={keyOf(path)}
      className="col-span-full mt-2 min-w-0 border-t border-border-muted pt-3"
    >
      <div className="mb-2 flex min-h-8 items-center gap-2">
        <span id={labelId} className="text-sm font-medium text-fg-strong">
          {label}
        </span>
        {optional && !absent && !readOnly && (
          <button
            type="button"
            aria-label={messages.remove(label)}
            className={ICON_BUTTON}
            onClick={() => {
              pending.current = "add";
              setRemovals(removals + 1);
              onValueChange(null);
            }}
          >
            <Icon d={CROSS} />
          </button>
        )}
      </div>
      {schema.description !== undefined && (
        <p id={descriptionId} className="mb-2 text-xs text-fg-muted">
          {schema.description}
        </p>
      )}
      {absent ? (
        !readOnly && (
          <button
            type="button"
            className={TEXT_BUTTON}
            onClick={() => {
              pending.current = "first";
              onValueChange(blank(schema));
            }}
          >
            {messages.add(label)}
          </button>
        )
      ) : (
        <Fields
          schema={schema}
          value={value}
          path={path}
          address={within(address, removals)}
          onValueChange={onValueChange}
          readOnly={readOnly}
        />
      )}
      <Problem id={`${id}-problem`} path={path} address={address} label={label} schema={schema} />
    </div>
  );
}

function Field({
  name,
  path,
  address,
  schema: stated,
  value,
  onValueChange,
  mark,
  readOnly,
  computed,
  lockable,
  formula,
}: {
  name: string;
  path: Path;
  address: string;
  schema: Schema;
  value: unknown;
  onValueChange: (value: unknown) => void;
  /** The word that sets this field apart from most of its group: optional, or required. */
  mark: string | undefined;
  readOnly: boolean;
  /** Whether its value is computed by its formula. */
  computed: boolean;
  /** Whether it is a field of the record itself, which can be locked. */
  lockable: boolean;
  formula: Issue | undefined;
}) {
  const id = useId();
  const labelId = `${id}-label`;
  const editor = useEditor();
  // A reference is followed here, where a value has reached it, and no sooner.
  const schema = resolve(stated);
  const label = schema.label ?? name;
  const lock = lockable ? editor.lockOf(name) : undefined;
  // A held value is not changed; nor is a computed one that cannot be frozen by typing over it.
  const locked = readOnly || schema.readOnly === true || lock === "held" || (computed && !lockable);
  const [choices, setChoices] = useChoices();
  const lockMark =
    lock === "held"
      ? editor.messages.heldMark
      : lock === "frozen"
        ? editor.messages.frozenMark
        : computed
          ? editor.messages.computedMark
          : undefined;

  if (schema.kind === "object") {
    return (
      <Group
        label={label}
        path={path}
        address={address}
        schema={schema}
        value={value}
        onValueChange={onValueChange}
        readOnly={locked}
        referred={stated.kind === "lazy"}
      />
    );
  }

  if (schema.kind === "list") {
    return (
      <>
        <span id={labelId} className={LABEL}>
          {label}
        </span>
        <List
          labelId={labelId}
          label={label}
          path={path}
          address={address}
          schema={schema}
          value={value}
          onValueChange={onValueChange}
          readOnly={locked}
        />
      </>
    );
  }

  const chosen = schema.kind === "variant" ? chosenOf(schema, value) : undefined;
  return (
    <>
      <label id={labelId} htmlFor={id} className={LABEL}>
        {label}
        {/* Assistive technology hears it from the control, which says it is required or does not. */}
        {mark !== undefined && (
          <span aria-hidden data-mark="" className="ms-1.5 text-xs">
            {mark}
          </span>
        )}
        {/* For the eye: the control's description says it in full. */}
        {lockMark !== undefined && (
          <span
            aria-hidden
            data-lock-mark=""
            className="ms-1.5 rounded-sm border border-border-muted px-1 text-xs whitespace-nowrap"
          >
            {lockMark}
          </span>
        )}
      </label>
      <Control
        id={id}
        path={path}
        address={address}
        label={label}
        schema={schema}
        value={value}
        onValueChange={(next) => {
          if (schema.kind === "variant") setChoices(choices + 1);
          onValueChange(next);
        }}
        readOnly={locked}
        labelledBy={labelId}
        computed={computed}
        lock={lock}
        lockable={lockable && !readOnly && schema.readOnly !== true}
        formula={formula}
      />
      {/* The chosen variant's fields follow the choice, in its columns, as one group. */}
      {schema.kind === "variant" && chosen !== undefined && chosen.schema.fields.length > 0 && (
        <Fields
          key={choices}
          schema={chosen.schema}
          value={value}
          path={path}
          address={within(within(address, schema.tag), choices)}
          onValueChange={onValueChange}
          readOnly={locked}
          within={labelId}
        />
      )}
    </>
  );
}

/** A field's formula that cannot be used, as a problem of the field. */
function formulaIssue(
  problems: ReturnType<typeof derivationsOf>["problems"],
  path: Path,
  key: string,
): Issue | undefined {
  const problem = problems.find((candidate) => candidate.key === key);
  if (problem === undefined) return undefined;
  return { path, code: "formula", message: problem.message, at: problem.at, length: problem.length };
}

/** Whether a single value must be given. A yes-or-no that must be answered always is: unticked is an answer. */
function asked(stated: Schema): "required" | "optional" | null {
  const schema = resolve(stated);
  if (schema.kind === "object" || schema.kind === "list") return null;
  if (schema.optional === true) return "optional";
  return schema.kind === "boolean" ? null : "required";
}

function Fields({
  schema,
  value,
  path,
  address,
  onValueChange,
  readOnly,
  within: groupLabel,
  lockable = false,
}: {
  schema: ObjectSchema;
  value: unknown;
  path: Path;
  address: string;
  onValueChange: (value: unknown) => void;
  readOnly: boolean;
  /** The id of what names these fields, where they are a group within another's columns. */
  within?: string;
  /** Whether these are the record's own fields, which can be locked. */
  lockable?: boolean;
}) {
  const { messages } = useEditor();
  const { plan, problems } = derivationsOf(schema);
  // Anything that is not a record is treated as one with nothing in it.
  const record = isRecord(value) ? value : NO_FIELDS;
  // Only the fewer are marked: where most fields are required, the optional ones.
  const kinds = schema.fields.map((field) => asked(field.schema));
  const required = kinds.filter((kind) => kind === "required").length;
  const optional = kinds.filter((kind) => kind === "optional").length;
  const marked = required === 0 || optional === 0 ? null : optional <= required ? "optional" : "required";
  return (
    <div
      {...(groupLabel !== undefined && { role: "group", "aria-labelledby": groupLabel })}
      className={groupLabel === undefined ? GRID : SUBGRID}
    >
      {schema.fields.map((field, index) => (
        <Field
          key={field.key}
          name={field.key}
          path={[...path, field.key]}
          address={within(address, field.key)}
          schema={field.schema}
          value={record[field.key]}
          onValueChange={(next) => onValueChange(withValueAt(record, [field.key], next))}
          mark={
            marked === null || kinds[index] !== marked
              ? undefined
              : marked === "optional"
                ? messages.optionalMark
                : messages.requiredMark
          }
          readOnly={readOnly}
          computed={plan.computes(field.key)}
          lockable={lockable}
          formula={formulaIssue(problems, [...path, field.key], field.key)}
        />
      ))}
    </div>
  );
}

export type ObjectEditorProps = Omit<ComponentProps<"div">, "onChange" | "ref"> & {
  schema: ObjectSchema;
  /**
   * The record. The editor takes a value it was not given back from
   * `onValueChange` to be another record, and starts afresh with it.
   */
  value: unknown;
  onValueChange: (value: unknown) => void;
  locale?: string;
  /** What the editor says, where the defaults are in the wrong language. */
  messages?: Partial<ObjectEditorMessages>;
  /** Show every problem now, rather than each as its field is left: after an attempt to submit. */
  showAllIssues?: boolean;
  /** Show the record without letting it be changed. */
  readOnly?: boolean;
  /**
   * Problems the schema cannot state — one field against another, or what only a
   * server knows — shown as the editor's own are, each at its path. The schema's
   * own problem at a path comes first.
   */
  issues?: readonly Issue[];
  /**
   * The locks on the record's fields, by key: a frozen computed field keeps its
   * value; a held field may not change, nor anything that would change it.
   * Typing over a computed field freezes it, and each field's menu holds,
   * freezes and unlocks.
   */
  locks?: ReadonlyMap<string, Lock>;
  defaultLocks?: ReadonlyMap<string, Lock>;
  /** Heard before `onValueChange` when one action changes both: they are one change. */
  onLocksChange?: (locks: ReadonlyMap<string, Lock>) => void;
  ref?: Ref<ObjectEditorHandle>;
};

const NO_LOCKS: ReadonlyMap<string, Lock> = new Map();
const NO_REFUSALS: ReadonlyMap<string, Refusal> = new Map();

/** Changes locks refused, by where they were made, and the record they were made to. */
interface Refusals {
  readonly of: number;
  readonly at: ReadonlyMap<string, Refusal>;
}

/**
 * An editor for a record, built from its schema: a labelled control for each
 * field, chosen by its kind; nested records as named groups; lists with their
 * items. A problem is shown beside its field once the field has been left.
 */
export function ObjectEditor({
  schema,
  value,
  onValueChange,
  locale: localeProp,
  messages: messagesProp,
  showAllIssues = false,
  readOnly = false,
  issues: issuesProp,
  locks: locksProp,
  defaultLocks = NO_LOCKS,
  onLocksChange,
  className,
  ref,
  onFocus,
  onBlur,
  ...props
}: ObjectEditorProps): ReactElement {
  const documentLocale = useDocumentLocale();
  const locale = localeProp ?? documentLocale;
  const root = useRef<HTMLDivElement>(null);
  const [left, setLeft] = useState(NOTHING);
  // Fields holding text that cannot be read as a value of their kind at all.
  const [unreadable, setUnreadable] = useState(NOTHING);
  const { announce, regionProps: announcementProps } = useAnnouncer();
  const [locks, setLocks] = useControllableState(locksProp, defaultLocks, onLocksChange);
  const offersLocks = locksProp !== undefined || defaultLocks !== NO_LOCKS || onLocksChange !== undefined;
  // Changes locks refused, where they were made, and the record they were made to. They
  // are read as keys go down, between renders, so they are kept where that sees them now.
  const [refusals, setRefusals] = useState<Refusals>({ of: 0, at: NO_REFUSALS });
  const refused = useRef(refusals);
  // The control the change on its way to the record was made in.
  const origin = useRef<{ readonly address: string; readonly label: string; readonly value: unknown }>(null);

  // A value that is neither the last one given nor the last one asked for is
  // another record: nothing remembered about the old one is true of it.
  const [seen, setSeen] = useState(value);
  const [requested, setRequested] = useState(value);
  const [record, setRecord] = useState(0);
  if (!Object.is(value, seen)) {
    setSeen(value);
    if (!Object.is(value, requested)) {
      setRecord(record + 1);
      setLeft(NOTHING);
      setUnreadable(NOTHING);
    }
  }

  // The focused element, to find its field again when a new record replaces every control.
  const focused = useRef<Element | null>(null);
  // The record that what is waiting was said of.
  const shown = useRef(record);
  useLayoutEffect(() => {
    shown.current = record;
    const was = focused.current;
    if (was === null || was.isConnected) return;
    focused.current = null;
    // Only focus that the new record dropped is put back; focus that has since gone elsewhere is left.
    if (document.activeElement !== null && document.activeElement !== document.body) return;
    const key = was.closest("[data-field]")?.getAttribute("data-field");
    if (key !== null && key !== undefined) focusPart(root.current, key);
  }, [record]);

  // While a pointer is pressed, what would move the page under it waits for the release.
  const pressed = useRef(false);
  const waiting = useRef<Array<{ readonly of: number; readonly change: () => void }>>([]);
  useEffect(() => {
    const press = () => {
      pressed.current = true;
    };
    const release = () => {
      pressed.current = false;
      const changes = waiting.current;
      waiting.current = [];
      // After the click the release may yet become, which belongs to what was pressed.
      if (changes.length === 0) return;
      setTimeout(() => {
        for (const { of, change } of changes) if (of === shown.current) change();
      }, 0);
    };
    document.addEventListener("pointerdown", press, true);
    document.addEventListener("pointerup", release, true);
    document.addEventListener("pointercancel", release, true);
    document.addEventListener("keydown", release, true);
    return () => {
      document.removeEventListener("pointerdown", press, true);
      document.removeEventListener("pointerup", release, true);
      document.removeEventListener("pointercancel", release, true);
      document.removeEventListener("keydown", release, true);
    };
  }, []);

  const issues = useMemo(() => {
    const byPath = new Map<string, Issue>();
    const note = (issue: Issue) => {
      const key = keyOf(issue.path);
      if (!byPath.has(key)) byPath.set(key, issue);
    };
    validate(schema, value).forEach(note);
    issuesProp?.forEach(note);
    return byPath;
  }, [schema, value, issuesProp]);

  const messages = useMemo(() => ({ ...EDITOR_MESSAGES, ...messagesProp }), [messagesProp]);
  const given = isRecord(value) ? value : NO_FIELDS;
  const labelOf = (key: string) => {
    const field = schema.fields.find((candidate) => candidate.key === key);
    return field === undefined ? key : (resolve(field.schema).label ?? key);
  };

  /** Puts what was refused where, as it is now: for this render and for a key's handlers before the next. */
  function putRefusal(address: string, refusal: Refusal | undefined): void {
    const current = refused.current.of === record ? refused.current.at : NO_REFUSALS;
    if (current.get(address) === refusal) return;
    const at = new Map(current);
    if (refusal === undefined) at.delete(address);
    else at.set(address, refusal);
    refused.current = { of: record, at };
    setRefusals(refused.current);
  }

  /** A change to the record, made with what it computes, or refused by a lock and said where it was made. */
  function commitRecord(next: unknown): void {
    const from = origin.current;
    origin.current = null;
    if (!isRecord(next)) {
      setRequested(next);
      onValueChange(next);
      return;
    }
    const { plan } = derivationsOf(schema);
    const edit = editRecord(plan, given, deriveNested(schema, given, next), (key) => locks.get(key));
    if (edit.refusedBy !== undefined) {
      const text = messages.refused(labelOf(edit.refusedBy));
      if (from !== null) putRefusal(from.address, { value: from.value, text });
      announce(from === null ? text : messages.problem(from.label, text));
      return;
    }
    if (from !== null) putRefusal(from.address, undefined);
    if (edit.frozen.length > 0) {
      const frozen = new Map(locks);
      for (const key of edit.frozen) frozen.set(key, "frozen");
      setLocks(frozen);
    }
    if (Object.is(edit.record, given)) return;
    setRequested(edit.record);
    onValueChange(edit.record);
  }

  const editor = useMemo<Editor>(() => {
    const settle = (change: () => void) => {
      if (pressed.current) waiting.current.push({ of: shown.current, change });
      else change();
    };
    const toggled = (set: ReadonlySet<string>, address: string, on: boolean) => {
      if (set.has(address) === on) return set;
      const next = new Set(set);
      if (on) next.add(address);
      else next.delete(address);
      return next;
    };
    const withLock = (key: string, lock: Lock | undefined) => {
      const next = new Map(locks);
      if (lock === undefined) next.delete(key);
      else next.set(key, lock);
      return next;
    };
    return {
      locale,
      messages,
      problemAt(path, address) {
        if (unreadable.has(address)) return { path, code: "type" };
        const issue = issues.get(keyOf(path));
        // A formula is checked as it is written, like text that cannot be read.
        if (issue?.code === "formula") return issue;
        return showAllIssues || left.has(address) ? issue : undefined;
      },
      isEarned: (address) => unreadable.has(address) || left.has(address),
      leave: (address) => settle(() => setLeft((current) => toggled(current, address, true))),
      setUnreadable: (address, is) => settle(() => setUnreadable((current) => toggled(current, address, is))),
      announce,
      lockOf: (key) => locks.get(key),
      offersLocks,
      lock: (key, lock) => setLocks(withLock(key, lock)),
      unlock(key, address, label) {
        const { plan } = derivationsOf(schema);
        const result = unlockRecord(plan, given, key, (field) => locks.get(field));
        if (result.refusedBy !== undefined) {
          const text = messages.refused(labelOf(result.refusedBy));
          putRefusal(address, { value: given[key], text });
          announce(messages.problem(label, text));
          return;
        }
        putRefusal(address, undefined);
        setLocks(withLock(key, undefined));
        if (Object.is(result.record, given)) return;
        setRequested(result.record);
        onValueChange(result.record);
      },
      from(address, label, next) {
        origin.current = { address, label, value: next };
      },
      refusalAt: (address) => (refusals.of === record ? refusals.at.get(address) : undefined),
      refusalNow: (address) =>
        refused.current.of === shown.current ? refused.current.at.get(address) : undefined,
      forget: (address) => putRefusal(address, undefined),
    };
    // `putRefusal`, `announce` and `labelOf` read only what is listed.
    // oxlint-disable-next-line react/exhaustive-deps
  }, [
    locale,
    issues,
    messages,
    showAllIssues,
    left,
    unreadable,
    locks,
    setLocks,
    offersLocks,
    schema,
    given,
    onValueChange,
    refusals,
    record,
  ]);

  useImperativeHandle(
    ref,
    () => ({
      focusFirstIssue() {
        // In the order of the document, which is the order people meet them in.
        for (const part of root.current?.querySelectorAll("[data-field], [data-group]") ?? []) {
          const key = part.getAttribute("data-field") ?? part.getAttribute("data-group")!;
          const tag = part.getAttribute("data-tag");
          const unread =
            part.hasAttribute("data-field") && part.querySelector('[aria-invalid="true"]') !== null;
          const found = unread || issues.has(key) || (tag !== null && issues.has(tag));
          if (found && focusPart(root.current, key)) return true;
        }
        return false;
      },
    }),
    [issues],
  );

  return (
    <div
      ref={root}
      className={cn("@container min-w-0", className)}
      {...props}
      onFocus={(event) => {
        onFocus?.(event);
        focused.current = event.target;
      }}
      onBlur={(event) => {
        onBlur?.(event);
        if (!event.currentTarget.contains(event.relatedTarget)) focused.current = null;
      }}
    >
      <EditorContext value={editor}>
        <Fields
          key={record}
          schema={schema}
          value={value}
          path={[]}
          address=""
          readOnly={readOnly}
          lockable
          onValueChange={commitRecord}
        />
      </EditorContext>
      {/* One place for everything said aloud: a problem, a removal, a move. */}
      <div {...announcementProps} />
    </div>
  );
}