schema-components 1.12.5 → 1.12.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +666 -0
- package/dist/react/SchemaComponent.mjs +3 -4
- package/dist/react/headless.mjs +1 -1
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
# schema-components
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/schema-components)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://github.com/Mearman/schema-components/actions)
|
|
6
|
+
|
|
7
|
+
React components that render UI from Zod schemas, JSON Schema, and OpenAPI documents.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install schema-components
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Peer dependencies: `zod@^4.0.0`, `react@^18.0.0 || ^19.0.0`.
|
|
16
|
+
|
|
17
|
+
## `SchemaComponent`
|
|
18
|
+
|
|
19
|
+
The single entry point. Accepts Zod schemas, JSON Schema objects, or OpenAPI documents:
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
import { SchemaComponent } from "schema-components/react/SchemaComponent";
|
|
23
|
+
|
|
24
|
+
// Zod schema
|
|
25
|
+
<SchemaComponent schema={z.object({ name: z.string() })} value={data} />
|
|
26
|
+
|
|
27
|
+
// JSON Schema object
|
|
28
|
+
<SchemaComponent schema={{ type: "object", properties: { name: { type: "string" } } }} value={data} />
|
|
29
|
+
|
|
30
|
+
// OpenAPI document + ref
|
|
31
|
+
<SchemaComponent schema={openApiSpec} ref="#/components/schemas/User" value={data} />
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Props
|
|
35
|
+
|
|
36
|
+
| Prop | Type | Description |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `schema` | `ZodType \| JSONObject \| OpenAPIDocument` | The schema to render |
|
|
39
|
+
| `value` | `unknown` | Current value for the schema |
|
|
40
|
+
| `onChange` | `(value: unknown) => void` | Callback when value changes |
|
|
41
|
+
| `readOnly` | `boolean` | Force read-only presentation |
|
|
42
|
+
| `writeOnly` | `boolean` | Force write-only (blank inputs) |
|
|
43
|
+
| `ref` | `string` | JSON Pointer into OpenAPI document |
|
|
44
|
+
| `fields` | `InferFields<T>` | Type-safe per-field overrides |
|
|
45
|
+
| `widgets` | `WidgetMap` | Instance-scoped widget overrides |
|
|
46
|
+
| `validate` | `boolean` | Enable Zod validation on change |
|
|
47
|
+
| `onValidationError` | `(error: unknown) => void` | Callback for validation errors |
|
|
48
|
+
| `onError` | `(error: SchemaError) => ReactNode \| void` | Per-component error handler |
|
|
49
|
+
| `resolver` | `ComponentResolver` | Theme adapter override |
|
|
50
|
+
| `meta` | `SchemaMeta` | Schema-level metadata override |
|
|
51
|
+
|
|
52
|
+
## Component editability
|
|
53
|
+
|
|
54
|
+
Fields render in one of three states, controlled by `readOnly` and `writeOnly` from three sources:
|
|
55
|
+
|
|
56
|
+
| State | Rendering |
|
|
57
|
+
|---|---|
|
|
58
|
+
| **Presentation** | Read-only display. Formatted text, links, badges. No inputs. |
|
|
59
|
+
| **Input** | Empty field. Blank inputs, "Select…" dropdowns, unchecked toggles. |
|
|
60
|
+
| **Editable** | Pre-populated input the user can change. |
|
|
61
|
+
|
|
62
|
+
### Three sources, priority order
|
|
63
|
+
|
|
64
|
+
1. **Schema property** (`.meta({ readOnly: true })`) — always wins
|
|
65
|
+
2. **Component props** (`readOnly` / `writeOnly` on `<SchemaComponent>`) — rendering context
|
|
66
|
+
3. **Schema root** (`.meta({ readOnly: true })` on root schema) — fallback default
|
|
67
|
+
4. Neither → Editable
|
|
68
|
+
|
|
69
|
+
### Overriding with `readOnly: false`
|
|
70
|
+
|
|
71
|
+
A field override can explicitly opt out of a higher-level `readOnly`:
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
<SchemaComponent
|
|
75
|
+
schema={userSchema}
|
|
76
|
+
value={user}
|
|
77
|
+
readOnly // everything presentation
|
|
78
|
+
fields={{
|
|
79
|
+
address: {
|
|
80
|
+
readOnly: false, // address subtree: editable
|
|
81
|
+
city: { readOnly: true }, // city: still presentation
|
|
82
|
+
},
|
|
83
|
+
}}
|
|
84
|
+
/>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Type-safe field overrides
|
|
88
|
+
|
|
89
|
+
The `fields` prop type is inferred from the schema:
|
|
90
|
+
|
|
91
|
+
```tsx
|
|
92
|
+
// Zod — full autocomplete
|
|
93
|
+
<SchemaComponent
|
|
94
|
+
schema={userSchema}
|
|
95
|
+
fields={{
|
|
96
|
+
name: { readOnly: true }, // ✓ type-safe
|
|
97
|
+
address: {
|
|
98
|
+
city: { description: "City" }, // ✓ nested, type-safe
|
|
99
|
+
},
|
|
100
|
+
// nme: { readOnly: true }, // ✗ TypeScript error: unknown key
|
|
101
|
+
}}
|
|
102
|
+
/>
|
|
103
|
+
|
|
104
|
+
// JSON Schema as const — full autocomplete
|
|
105
|
+
const jsonSchema = {
|
|
106
|
+
type: "object" as const,
|
|
107
|
+
properties: {
|
|
108
|
+
name: { type: "string" as const },
|
|
109
|
+
email: { type: "string" as const, format: "email" },
|
|
110
|
+
},
|
|
111
|
+
required: ["name"],
|
|
112
|
+
} as const;
|
|
113
|
+
|
|
114
|
+
<SchemaComponent
|
|
115
|
+
schema={jsonSchema}
|
|
116
|
+
fields={{
|
|
117
|
+
name: { readOnly: true }, // ✓ inferred from as const
|
|
118
|
+
// nme: { readOnly: true }, // ✗ TypeScript error
|
|
119
|
+
}}
|
|
120
|
+
/>
|
|
121
|
+
|
|
122
|
+
// OpenAPI as const + ref — full autocomplete
|
|
123
|
+
const spec = {
|
|
124
|
+
openapi: "3.1.0",
|
|
125
|
+
components: {
|
|
126
|
+
schemas: {
|
|
127
|
+
User: {
|
|
128
|
+
type: "object" as const,
|
|
129
|
+
properties: {
|
|
130
|
+
id: { type: "string" as const },
|
|
131
|
+
name: { type: "string" as const },
|
|
132
|
+
},
|
|
133
|
+
required: ["id", "name"],
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
} as const;
|
|
138
|
+
|
|
139
|
+
<SchemaComponent
|
|
140
|
+
schema={spec}
|
|
141
|
+
ref="#/components/schemas/User"
|
|
142
|
+
fields={{
|
|
143
|
+
id: { readOnly: true }, // ✓ inferred through ref
|
|
144
|
+
}}
|
|
145
|
+
/>
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### `FieldOverride`
|
|
149
|
+
|
|
150
|
+
Each field override accepts:
|
|
151
|
+
|
|
152
|
+
| Property | Type | Description |
|
|
153
|
+
|---|---|---|
|
|
154
|
+
| `readOnly` | `boolean` | Override editability for this field |
|
|
155
|
+
| `writeOnly` | `boolean` | Override write-only state |
|
|
156
|
+
| `visible` | `boolean` | Hide the field entirely when `false` |
|
|
157
|
+
| `order` | `number` | Sort order within parent object |
|
|
158
|
+
| `onValidationError` | `(error: unknown) => void` | Per-field validation callback |
|
|
159
|
+
| `description` | `string` | Override label / description |
|
|
160
|
+
| `default` | `unknown` | Override default value |
|
|
161
|
+
| `component` | `string` | Widget name for custom rendering |
|
|
162
|
+
|
|
163
|
+
Plus any standard JSON Schema meta properties (`title`, `format`, `pattern`, etc.).
|
|
164
|
+
|
|
165
|
+
## Individual fields
|
|
166
|
+
|
|
167
|
+
```tsx
|
|
168
|
+
import { SchemaField } from "schema-components/react/SchemaComponent";
|
|
169
|
+
|
|
170
|
+
// Type-safe path — only valid dot-paths accepted
|
|
171
|
+
<SchemaField
|
|
172
|
+
schema={userSchema}
|
|
173
|
+
path="address.city" // ✓ type-safe
|
|
174
|
+
// path="address.cty" // ✗ TypeScript error
|
|
175
|
+
value={user}
|
|
176
|
+
onChange={setUser}
|
|
177
|
+
/>
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
When the schema is a Zod schema or typed `as const`, only valid dot-paths like `"address.city"` are accepted. Invalid paths trigger TypeScript errors. Runtime schemas accept any string.
|
|
181
|
+
|
|
182
|
+
## OpenAPI components
|
|
183
|
+
|
|
184
|
+
Render API operations with type-safe field overrides:
|
|
185
|
+
|
|
186
|
+
```tsx
|
|
187
|
+
import {
|
|
188
|
+
ApiOperation,
|
|
189
|
+
ApiParameters,
|
|
190
|
+
ApiRequestBody,
|
|
191
|
+
ApiResponse,
|
|
192
|
+
} from "schema-components/openapi/components";
|
|
193
|
+
|
|
194
|
+
const petStore = {
|
|
195
|
+
openapi: "3.1.0",
|
|
196
|
+
paths: {
|
|
197
|
+
"/pets": {
|
|
198
|
+
post: {
|
|
199
|
+
requestBody: {
|
|
200
|
+
content: {
|
|
201
|
+
"application/json": {
|
|
202
|
+
schema: {
|
|
203
|
+
type: "object" as const,
|
|
204
|
+
properties: {
|
|
205
|
+
name: { type: "string" as const },
|
|
206
|
+
tag: { type: "string" as const },
|
|
207
|
+
},
|
|
208
|
+
required: ["name"],
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
responses: { "201": { description: "Created" } },
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
} as const;
|
|
218
|
+
|
|
219
|
+
// Full operation — parameters, request body, responses
|
|
220
|
+
<ApiOperation schema={petStore} path="/pets" method="post" />
|
|
221
|
+
|
|
222
|
+
// Just the request body with type-safe fields
|
|
223
|
+
<ApiRequestBody
|
|
224
|
+
schema={petStore}
|
|
225
|
+
path="/pets"
|
|
226
|
+
method="post"
|
|
227
|
+
fields={{
|
|
228
|
+
name: { description: "Pet name" }, // ✓ inferred from as const
|
|
229
|
+
}}
|
|
230
|
+
/>
|
|
231
|
+
|
|
232
|
+
// Just parameters with type-safe overrides
|
|
233
|
+
<ApiParameters
|
|
234
|
+
schema={petStore}
|
|
235
|
+
path="/pets"
|
|
236
|
+
method="get"
|
|
237
|
+
overrides={{
|
|
238
|
+
limit: { description: "Max results" },
|
|
239
|
+
}}
|
|
240
|
+
/>
|
|
241
|
+
|
|
242
|
+
// Response schema
|
|
243
|
+
<ApiResponse schema={petStore} path="/pets" method="get" status="200" />
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Theme adapters
|
|
247
|
+
|
|
248
|
+
Headless by default (plain HTML). Wrap with a theme adapter for styled components:
|
|
249
|
+
|
|
250
|
+
### shadcn/ui
|
|
251
|
+
|
|
252
|
+
```tsx
|
|
253
|
+
import { SchemaProvider } from "schema-components/react/SchemaComponent";
|
|
254
|
+
import { shadcnResolver } from "schema-components/themes/shadcn";
|
|
255
|
+
|
|
256
|
+
<SchemaProvider resolver={shadcnResolver}>
|
|
257
|
+
<SchemaComponent schema={userSchema} value={user} onChange={setUser} />
|
|
258
|
+
</SchemaProvider>
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### MUI
|
|
262
|
+
|
|
263
|
+
```tsx
|
|
264
|
+
import { registerMuiComponents } from "schema-components/themes/mui";
|
|
265
|
+
import { shadcnResolver } from "schema-components/themes/shadcn";
|
|
266
|
+
|
|
267
|
+
// Register MUI components at app startup
|
|
268
|
+
registerMuiComponents();
|
|
269
|
+
|
|
270
|
+
// Use via SchemaProvider
|
|
271
|
+
<SchemaProvider resolver={shadcnResolver}>
|
|
272
|
+
<SchemaComponent schema={userSchema} value={user} onChange={setUser} />
|
|
273
|
+
</SchemaProvider>
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### Mantine
|
|
277
|
+
|
|
278
|
+
```tsx
|
|
279
|
+
import { registerMantineComponents } from "schema-components/themes/mantine";
|
|
280
|
+
import { shadcnResolver } from "schema-components/themes/shadcn";
|
|
281
|
+
|
|
282
|
+
registerMantineComponents();
|
|
283
|
+
|
|
284
|
+
<SchemaProvider resolver={shadcnResolver}>
|
|
285
|
+
<SchemaComponent schema={userSchema} value={user} onChange={setUser} />
|
|
286
|
+
</SchemaProvider>
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### Radix Themes
|
|
290
|
+
|
|
291
|
+
```tsx
|
|
292
|
+
import { registerRadixComponents } from "schema-components/themes/radix";
|
|
293
|
+
import { shadcnResolver } from "schema-components/themes/shadcn";
|
|
294
|
+
|
|
295
|
+
registerRadixComponents();
|
|
296
|
+
|
|
297
|
+
<SchemaProvider resolver={shadcnResolver}>
|
|
298
|
+
<SchemaComponent schema={userSchema} value={user} onChange={setUser} />
|
|
299
|
+
</SchemaProvider>
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### Custom adapter
|
|
303
|
+
|
|
304
|
+
```tsx
|
|
305
|
+
import type { RenderProps, ComponentResolver } from "schema-components/core/renderer";
|
|
306
|
+
|
|
307
|
+
const myResolver: ComponentResolver = {
|
|
308
|
+
string: (props: RenderProps) => {
|
|
309
|
+
if (props.readOnly) return <span>{props.value}</span>;
|
|
310
|
+
return <input value={props.value} onChange={(e) => props.onChange(e.target.value)} />;
|
|
311
|
+
},
|
|
312
|
+
object: (props: RenderProps) => {
|
|
313
|
+
return (
|
|
314
|
+
<div>
|
|
315
|
+
{props.fields && Object.entries(props.fields).map(([key, field]) => (
|
|
316
|
+
<div key={key}>
|
|
317
|
+
<label>{field.meta.description}</label>
|
|
318
|
+
{props.renderChild(field, (props.value as Record<string, unknown>)?.[key], (v) => {
|
|
319
|
+
props.onChange({ ...(props.value as object), [key]: v });
|
|
320
|
+
})}
|
|
321
|
+
</div>
|
|
322
|
+
))}
|
|
323
|
+
</div>
|
|
324
|
+
);
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Every render function receives `props.renderChild` for recursive rendering — no need to know about the resolver or rendering context.
|
|
330
|
+
|
|
331
|
+
## Custom widgets
|
|
332
|
+
|
|
333
|
+
Widgets let you override rendering for specific fields using `.meta({ component: name })`. Three scopes are available, checked in order:
|
|
334
|
+
|
|
335
|
+
1. **Instance** — `widgets` prop on `<SchemaComponent>`
|
|
336
|
+
2. **Context** — `widgets` prop on `<SchemaProvider>`
|
|
337
|
+
3. **Global** — `registerWidget()` for app-wide defaults
|
|
338
|
+
|
|
339
|
+
### Global registration
|
|
340
|
+
|
|
341
|
+
```tsx
|
|
342
|
+
import { registerWidget } from "schema-components/react/SchemaComponent";
|
|
343
|
+
|
|
344
|
+
registerWidget("richtext", ({ value, onChange }) => (
|
|
345
|
+
<RichTextEditor value={value} onChange={onChange} />
|
|
346
|
+
));
|
|
347
|
+
|
|
348
|
+
const schema = z.object({
|
|
349
|
+
bio: z.string().meta({ component: "richtext" }),
|
|
350
|
+
});
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
### Context-scoped widgets
|
|
354
|
+
|
|
355
|
+
```tsx
|
|
356
|
+
import { SchemaProvider } from "schema-components/react/SchemaComponent";
|
|
357
|
+
import type { WidgetMap } from "schema-components/react/SchemaComponent";
|
|
358
|
+
|
|
359
|
+
const adminWidgets: WidgetMap = new Map([
|
|
360
|
+
["richtext", ({ value, onChange }) => <RichTextEditor value={value} onChange={onChange} />],
|
|
361
|
+
["avatar", ({ value, onChange }) => <AvatarUploader value={value} onChange={onChange} />],
|
|
362
|
+
]);
|
|
363
|
+
|
|
364
|
+
<SchemaProvider resolver={shadcnResolver} widgets={adminWidgets}>
|
|
365
|
+
<SchemaComponent schema={userSchema} value={user} onChange={setUser} />
|
|
366
|
+
<SchemaComponent schema={profileSchema} value={profile} onChange={setProfile} />
|
|
367
|
+
</SchemaProvider>
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
### Instance-scoped widgets
|
|
371
|
+
|
|
372
|
+
```tsx
|
|
373
|
+
const formWidgets: WidgetMap = new Map([
|
|
374
|
+
["richtext", ({ value, onChange }) => <SimpleTextarea value={value} onChange={onChange} />],
|
|
375
|
+
]);
|
|
376
|
+
|
|
377
|
+
<SchemaComponent schema={formSchema} value={form} widgets={formWidgets} />
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
### Resolution order
|
|
381
|
+
|
|
382
|
+
```
|
|
383
|
+
.meta({ component }) hint → instance widgets → context widgets → global registerWidget() → theme adapter → headless default
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
### `WidgetMap` type
|
|
387
|
+
|
|
388
|
+
```tsx
|
|
389
|
+
import type { WidgetMap } from "schema-components/react/SchemaComponent";
|
|
390
|
+
|
|
391
|
+
// ReadonlyMap<string, (props: RenderProps) => unknown>
|
|
392
|
+
const widgets: WidgetMap = new Map([
|
|
393
|
+
["name", (props) => <MyInput {...props} />],
|
|
394
|
+
]);
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Server Components: `<SchemaView>` accepts a `widgets` prop directly (no React context available):
|
|
398
|
+
|
|
399
|
+
```tsx
|
|
400
|
+
<SchemaView schema={schema} value={data} widgets={serverWidgets} />
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
## Validation
|
|
404
|
+
|
|
405
|
+
```tsx
|
|
406
|
+
<SchemaComponent
|
|
407
|
+
schema={userSchema}
|
|
408
|
+
value={user}
|
|
409
|
+
onChange={setUser}
|
|
410
|
+
validate
|
|
411
|
+
onValidationError={(error) => console.error(error)}
|
|
412
|
+
/>
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
Validation uses the original Zod schema (if input was Zod) or `z.fromJSONSchema()` (if input was JSON Schema / OpenAPI).
|
|
416
|
+
|
|
417
|
+
### Per-field validation errors
|
|
418
|
+
|
|
419
|
+
Add `onValidationError` to individual field overrides to receive errors for specific fields:
|
|
420
|
+
|
|
421
|
+
```tsx
|
|
422
|
+
<SchemaComponent
|
|
423
|
+
schema={userSchema}
|
|
424
|
+
value={user}
|
|
425
|
+
onChange={setUser}
|
|
426
|
+
validate
|
|
427
|
+
fields={{
|
|
428
|
+
email: { onValidationError: (err) => setEmailError(err) },
|
|
429
|
+
name: { onValidationError: (err) => setNameError(err) },
|
|
430
|
+
}}
|
|
431
|
+
/>
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
Errors are dispatched based on Zod error paths. The root-level `onValidationError` still receives all errors.
|
|
435
|
+
|
|
436
|
+
## Field visibility
|
|
437
|
+
|
|
438
|
+
Hide fields conditionally using the `visible` override:
|
|
439
|
+
|
|
440
|
+
```tsx
|
|
441
|
+
<SchemaComponent
|
|
442
|
+
schema={paymentSchema}
|
|
443
|
+
value={payment}
|
|
444
|
+
fields={{
|
|
445
|
+
cardNumber: { visible: payment.method === "card" },
|
|
446
|
+
sortCode: { visible: payment.method === "bank" },
|
|
447
|
+
}}
|
|
448
|
+
/>
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
When `visible: false`, the field is completely removed — no label, no empty placeholder, no hidden input.
|
|
452
|
+
|
|
453
|
+
## Field ordering
|
|
454
|
+
|
|
455
|
+
Control the order fields appear in rendered objects using `order`:
|
|
456
|
+
|
|
457
|
+
```tsx
|
|
458
|
+
<SchemaComponent
|
|
459
|
+
schema={userSchema}
|
|
460
|
+
value={user}
|
|
461
|
+
fields={{
|
|
462
|
+
email: { order: 1 },
|
|
463
|
+
name: { order: 2 },
|
|
464
|
+
role: { order: 3 },
|
|
465
|
+
}}
|
|
466
|
+
/>
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
Lower `order` values render first. Fields without `order` keep their insertion order and appear after ordered fields. Can also be set in schema metadata:
|
|
470
|
+
|
|
471
|
+
```tsx
|
|
472
|
+
const schema = z.object({
|
|
473
|
+
summary: z.string().meta({ order: 1 }),
|
|
474
|
+
title: z.string().meta({ order: 2 }),
|
|
475
|
+
});
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
## Discriminated unions
|
|
479
|
+
|
|
480
|
+
Discriminated unions (`z.discriminatedUnion` or JSON Schema `oneOf` with `const` properties) render as tabbed panels. Each tab is labelled by the discriminator's `const` value. Clicking a tab resets the value with the new discriminator.
|
|
481
|
+
|
|
482
|
+
```tsx
|
|
483
|
+
const payment = z.discriminatedUnion("method", [
|
|
484
|
+
z.object({
|
|
485
|
+
method: z.literal("card"),
|
|
486
|
+
cardNumber: z.string(),
|
|
487
|
+
expiry: z.string(),
|
|
488
|
+
}),
|
|
489
|
+
z.object({
|
|
490
|
+
method: z.literal("bank"),
|
|
491
|
+
accountNumber: z.string(),
|
|
492
|
+
sortCode: z.string(),
|
|
493
|
+
}),
|
|
494
|
+
]);
|
|
495
|
+
|
|
496
|
+
<SchemaComponent schema={payment} value={{ method: "card", cardNumber: "4111...", expiry: "12/28" }} />
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
In read-only mode, only the active variant is rendered (no tabs).
|
|
500
|
+
|
|
501
|
+
## Date and time inputs
|
|
502
|
+
|
|
503
|
+
String schemas with `format: "date"`, `format: "time"`, or `format: "date-time"` render as the corresponding HTML5 input types:
|
|
504
|
+
|
|
505
|
+
```tsx
|
|
506
|
+
const eventSchema = z.object({
|
|
507
|
+
date: z.string().meta({ format: "date" }),
|
|
508
|
+
startTime: z.string().meta({ format: "time" }),
|
|
509
|
+
createdAt: z.string().meta({ format: "date-time" }),
|
|
510
|
+
});
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
This produces `<input type="date">`, `<input type="time">`, and `<input type="datetime-local">` respectively. In read-only mode, dates are formatted using `toLocaleDateString()` / `toLocaleString()`.
|
|
514
|
+
|
|
515
|
+
## Schema defaults
|
|
516
|
+
|
|
517
|
+
Default values from `z.string().default("hello")` or JSON Schema `"default": "hello"` are used when the `value` prop is `undefined`:
|
|
518
|
+
|
|
519
|
+
```tsx
|
|
520
|
+
const schema = z.object({
|
|
521
|
+
name: z.string().default("World"),
|
|
522
|
+
count: z.number().default(0),
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// Renders with "World" and 0 pre-filled
|
|
526
|
+
<SchemaComponent schema={schema} />
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
Defaults propagate through nested objects — each field uses its own default independently.
|
|
530
|
+
|
|
531
|
+
## File uploads
|
|
532
|
+
|
|
533
|
+
String schemas with `format: "binary"` render as `<input type="file">`. Use `contentMediaType` to restrict accepted MIME types:
|
|
534
|
+
|
|
535
|
+
```tsx
|
|
536
|
+
const schema = z.object({
|
|
537
|
+
avatar: z.string().meta({ format: "binary" }),
|
|
538
|
+
resume: z.string().meta({ format: "binary", contentMediaType: "application/pdf" }),
|
|
539
|
+
});
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
In read-only mode, file fields display a static label ("File field") since there is no value to show. The `onChange` callback receives the `File` object from the browser.
|
|
543
|
+
|
|
544
|
+
## Server Components
|
|
545
|
+
|
|
546
|
+
For read-only rendering in a React Server Component, use `<SchemaView>`. It has zero hooks — no `useContext`, no `useMemo`, no `useCallback` — so it works without the `"use client"` directive.
|
|
547
|
+
|
|
548
|
+
```tsx
|
|
549
|
+
import { SchemaView } from "schema-components/react/SchemaView";
|
|
550
|
+
|
|
551
|
+
export default async function Page() {
|
|
552
|
+
const user = await getUser();
|
|
553
|
+
return <SchemaView schema={userSchema} value={user} />;
|
|
554
|
+
}
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
`SchemaView` always renders read-only. For editable forms, use `<SchemaComponent>` (which requires `"use client"`).
|
|
558
|
+
|
|
559
|
+
Pass the resolver explicitly since React context is unavailable in Server Components:
|
|
560
|
+
|
|
561
|
+
```tsx
|
|
562
|
+
<SchemaView schema={schema} value={data} resolver={shadcnResolver} />
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
`SchemaView` produces identical output to `<SchemaComponent readOnly>` — verified by parity tests.
|
|
566
|
+
|
|
567
|
+
## HTML rendering
|
|
568
|
+
|
|
569
|
+
Render schemas to HTML strings — no React needed. Useful for server-side rendering, email templates, static sites, and non-React environments.
|
|
570
|
+
|
|
571
|
+
```tsx
|
|
572
|
+
import { renderToHtml } from "schema-components/html/renderToHtml";
|
|
573
|
+
|
|
574
|
+
const html = renderToHtml(userSchema, {
|
|
575
|
+
value: { name: "Ada Lovelace", email: "ada@example.com", role: "admin" },
|
|
576
|
+
readOnly: true,
|
|
577
|
+
});
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
All HTML output uses `sc-` prefixed classes for styling hooks. HTML is properly escaped by the serialiser.
|
|
581
|
+
|
|
582
|
+
A default stylesheet is included:
|
|
583
|
+
|
|
584
|
+
```html
|
|
585
|
+
<link rel="stylesheet" href="node_modules/schema-components/dist/html/styles.css">
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
Or import in JS:
|
|
589
|
+
|
|
590
|
+
```ts
|
|
591
|
+
import "schema-components/styles.css";
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
### Streaming HTML
|
|
595
|
+
|
|
596
|
+
Three output formats for incremental rendering:
|
|
597
|
+
|
|
598
|
+
```ts
|
|
599
|
+
import { renderToHtmlChunks } from "schema-components/html/renderToHtmlStream";
|
|
600
|
+
import { renderToHtmlStream } from "schema-components/html/renderToHtmlStream";
|
|
601
|
+
import { renderToHtmlReadable } from "schema-components/html/renderToHtmlStream";
|
|
602
|
+
|
|
603
|
+
// Sync iterable — chunks yielded at field/item/entry boundaries
|
|
604
|
+
const chunks: string[] = [...renderToHtmlChunks(schema, { value })];
|
|
605
|
+
|
|
606
|
+
// Async iterable — yields control to event loop between chunks
|
|
607
|
+
for await (const chunk of renderToHtmlStream(schema, { value })) {
|
|
608
|
+
res.write(chunk);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Web ReadableStream — for Response, TransformStream, etc.
|
|
612
|
+
return new Response(renderToHtmlReadable(schema, { value }), {
|
|
613
|
+
headers: { "Content-Type": "text/html" },
|
|
614
|
+
});
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
### Structured HTML construction
|
|
618
|
+
|
|
619
|
+
The HTML renderer uses a typed `h()` builder instead of string templates:
|
|
620
|
+
|
|
621
|
+
```ts
|
|
622
|
+
import { h, serialize, raw } from "schema-components/html/html";
|
|
623
|
+
|
|
624
|
+
const input = h("input", { type: "text", id: "name", value: userValue });
|
|
625
|
+
serialize(input); // → <input type="text" id="name" value="Ada">
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
The builder handles void elements, boolean attributes, fragments, and nested children.
|
|
629
|
+
|
|
630
|
+
### Accessibility
|
|
631
|
+
|
|
632
|
+
The HTML renderer produces WAI-ARIA-compliant markup:
|
|
633
|
+
|
|
634
|
+
| Attribute | When |
|
|
635
|
+
|---|---|
|
|
636
|
+
| `id="<key>"` | All editable inputs |
|
|
637
|
+
| `aria-required="true"` | Required fields |
|
|
638
|
+
| `aria-describedby="<id>-hint"` | Fields with constraints |
|
|
639
|
+
| `aria-readonly="true"` | Read-only presentation spans |
|
|
640
|
+
| `aria-label="<description>"` | Checkboxes |
|
|
641
|
+
| `role="group"` | Record containers |
|
|
642
|
+
|
|
643
|
+
## Error handling
|
|
644
|
+
|
|
645
|
+
Typed errors with `onError` callback for graceful degradation:
|
|
646
|
+
|
|
647
|
+
```tsx
|
|
648
|
+
import { SchemaErrorBoundary } from "schema-components/react/SchemaErrorBoundary";
|
|
649
|
+
|
|
650
|
+
// Error boundary catches render errors from theme adapters
|
|
651
|
+
<SchemaErrorBoundary fallback={(error, reset) => <p>Error: {error.message}</p>}>
|
|
652
|
+
<SchemaComponent schema={schema} value={data} />
|
|
653
|
+
</SchemaErrorBoundary>
|
|
654
|
+
|
|
655
|
+
// Per-component error callback
|
|
656
|
+
<SchemaComponent
|
|
657
|
+
schema={schema}
|
|
658
|
+
value={data}
|
|
659
|
+
onError={(error) => {
|
|
660
|
+
console.error(error);
|
|
661
|
+
return null; // graceful degradation
|
|
662
|
+
}}
|
|
663
|
+
/>
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
Without `onError`, errors re-throw. Error hierarchy: `SchemaError` → `SchemaNormalisationError` | `SchemaRenderError` | `SchemaFieldError`.
|
|
@@ -147,10 +147,9 @@ function renderField(tree, value, onChange, userResolver, renderChild, instanceW
|
|
|
147
147
|
} catch (err) {
|
|
148
148
|
throw new SchemaRenderError(err instanceof Error ? err.message : `Render function threw for type "${tree.type}"`, tree, tree.type, err);
|
|
149
149
|
}
|
|
150
|
-
if (result
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
150
|
+
if (result === null || result === void 0) return null;
|
|
151
|
+
if (isValidElement(result)) return result;
|
|
152
|
+
if (typeof result === "string" || typeof result === "number") return result;
|
|
154
153
|
}
|
|
155
154
|
if (value === void 0 || value === null) return /* @__PURE__ */ jsx("span", { children: "—" });
|
|
156
155
|
return /* @__PURE__ */ jsx("span", { children: typeof value === "string" ? value : JSON.stringify(value) });
|
package/dist/react/headless.mjs
CHANGED
|
@@ -292,7 +292,7 @@ function renderArray(props) {
|
|
|
292
292
|
const arr = Array.isArray(props.value) ? props.value : [];
|
|
293
293
|
const element = props.element;
|
|
294
294
|
if (element === void 0) return null;
|
|
295
|
-
if (
|
|
295
|
+
if (arr.length === 0) return null;
|
|
296
296
|
return /* @__PURE__ */ jsx("div", {
|
|
297
297
|
role: "group",
|
|
298
298
|
"aria-label": props.meta.description ?? void 0,
|