schema-components 1.16.3 → 1.17.0

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.
@@ -1,2 +1,2 @@
1
- import { a as HtmlRenderProps, c as RenderFunction, d as getRenderFunction, f as mergeHtmlResolvers, i as HtmlRenderFunction, l as RenderProps, m as typeToKey, n as BaseFieldProps, o as HtmlResolver, p as mergeResolvers, r as ComponentResolver, s as RESOLVER_KEYS, t as AllConstraints, u as getHtmlRenderFn } from "../renderer-BdSqllx5.mjs";
1
+ import { a as HtmlRenderProps, c as RenderFunction, d as getRenderFunction, f as mergeHtmlResolvers, i as HtmlRenderFunction, l as RenderProps, m as typeToKey, n as BaseFieldProps, o as HtmlResolver, p as mergeResolvers, r as ComponentResolver, s as RESOLVER_KEYS, t as AllConstraints, u as getHtmlRenderFn } from "../renderer-B3s8o2B8.mjs";
2
2
  export { AllConstraints, BaseFieldProps, ComponentResolver, HtmlRenderFunction, HtmlRenderProps, HtmlResolver, RESOLVER_KEYS, RenderFunction, RenderProps, getHtmlRenderFn, getRenderFunction, mergeHtmlResolvers, mergeResolvers, typeToKey };
@@ -1,5 +1,5 @@
1
1
  import { M as WalkedField } from "../types-D_5ST7SS.mjs";
2
- import { t as AllConstraints } from "../renderer-BdSqllx5.mjs";
2
+ import { t as AllConstraints } from "../renderer-B3s8o2B8.mjs";
3
3
  import { HtmlAttributes, HtmlNode } from "./html.mjs";
4
4
 
5
5
  //#region src/html/a11y.d.ts
@@ -1,5 +1,5 @@
1
1
  import { T as SchemaMeta } from "../types-D_5ST7SS.mjs";
2
- import { o as HtmlResolver } from "../renderer-BdSqllx5.mjs";
2
+ import { o as HtmlResolver } from "../renderer-B3s8o2B8.mjs";
3
3
 
4
4
  //#region src/html/renderToHtml.d.ts
5
5
  interface RenderToHtmlOptions {
@@ -1,5 +1,5 @@
1
1
  import { T as SchemaMeta } from "../types-D_5ST7SS.mjs";
2
- import { o as HtmlResolver } from "../renderer-BdSqllx5.mjs";
2
+ import { o as HtmlResolver } from "../renderer-B3s8o2B8.mjs";
3
3
 
4
4
  //#region src/html/renderToHtmlStream.d.ts
5
5
  interface StreamRenderOptions {
@@ -1,5 +1,5 @@
1
1
  import { M as WalkedField } from "../types-D_5ST7SS.mjs";
2
- import { o as HtmlResolver } from "../renderer-BdSqllx5.mjs";
2
+ import { o as HtmlResolver } from "../renderer-B3s8o2B8.mjs";
3
3
 
4
4
  //#region src/html/renderers.d.ts
5
5
  declare function dateInputType(format: string | undefined): string | undefined;
@@ -1,5 +1,5 @@
1
1
  import { M as WalkedField } from "../types-D_5ST7SS.mjs";
2
- import { o as HtmlResolver } from "../renderer-BdSqllx5.mjs";
2
+ import { o as HtmlResolver } from "../renderer-B3s8o2B8.mjs";
3
3
  import { HtmlElement } from "./html.mjs";
4
4
 
5
5
  //#region src/html/streamRenderers.d.ts
@@ -7,10 +7,24 @@ import { ApiLinks } from "./ApiLinks.mjs";
7
7
  import { ApiResponseHeaders } from "./ApiResponseHeaders.mjs";
8
8
  import { ApiSecurity } from "./ApiSecurity.mjs";
9
9
  import { getLinks, getSecurityRequirements, getSecuritySchemes, listCallbacks } from "./parser.mjs";
10
- import { renderField } from "../react/SchemaComponent.mjs";
10
+ import { joinPath, renderField, sanitisePrefix } from "../react/SchemaComponent.mjs";
11
11
  import { getParsed, resolveOperation, resolveParameters, resolveRequestBody, resolveResponse, toDoc } from "./resolve.mjs";
12
12
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
13
+ import { useId } from "react";
13
14
  //#region src/openapi/components.tsx
15
+ /**
16
+ * OpenAPI React components with type-safe generics.
17
+ *
18
+ * Render API operations, parameters, request bodies, and response schemas
19
+ * from OpenAPI 3.x documents. When the document is typed `as const`,
20
+ * the `fields` / `overrides` props get full autocomplete.
21
+ *
22
+ * Type safety is enforced at the outer component's props level via
23
+ * conditional types (InferRequestBodyFields, InferResponseFields,
24
+ * InferParameterOverrides). Internally, schemas are extracted and
25
+ * rendered via the walker + headless resolver directly, bypassing
26
+ * SchemaComponent to avoid deferred-conditional-type compatibility issues.
27
+ */
14
28
  function noop() {}
15
29
  function renderSchema(schema, rootDocument, options) {
16
30
  let jsonSchema;
@@ -32,8 +46,11 @@ function renderSchema(schema, rootDocument, options) {
32
46
  rootDocument
33
47
  };
34
48
  const tree = walk(jsonSchema, walkOpts);
35
- const renderChild = (childTree, childValue, childOnChange) => renderField(childTree, childValue, childOnChange, void 0, renderChild, options.widgets);
36
- return renderField(tree, options.value, options.onChange ?? noop, void 0, renderChild, options.widgets);
49
+ const makeRenderChild = (parentPath) => (childTree, childValue, childOnChange, pathSuffix) => {
50
+ const childPath = joinPath(parentPath, pathSuffix);
51
+ return renderField(childTree, childValue, childOnChange, void 0, makeRenderChild(childPath), childPath, options.widgets);
52
+ };
53
+ return renderField(tree, options.value, options.onChange ?? noop, void 0, makeRenderChild(options.rootPath), options.rootPath, options.widgets);
37
54
  }
38
55
  function ApiOperation({ schema: doc, path, method, requestBodyValue, onRequestBodyChange, responseValue, meta, requestBodyFields, widgets }) {
39
56
  const rootDoc = toDoc(doc);
@@ -42,6 +59,7 @@ function ApiOperation({ schema: doc, path, method, requestBodyValue, onRequestBo
42
59
  const securityReqs = getSecurityRequirements(parsed, path, method);
43
60
  const securitySchemes = getSecuritySchemes(parsed);
44
61
  const callbacks = listCallbacks(parsed, path, method);
62
+ const instancePrefix = sanitisePrefix(useId());
45
63
  return /* @__PURE__ */ jsxs("section", {
46
64
  "data-operation": `${method.toUpperCase()} ${path}`,
47
65
  children: [
@@ -57,7 +75,8 @@ function ApiOperation({ schema: doc, path, method, requestBodyValue, onRequestBo
57
75
  parameters: resolved.parameters,
58
76
  rootDoc,
59
77
  meta,
60
- widgets
78
+ widgets,
79
+ idPrefix: joinPath(instancePrefix, "params")
61
80
  })]
62
81
  }),
63
82
  resolved.requestBody?.schema !== void 0 && /* @__PURE__ */ jsxs("section", {
@@ -77,7 +96,8 @@ function ApiOperation({ schema: doc, path, method, requestBodyValue, onRequestBo
77
96
  onChange: onRequestBodyChange,
78
97
  fields: requestBodyFields,
79
98
  meta,
80
- widgets
99
+ widgets,
100
+ rootPath: joinPath(instancePrefix, "requestBody")
81
101
  })
82
102
  ]
83
103
  }),
@@ -90,7 +110,8 @@ function ApiOperation({ schema: doc, path, method, requestBodyValue, onRequestBo
90
110
  meta,
91
111
  widgets,
92
112
  path,
93
- method
113
+ method,
114
+ idPrefix: joinPath(instancePrefix, `response-${response.statusCode}`)
94
115
  }, response.statusCode))]
95
116
  })
96
117
  ]
@@ -99,6 +120,7 @@ function ApiOperation({ schema: doc, path, method, requestBodyValue, onRequestBo
99
120
  function ApiParameters({ schema: doc, path, method, meta, overrides, widgets }) {
100
121
  const rootDoc = toDoc(doc);
101
122
  const params = resolveParameters(rootDoc, path, method);
123
+ const instancePrefix = sanitisePrefix(useId());
102
124
  if (params.length === 0) return null;
103
125
  return /* @__PURE__ */ jsxs("section", {
104
126
  "data-parameters": true,
@@ -107,13 +129,15 @@ function ApiParameters({ schema: doc, path, method, meta, overrides, widgets })
107
129
  rootDoc,
108
130
  overrides,
109
131
  meta,
110
- widgets
132
+ widgets,
133
+ idPrefix: instancePrefix
111
134
  })]
112
135
  });
113
136
  }
