SchemaFields
$ npx shadcn@latest add https://lacodda.github.io/dowel/r/schema-fields.jsonThe data is already described once. Settings, a profile, a config file: a
zod schema or a Rust struct says what the fields are, what is required and what
range a number keeps to. A form written by hand beside it is a second
description, and the two drift - a field added to the struct that nobody can
edit, a range the form allows and the server refuses. SchemaFields draws the
form from the first description.
The schema is the adapter. Both writers already speak JSON Schema, so there is nothing to install for either:
import { z } from 'zod'import { SchemaFields } from '@/components/ui/schema-fields'
const Workspace = z.object({ name: z.string().min(2).meta({ title: 'Workspace name' }), port: z.number().int().min(1).max(65535).default(8443).meta({ title: 'Port' }), theme: z.enum(['system', 'light', 'dark']).meta({ title: 'Theme', 'x-control': 'segmented' }), letter: z.boolean().default(true).meta({ title: 'Weekly letter' }),})const schema = z.toJSONSchema(Workspace)
<SchemaFields schema={schema} value={value} onValueChange={setValue} words={words} />A Rust struct reaches the same place through schemars - schema_for!(Config)
sent to the screen as JSON. $ref into $defs, Option<T> written either way
schemars writes it, and a documented enum as a union of consts are all read.
What is drawn as what. The type of the value picks the control; x-control
in the schema picks between the drawings one type has.
| The schema says | Drawn as |
|---|---|
type: string |
an Input |
format: email, format: uri |
an Input of that type |
format: date |
a DatePicker |
writeOnly: true or format: password |
a PasswordField |
x-control: multiline |
a Textarea |
type: number, type: integer |
a NumberField, stepping by multipleOf |
type: boolean |
a Switch beside its name |
enum, or a union of consts with titles |
a Select |
…with x-control: segmented or radio |
a SegmentedControl or a RadioGroup |
| an array of answers | a column of Checkboxes |
| an array of plain strings | a TagInput |
type: object |
a FieldGroup of its own fields |
const |
nothing - a value nobody chooses; defaultsOf writes it |
The label is the title, else the property’s own name; the hint is the
description. Every field is a Field or a FieldGroup, so the label, the
hint and the error are wired the one way the set wires them.
Refused by name, never left out. A list of records, a map, a union of
objects, not, if - a keyword this cannot draw throws a SchemaError naming
the field when the schema is read. A form that silently leaves a field out is
a form the data can no longer be edited through, and nobody notices until the
value is wrong. Draw such a field yourself with custom, or change the schema:
<SchemaFields schema={schema} custom={{ stops: ({ field, value, onValueChange, error }) => ( <StopsEditor label={field.label} value={value} onChange={onValueChange} error={error} /> ), }} .../>An empty box writes nothing. The key is removed, or set to null where the
schema allows null. An optional field left empty is absent rather than an
empty string that fails its own minLength - which is what a hand-written form
does on its first day.
A problem is said once its field is left, not on the first keystroke and
not before anyone has touched the form. After a save that was refused, reveal
says every problem at once. A number out of range is said, not quietly moved
to the bound.
Problems from elsewhere go over the schema’s own. A name that is taken, a
zod refine the schema cannot state, a server that said no - pass them by key
in errors, and they are said at once:
<SchemaFields errors={{ name: 'A workspace by that name exists.', 'server.port': 'Busy.' }} ... />Ask before saving. The check is a function, so the Save button can use it:
import { readSchema } from '@/components/ui/schema-shape'import { checkValue } from '@/components/ui/schema-check'
const fields = useMemo(() => readSchema(schema), [schema])const problems = checkValue(fields, value)
<Button disabled={problems.length > 0}>Save</Button>Put noValidate on the <form> around it: the browser’s own bubbles would
say the same things a second time, in the browser’s language.
No words of its own. What a problem is called is words.issue, given the
issue’s code and limit - in the product’s language, through its own
translations:
const words: SchemaFieldsWords = { issue: (issue) => { switch (issue.code) { case 'required': return t('form.required') case 'minimum': return t('form.atLeast', { n: issue.limit }) // ... } }, none: t('form.none'), choose: t('form.choose'), pickDate: t('form.pickDate'), previousMonth: t('calendar.previous'), nextMonth: t('calendar.next'), clearDate: t('calendar.clear'), showSecret: t('form.show'), hideSecret: t('form.hide'), removeTag: (tag) => t('form.removeTag', { tag }),}Props
Read from the types in ui/schema-fields.tsx, the file a product copies - so the description of each prop is also what an editor shows over it there.
| Prop | Type | Default | |
|---|---|---|---|
schemarequired | JsonSchema | An object schema: its properties are the fields, in the order written. | |
valuerequired | Readonly<Record<string, unknown>> | The record the fields edit, by property key. | |
onValueChangerequired | (value: Record<string, unknown>) => void | Told the next record - a copy, never the same object changed. | |
wordsrequired | SchemaFieldsWords | Every word the fields show, so the product can translate them. | |
errors | Readonly<Record<string, ReactNode>> | Problems from elsewhere - the server, a rule the schema cannot state - by key (server.port). Said at once, and over the schema's own. | |
reveal | boolean | false | Say every problem now, left or not - after a submit that was refused. |
custom | Readonly<Record<string, (props: SchemaCustomProps) => ReactNode>> | Fields the product draws itself, by key. Read for their label and help; never refused, never checked here. | |
disabled | boolean | false | Disables every field at once, for example while the record saves. |
className | string |
Accessibility
The examples on the stand, through axe 4.14.0 in a browser, in both themes, against WCAG 2.2 A and AA and axe's best practices: 24 rules applied and passed, and none failed. The accessibility report has every item.
History
Each release that changed ui/schema-fields.tsx - the file a product copies - newest first.