schema-components 1.0.0 → 1.2.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.
- package/CHANGELOG.md +39 -0
- package/README.md +102 -22
- package/dist/core/adapter.d.mts +1 -1
- package/dist/core/renderer.d.mts +1 -1
- package/dist/core/renderer.mjs +2 -1
- package/dist/core/types.d.mts +1 -1
- package/dist/core/walker.d.mts +1 -1
- package/dist/html/a11y.d.mts +1 -1
- package/dist/html/renderToHtml.d.mts +1 -1
- package/dist/html/renderToHtml.mjs +103 -12
- package/dist/html/renderToHtmlStream.mjs +67 -4
- package/dist/html/styles.css +43 -0
- package/dist/openapi/components.d.mts +1 -1
- package/dist/openapi/parser.d.mts +1 -1
- package/dist/react/SchemaComponent.d.mts +1 -1
- package/dist/react/SchemaComponent.mjs +2 -1
- package/dist/react/SchemaErrorBoundary.mjs +1 -0
- package/dist/react/SchemaView.d.mts +41 -0
- package/dist/react/SchemaView.mjs +102 -0
- package/dist/react/headless.d.mts +1 -1
- package/dist/react/headless.mjs +339 -24
- package/dist/themes/mui.d.mts +17 -0
- package/dist/themes/mui.mjs +222 -0
- package/dist/themes/shadcn.d.mts +1 -1
- package/dist/{types-BU0ETFHk.d.mts → types-DDCD6Xnx.d.mts} +3 -1
- package/package.json +9 -7
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { b as ComponentResolver, m as SchemaMeta } from "../types-DDCD6Xnx.mjs";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
//#region src/react/SchemaView.d.ts
|
|
5
|
+
interface SchemaViewProps {
|
|
6
|
+
/** Zod schema, JSON Schema object, or OpenAPI document. */
|
|
7
|
+
schema: unknown;
|
|
8
|
+
/** For OpenAPI: a ref string like "#/components/schemas/User". */
|
|
9
|
+
ref?: string;
|
|
10
|
+
/** Current value to render. */
|
|
11
|
+
value?: unknown;
|
|
12
|
+
/** Per-field meta overrides. */
|
|
13
|
+
fields?: Record<string, unknown>;
|
|
14
|
+
/** Meta overrides applied to the root schema. */
|
|
15
|
+
meta?: SchemaMeta;
|
|
16
|
+
/** Convenience: sets description on the root. */
|
|
17
|
+
description?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Theme resolver. In a Server Component you pass this explicitly
|
|
20
|
+
* since `SchemaProvider` (React context) is unavailable.
|
|
21
|
+
* Falls back to the headless resolver if omitted.
|
|
22
|
+
*/
|
|
23
|
+
resolver?: ComponentResolver;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Server-safe schema renderer — no hooks, no context, no state.
|
|
27
|
+
*
|
|
28
|
+
* Always renders in read-only mode. For editable forms, use
|
|
29
|
+
* `<SchemaComponent>` with `"use client"`.
|
|
30
|
+
*/
|
|
31
|
+
declare function SchemaView({
|
|
32
|
+
schema: schemaInput,
|
|
33
|
+
ref: refInput,
|
|
34
|
+
value,
|
|
35
|
+
fields,
|
|
36
|
+
meta: componentMeta,
|
|
37
|
+
description,
|
|
38
|
+
resolver
|
|
39
|
+
}: SchemaViewProps): ReactNode;
|
|
40
|
+
//#endregion
|
|
41
|
+
export { SchemaView, SchemaViewProps };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { normaliseSchema } from "../core/adapter.mjs";
|
|
2
|
+
import { SchemaNormalisationError, SchemaRenderError } from "../core/errors.mjs";
|
|
3
|
+
import { getRenderFunction, mergeResolvers } from "../core/renderer.mjs";
|
|
4
|
+
import { walk } from "../core/walker.mjs";
|
|
5
|
+
import { headlessResolver } from "./headless.mjs";
|
|
6
|
+
import { isValidElement } from "react";
|
|
7
|
+
import { jsx } from "react/jsx-runtime";
|
|
8
|
+
//#region src/react/SchemaView.tsx
|
|
9
|
+
/**
|
|
10
|
+
* React Server Component for read-only schema rendering.
|
|
11
|
+
*
|
|
12
|
+
* This component has zero hooks — no `useContext`, no `useMemo`,
|
|
13
|
+
* no `useCallback`. It can run in a React Server Component environment
|
|
14
|
+
* without the `"use client"` directive.
|
|
15
|
+
*
|
|
16
|
+
* **Read-only only.** For interactive forms with `onChange`, use
|
|
17
|
+
* `<SchemaComponent>` (which requires `"use client"`).
|
|
18
|
+
*
|
|
19
|
+
* Usage in a Server Component:
|
|
20
|
+
* ```tsx
|
|
21
|
+
* import { SchemaView } from "schema-components/react/SchemaView";
|
|
22
|
+
*
|
|
23
|
+
* export default async function Page() {
|
|
24
|
+
* const user = await getUser();
|
|
25
|
+
* return <SchemaView schema={userSchema} value={user} />;
|
|
26
|
+
* }
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* The `resolver` prop replaces the `SchemaProvider` context —
|
|
30
|
+
* Server Components cannot use React context, so the resolver
|
|
31
|
+
* is passed explicitly.
|
|
32
|
+
*/
|
|
33
|
+
function noop() {}
|
|
34
|
+
/**
|
|
35
|
+
* Server-safe schema renderer — no hooks, no context, no state.
|
|
36
|
+
*
|
|
37
|
+
* Always renders in read-only mode. For editable forms, use
|
|
38
|
+
* `<SchemaComponent>` with `"use client"`.
|
|
39
|
+
*/
|
|
40
|
+
function SchemaView({ schema: schemaInput, ref: refInput, value, fields, meta: componentMeta, description, resolver }) {
|
|
41
|
+
const mergedMeta = {
|
|
42
|
+
...componentMeta,
|
|
43
|
+
readOnly: true
|
|
44
|
+
};
|
|
45
|
+
if (description !== void 0) mergedMeta.description = description;
|
|
46
|
+
let jsonSchema;
|
|
47
|
+
let rootMeta;
|
|
48
|
+
let rootDocument;
|
|
49
|
+
try {
|
|
50
|
+
const normalised = normaliseSchema(schemaInput, refInput);
|
|
51
|
+
jsonSchema = normalised.jsonSchema;
|
|
52
|
+
rootMeta = normalised.rootMeta;
|
|
53
|
+
rootDocument = normalised.rootDocument;
|
|
54
|
+
} catch (err) {
|
|
55
|
+
throw new SchemaNormalisationError(err instanceof Error ? err.message : "Failed to normalise schema", schemaInput, "unknown");
|
|
56
|
+
}
|
|
57
|
+
const tree = walk(jsonSchema, {
|
|
58
|
+
componentMeta: mergedMeta,
|
|
59
|
+
rootMeta,
|
|
60
|
+
fieldOverrides: fields,
|
|
61
|
+
rootDocument
|
|
62
|
+
});
|
|
63
|
+
const userResolver = resolver !== void 0 ? mergeResolvers(resolver, headlessResolver) : headlessResolver;
|
|
64
|
+
const renderChild = (childTree, childValue) => renderFieldServer(childTree, childValue, userResolver, renderChild);
|
|
65
|
+
return renderFieldServer(tree, value ?? tree.defaultValue, userResolver, renderChild);
|
|
66
|
+
}
|
|
67
|
+
function renderFieldServer(tree, value, resolver, renderChild) {
|
|
68
|
+
const renderFn = getRenderFunction(tree.type, resolver);
|
|
69
|
+
if (renderFn !== void 0) {
|
|
70
|
+
const props = {
|
|
71
|
+
value,
|
|
72
|
+
onChange: noop,
|
|
73
|
+
readOnly: true,
|
|
74
|
+
writeOnly: false,
|
|
75
|
+
meta: tree.meta,
|
|
76
|
+
constraints: tree.constraints,
|
|
77
|
+
path: "",
|
|
78
|
+
tree,
|
|
79
|
+
renderChild: (childTree, childValue) => renderChild(childTree, childValue)
|
|
80
|
+
};
|
|
81
|
+
if (tree.enumValues !== void 0) props.enumValues = tree.enumValues;
|
|
82
|
+
if (tree.element !== void 0) props.element = tree.element;
|
|
83
|
+
if (tree.fields !== void 0) props.fields = tree.fields;
|
|
84
|
+
if (tree.options !== void 0) props.options = tree.options;
|
|
85
|
+
if (tree.discriminator !== void 0) props.discriminator = tree.discriminator;
|
|
86
|
+
if (tree.keyType !== void 0) props.keyType = tree.keyType;
|
|
87
|
+
if (tree.valueType !== void 0) props.valueType = tree.valueType;
|
|
88
|
+
try {
|
|
89
|
+
const result = renderFn(props);
|
|
90
|
+
if (result !== void 0 && result !== null) {
|
|
91
|
+
if (isValidElement(result)) return result;
|
|
92
|
+
if (typeof result === "string" || typeof result === "number") return result;
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
throw new SchemaRenderError(err instanceof Error ? err.message : `Render function threw for type "${tree.type}"`, tree, tree.type, err);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (value === void 0 || value === null) return /* @__PURE__ */ jsx("span", { children: "\\u2014" });
|
|
99
|
+
return /* @__PURE__ */ jsx("span", { children: typeof value === "string" ? value : JSON.stringify(value) });
|
|
100
|
+
}
|
|
101
|
+
//#endregion
|
|
102
|
+
export { SchemaView };
|
package/dist/react/headless.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isObject } from "../core/guards.mjs";
|
|
2
|
-
import { isValidElement } from "react";
|
|
2
|
+
import { isValidElement, useCallback, useRef } from "react";
|
|
3
3
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
4
|
//#region src/react/headless.tsx
|
|
5
5
|
/**
|
|
@@ -8,6 +8,13 @@ import { jsx, jsxs } from "react/jsx-runtime";
|
|
|
8
8
|
* Produces plain HTML elements for every schema type. Theme adapters
|
|
9
9
|
* replace this by implementing ComponentResolver with their own components.
|
|
10
10
|
*
|
|
11
|
+
* Accessibility:
|
|
12
|
+
* - All inputs have `id`; labels use `htmlFor` for programmatic association
|
|
13
|
+
* - Discriminated union tabs follow WAI-ARIA tabs pattern (role, aria-selected,
|
|
14
|
+
* arrow key navigation, Home/End)
|
|
15
|
+
* - Checkboxes are linked to visible labels where available
|
|
16
|
+
* - Validation state surfaced via `aria-invalid` and `aria-errormessage`
|
|
17
|
+
*
|
|
11
18
|
* This module imports React and lives in the react layer, not core,
|
|
12
19
|
* because it produces ReactNode values.
|
|
13
20
|
*/
|
|
@@ -17,36 +24,122 @@ function toReactNode(value) {
|
|
|
17
24
|
if (isValidElement(value)) return value;
|
|
18
25
|
return null;
|
|
19
26
|
}
|
|
27
|
+
function formatDateTime(value) {
|
|
28
|
+
if (typeof value !== "string" || value.length === 0) return void 0;
|
|
29
|
+
try {
|
|
30
|
+
const date = new Date(value);
|
|
31
|
+
if (isNaN(date.getTime())) return void 0;
|
|
32
|
+
return date.toLocaleString();
|
|
33
|
+
} catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function formatDate(value) {
|
|
38
|
+
if (typeof value !== "string" || value.length === 0) return void 0;
|
|
39
|
+
try {
|
|
40
|
+
const date = new Date(value);
|
|
41
|
+
if (isNaN(date.getTime())) return void 0;
|
|
42
|
+
return date.toLocaleDateString();
|
|
43
|
+
} catch {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function formatTime(value) {
|
|
48
|
+
if (typeof value !== "string" || value.length === 0) return void 0;
|
|
49
|
+
try {
|
|
50
|
+
const date = new Date(value);
|
|
51
|
+
if (isNaN(date.getTime())) return void 0;
|
|
52
|
+
return date.toLocaleTimeString();
|
|
53
|
+
} catch {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function dateInputType(format) {
|
|
58
|
+
if (format === "date") return "date";
|
|
59
|
+
if (format === "time") return "time";
|
|
60
|
+
if (format === "date-time" || format === "datetime") return "datetime-local";
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Build a stable, unique-ish input ID from the path.
|
|
64
|
+
* Used for `htmlFor`/`id` association between labels and inputs.
|
|
65
|
+
*/
|
|
66
|
+
function inputId(path) {
|
|
67
|
+
if (path.length === 0) return "sc-field";
|
|
68
|
+
return `sc-${path}`;
|
|
69
|
+
}
|
|
20
70
|
function renderString(props) {
|
|
71
|
+
const id = inputId(props.path);
|
|
21
72
|
if (props.readOnly) {
|
|
22
73
|
const strValue = typeof props.value === "string" ? props.value : void 0;
|
|
23
|
-
if (strValue === void 0 || strValue.length === 0) return /* @__PURE__ */ jsx("span", {
|
|
74
|
+
if (strValue === void 0 || strValue.length === 0) return /* @__PURE__ */ jsx("span", {
|
|
75
|
+
id,
|
|
76
|
+
"aria-readonly": "true",
|
|
77
|
+
children: "\\u2014"
|
|
78
|
+
});
|
|
24
79
|
const format = props.constraints.format;
|
|
25
80
|
if (format === "email") return /* @__PURE__ */ jsx("a", {
|
|
26
81
|
href: `mailto:${strValue}`,
|
|
82
|
+
id,
|
|
83
|
+
"aria-readonly": "true",
|
|
27
84
|
children: strValue
|
|
28
85
|
});
|
|
29
86
|
if (format === "uri" || format === "url") return /* @__PURE__ */ jsx("a", {
|
|
30
87
|
href: strValue,
|
|
88
|
+
id,
|
|
89
|
+
"aria-readonly": "true",
|
|
90
|
+
children: strValue
|
|
91
|
+
});
|
|
92
|
+
if (format === "date") return /* @__PURE__ */ jsx("span", {
|
|
93
|
+
id,
|
|
94
|
+
"aria-readonly": "true",
|
|
95
|
+
children: formatDate(strValue) ?? strValue
|
|
96
|
+
});
|
|
97
|
+
if (format === "time") return /* @__PURE__ */ jsx("span", {
|
|
98
|
+
id,
|
|
99
|
+
"aria-readonly": "true",
|
|
100
|
+
children: formatTime(strValue) ?? strValue
|
|
101
|
+
});
|
|
102
|
+
if (format === "date-time" || format === "datetime") return /* @__PURE__ */ jsx("span", {
|
|
103
|
+
id,
|
|
104
|
+
"aria-readonly": "true",
|
|
105
|
+
children: formatDateTime(strValue) ?? strValue
|
|
106
|
+
});
|
|
107
|
+
return /* @__PURE__ */ jsx("span", {
|
|
108
|
+
id,
|
|
109
|
+
"aria-readonly": "true",
|
|
31
110
|
children: strValue
|
|
32
111
|
});
|
|
33
|
-
return /* @__PURE__ */ jsx("span", { children: strValue });
|
|
34
112
|
}
|
|
35
113
|
const strValue = typeof props.value === "string" ? props.value : "";
|
|
114
|
+
const dateType = dateInputType(props.constraints.format);
|
|
115
|
+
const ariaAttrs = {};
|
|
116
|
+
if (props.tree.isOptional === false) ariaAttrs["aria-required"] = "true";
|
|
117
|
+
if (dateType !== void 0) return /* @__PURE__ */ jsx("input", {
|
|
118
|
+
id,
|
|
119
|
+
type: dateType,
|
|
120
|
+
value: props.writeOnly ? "" : strValue,
|
|
121
|
+
onChange: (e) => {
|
|
122
|
+
props.onChange(e.target.value);
|
|
123
|
+
},
|
|
124
|
+
...ariaAttrs
|
|
125
|
+
});
|
|
36
126
|
if (props.enumValues !== void 0 && props.enumValues.length > 0) return /* @__PURE__ */ jsxs("select", {
|
|
127
|
+
id,
|
|
37
128
|
value: strValue,
|
|
38
129
|
onChange: (e) => {
|
|
39
130
|
props.onChange(e.target.value);
|
|
40
131
|
},
|
|
132
|
+
...ariaAttrs,
|
|
41
133
|
children: [/* @__PURE__ */ jsx("option", {
|
|
42
134
|
value: "",
|
|
43
|
-
children: "Select
|
|
135
|
+
children: "Select\\u2026"
|
|
44
136
|
}), props.enumValues.map((v) => /* @__PURE__ */ jsx("option", {
|
|
45
137
|
value: v,
|
|
46
138
|
children: v
|
|
47
139
|
}, v))]
|
|
48
140
|
});
|
|
49
141
|
return /* @__PURE__ */ jsx("input", {
|
|
142
|
+
id,
|
|
50
143
|
type: props.constraints.format === "email" ? "email" : props.constraints.format === "uri" ? "url" : "text",
|
|
51
144
|
value: props.writeOnly ? "" : strValue,
|
|
52
145
|
onChange: (e) => {
|
|
@@ -54,49 +147,86 @@ function renderString(props) {
|
|
|
54
147
|
},
|
|
55
148
|
placeholder: typeof props.meta.description === "string" ? props.meta.description : void 0,
|
|
56
149
|
minLength: props.constraints.minLength,
|
|
57
|
-
maxLength: props.constraints.maxLength
|
|
150
|
+
maxLength: props.constraints.maxLength,
|
|
151
|
+
...ariaAttrs
|
|
58
152
|
});
|
|
59
153
|
}
|
|
60
154
|
function renderNumber(props) {
|
|
155
|
+
const id = inputId(props.path);
|
|
61
156
|
if (props.readOnly) {
|
|
62
|
-
if (typeof props.value !== "number") return /* @__PURE__ */ jsx("span", {
|
|
63
|
-
|
|
157
|
+
if (typeof props.value !== "number") return /* @__PURE__ */ jsx("span", {
|
|
158
|
+
id,
|
|
159
|
+
"aria-readonly": "true",
|
|
160
|
+
children: "\\u2014"
|
|
161
|
+
});
|
|
162
|
+
return /* @__PURE__ */ jsx("span", {
|
|
163
|
+
id,
|
|
164
|
+
"aria-readonly": "true",
|
|
165
|
+
children: props.value.toLocaleString()
|
|
166
|
+
});
|
|
64
167
|
}
|
|
65
168
|
const numValue = typeof props.value === "number" ? props.value : "";
|
|
169
|
+
const ariaAttrs = {};
|
|
170
|
+
if (props.tree.isOptional === false) ariaAttrs["aria-required"] = "true";
|
|
66
171
|
return /* @__PURE__ */ jsx("input", {
|
|
172
|
+
id,
|
|
67
173
|
type: "number",
|
|
68
174
|
value: props.writeOnly ? "" : numValue,
|
|
69
175
|
onChange: (e) => {
|
|
70
176
|
props.onChange(Number(e.target.value));
|
|
71
177
|
},
|
|
72
178
|
min: props.constraints.minimum,
|
|
73
|
-
max: props.constraints.maximum
|
|
179
|
+
max: props.constraints.maximum,
|
|
180
|
+
...ariaAttrs
|
|
74
181
|
});
|
|
75
182
|
}
|
|
76
183
|
function renderBoolean(props) {
|
|
184
|
+
const id = inputId(props.path);
|
|
77
185
|
if (props.readOnly) {
|
|
78
|
-
if (typeof props.value !== "boolean") return /* @__PURE__ */ jsx("span", {
|
|
79
|
-
|
|
186
|
+
if (typeof props.value !== "boolean") return /* @__PURE__ */ jsx("span", {
|
|
187
|
+
id,
|
|
188
|
+
"aria-readonly": "true",
|
|
189
|
+
children: "\\u2014"
|
|
190
|
+
});
|
|
191
|
+
return /* @__PURE__ */ jsx("span", {
|
|
192
|
+
id,
|
|
193
|
+
"aria-readonly": "true",
|
|
194
|
+
children: props.value ? "Yes" : "No"
|
|
195
|
+
});
|
|
80
196
|
}
|
|
197
|
+
const ariaAttrs = {};
|
|
198
|
+
if (props.tree.isOptional === false) ariaAttrs["aria-required"] = "true";
|
|
199
|
+
if (typeof props.meta.description === "string") ariaAttrs["aria-label"] = props.meta.description;
|
|
81
200
|
return /* @__PURE__ */ jsx("input", {
|
|
201
|
+
id,
|
|
82
202
|
type: "checkbox",
|
|
83
203
|
checked: props.value === true,
|
|
84
204
|
onChange: (e) => {
|
|
85
205
|
props.onChange(e.target.checked);
|
|
86
|
-
}
|
|
206
|
+
},
|
|
207
|
+
...ariaAttrs
|
|
87
208
|
});
|
|
88
209
|
}
|
|
89
210
|
function renderEnum(props) {
|
|
211
|
+
const id = inputId(props.path);
|
|
90
212
|
const enumValue = typeof props.value === "string" ? props.value : "";
|
|
91
|
-
if (props.readOnly) return /* @__PURE__ */ jsx("span", {
|
|
213
|
+
if (props.readOnly) return /* @__PURE__ */ jsx("span", {
|
|
214
|
+
id,
|
|
215
|
+
"aria-readonly": "true",
|
|
216
|
+
children: enumValue || "—"
|
|
217
|
+
});
|
|
218
|
+
const ariaAttrs = {};
|
|
219
|
+
if (props.tree.isOptional === false) ariaAttrs["aria-required"] = "true";
|
|
92
220
|
return /* @__PURE__ */ jsxs("select", {
|
|
221
|
+
id,
|
|
93
222
|
value: props.writeOnly ? "" : enumValue,
|
|
94
223
|
onChange: (e) => {
|
|
95
224
|
props.onChange(e.target.value);
|
|
96
225
|
},
|
|
226
|
+
...ariaAttrs,
|
|
97
227
|
children: [/* @__PURE__ */ jsx("option", {
|
|
98
228
|
value: "",
|
|
99
|
-
children: "Select
|
|
229
|
+
children: "Select\\u2026"
|
|
100
230
|
}), props.enumValues?.map((v) => /* @__PURE__ */ jsx("option", {
|
|
101
231
|
value: v,
|
|
102
232
|
children: v
|
|
@@ -109,35 +239,210 @@ function renderObject(props) {
|
|
|
109
239
|
if (fields === void 0) return null;
|
|
110
240
|
return /* @__PURE__ */ jsxs("fieldset", { children: [typeof props.meta.description === "string" && /* @__PURE__ */ jsx("legend", { children: props.meta.description }), Object.entries(fields).map(([key, field]) => {
|
|
111
241
|
const childValue = obj[key];
|
|
242
|
+
const childId = inputId(props.path ? `${props.path}.${key}` : key);
|
|
112
243
|
const childOnChange = (v) => {
|
|
113
244
|
const updated = {};
|
|
114
245
|
for (const [k, val] of Object.entries(obj)) updated[k] = val;
|
|
115
246
|
updated[key] = v;
|
|
116
247
|
props.onChange(updated);
|
|
117
248
|
};
|
|
118
|
-
return /* @__PURE__ */ jsxs("div", { children: [typeof field.meta.description === "string" && /* @__PURE__ */
|
|
249
|
+
return /* @__PURE__ */ jsxs("div", { children: [typeof field.meta.description === "string" && /* @__PURE__ */ jsxs("label", {
|
|
250
|
+
htmlFor: childId,
|
|
251
|
+
children: [field.meta.description, field.isOptional === false && /* @__PURE__ */ jsxs("span", {
|
|
252
|
+
"aria-hidden": "true",
|
|
253
|
+
style: { color: "#dc2626" },
|
|
254
|
+
children: [" ", "*"]
|
|
255
|
+
})]
|
|
256
|
+
}), toReactNode(props.renderChild(field, childValue, childOnChange))] }, key);
|
|
119
257
|
})] });
|
|
120
258
|
}
|
|
121
259
|
function renderArray(props) {
|
|
122
260
|
const arr = Array.isArray(props.value) ? props.value : [];
|
|
123
261
|
const element = props.element;
|
|
124
262
|
if (element === void 0) return null;
|
|
125
|
-
return /* @__PURE__ */ jsx("div", {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
263
|
+
return /* @__PURE__ */ jsx("div", {
|
|
264
|
+
role: "group",
|
|
265
|
+
"aria-label": props.meta.description ?? void 0,
|
|
266
|
+
children: arr.map((item, i) => {
|
|
267
|
+
const childOnChange = (v) => {
|
|
268
|
+
const next = arr.slice();
|
|
269
|
+
next[i] = v;
|
|
270
|
+
props.onChange(next);
|
|
271
|
+
};
|
|
272
|
+
return /* @__PURE__ */ jsx("div", { children: toReactNode(props.renderChild(element, item, childOnChange)) }, String(i));
|
|
273
|
+
})
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
function renderUnion(props) {
|
|
277
|
+
const options = props.options;
|
|
278
|
+
if (options === void 0 || options.length === 0) {
|
|
279
|
+
if (props.value === void 0 || props.value === null) return /* @__PURE__ */ jsx("span", { children: "\\u2014" });
|
|
280
|
+
return /* @__PURE__ */ jsx("span", { children: JSON.stringify(props.value) });
|
|
281
|
+
}
|
|
282
|
+
const matched = matchUnionOption(options, props.value);
|
|
283
|
+
if (matched !== void 0) return toReactNode(props.renderChild(matched, props.value, props.onChange));
|
|
284
|
+
const firstOption = options[0];
|
|
285
|
+
if (firstOption !== void 0) return toReactNode(props.renderChild(firstOption, props.value, props.onChange));
|
|
286
|
+
return /* @__PURE__ */ jsx("span", { children: "\\u2014" });
|
|
287
|
+
}
|
|
288
|
+
function renderDiscriminatedUnion(props) {
|
|
289
|
+
const options = props.options;
|
|
290
|
+
const discriminator = props.discriminator;
|
|
291
|
+
if (options === void 0 || options.length === 0) {
|
|
292
|
+
if (props.value === void 0 || props.value === null) return /* @__PURE__ */ jsx("span", { children: "\\u2014" });
|
|
293
|
+
return /* @__PURE__ */ jsx("span", { children: JSON.stringify(props.value) });
|
|
294
|
+
}
|
|
295
|
+
const obj = isObject(props.value) ? props.value : {};
|
|
296
|
+
const discKey = discriminator ?? "";
|
|
297
|
+
const currentDiscriminatorValue = typeof obj[discKey] === "string" ? obj[discKey] : void 0;
|
|
298
|
+
const optionLabels = options.map((opt) => {
|
|
299
|
+
const discriminatorField = opt.fields?.[discKey];
|
|
300
|
+
if (discriminatorField !== void 0) {
|
|
301
|
+
const constVal = discriminatorField.literalValues?.[0];
|
|
302
|
+
if (typeof constVal === "string") return constVal;
|
|
303
|
+
}
|
|
304
|
+
return typeof opt.meta.title === "string" ? opt.meta.title : opt.type;
|
|
305
|
+
});
|
|
306
|
+
let activeIndex = 0;
|
|
307
|
+
if (currentDiscriminatorValue !== void 0) {
|
|
308
|
+
const found = optionLabels.indexOf(currentDiscriminatorValue);
|
|
309
|
+
if (found !== -1) activeIndex = found;
|
|
310
|
+
}
|
|
311
|
+
const activeOption = options[activeIndex];
|
|
312
|
+
const panelId = inputId(props.path);
|
|
313
|
+
if (props.readOnly) {
|
|
314
|
+
if (activeOption !== void 0) return toReactNode(props.renderChild(activeOption, props.value, props.onChange));
|
|
315
|
+
return /* @__PURE__ */ jsx("span", { children: "\\u2014" });
|
|
316
|
+
}
|
|
317
|
+
return /* @__PURE__ */ jsx(DiscriminatedUnionTabs, {
|
|
318
|
+
options,
|
|
319
|
+
optionLabels,
|
|
320
|
+
activeIndex,
|
|
321
|
+
panelId,
|
|
322
|
+
discKey,
|
|
323
|
+
props
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* WAI-ARIA tabs component for discriminated unions.
|
|
328
|
+
* Implements the full tabs keyboard pattern:
|
|
329
|
+
* - Left/Right arrow keys move between tabs
|
|
330
|
+
* - Home/End move to first/last tab
|
|
331
|
+
* - Tab moves focus into the active panel
|
|
332
|
+
* - aria-selected, aria-controls, role="tablist"/"tab"/"tabpanel"
|
|
333
|
+
*/
|
|
334
|
+
function DiscriminatedUnionTabs({ options, optionLabels, activeIndex, panelId, discKey, props }) {
|
|
335
|
+
const tabRefs = useRef([]);
|
|
336
|
+
const handleTabChange = useCallback((newIndex) => {
|
|
337
|
+
const label = optionLabels[newIndex];
|
|
338
|
+
if (label === void 0) return;
|
|
339
|
+
props.onChange({ [discKey]: label });
|
|
340
|
+
}, [
|
|
341
|
+
optionLabels,
|
|
342
|
+
discKey,
|
|
343
|
+
props
|
|
344
|
+
]);
|
|
345
|
+
const focusTab = useCallback((index) => {
|
|
346
|
+
const clamped = (index % options.length + options.length) % options.length;
|
|
347
|
+
tabRefs.current[clamped]?.focus();
|
|
348
|
+
}, [options.length]);
|
|
349
|
+
const handleKeyDown = useCallback((e) => {
|
|
350
|
+
if (e.key === "ArrowRight") {
|
|
351
|
+
e.preventDefault();
|
|
352
|
+
focusTab(activeIndex + 1);
|
|
353
|
+
} else if (e.key === "ArrowLeft") {
|
|
354
|
+
e.preventDefault();
|
|
355
|
+
focusTab(activeIndex - 1);
|
|
356
|
+
} else if (e.key === "Home") {
|
|
357
|
+
e.preventDefault();
|
|
358
|
+
focusTab(0);
|
|
359
|
+
} else if (e.key === "End") {
|
|
360
|
+
e.preventDefault();
|
|
361
|
+
focusTab(options.length - 1);
|
|
362
|
+
}
|
|
363
|
+
}, [
|
|
364
|
+
activeIndex,
|
|
365
|
+
focusTab,
|
|
366
|
+
options.length
|
|
367
|
+
]);
|
|
368
|
+
const activeOption = options[activeIndex];
|
|
369
|
+
return /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("div", {
|
|
370
|
+
role: "tablist",
|
|
371
|
+
"aria-label": "Select variant",
|
|
372
|
+
style: {
|
|
373
|
+
display: "flex",
|
|
374
|
+
gap: "0.25rem",
|
|
375
|
+
marginBottom: "0.5rem"
|
|
376
|
+
},
|
|
377
|
+
onKeyDown: handleKeyDown,
|
|
378
|
+
children: options.map((_opt, i) => /* @__PURE__ */ jsx("button", {
|
|
379
|
+
ref: (el) => {
|
|
380
|
+
tabRefs.current[i] = el;
|
|
381
|
+
},
|
|
382
|
+
type: "button",
|
|
383
|
+
role: "tab",
|
|
384
|
+
"aria-selected": i === activeIndex ? "true" : void 0,
|
|
385
|
+
"aria-controls": `${panelId}-panel`,
|
|
386
|
+
tabIndex: i === activeIndex ? 0 : -1,
|
|
387
|
+
onClick: () => {
|
|
388
|
+
handleTabChange(i);
|
|
389
|
+
},
|
|
390
|
+
style: {
|
|
391
|
+
padding: "0.25rem 0.75rem",
|
|
392
|
+
border: i === activeIndex ? "1px solid #3b82f6" : "1px solid #d1d5db",
|
|
393
|
+
borderRadius: "0.25rem",
|
|
394
|
+
background: i === activeIndex ? "#eff6ff" : "transparent",
|
|
395
|
+
cursor: "pointer",
|
|
396
|
+
fontSize: "0.875rem"
|
|
397
|
+
},
|
|
398
|
+
children: optionLabels[i]
|
|
399
|
+
}, String(i)))
|
|
400
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
401
|
+
role: "tabpanel",
|
|
402
|
+
id: `${panelId}-panel`,
|
|
403
|
+
"aria-labelledby": `${panelId}-tab-${String(activeIndex)}`,
|
|
404
|
+
children: activeOption !== void 0 && toReactNode(props.renderChild(activeOption, props.value, props.onChange))
|
|
405
|
+
})] });
|
|
406
|
+
}
|
|
407
|
+
function renderFile(props) {
|
|
408
|
+
const id = inputId(props.path);
|
|
409
|
+
const accept = props.constraints.mimeTypes?.join(",");
|
|
410
|
+
if (props.readOnly) return /* @__PURE__ */ jsx("span", {
|
|
411
|
+
id,
|
|
412
|
+
"aria-readonly": "true",
|
|
413
|
+
children: "File field"
|
|
414
|
+
});
|
|
415
|
+
const ariaAttrs = {};
|
|
416
|
+
if (props.tree.isOptional === false) ariaAttrs["aria-required"] = "true";
|
|
417
|
+
if (typeof props.meta.description === "string") ariaAttrs["aria-label"] = props.meta.description;
|
|
418
|
+
return /* @__PURE__ */ jsx("input", {
|
|
419
|
+
id,
|
|
420
|
+
type: "file",
|
|
421
|
+
accept,
|
|
422
|
+
onChange: (e) => {
|
|
423
|
+
const file = e.target.files?.[0];
|
|
424
|
+
if (file !== void 0) props.onChange(file);
|
|
425
|
+
},
|
|
426
|
+
...ariaAttrs
|
|
427
|
+
});
|
|
133
428
|
}
|
|
134
429
|
function renderUnknown(props) {
|
|
430
|
+
const id = inputId(props.path);
|
|
135
431
|
if (props.readOnly) {
|
|
136
|
-
if (props.value === void 0 || props.value === null) return /* @__PURE__ */ jsx("span", {
|
|
137
|
-
|
|
432
|
+
if (props.value === void 0 || props.value === null) return /* @__PURE__ */ jsx("span", {
|
|
433
|
+
id,
|
|
434
|
+
"aria-readonly": "true",
|
|
435
|
+
children: "\\u2014"
|
|
436
|
+
});
|
|
437
|
+
return /* @__PURE__ */ jsx("span", {
|
|
438
|
+
id,
|
|
439
|
+
"aria-readonly": "true",
|
|
440
|
+
children: typeof props.value === "string" ? props.value : JSON.stringify(props.value)
|
|
441
|
+
});
|
|
138
442
|
}
|
|
139
443
|
const strValue = typeof props.value === "string" ? props.value : "";
|
|
140
444
|
return /* @__PURE__ */ jsx("input", {
|
|
445
|
+
id,
|
|
141
446
|
type: "text",
|
|
142
447
|
value: props.writeOnly ? "" : strValue,
|
|
143
448
|
onChange: (e) => {
|
|
@@ -145,6 +450,13 @@ function renderUnknown(props) {
|
|
|
145
450
|
}
|
|
146
451
|
});
|
|
147
452
|
}
|
|
453
|
+
function matchUnionOption(options, value) {
|
|
454
|
+
if (typeof value === "string") return options.find((o) => o.type === "string" || o.type === "enum");
|
|
455
|
+
if (typeof value === "number") return options.find((o) => o.type === "number");
|
|
456
|
+
if (typeof value === "boolean") return options.find((o) => o.type === "boolean");
|
|
457
|
+
if (Array.isArray(value)) return options.find((o) => o.type === "array");
|
|
458
|
+
if (typeof value === "object" && value !== null) return options.find((o) => o.type === "object");
|
|
459
|
+
}
|
|
148
460
|
/**
|
|
149
461
|
* The headless resolver uses props.renderChild for recursive rendering.
|
|
150
462
|
* No factory function needed — the renderChild is always available
|
|
@@ -157,6 +469,9 @@ const headlessResolver = {
|
|
|
157
469
|
enum: renderEnum,
|
|
158
470
|
object: renderObject,
|
|
159
471
|
array: renderArray,
|
|
472
|
+
union: renderUnion,
|
|
473
|
+
discriminatedUnion: renderDiscriminatedUnion,
|
|
474
|
+
file: renderFile,
|
|
160
475
|
unknown: renderUnknown
|
|
161
476
|
};
|
|
162
477
|
//#endregion
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { b as ComponentResolver } from "../types-DDCD6Xnx.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/themes/mui.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Register real MUI components. Call once at app startup.
|
|
6
|
+
*/
|
|
7
|
+
declare function registerMuiComponents(components: {
|
|
8
|
+
TextField: React.ComponentType<Record<string, unknown>>;
|
|
9
|
+
Checkbox: React.ComponentType<Record<string, unknown>>;
|
|
10
|
+
Typography: React.ComponentType<Record<string, unknown>>;
|
|
11
|
+
Box: React.ComponentType<Record<string, unknown>>;
|
|
12
|
+
MenuItem: React.ComponentType<Record<string, unknown>>;
|
|
13
|
+
FormControlLabel: React.ComponentType<Record<string, unknown>>;
|
|
14
|
+
}): void;
|
|
15
|
+
declare const muiResolver: ComponentResolver;
|
|
16
|
+
//#endregion
|
|
17
|
+
export { muiResolver, registerMuiComponents };
|