114
137
  function ApiRequestBody({ schema: doc, path, method, value, onChange, meta, fields, widgets }) {
115
138
  const rootDoc = toDoc(doc);
116
139
  const requestBody = resolveRequestBody(rootDoc, path, method);
140
+ const instancePrefix = sanitisePrefix(useId());
117
141
  if (requestBody?.schema === void 0) return null;
118
142
  return /* @__PURE__ */ jsxs("section", {
119
143
  "data-request-body": true,
@@ -132,7 +156,8 @@ function ApiRequestBody({ schema: doc, path, method, value, onChange, meta, fiel
132
156
  onChange,
133
157
  fields,
134
158
  meta,
135
- widgets
159
+ widgets,
160
+ rootPath: instancePrefix
136
161
  })
137
162
  ]
138
163
  });
@@ -140,6 +165,7 @@ function ApiRequestBody({ schema: doc, path, method, value, onChange, meta, fiel
140
165
  function ApiResponse({ schema: doc, path, method, status, value, meta, fields, widgets }) {
141
166
  const rootDoc = toDoc(doc);
142
167
  const response = resolveResponse(rootDoc, path, method, status);
168
+ const instancePrefix = sanitisePrefix(useId());
143
169
  if (response.schema === void 0) return /* @__PURE__ */ jsxs("div", {
144
170
  "data-status": status,
145
171
  children: [
@@ -156,7 +182,8 @@ function ApiResponse({ schema: doc, path, method, status, value, meta, fields, w
156
182
  meta,
157
183
  widgets,
158
184
  path,
159
- method
185
+ method,
186
+ idPrefix: instancePrefix
160
187
  });
161
188
  }
162
189
  function OperationHeader({ operation }) {
@@ -173,7 +200,7 @@ function OperationHeader({ operation }) {
173
200
  })
174
201
  ] });
175
202
  }
176
- function ParameterList({ parameters, rootDoc, overrides, meta, widgets }) {
203
+ function ParameterList({ parameters, rootDoc, overrides, meta, widgets, idPrefix }) {
177
204
  return /* @__PURE__ */ jsx(Fragment, { children: parameters.map((param) => /* @__PURE__ */ jsxs("div", {
178
205
  "data-parameter": param.name,
179
206
  children: [
@@ -187,12 +214,13 @@ function ParameterList({ parameters, rootDoc, overrides, meta, widgets }) {
187
214
  }),
188
215
  renderSchema(param.schema ?? { type: "string" }, rootDoc, {
189
216
  meta: buildParamMeta(param, overrides, meta),
190
- widgets
217
+ widgets,
218
+ rootPath: joinPath(idPrefix, param.name)
191
219
  })
192
220
  ]
193
221
  }, param.name)) });
194
222
  }
195
- function ResponseCard({ response, rootDoc, value, fields, meta, widgets, path, method }) {
223
+ function ResponseCard({ response, rootDoc, value, fields, meta, widgets, path, method, idPrefix }) {
196
224
  if (response.schema === void 0) return /* @__PURE__ */ jsxs("div", {
197
225
  "data-status": response.statusCode,
198
226
  children: [
@@ -217,7 +245,8 @@ function ResponseCard({ response, rootDoc, value, fields, meta, widgets, path, m
217
245
  readOnly: true,
218
246
  ...meta
219
247
  },
220
- widgets
248
+ widgets,
249
+ rootPath: idPrefix
221
250
  }),
222
251
  /* @__PURE__ */ jsx(ApiResponseHeaders, { headers: response.headers }),
223
252
  /* @__PURE__ */ jsx(ApiLinks, { links })
@@ -1,7 +1,7 @@
1
1
  import { M as WalkedField, T as SchemaMeta, d as FieldOverrides, u as FieldOverride } from "../types-D_5ST7SS.mjs";
2
2
  import { t as Diagnostic } from "../diagnostics-DzbZmcLI.mjs";
3
3
  import { t as SchemaError } from "../errors-C5zRC2PU.mjs";
4
- import { l as RenderProps, r as ComponentResolver } from "../renderer-BdSqllx5.mjs";
4
+ import { l as RenderProps, r as ComponentResolver } from "../renderer-B3s8o2B8.mjs";
5
5
  import { c as ResolveOpenAPIRef, s as PathOfType, t as FromJSONSchema } from "../typeInference-k7FXfTVO.mjs";
6
6
  import { z } from "zod";
7
7
  import * as _$react_jsx_runtime0 from "react/jsx-runtime";
@@ -70,6 +70,13 @@ interface SchemaComponentProps<T = unknown, Ref extends string | undefined = und
70
70
  description?: string;
71
71
  /** Instance-scoped widgets — override context and global widgets. */
72
72
  widgets?: WidgetMap;
73
+ /**
74
+ * Prefix used for every input `id`/label `htmlFor` in this component
75
+ * subtree. Defaults to a per-instance value from `useId()` so multiple
76
+ * `<SchemaComponent>` instances on the same page never collide. Override
77
+ * for deterministic ids in screenshot tests.
78
+ */
79
+ idPrefix?: string;
73
80
  }
74
81
  declare function SchemaComponent<T = unknown, Ref extends string | undefined = undefined>({
75
82
  schema: schemaInput,
@@ -86,9 +93,30 @@ declare function SchemaComponent<T = unknown, Ref extends string | undefined = u
86
93
  readOnly,
87
94
  writeOnly,
88
95
  description,
89
- widgets: instanceWidgets
96
+ widgets: instanceWidgets,
97
+ idPrefix
90
98
  }: SchemaComponentProps<T, Ref>): ReactNode;
91
- declare function renderField(tree: WalkedField, value: unknown, onChange: (v: unknown) => void, userResolver: ComponentResolver | undefined, renderChild: (tree: WalkedField, value: unknown, onChange: (v: unknown) => void) => ReactNode, instanceWidgets?: WidgetMap, contextWidgets?: WidgetMap, depth?: number): ReactNode;
99
+ /**
100
+ * Default root-path sentinel used when no `idPrefix` is supplied AND the
101
+ * component is rendered outside a React tree (e.g. server-side bundling
102
+ * test harnesses). Production callers receive a `useId()`-derived prefix
103
+ * that is unique per instance.
104
+ */
105
+ declare const ROOT_PATH = "root";
106
+ /**
107
+ * Append a child path suffix to a parent path. When the suffix is omitted
108
+ * (e.g. transparent wrappers like union options), the parent path is
109
+ * returned unchanged so the child inherits the parent's id.
110
+ */
111
+ declare function joinPath(parent: string, suffix: string | undefined): string;
112
+ /**
113
+ * Normalise a `useId()` value into a DOM-id-safe prefix. React's `useId`
114
+ * returns values containing `:` characters (e.g. `«:r0:»`) which are
115
+ * invalid in CSS selectors. Replace any run of non-alphanumeric characters
116
+ * with a single hyphen and trim leading/trailing hyphens.
117
+ */
118
+ declare function sanitisePrefix(value: string): string;
119
+ declare function renderField(tree: WalkedField, value: unknown, onChange: (v: unknown) => void, userResolver: ComponentResolver | undefined, renderChild: (tree: WalkedField, value: unknown, onChange: (v: unknown) => void, pathSuffix?: string) => ReactNode, path: string, instanceWidgets?: WidgetMap, contextWidgets?: WidgetMap, depth?: number): ReactNode;
92
120
  /**
93
121
  * Infer the schema's output type for SchemaField path inference.
94
122
  */
@@ -125,4 +153,4 @@ declare function SchemaField<T = unknown, Ref extends string | undefined = undef
125
153
  onValidationError
126
154
  }: SchemaFieldProps<T, Ref, P>): ReactNode;
127
155
  //#endregion
128
- export { SchemaComponent, SchemaComponentProps, SchemaField, SchemaFieldProps, SchemaProvider, WidgetMap, registerWidget, renderField };
156
+ export { ROOT_PATH, SchemaComponent, SchemaComponentProps, SchemaField, SchemaFieldProps, SchemaProvider, WidgetMap, joinPath, registerWidget, renderField, sanitisePrefix };
@@ -8,7 +8,7 @@ import { headlessResolver } from "./headless.mjs";
8
8
  import { resolvePath, resolveValue, setNestedValue } from "./fieldPath.mjs";
9
9
  import { z } from "zod";
10
10
  import { jsx, jsxs } from "react/jsx-runtime";
11
- import { createContext, isValidElement, useCallback, useContext, useMemo } from "react";
11
+ import { createContext, isValidElement, useCallback, useContext, useId, useMemo } from "react";
12
12
  //#region src/react/SchemaComponent.tsx
13
13
  /**
14
14
  * <SchemaComponent> — renders UI from Zod, JSON Schema, or OpenAPI schemas.
@@ -46,9 +46,11 @@ const globalWidgets = /* @__PURE__ */ new Map();
46
46
  function registerWidget(name, render) {
47
47
  globalWidgets.set(name, render);
48
48
  }
49
- function SchemaComponent({ schema: schemaInput, ref: refInput, value, onChange, validate, onValidationError, onError, onDiagnostic, strict, fields, meta: componentMeta, readOnly, writeOnly, description, widgets: instanceWidgets }) {
49
+ function SchemaComponent({ schema: schemaInput, ref: refInput, value, onChange, validate, onValidationError, onError, onDiagnostic, strict, fields, meta: componentMeta, readOnly, writeOnly, description, widgets: instanceWidgets, idPrefix }) {
50
50
  const userResolver = useContext(UserResolverContext);
51
51
  const contextWidgets = useContext(WidgetsContext);
52
+ const generatedId = useId();
53
+ const rootPath = idPrefix ?? sanitisePrefix(generatedId);
52
54
  const mergedMeta = useMemo(() => {
53
55
  const merged = { ...componentMeta };
54
56
  if (readOnly === true) merged.readOnly = true;
@@ -108,11 +110,40 @@ function SchemaComponent({ schema: schemaInput, ref: refInput, value, onChange,
108
110
  ...diagnostics !== void 0 ? { diagnostics } : {}
109
111
  };
110
112
  const tree = walk(jsonSchema, walkOptions);
111
- const makeRenderChild = (currentDepth) => (childTree, childValue, childOnChange) => {
112
- return renderField(childTree, childValue, childOnChange, userResolver, makeRenderChild(currentDepth + 1), instanceWidgets, contextWidgets, currentDepth + 1);
113
+ const makeRenderChild = (currentDepth, parentPath) => (childTree, childValue, childOnChange, pathSuffix) => {
114
+ const childPath = joinPath(parentPath, pathSuffix);
115
+ return renderField(childTree, childValue, childOnChange, userResolver, makeRenderChild(currentDepth + 1, childPath), childPath, instanceWidgets, contextWidgets, currentDepth + 1);
113
116
  };
114
- const renderChild = makeRenderChild(0);
115
- return renderField(tree, value ?? tree.defaultValue, handleChange, userResolver, renderChild, instanceWidgets, contextWidgets, 0);
117
+ const renderChild = makeRenderChild(0, rootPath);
118
+ return renderField(tree, value ?? tree.defaultValue, handleChange, userResolver, renderChild, rootPath, instanceWidgets, contextWidgets, 0);
119
+ }
120
+ /**
121
+ * Default root-path sentinel used when no `idPrefix` is supplied AND the
122
+ * component is rendered outside a React tree (e.g. server-side bundling
123
+ * test harnesses). Production callers receive a `useId()`-derived prefix
124
+ * that is unique per instance.
125
+ */
126
+ const ROOT_PATH = "root";
127
+ /**
128
+ * Append a child path suffix to a parent path. When the suffix is omitted
129
+ * (e.g. transparent wrappers like union options), the parent path is
130
+ * returned unchanged so the child inherits the parent's id.
131
+ */
132
+ function joinPath(parent, suffix) {
133
+ if (suffix === void 0 || suffix.length === 0) return parent;
134
+ if (parent.length === 0) return suffix;
135
+ return `${parent}.${suffix}`;
136
+ }
137
+ /**
138
+ * Normalise a `useId()` value into a DOM-id-safe prefix. React's `useId`
139
+ * returns values containing `:` characters (e.g. `«:r0:»`) which are
140
+ * invalid in CSS selectors. Replace any run of non-alphanumeric characters
141
+ * with a single hyphen and trim leading/trailing hyphens.
142
+ */
143
+ function sanitisePrefix(value) {
144
+ const sanitised = value.replace(/[^a-zA-Z0-9_]+/g, "-").replace(/^-+|-+$/g, "");
145
+ if (sanitised.length === 0) throw new Error(`Cannot derive a DOM-safe id prefix from "${value}". Pass an explicit idPrefix prop.`);
146
+ return sanitised;
116
147
  }
117
148
  function runValidation(zodSchema, jsonSchema, value) {
118
149
  if (zodSchema !== void 0 && isObject(zodSchema)) {
@@ -134,7 +165,8 @@ function runValidation(zodSchema, jsonSchema, value) {
134
165
  }
135
166
  /** Maximum rendering depth before treating a field as recursive. */
136
167
  const MAX_RENDER_DEPTH = 10;
137
- function renderField(tree, value, onChange, userResolver, renderChild, instanceWidgets, contextWidgets, depth = 0) {
168
+ function renderField(tree, value, onChange, userResolver, renderChild, path, instanceWidgets, contextWidgets, depth = 0) {
169
+ if (path.length === 0) throw new Error("renderField requires a non-empty path. Pass ROOT_PATH for the root field and use renderChild's pathSuffix to derive child paths.");
138
170
  if (depth >= MAX_RENDER_DEPTH) {
139
171
  const refTarget = tree.type === "recursive" ? tree.refTarget : "";
140
172
  return /* @__PURE__ */ jsx("fieldset", { children: /* @__PURE__ */ jsxs("em", { children: [
@@ -147,7 +179,7 @@ function renderField(tree, value, onChange, userResolver, renderChild, instanceW
147
179
  if (typeof componentHint === "string") {
148
180
  const widget = instanceWidgets?.get(componentHint) ?? contextWidgets?.get(componentHint) ?? globalWidgets.get(componentHint);
149
181
  if (widget !== void 0) {
150
- const result = widget(buildRenderProps(tree, value, onChange, renderChild));
182
+ const result = widget(buildRenderProps(tree, value, onChange, renderChild, path));
151
183
  if (result !== void 0 && result !== null) {
152
184
  if (isValidElement(result)) return result;
153
185
  if (typeof result === "string" || typeof result === "number") return result;
@@ -160,7 +192,7 @@ function renderField(tree, value, onChange, userResolver, renderChild, instanceW
160
192
  if (renderFn !== void 0) {
161
193
  let result;
162
194
  try {
163
- result = renderFn(buildRenderProps(tree, value, onChange, renderChild));
195
+ result = renderFn(buildRenderProps(tree, value, onChange, renderChild, path));
164
196
  } catch (err) {
165
197
  throw new SchemaRenderError(err instanceof Error ? err.message : `Render function threw for type "${tree.type}"`, tree, tree.type, err);
166
198
  }
@@ -171,7 +203,7 @@ function renderField(tree, value, onChange, userResolver, renderChild, instanceW
171
203
  if (value === void 0 || value === null) return /* @__PURE__ */ jsx("span", { children: "—" });
172
204
  return /* @__PURE__ */ jsx("span", { children: typeof value === "string" ? value : JSON.stringify(value) });
173
205
  }
174
- function buildRenderProps(tree, value, onChange, renderChild) {
206
+ function buildRenderProps(tree, value, onChange, renderChild, path) {
175
207
  const props = {
176
208
  value,
177
209
  onChange,
@@ -179,7 +211,7 @@ function buildRenderProps(tree, value, onChange, renderChild) {
179
211
  writeOnly: tree.editability === "input",
180
212
  meta: tree.meta,
181
213
  constraints: tree.constraints,
182
- path: "",
214
+ path,
183
215
  tree,
184
216
  renderChild
185
217
  };
@@ -203,6 +235,7 @@ function buildRenderProps(tree, value, onChange, renderChild) {
203
235
  function SchemaField({ path, schema: schemaInput, ref: refInput, value, onChange, meta: fieldMeta, validate, onValidationError }) {
204
236
  const userResolver = useContext(UserResolverContext);
205
237
  const contextWidgets = useContext(WidgetsContext);
238
+ const generatedId = useId();
206
239
  let jsonSchema;
207
240
  let zodSchema;
208
241
  let rootMeta;
@@ -240,10 +273,12 @@ function SchemaField({ path, schema: schemaInput, ref: refInput, value, onChange
240
273
  onChange,
241
274
  onValidationError
242
275
  ]);
243
- const makeRenderChild = (currentDepth) => (childTree, childValue, childOnChange) => {
244
- return renderField(childTree, childValue, childOnChange, userResolver, makeRenderChild(currentDepth + 1), void 0, contextWidgets, currentDepth + 1);
276
+ const makeRenderChild = (currentDepth, parentPath) => (childTree, childValue, childOnChange, pathSuffix) => {
277
+ const childPath = joinPath(parentPath, pathSuffix);
278
+ return renderField(childTree, childValue, childOnChange, userResolver, makeRenderChild(currentDepth + 1, childPath), childPath, void 0, contextWidgets, currentDepth + 1);
245
279
  };
246
- return renderField(fieldTree, fieldValue, handleChange, userResolver, makeRenderChild(0), void 0, contextWidgets, 0);
280
+ const rootPath = joinPath(sanitisePrefix(generatedId), path);
281
+ return renderField(fieldTree, fieldValue, handleChange, userResolver, makeRenderChild(0, rootPath), rootPath, void 0, contextWidgets, 0);
247
282
  }
248
283
  /**
249
284
  * Dispatch Zod errors to per-field onValidationError callbacks.
@@ -290,4 +325,4 @@ function detectNormalisationKind(err) {
290
325
  return "unknown";
291
326
  }
292
327
  //#endregion
293
- export { SchemaComponent, SchemaField, SchemaProvider, registerWidget, renderField };
328
+ export { ROOT_PATH, SchemaComponent, SchemaField, SchemaProvider, joinPath, registerWidget, renderField, sanitisePrefix };
@@ -1,6 +1,6 @@
1
1
  import { T as SchemaMeta } from "../types-D_5ST7SS.mjs";
2
2
  import { t as Diagnostic } from "../diagnostics-DzbZmcLI.mjs";
3
- import { r as ComponentResolver } from "../renderer-BdSqllx5.mjs";
3
+ import { r as ComponentResolver } from "../renderer-B3s8o2B8.mjs";
4
4
  import { WidgetMap } from "./SchemaComponent.mjs";
5
5
  import { ReactNode } from "react";
6
6
 
@@ -30,6 +30,12 @@ interface SchemaViewProps {
30
30
  onDiagnostic?: (diagnostic: Diagnostic) => void;
31
31
  /** When true, any diagnostic becomes a thrown error. */
32
32
  strict?: boolean;
33
+ /**
34
+ * Prefix used for every input `id`/label `htmlFor` in this view subtree.
35
+ * Defaults to a per-instance value from `useId()`; pass a deterministic
36
+ * value when stable ids matter (e.g. snapshot tests).
37
+ */
38
+ idPrefix?: string;
33
39
  }
34
40
  /**
35
41
  * Server-safe schema renderer — no hooks, no context, no state.
@@ -47,7 +53,8 @@ declare function SchemaView({
47
53
  resolver,
48
54
  widgets,
49
55
  onDiagnostic,
50
- strict
56
+ strict,
57
+ idPrefix
51
58
  }: SchemaViewProps): ReactNode;
52
59
  //#endregion
53
60
  export { SchemaView, SchemaViewProps };
@@ -3,8 +3,9 @@ import { normaliseSchema } from "../core/adapter.mjs";
3
3
  import { getRenderFunction, mergeResolvers } from "../core/renderer.mjs";
4
4
  import { walk } from "../core/walker.mjs";
5
5
  import { headlessResolver } from "./headless.mjs";
6
+ import { joinPath, sanitisePrefix } from "./SchemaComponent.mjs";
6
7
  import { jsx } from "react/jsx-runtime";
7
- import { createElement, isValidElement } from "react";
8
+ import { createElement, isValidElement, useId } from "react";
8
9
  //#region src/react/SchemaView.tsx
9
10
  /**
10
11
  * React Server Component for read-only schema rendering.
@@ -37,7 +38,9 @@ function noop() {}
37
38
  * Always renders in read-only mode. For editable forms, use
38
39
  * `<SchemaComponent>` with `"use client"`.
39
40
  */
40
- function SchemaView({ schema: schemaInput, ref: refInput, value, fields, meta: componentMeta, description, resolver, widgets, onDiagnostic, strict }) {
41
+ function SchemaView({ schema: schemaInput, ref: refInput, value, fields, meta: componentMeta, description, resolver, widgets, onDiagnostic, strict, idPrefix }) {
42
+ const generatedId = useId();
43
+ const rootPath = idPrefix ?? sanitisePrefix(generatedId);
41
44
  const mergedMeta = {
42
45
  ...componentMeta,
43
46
  readOnly: true
@@ -68,18 +71,21 @@ function SchemaView({ schema: schemaInput, ref: refInput, value, fields, meta: c
68
71
  const tree = walk(jsonSchema, walkOptions);
69
72
  const userResolver = resolver !== void 0 ? mergeResolvers(resolver, headlessResolver) : headlessResolver;
70
73
  const MAX_SERVER_DEPTH = 10;
71
- const makeRenderChild = (currentDepth) => (childTree, childValue) => {
74
+ const makeRenderChild = (currentDepth, parentPath) => (childTree, childValue, pathSuffix) => {
75
+ const childPath = joinPath(parentPath, pathSuffix);
72
76
  if (currentDepth >= MAX_SERVER_DEPTH) return createElement("fieldset", null, createElement("em", null, `\u21bb ${typeof childTree.meta.description === "string" ? childTree.meta.description : childTree.type === "recursive" ? childTree.refTarget : "schema"} (recursive)`));
73
- return renderFieldServer(childTree, childValue, userResolver, makeRenderChild(currentDepth + 1), widgets);
77
+ return renderFieldServer(childTree, childValue, userResolver, makeRenderChild(currentDepth + 1, childPath), childPath, widgets);
74
78
  };
75
- const renderChild = makeRenderChild(0);
76
- return renderFieldServer(tree, value ?? tree.defaultValue, userResolver, renderChild, widgets);
79
+ const renderChild = makeRenderChild(0, rootPath);
80
+ return renderFieldServer(tree, value ?? tree.defaultValue, userResolver, renderChild, rootPath, widgets);
77
81
  }
78
- function renderFieldServer(tree, value, resolver, renderChild, widgets) {
82
+ function renderFieldServer(tree, value, resolver, renderChild, path, widgets) {
83
+ if (path.length === 0) throw new Error("renderFieldServer requires a non-empty path. Pass ROOT_PATH at the root and join children via joinPath().");
79
84
  const componentHint = tree.meta.component;
80
85
  if (typeof componentHint === "string") {
81
86
  const widget = widgets?.get(componentHint);
82
87
  if (widget !== void 0) {
88
+ const wrapRenderChild = (childTree, childValue, _childOnChange, pathSuffix) => renderChild(childTree, childValue, pathSuffix);
83
89
  const result = widget({
84
90
  value,
85
91
  onChange: noop,
@@ -87,9 +93,9 @@ function renderFieldServer(tree, value, resolver, renderChild, widgets) {
87
93
  writeOnly: false,
88
94
  meta: tree.meta,
89
95
  constraints: tree.constraints,
90
- path: "",
96
+ path,
91
97
  tree,
92
- renderChild: (childTree, childValue) => renderChild(childTree, childValue)
98
+ renderChild: wrapRenderChild
93
99
  });
94
100
  if (result !== void 0 && result !== null) {
95
101
  if (isValidElement(result)) return result;
@@ -106,9 +112,9 @@ function renderFieldServer(tree, value, resolver, renderChild, widgets) {
106
112
  writeOnly: false,
107
113
  meta: tree.meta,
108
114
  constraints: tree.constraints,
109
- path: "",
115
+ path,
110
116
  tree,
111
- renderChild: (childTree, childValue) => renderChild(childTree, childValue)
117
+ renderChild: (childTree, childValue, _childOnChange, pathSuffix) => renderChild(childTree, childValue, pathSuffix)
112
118
  };
113
119
  if (tree.type === "enum") props.enumValues = tree.enumValues;
114
120
  if (tree.type === "array" && tree.element !== void 0) props.element = tree.element;
@@ -1,4 +1,4 @@
1
- import { r as ComponentResolver } from "../renderer-BdSqllx5.mjs";
1
+ import { r as ComponentResolver } from "../renderer-B3s8o2B8.mjs";
2
2
 
3
3
  //#region src/react/headless.d.ts
4
4
  /**
@@ -1,5 +1,5 @@
1
1
  import { M as WalkedField } from "../types-D_5ST7SS.mjs";
2
- import { l as RenderProps } from "../renderer-BdSqllx5.mjs";
2
+ import { l as RenderProps } from "../renderer-B3s8o2B8.mjs";
3
3
  import { ReactNode } from "react";
4
4
 
5
5
  //#region src/react/headlessRenderers.d.ts
@@ -8,6 +8,19 @@ import { ReactNode } from "react";
8
8
  * Returns `null` for unrecognised values.
9
9
  */
10
10
  declare function toReactNode(value: unknown): ReactNode;
11
+ /**
12
+ * Build a stable, unique input ID from the path.
13
+ * Used for `htmlFor`/`id` association between labels and inputs.
14
+ *
15
+ * Throws on an empty path: the previous "sc-field" fallback caused every
16
+ * input across a form to share the same id, breaking label-input pairing
17
+ * and screen reader navigation. Callers must thread a non-empty path
18
+ * (see `ROOT_PATH` and `joinPath` in `SchemaComponent.tsx`).
19
+ *
20
+ * Dots and bracket indices in paths are converted to hyphens to keep the
21
+ * id valid as a CSS selector and predictable in test queries.
22
+ */
23
+ declare function inputId(path: string): string;
11
24
  declare function renderString(props: RenderProps): ReactNode;
12
25
  declare function renderNumber(props: RenderProps): ReactNode;
13
26
  declare function renderBoolean(props: RenderProps): ReactNode;
@@ -46,4 +59,4 @@ declare function renderFile(props: RenderProps): ReactNode;
46
59
  declare function renderRecursive(props: RenderProps): ReactNode;
47
60
  declare function renderUnknown(props: RenderProps): ReactNode;
48
61
  //#endregion
49
- export { defaultRecordValue, discriminatedUnionValueForTab, nextRecordKey, renameRecordKey, renderArray, renderBoolean, renderDiscriminatedUnion, renderEnum, renderFile, renderNumber, renderObject, renderRecord, renderRecursive, renderString, renderUnion, renderUnknown, toReactNode };
62
+ export { defaultRecordValue, discriminatedUnionValueForTab, inputId, nextRecordKey, renameRecordKey, renderArray, renderBoolean, renderDiscriminatedUnion, renderEnum, renderFile, renderNumber, renderObject, renderRecord, renderRecursive, renderString, renderUnion, renderUnknown, toReactNode };
@@ -1,6 +1,6 @@
1
1
  import { isObject } from "../core/guards.mjs";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
- import { isValidElement, useCallback, useRef } from "react";
3
+ import { isValidElement, useCallback, useEffect, useRef } from "react";
4
4
  //#region src/react/headlessRenderers.tsx
5
5
  /**
6
6
  * Headless renderer functions — one per schema type.
@@ -57,12 +57,20 @@ function dateInputType(format) {
57
57
  if (format === "date-time" || format === "datetime") return "datetime-local";
58
58
  }
59
59
  /**
60
- * Build a stable, unique-ish input ID from the path.
60
+ * Build a stable, unique input ID from the path.
61
61
  * Used for `htmlFor`/`id` association between labels and inputs.
62
+ *
63
+ * Throws on an empty path: the previous "sc-field" fallback caused every
64
+ * input across a form to share the same id, breaking label-input pairing
65
+ * and screen reader navigation. Callers must thread a non-empty path
66
+ * (see `ROOT_PATH` and `joinPath` in `SchemaComponent.tsx`).
67
+ *
68
+ * Dots and bracket indices in paths are converted to hyphens to keep the
69
+ * id valid as a CSS selector and predictable in test queries.
62
70
  */
63
71
  function inputId(path) {
64
- if (path.length === 0) return "sc-field";
65
- return `sc-${path}`;
72
+ if (path.length === 0) throw new Error("inputId requires a non-empty path. Pass ROOT_PATH for the root field and use renderChild's pathSuffix to derive child paths.");
73
+ return `sc-${path.replace(/[.[\]]+/g, "-").replace(/-+$/g, "")}`;
66
74
  }
67
75
  function renderString(props) {
68
76
  const id = inputId(props.path);
@@ -245,14 +253,14 @@ function renderObject(props) {
245
253
  });
246
254
  return /* @__PURE__ */ jsxs("fieldset", { children: [typeof props.meta.description === "string" && /* @__PURE__ */ jsx("legend", { children: props.meta.description }), sortedEntries.filter(([, field]) => field.meta.visible !== false).map(([key, field]) => {
247
255
  const childValue = obj[key];
248
- const childId = inputId(props.path ? `${props.path}.${key}` : key);
256
+ const childId = inputId(`${props.path}.${key}`);
249
257
  const childOnChange = (v) => {
250
258
  const updated = {};
251
259
  for (const [k, val] of Object.entries(obj)) updated[k] = val;
252
260
  updated[key] = v;
253
261
  props.onChange(updated);
254
262
  };
255
- const child = toReactNode(props.renderChild(field, childValue, childOnChange));
263
+ const child = toReactNode(props.renderChild(field, childValue, childOnChange, key));
256
264
  if (child === null || child === void 0) return null;
257
265
  return /* @__PURE__ */ jsxs("div", { children: [typeof field.meta.description === "string" && /* @__PURE__ */ jsxs("label", {
258
266
  htmlFor: childId,
@@ -318,9 +326,9 @@ function renderRecord(props) {
318
326
  "aria-label": props.meta.description ?? "Record",
319
327
  children: entries.map(([key, value]) => {
320
328
  return /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("label", {
321
- htmlFor: inputId(props.path ? `${props.path}.${key}` : key),
329
+ htmlFor: inputId(`${props.path}.${key}`),
322
330
  children: key
323
- }), toReactNode(props.renderChild(valueType, value, () => {}))] }, key);
331
+ }), toReactNode(props.renderChild(valueType, value, () => {}, key))] }, key);
324
332
  })
325
333
  });
326
334
  }
@@ -355,7 +363,7 @@ function renderRecord(props) {
355
363
  children: [entries.map(([key, value]) => {
356
364
  return /* @__PURE__ */ jsxs("div", { children: [
357
365
  /* @__PURE__ */ jsx("input", {
358
- id: `${inputId(props.path ? `${props.path}.${key}` : key)}-key`,
366
+ id: `${inputId(`${props.path}.${key}`)}-key`,
359
367
  type: "text",
360
368
  "aria-label": "Entry key",
361
369
  defaultValue: key,
@@ -365,7 +373,7 @@ function renderRecord(props) {
365
373
  }),
366
374
  toReactNode(props.renderChild(valueType, value, (nextValue) => {
367
375
  handleValueChange(key, nextValue);
368
- })),
376
+ }, key)),
369
377
  /* @__PURE__ */ jsx("button", {
370
378
  type: "button",
371
379
  "aria-label": `Remove entry ${key}`,
@@ -397,7 +405,7 @@ function renderArray(props) {
397
405
  next[i] = v;
398
406
  props.onChange(next);
399
407
  };
400
- return /* @__PURE__ */ jsx("div", { children: toReactNode(props.renderChild(element, item, childOnChange)) }, String(i));
408
+ return /* @__PURE__ */ jsx("div", { children: toReactNode(props.renderChild(element, item, childOnChange, `[${String(i)}]`)) }, String(i));
401
409
  })
402
410
  });
403
411
  }
@@ -467,14 +475,20 @@ function discriminatedUnionValueForTab(optionLabels, discKey, newIndex) {
467
475
  }
468
476
  /**
469
477
  * WAI-ARIA tabs component for discriminated unions.
470
- * Implements the full tabs keyboard pattern:
471
- * - Left/Right arrow keys move between tabs
472
- * - Home/End move to first/last tab
473
- * - Tab moves focus into the active panel
474
- * - aria-selected, aria-controls, role="tablist"/"tab"/"tabpanel"
478
+ *
479
+ * Implements the WAI-ARIA "Tabs with Automatic Activation" pattern
480
+ * (https://www.w3.org/WAI/ARIA/apg/patterns/tabs/):
481
+ * - ArrowRight / ArrowLeft move between tabs, wrapping at the extremes
482
+ * - Home / End jump to the first / last tab
483
+ * - aria-selected, aria-controls, role="tablist" / "tab" / "tabpanel"
484
+ * - Roving tabindex: the active tab has tabindex=0, the rest tabindex=-1
485
+ *
486
+ * "Automatic activation" means each arrow key both moves focus and
487
+ * activates the new tab in one step — selection and focus stay aligned.
475
488
  */
476
489
  function DiscriminatedUnionTabs({ options, optionLabels, activeIndex, panelId, discKey, props }) {
477
490
  const tabRefs = useRef([]);
491
+ const pendingFocusRef = useRef(false);
478
492
  const handleTabChange = useCallback((newIndex) => {
479
493
  const next = discriminatedUnionValueForTab(optionLabels, discKey, newIndex);
480
494
  if (next === void 0) return;
@@ -484,29 +498,29 @@ function DiscriminatedUnionTabs({ options, optionLabels, activeIndex, panelId, d
484
498
  discKey,
485
499
  props
486
500
  ]);
487
- const focusTab = useCallback((index) => {
488
- const clamped = (index % options.length + options.length) % options.length;
489
- tabRefs.current[clamped]?.focus();
490
- }, [options.length]);
501
+ const wrapIndex = useCallback((index) => (index % options.length + options.length) % options.length, [options.length]);
491
502
  const handleKeyDown = useCallback((e) => {
492
- if (e.key === "ArrowRight") {
493
- e.preventDefault();
494
- focusTab(activeIndex + 1);
495
- } else if (e.key === "ArrowLeft") {
496
- e.preventDefault();
497
- focusTab(activeIndex - 1);
498
- } else if (e.key === "Home") {
499
- e.preventDefault();
500
- focusTab(0);
501
- } else if (e.key === "End") {
502
- e.preventDefault();
503
- focusTab(options.length - 1);
504
- }
503
+ let target;
504
+ if (e.key === "ArrowRight") target = wrapIndex(activeIndex + 1);
505
+ else if (e.key === "ArrowLeft") target = wrapIndex(activeIndex - 1);
506
+ else if (e.key === "Home") target = 0;
507
+ else if (e.key === "End") target = options.length - 1;
508
+ if (target === void 0) return;
509
+ e.preventDefault();
510
+ if (target === activeIndex) return;
511
+ pendingFocusRef.current = true;
512
+ handleTabChange(target);
505
513
  }, [
506
514
  activeIndex,
507
- focusTab,
508
- options.length
515
+ handleTabChange,
516
+ options.length,
517
+ wrapIndex
509
518
  ]);
519
+ useEffect(() => {
520
+ if (!pendingFocusRef.current) return;
521
+ pendingFocusRef.current = false;
522
+ tabRefs.current[activeIndex]?.focus();
523
+ }, [activeIndex]);
510
524
  const activeOption = options[activeIndex];
511
525
  return /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("div", {
512
526
  role: "tablist",
@@ -608,4 +622,4 @@ function matchUnionOption(options, value) {
608
622
  if (typeof value === "object" && value !== null) return options.find((o) => o.type === "object");
609
623
  }
610
624
  //#endregion
611
- export { defaultRecordValue, discriminatedUnionValueForTab, nextRecordKey, renameRecordKey, renderArray, renderBoolean, renderDiscriminatedUnion, renderEnum, renderFile, renderNumber, renderObject, renderRecord, renderRecursive, renderString, renderUnion, renderUnknown, toReactNode };
625
+ export { defaultRecordValue, discriminatedUnionValueForTab, inputId, nextRecordKey, renameRecordKey, renderArray, renderBoolean, renderDiscriminatedUnion, renderEnum, renderFile, renderNumber, renderObject, renderRecord, renderRecursive, renderString, renderUnion, renderUnknown, toReactNode };
@@ -69,8 +69,17 @@ interface RenderProps extends BaseFieldProps {
69
69
  * Render a child field. Theme adapters call this to recursively render
70
70
  * nested structures (object fields, array elements, union options).
71
71
  * The resolver and rendering context are already wired in.
72
+ *
73
+ * @param tree - The walked field tree for the child
74
+ * @param value - The child's current value
75
+ * @param onChange - Callback receiving the child's next value
76
+ * @param pathSuffix - Path segment from the parent (e.g. "city",
77
+ * "[0]"). Joined to the parent's path with a dot, or substituted
78
+ * when the parent acts as a transparent wrapper (union options).
79
+ * Required for every container — without it children inherit no
80
+ * path and `inputId()` will throw.
72
81
  */
73
- renderChild: (tree: WalkedField, value: unknown, onChange: (v: unknown) => void) => unknown;
82
+ renderChild: (tree: WalkedField, value: unknown, onChange: (v: unknown) => void, pathSuffix?: string) => unknown;
74
83
  }
75
84
  /**
76
85
  * Props for HTML render functions. Extends BaseFieldProps with:
@@ -1,9 +1,13 @@
1
- import { r as ComponentResolver } from "../renderer-BdSqllx5.mjs";
1
+ import { r as ComponentResolver } from "../renderer-B3s8o2B8.mjs";
2
2
 
3
3
  //#region src/themes/mantine.d.ts
4
4
  /**
5
5
  * Register real Mantine components for the resolver to use.
6
6
  * Call once at app startup before rendering.
7
+ *
8
+ * `Text` is required so read-only scalars render as a styled Mantine
9
+ * `<Text>` element instead of a bare `<span>`, matching the visual
10
+ * weight of the editable variants.
7
11
  */
8
12
  declare function registerMantineComponents(components: {
9
13
  TextInput: React.ElementType;
@@ -11,6 +15,7 @@ declare function registerMantineComponents(components: {
11
15
  Switch: React.ElementType;
12
16
  Select: React.ElementType;
13
17
  Fieldset: React.ElementType;
18
+ Text: React.ElementType;
14
19
  }): void;
15
20
  declare const mantineResolver: ComponentResolver;
16
21
  //#endregion
@@ -1,5 +1,5 @@
1
1
  import { isObject } from "../core/guards.mjs";
2
- import { toReactNode } from "../react/headlessRenderers.mjs";
2
+ import { inputId, toReactNode } from "../react/headlessRenderers.mjs";
3
3
  import { headlessResolver } from "../react/headless.mjs";
4
4
  import { jsx } from "react/jsx-runtime";
5
5
  //#region src/themes/mantine.tsx
@@ -17,9 +17,14 @@ let MantineSwitch = (props) => /* @__PURE__ */ jsx("input", {
17
17
  });
18
18
  let MantineSelect = (props) => /* @__PURE__ */ jsx("select", { ...props });
19
19
  let MantineFieldset = (props) => /* @__PURE__ */ jsx("fieldset", { ...props });
20
+ let MantineText = (props) => /* @__PURE__ */ jsx("span", { ...props });
20
21
  /**
21
22
  * Register real Mantine components for the resolver to use.
22
23
  * Call once at app startup before rendering.
24
+ *
25
+ * `Text` is required so read-only scalars render as a styled Mantine
26
+ * `<Text>` element instead of a bare `<span>`, matching the visual
27
+ * weight of the editable variants.
23
28
  */
24
29
  function registerMantineComponents(components) {
25
30
  MantineTextInput = components.TextInput;
@@ -27,12 +32,18 @@ function registerMantineComponents(components) {
27
32
  MantineSwitch = components.Switch;
28
33
  MantineSelect = components.Select;
29
34
  MantineFieldset = components.Fieldset;
35
+ MantineText = components.Text;
30
36
  }
31
37
  function renderStringInput(props) {
32
38
  const strValue = typeof props.value === "string" ? props.value : "";
33
39
  const label = getLabel(props);
34
- if (props.readOnly) return /* @__PURE__ */ jsx("span", { children: strValue || "—" });
40
+ const id = inputId(props.path);
41
+ if (props.readOnly) return /* @__PURE__ */ jsx(MantineText, {
42
+ id,
43
+ children: strValue || "—"
44
+ });
35
45
  return /* @__PURE__ */ jsx(MantineTextInput, {
46
+ id,
36
47
  label,
37
48
  value: props.writeOnly ? "" : strValue,
38
49
  onChange: (e) => {
@@ -42,11 +53,19 @@ function renderStringInput(props) {
42
53
  }
43
54
  function renderNumberInput(props) {
44
55
  const label = getLabel(props);
56
+ const id = inputId(props.path);
45
57
  if (props.readOnly) {
46
- if (typeof props.value !== "number") return /* @__PURE__ */ jsx("span", { children: "—" });
47
- return /* @__PURE__ */ jsx("span", { children: props.value.toLocaleString() });
58
+ if (typeof props.value !== "number") return /* @__PURE__ */ jsx(MantineText, {
59
+ id,
60
+ children: "—"
61
+ });
62
+ return /* @__PURE__ */ jsx(MantineText, {
63
+ id,
64
+ children: props.value.toLocaleString()
65
+ });
48
66
  }
49
67
  return /* @__PURE__ */ jsx(MantineNumberInput, {
68
+ id,
50
69
  label,
51
70
  value: props.writeOnly ? void 0 : typeof props.value === "number" ? props.value : void 0,
52
71
  onChange: (v) => {
@@ -56,11 +75,19 @@ function renderNumberInput(props) {
56
75
  }
57
76
  function renderBooleanInput(props) {
58
77
  const label = getLabel(props);
78
+ const id = inputId(props.path);
59
79
  if (props.readOnly) {
60
- if (typeof props.value !== "boolean") return /* @__PURE__ */ jsx("span", { children: "—" });
61
- return /* @__PURE__ */ jsx("span", { children: props.value ? "Yes" : "No" });
80
+ if (typeof props.value !== "boolean") return /* @__PURE__ */ jsx(MantineText, {
81
+ id,
82
+ children: "—"
83
+ });
84
+ return /* @__PURE__ */ jsx(MantineText, {
85
+ id,
86
+ children: props.value ? "Yes" : "No"
87
+ });
62
88
  }
63
89
  return /* @__PURE__ */ jsx(MantineSwitch, {
90
+ id,
64
91
  label,
65
92
  checked: props.writeOnly ? false : props.value === true,
66
93
  onChange: (e) => {
@@ -71,8 +98,13 @@ function renderBooleanInput(props) {
71
98
  function renderEnumInput(props) {
72
99
  const enumValue = typeof props.value === "string" ? props.value : "";
73
100
  const label = getLabel(props);
74
- if (props.readOnly) return /* @__PURE__ */ jsx("span", { children: enumValue || "—" });
101
+ const id = inputId(props.path);
102
+ if (props.readOnly) return /* @__PURE__ */ jsx(MantineText, {
103
+ id,
104
+ children: enumValue || "—"
105
+ });
75
106
  return /* @__PURE__ */ jsx(MantineSelect, {
107
+ id,
76
108
  label,
77
109
  value: props.writeOnly ? null : enumValue || null,
78
110
  onChange: (v) => {
@@ -100,7 +132,7 @@ function renderObjectContainer(props) {
100
132
  };
101
133
  return /* @__PURE__ */ jsx("div", {
102
134
  style: { marginBottom: "0.5rem" },
103
- children: toReactNode(props.renderChild(field, childValue, childOnChange))
135
+ children: toReactNode(props.renderChild(field, childValue, childOnChange, key))
104
136
  }, key);
105
137
  })
106
138
  });
@@ -1,4 +1,4 @@
1
- import { r as ComponentResolver } from "../renderer-BdSqllx5.mjs";
1
+ import { r as ComponentResolver } from "../renderer-B3s8o2B8.mjs";
2
2
 
3
3
  //#region src/themes/mui.d.ts
4
4
  /**
@@ -1,5 +1,5 @@
1
1
  import { isObject } from "../core/guards.mjs";
2
- import { toReactNode } from "../react/headlessRenderers.mjs";
2
+ import { inputId, toReactNode } from "../react/headlessRenderers.mjs";
3
3
  import { headlessResolver } from "../react/headless.mjs";
4
4
  import { jsx, jsxs } from "react/jsx-runtime";
5
5
  import { isValidElement } from "react";
@@ -10,11 +10,14 @@ function ariaRequired(tree) {
10
10
  function renderStringInput(props) {
11
11
  const strValue = typeof props.value === "string" ? props.value : "";
12
12
  const label = typeof props.meta.description === "string" ? props.meta.description : void 0;
13
+ const id = inputId(props.path);
13
14
  if (props.readOnly) return /* @__PURE__ */ jsx(MuiTypography, {
15
+ id,
14
16
  variant: "body2",
15
17
  children: strValue || "—"
16
18
  });
17
19
  return /* @__PURE__ */ jsx(MuiTextField, {
20
+ id,
18
21
  label,
19
22
  type: props.constraints.format === "email" ? "email" : props.constraints.format === "uri" ? "url" : "text",
20
23
  value: props.writeOnly ? "" : strValue,
@@ -33,17 +36,21 @@ function renderStringInput(props) {
33
36
  }
34
37
  function renderNumberInput(props) {
35
38
  const label = typeof props.meta.description === "string" ? props.meta.description : void 0;
39
+ const id = inputId(props.path);
36
40
  if (props.readOnly) {
37
41
  if (typeof props.value !== "number") return /* @__PURE__ */ jsx(MuiTypography, {
42
+ id,
38
43
  variant: "body2",
39
44
  children: "—"
40
45
  });
41
46
  return /* @__PURE__ */ jsx(MuiTypography, {
47
+ id,
42
48
  variant: "body2",
43
49
  children: props.value.toLocaleString()
44
50
  });
45
51
  }
46
52
  return /* @__PURE__ */ jsx(MuiTextField, {
53
+ id,
47
54
  label,
48
55
  type: "number",
49
56
  value: props.writeOnly ? "" : typeof props.value === "number" ? props.value : "",
@@ -62,18 +69,22 @@ function renderNumberInput(props) {
62
69
  }
63
70
  function renderBooleanInput(props) {
64
71
  const label = typeof props.meta.description === "string" ? props.meta.description : void 0;
72
+ const id = inputId(props.path);
65
73
  if (props.readOnly) {
66
74
  if (typeof props.value !== "boolean") return /* @__PURE__ */ jsx(MuiTypography, {
75
+ id,
67
76
  variant: "body2",
68
77
  children: "—"
69
78
  });
70
79
  return /* @__PURE__ */ jsx(MuiTypography, {
80
+ id,
71
81
  variant: "body2",
72
82
  children: props.value ? "Yes" : "No"
73
83
  });
74
84
  }
75
85
  return /* @__PURE__ */ jsx(MuiFormControlLabel, {
76
86
  control: /* @__PURE__ */ jsx(MuiCheckbox, {
87
+ id,
77
88
  checked: props.writeOnly ? false : props.value === true,
78
89
  onChange: (e) => {
79
90
  props.onChange(e.target.checked);
@@ -85,11 +96,14 @@ function renderBooleanInput(props) {
85
96
  function renderEnumInput(props) {
86
97
  const enumValue = typeof props.value === "string" ? props.value : "";
87
98
  const label = typeof props.meta.description === "string" ? props.meta.description : void 0;
99
+ const id = inputId(props.path);
88
100
  if (props.readOnly) return /* @__PURE__ */ jsx(MuiTypography, {
101
+ id,
89
102
  variant: "body2",
90
103
  children: enumValue || "—"
91
104
  });
92
105
  return /* @__PURE__ */ jsxs(MuiTextField, {
106
+ id,
93
107
  select: true,
94
108
  label,
95
109
  value: props.writeOnly ? "" : enumValue,
@@ -130,7 +144,7 @@ function renderObjectContainer(props) {
130
144
  updated[key] = v;
131
145
  props.onChange(updated);
132
146
  };
133
- return /* @__PURE__ */ jsx("div", { children: toReactNode(props.renderChild(field, childValue, childOnChange)) }, key);
147
+ return /* @__PURE__ */ jsx("div", { children: toReactNode(props.renderChild(field, childValue, childOnChange, key)) }, key);
134
148
  })]
135
149
  });
136
150
  }
@@ -150,7 +164,7 @@ function renderArrayContainer(props) {
150
164
  next[i] = v;
151
165
  props.onChange(next);
152
166
  };
153
- return /* @__PURE__ */ jsx("div", { children: toReactNode(props.renderChild(element, item, childOnChange)) }, String(i));
167
+ return /* @__PURE__ */ jsx("div", { children: toReactNode(props.renderChild(element, item, childOnChange, `[${String(i)}]`)) }, String(i));
154
168
  })
155
169
  });
156
170
  }
@@ -1,4 +1,4 @@
1
- import { r as ComponentResolver } from "../renderer-BdSqllx5.mjs";
1
+ import { r as ComponentResolver } from "../renderer-B3s8o2B8.mjs";
2
2
 
3
3
  //#region src/themes/radix.d.ts
4
4
  /**
@@ -1,5 +1,5 @@
1
1
  import { isObject } from "../core/guards.mjs";
2
- import { toReactNode } from "../react/headlessRenderers.mjs";
2
+ import { inputId, toReactNode } from "../react/headlessRenderers.mjs";
3
3
  import { headlessResolver } from "../react/headless.mjs";
4
4
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
5
  //#region src/themes/radix.tsx
@@ -41,13 +41,19 @@ function registerRadixComponents(components) {
41
41
  function renderStringInput(props) {
42
42
  const strValue = typeof props.value === "string" ? props.value : "";
43
43
  const label = getLabel(props);
44
- if (props.readOnly) return /* @__PURE__ */ jsx(RadixText, { children: strValue || "—" });
44
+ const id = inputId(props.path);
45
+ if (props.readOnly) return /* @__PURE__ */ jsx(RadixText, {
46
+ id,
47
+ children: strValue || "—"
48
+ });
45
49
  return /* @__PURE__ */ jsxs(RadixBox, { children: [label !== void 0 && /* @__PURE__ */ jsx(RadixText, {
46
50
  as: "label",
47
51
  size: "2",
48
52
  weight: "medium",
53
+ htmlFor: id,
49
54
  children: label
50
55
  }), /* @__PURE__ */ jsx(RadixTextField, {
56
+ id,
51
57
  type: props.constraints.format === "email" ? "email" : props.constraints.format === "uri" ? "url" : "text",
52
58
  value: props.writeOnly ? "" : strValue,
53
59
  onChange: (e) => {
@@ -58,16 +64,25 @@ function renderStringInput(props) {
58
64
  }
59
65
  function renderNumberInput(props) {
60
66
  const label = getLabel(props);
67
+ const id = inputId(props.path);
61
68
  if (props.readOnly) {
62
- if (typeof props.value !== "number") return /* @__PURE__ */ jsx(RadixText, { children: "—" });
63
- return /* @__PURE__ */ jsx(RadixText, { children: props.value.toLocaleString() });
69
+ if (typeof props.value !== "number") return /* @__PURE__ */ jsx(RadixText, {
70
+ id,
71
+ children: "—"
72
+ });
73
+ return /* @__PURE__ */ jsx(RadixText, {
74
+ id,
75
+ children: props.value.toLocaleString()
76
+ });
64
77
  }
65
78
  return /* @__PURE__ */ jsxs(RadixBox, { children: [label !== void 0 && /* @__PURE__ */ jsx(RadixText, {
66
79
  as: "label",
67
80
  size: "2",
68
81
  weight: "medium",
82
+ htmlFor: id,
69
83
  children: label
70
84
  }), /* @__PURE__ */ jsx(RadixTextField, {
85
+ id,
71
86
  type: "number",
72
87
  value: props.writeOnly ? "" : typeof props.value === "number" ? props.value : "",
73
88
  onChange: (e) => {
@@ -78,20 +93,29 @@ function renderNumberInput(props) {
78
93
  }
79
94
  function renderBooleanInput(props) {
80
95
  const label = getLabel(props);
96
+ const id = inputId(props.path);
81
97
  if (props.readOnly) {
82
- if (typeof props.value !== "boolean") return /* @__PURE__ */ jsx(RadixText, { children: "—" });
83
- return /* @__PURE__ */ jsx(RadixText, { children: props.value ? "Yes" : "No" });
98
+ if (typeof props.value !== "boolean") return /* @__PURE__ */ jsx(RadixText, {
99
+ id,
100
+ children: "—"
101
+ });
102
+ return /* @__PURE__ */ jsx(RadixText, {
103
+ id,
104
+ children: props.value ? "Yes" : "No"
105
+ });
84
106
  }
85
107
  return /* @__PURE__ */ jsxs(RadixFlex, {
86
108
  align: "center",
87
109
  gap: "2",
88
110
  children: [/* @__PURE__ */ jsx(RadixCheckbox, {
111
+ id,
89
112
  checked: props.writeOnly ? false : props.value === true,
90
113
  onCheckedChange: (checked) => {
91
114
  if (typeof checked === "boolean") props.onChange(checked);
92
115
  }
93
116
  }), label !== void 0 && /* @__PURE__ */ jsx(RadixText, {
94
117
  as: "label",
118
+ htmlFor: id,
95
119
  children: label
96
120
  })]
97
121
  });
@@ -99,18 +123,26 @@ function renderBooleanInput(props) {
99
123
  function renderEnumInput(props) {
100
124
  const enumValue = typeof props.value === "string" ? props.value : "";
101
125
  const label = getLabel(props);
102
- if (props.readOnly) return /* @__PURE__ */ jsx(RadixText, { children: enumValue || "—" });
126
+ const id = inputId(props.path);
127
+ if (props.readOnly) return /* @__PURE__ */ jsx(RadixText, {
128
+ id,
129
+ children: enumValue || "—"
130
+ });
103
131
  return /* @__PURE__ */ jsxs(RadixBox, { children: [label !== void 0 && /* @__PURE__ */ jsx(RadixText, {
104
132
  as: "label",
105
133
  size: "2",
106
134
  weight: "medium",
135
+ htmlFor: id,
107
136
  children: label
108
137
  }), /* @__PURE__ */ jsxs(RadixSelectRoot, {
109
138
  value: props.writeOnly ? "" : enumValue,
110
139
  onValueChange: (value) => {
111
140
  props.onChange(value);
112
141
  },
113
- children: [/* @__PURE__ */ jsx(RadixSelectTrigger, { mt: "1" }), /* @__PURE__ */ jsx(RadixSelectContent, { children: (props.enumValues ?? []).map((value) => /* @__PURE__ */ jsx(RadixSelectItem, {
142
+ children: [/* @__PURE__ */ jsx(RadixSelectTrigger, {
143
+ id,
144
+ mt: "1"
145
+ }), /* @__PURE__ */ jsx(RadixSelectContent, { children: (props.enumValues ?? []).map((value) => /* @__PURE__ */ jsx(RadixSelectItem, {
114
146
  value,
115
147
  children: value
116
148
  }, value)) })]
@@ -137,7 +169,7 @@ function renderObjectContainer(props) {
137
169
  updated[key] = v;
138
170
  props.onChange(updated);
139
171
  };
140
- return /* @__PURE__ */ jsx(RadixBox, { children: toReactNode(props.renderChild(field, childValue, childOnChange)) }, key);
172
+ return /* @__PURE__ */ jsx(RadixBox, { children: toReactNode(props.renderChild(field, childValue, childOnChange, key)) }, key);
141
173
  })
142
174
  })] });
143
175
  }
@@ -1,4 +1,4 @@
1
- import { r as ComponentResolver } from "../renderer-BdSqllx5.mjs";
1
+ import { r as ComponentResolver } from "../renderer-B3s8o2B8.mjs";
2
2
 
3
3
  //#region src/themes/shadcn.d.ts
4
4
  declare const shadcnResolver: ComponentResolver;
@@ -1,5 +1,5 @@
1
1
  import { toRecord } from "../core/guards.mjs";
2
- import { toReactNode } from "../react/headlessRenderers.mjs";
2
+ import { inputId, toReactNode } from "../react/headlessRenderers.mjs";
3
3
  import { headlessResolver } from "../react/headless.mjs";
4
4
  import { jsx, jsxs } from "react/jsx-runtime";
5
5
  //#region src/themes/shadcn.tsx
@@ -11,12 +11,15 @@ function buildClassNames(...classes) {
11
11
  }
12
12
  function renderStringInput(props) {
13
13
  const strValue = typeof props.value === "string" ? props.value : "";
14
+ const id = inputId(props.path);
14
15
  const className = buildClassNames("flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors", "file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground", "placeholder:text-muted-foreground", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", "disabled:cursor-not-allowed disabled:opacity-50");
15
16
  if (props.readOnly) return /* @__PURE__ */ jsx("span", {
17
+ id,
16
18
  className: "text-sm",
17
19
  children: strValue || "—"
18
20
  });
19
21
  if (props.writeOnly) return /* @__PURE__ */ jsx("input", {
22
+ id,
20
23
  type: props.constraints.format === "email" ? "email" : "text",
21
24
  className,
22
25
  placeholder: typeof props.meta.description === "string" ? props.meta.description : void 0,
@@ -26,6 +29,7 @@ function renderStringInput(props) {
26
29
  }
27
30
  });
28
31
  return /* @__PURE__ */ jsx("input", {
32
+ id,
29
33
  type: props.constraints.format === "email" ? "email" : "text",
30
34
  className,
31
35
  value: strValue,
@@ -38,18 +42,22 @@ function renderStringInput(props) {
38
42
  });
39
43
  }
40
44
  function renderNumberInput(props) {
45
+ const id = inputId(props.path);
41
46
  const className = buildClassNames("flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors", "placeholder:text-muted-foreground", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", "disabled:cursor-not-allowed disabled:opacity-50");
42
47
  if (props.readOnly) {
43
48
  if (typeof props.value !== "number") return /* @__PURE__ */ jsx("span", {
49
+ id,
44
50
  className: "text-sm",
45
51
  children: "—"
46
52
  });
47
53
  return /* @__PURE__ */ jsx("span", {
54
+ id,
48
55
  className: "text-sm",
49
56
  children: props.value.toLocaleString()
50
57
  });
51
58
  }
52
59
  return /* @__PURE__ */ jsx("input", {
60
+ id,
53
61
  type: "number",
54
62
  className,
55
63
  value: props.writeOnly ? "" : typeof props.value === "number" ? props.value : "",
@@ -61,18 +69,22 @@ function renderNumberInput(props) {
61
69
  });
62
70
  }
63
71
  function renderBooleanInput(props) {
72
+ const id = inputId(props.path);
64
73
  const className = buildClassNames("h-4 w-4 rounded border border-primary shadow", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", "disabled:cursor-not-allowed disabled:opacity-50");
65
74
  if (props.readOnly) {
66
75
  if (typeof props.value !== "boolean") return /* @__PURE__ */ jsx("span", {
76
+ id,
67
77
  className: "text-sm",
68
78
  children: "—"
69
79
  });
70
80
  return /* @__PURE__ */ jsx("span", {
81
+ id,
71
82
  className: "text-sm",
72
83
  children: props.value ? "Yes" : "No"
73
84
  });
74
85
  }
75
86
  return /* @__PURE__ */ jsx("input", {
87
+ id,
76
88
  type: "checkbox",
77
89
  className,
78
90
  checked: props.writeOnly ? false : props.value === true,
@@ -92,6 +104,7 @@ function renderObjectContainer(props) {
92
104
  children: props.meta.description
93
105
  }), Object.entries(fields).map(([key, field]) => {
94
106
  const childValue = toRecord(obj)[key];
107
+ const childId = inputId(`${props.path}.${key}`);
95
108
  const childOnChange = (v) => {
96
109
  const updated = {};
97
110
  for (const [k, val] of Object.entries(obj)) updated[k] = val;
@@ -101,9 +114,10 @@ function renderObjectContainer(props) {
101
114
  return /* @__PURE__ */ jsxs("div", {
102
115
  className: "space-y-1",
103
116
  children: [/* @__PURE__ */ jsx("label", {
117
+ htmlFor: childId,
104
118
  className: "text-sm font-medium leading-none",
105
119
  children: field.meta.description ?? key
106
- }), toReactNode(props.renderChild(field, childValue, childOnChange))]
120
+ }), toReactNode(props.renderChild(field, childValue, childOnChange, key))]
107
121
  }, key);
108
122
  })]
109
123
  });
@@ -120,26 +134,29 @@ function renderArrayContainer(props) {
120
134
  next[i] = v;
121
135
  props.onChange(next);
122
136
  };
123
- return /* @__PURE__ */ jsx("div", { children: toReactNode(props.renderChild(element, item, childOnChange)) }, String(i));
137
+ return /* @__PURE__ */ jsx("div", { children: toReactNode(props.renderChild(element, item, childOnChange, `[${String(i)}]`)) }, String(i));
124
138
  })
125
139
  });
126
140
  }
127
141
  function renderEnumInput(props) {
142
+ const id = inputId(props.path);
128
143
  const className = buildClassNames("flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm", "focus:outline-none focus:ring-1 focus:ring-ring", "disabled:cursor-not-allowed disabled:opacity-50");
129
144
  const enumValue = typeof props.value === "string" ? props.value : "";
130
145
  if (props.readOnly) return /* @__PURE__ */ jsx("span", {
146
+ id,
131
147
  className: "text-sm",
132
148
  children: enumValue || "—"
133
149
  });
134
150
  return /* @__PURE__ */ jsxs("select", {
151
+ id,
135
152
  className,
136
153
  value: props.writeOnly ? "" : enumValue,
137
154
  onChange: (e) => {
138
155
  props.onChange(e.target.value);
139
156
  },
140
- children: [/* @__PURE__ */ jsx("option", {
157
+ children: [/* @__PURE__ */ jsxs("option", {
141
158
  value: "",
142
- children: "Select\\u2026"
159
+ children: ["Select", "…"]
143
160
  }), props.enumValues?.map((v) => {
144
161
  const display = v === null ? "null" : typeof v === "string" ? v : String(v);
145
162
  return /* @__PURE__ */ jsx("option", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "schema-components",
3
- "version": "1.16.3",
3
+ "version": "1.17.0",
4
4
  "description": "React components that render UI from Zod schemas, JSON Schema, and OpenAPI documents",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -78,13 +78,16 @@
78
78
  },
79
79
  "devDependencies": {
80
80
  "@eslint/js": "10.0.1",
81
- "@types/node": "25.6.0",
81
+ "@testing-library/react": "16.3.2",
82
+ "@testing-library/user-event": "14.6.1",
83
+ "@types/node": "25.6.2",
82
84
  "@types/react": "19.2.14",
83
85
  "@types/react-dom": "19.2.3",
84
86
  "@vitest/coverage-v8": "4.1.5",
85
87
  "eslint": "10.3.0",
86
88
  "eslint-config-prettier": "10.1.8",
87
89
  "eslint-plugin-prettier": "5.5.5",
90
+ "happy-dom": "20.9.0",
88
91
  "prettier": "3.8.3",
89
92
  "react": "19.2.6",
90
93
  "react-dom": "19.2.6",