schema-components 2.1.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -0
- package/dist/core/renderField.mjs +41 -7
- package/dist/html/streamRenderers.d.mts +12 -3
- package/dist/html/streamRenderers.mjs +56 -10
- package/package.json +5 -3
- package/src/vue/SchemaComponent.vue +274 -0
- package/src/vue/SchemaErrorBoundary.vue +60 -0
- package/src/vue/SchemaField.vue +178 -0
- package/src/vue/SchemaProvider.vue +39 -0
- package/src/vue/SchemaView.vue +198 -0
- package/src/vue/VNodeHost.ts +32 -0
- package/src/vue/contexts.ts +116 -0
- package/src/vue/eventTargets.ts +35 -0
- package/src/vue/headless.ts +61 -0
- package/src/vue/idPrefix.ts +79 -0
- package/src/vue/renderField.ts +182 -0
- package/src/vue/renderers.ts +1297 -0
- package/src/vue/resolver.ts +45 -0
- package/src/vue/types.ts +140 -0
- package/src/vue/vue-shim.d.ts +25 -0
- package/src/vue/widget.ts +51 -0
|
@@ -0,0 +1,1297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless Vue renderer functions — one per schema field type.
|
|
3
|
+
*
|
|
4
|
+
* Mechanical port of `react/headlessRenderers.tsx` from JSX over React
|
|
5
|
+
* `ReactNode` to Vue's hyperscript `h()` over {@link VNode}. The shape
|
|
6
|
+
* of each rendered tree (tag, attributes, children, ARIA wiring,
|
|
7
|
+
* recursive descent) is intentionally identical so a future test
|
|
8
|
+
* harness can assert React/Vue parity field-by-field.
|
|
9
|
+
*
|
|
10
|
+
* The discriminated-union `WAI-ARIA tabs` widget is implemented inside
|
|
11
|
+
* this module as a small functional Vue component
|
|
12
|
+
* (`DiscriminatedUnionTabs`) so the keyboard focus state machine
|
|
13
|
+
* (`pendingFocusRef`, `useEffect`-equivalent `watch`) survives the
|
|
14
|
+
* port. Every other renderer is a pure function returning a single
|
|
15
|
+
* {@link VNode}.
|
|
16
|
+
*
|
|
17
|
+
* `inputId(path)` is re-exported as an alias for {@link fieldDomId} so
|
|
18
|
+
* downstream Vue themes import a name parallel to the React adapter's
|
|
19
|
+
* `inputId` export. Both pipelines must derive the same DOM id from
|
|
20
|
+
* the same path — `fieldDomId` in `core/idPath.ts` is the single
|
|
21
|
+
* source of truth.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
defineComponent,
|
|
26
|
+
h,
|
|
27
|
+
nextTick,
|
|
28
|
+
onMounted,
|
|
29
|
+
ref,
|
|
30
|
+
watch,
|
|
31
|
+
type VNode,
|
|
32
|
+
} from "vue";
|
|
33
|
+
import { dateInputType } from "../core/formats.ts";
|
|
34
|
+
import { isObject } from "../core/guards.ts";
|
|
35
|
+
import { sortFieldsByOrder } from "../core/fieldOrder.ts";
|
|
36
|
+
import type { WalkedField } from "../core/types.ts";
|
|
37
|
+
import { isSafeHyperlink, isSafeMailtoAddress } from "../core/uri.ts";
|
|
38
|
+
import { displayJsonValue } from "../core/walkBuilders.ts";
|
|
39
|
+
import { fieldDomId, hintIdFor, panelIdFor, tabIdFor } from "../core/idPath.ts";
|
|
40
|
+
import { EM_DASH, ELLIPSIS, SC_CLASSES } from "../core/cssClasses.ts";
|
|
41
|
+
import { constraintHint as coreConstraintHint } from "../core/constraintHint.ts";
|
|
42
|
+
import {
|
|
43
|
+
matchUnionOption as matchUnionOptionShared,
|
|
44
|
+
resolveDiscriminatedActive,
|
|
45
|
+
} from "../core/unionMatch.ts";
|
|
46
|
+
import type { AllConstraints } from "../core/renderer.ts";
|
|
47
|
+
import { inputTarget, selectTarget } from "./eventTargets.ts";
|
|
48
|
+
import type { VueRenderProps } from "./types.ts";
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Accessibility helpers — Vue counterparts of `react/a11y.ts`
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Hint descriptor emitted alongside an input. Mirrors `react/a11y.ts`
|
|
56
|
+
* `HintInfo`.
|
|
57
|
+
*/
|
|
58
|
+
interface HintInfo {
|
|
59
|
+
readonly id: string;
|
|
60
|
+
readonly hint: string;
|
|
61
|
+
readonly ariaDescribedBy: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build {@link HintInfo} for a field at `inputId` given its declared
|
|
66
|
+
* constraints. Returns `undefined` when no constraint message would be
|
|
67
|
+
* produced — the renderers then skip emitting the hint element entirely.
|
|
68
|
+
*/
|
|
69
|
+
function buildHintInfo(
|
|
70
|
+
inputId: string,
|
|
71
|
+
constraints: AllConstraints
|
|
72
|
+
): HintInfo | undefined {
|
|
73
|
+
const hint = coreConstraintHint(constraints);
|
|
74
|
+
if (hint === undefined) return undefined;
|
|
75
|
+
const id = hintIdFor(inputId);
|
|
76
|
+
return { id, hint, ariaDescribedBy: id };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build the ARIA attribute bundle for a renderer. Returns a plain
|
|
81
|
+
* `Record<string, string>` so callers can spread it into the `props`
|
|
82
|
+
* object passed to `h()`.
|
|
83
|
+
*
|
|
84
|
+
* Matches `react/a11y.ts` `buildAriaAttrs` semantics so both adapters
|
|
85
|
+
* emit identical accessibility metadata for the same field.
|
|
86
|
+
*/
|
|
87
|
+
function buildAriaAttrs(
|
|
88
|
+
tree: WalkedField,
|
|
89
|
+
description?: unknown,
|
|
90
|
+
inputId?: string,
|
|
91
|
+
constraints?: AllConstraints
|
|
92
|
+
): Record<string, string> {
|
|
93
|
+
const attrs: Record<string, string> = {};
|
|
94
|
+
if (tree.isOptional === false) {
|
|
95
|
+
attrs["aria-required"] = "true";
|
|
96
|
+
}
|
|
97
|
+
if (
|
|
98
|
+
inputId !== undefined &&
|
|
99
|
+
constraints !== undefined &&
|
|
100
|
+
coreConstraintHint(constraints) !== undefined
|
|
101
|
+
) {
|
|
102
|
+
attrs["aria-describedby"] = hintIdFor(inputId);
|
|
103
|
+
}
|
|
104
|
+
if (typeof description === "string" && description.length > 0) {
|
|
105
|
+
attrs["aria-label"] = description;
|
|
106
|
+
}
|
|
107
|
+
return attrs;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Narrow `meta.description` (typed `unknown`) to a string value safe to
|
|
112
|
+
* pass into Vue's `aria-label`. Returns `undefined` for non-string or
|
|
113
|
+
* empty-string descriptions so Vue drops the attribute rather than
|
|
114
|
+
* stringifying `{}` to `"[object Object]"`.
|
|
115
|
+
*/
|
|
116
|
+
function ariaLabel(description: unknown): string | undefined {
|
|
117
|
+
if (typeof description !== "string") return undefined;
|
|
118
|
+
if (description.length === 0) return undefined;
|
|
119
|
+
return description;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Date/time formatting helpers
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
function formatDateTime(value: unknown): string | undefined {
|
|
127
|
+
if (typeof value !== "string" || value.length === 0) return undefined;
|
|
128
|
+
const date = new Date(value);
|
|
129
|
+
if (isNaN(date.getTime())) return undefined;
|
|
130
|
+
return date.toLocaleString();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function formatDate(value: unknown): string | undefined {
|
|
134
|
+
if (typeof value !== "string" || value.length === 0) return undefined;
|
|
135
|
+
const date = new Date(value);
|
|
136
|
+
if (isNaN(date.getTime())) return undefined;
|
|
137
|
+
return date.toLocaleDateString();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function formatTime(value: unknown): string | undefined {
|
|
141
|
+
if (typeof value !== "string" || value.length === 0) return undefined;
|
|
142
|
+
const date = new Date(value);
|
|
143
|
+
if (isNaN(date.getTime())) return undefined;
|
|
144
|
+
return date.toLocaleTimeString();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Accessibility: ID generation
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Build a stable, unique input ID from the path.
|
|
153
|
+
*
|
|
154
|
+
* Re-exported alias for {@link fieldDomId} so external Vue themes
|
|
155
|
+
* import a name parallel to the React adapter's `inputId` export. Both
|
|
156
|
+
* the React and Vue renderers (and the HTML pipeline) must derive the
|
|
157
|
+
* same id from the same path — `fieldDomId` in `core/idPath.ts` is the
|
|
158
|
+
* canonical implementation.
|
|
159
|
+
*/
|
|
160
|
+
export function inputId(path: string): string {
|
|
161
|
+
return fieldDomId(path);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Utility — hint rendering
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
function renderHint(hintInfo: HintInfo): VNode {
|
|
169
|
+
return h("small", { id: hintInfo.id, class: "sc-hint" }, hintInfo.hint);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Wrap an input VNode together with an optional sibling hint
|
|
174
|
+
* `<small>` element. When no hint is needed the input is returned
|
|
175
|
+
* unchanged so the renderer keeps the single-element contract that
|
|
176
|
+
* union renderers rely on. When a hint applies, the pair is wrapped in
|
|
177
|
+
* a Vue fragment (`h(Fragment, ...)`); Vue renders a fragment as a list
|
|
178
|
+
* of siblings with no surrounding tag — exactly mirroring the React
|
|
179
|
+
* `<>{input}{hint}</>` shape.
|
|
180
|
+
*/
|
|
181
|
+
function withHint(input: VNode, hintInfo: HintInfo | undefined): VNode {
|
|
182
|
+
if (hintInfo === undefined) return input;
|
|
183
|
+
// `h(Symbol(Fragment), ...)` produces a Vue fragment that renders
|
|
184
|
+
// its children as siblings without a wrapping element. Imported
|
|
185
|
+
// here as a runtime helper rather than re-exported because no
|
|
186
|
+
// caller outside this module needs the fragment symbol.
|
|
187
|
+
return h("template", undefined, [input, renderHint(hintInfo)]);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Headless renderers — one per schema type
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Headless renderer for `StringField` — plain `<input>` / `<span>`.
|
|
196
|
+
*/
|
|
197
|
+
export function renderString(props: VueRenderProps): VNode {
|
|
198
|
+
const id = inputId(props.path);
|
|
199
|
+
|
|
200
|
+
if (props.readOnly) {
|
|
201
|
+
const strValue =
|
|
202
|
+
typeof props.value === "string" ? props.value : undefined;
|
|
203
|
+
if (strValue === undefined || strValue.length === 0)
|
|
204
|
+
return h("span", { id }, EM_DASH);
|
|
205
|
+
const format = props.constraints.format;
|
|
206
|
+
if (format === "email" && isSafeMailtoAddress(strValue))
|
|
207
|
+
return h(
|
|
208
|
+
"a",
|
|
209
|
+
{ href: `mailto:${strValue}`, id, "aria-readonly": "true" },
|
|
210
|
+
strValue
|
|
211
|
+
);
|
|
212
|
+
if ((format === "uri" || format === "url") && isSafeHyperlink(strValue))
|
|
213
|
+
return h(
|
|
214
|
+
"a",
|
|
215
|
+
{ href: strValue, id, "aria-readonly": "true" },
|
|
216
|
+
strValue
|
|
217
|
+
);
|
|
218
|
+
if (format === "date") {
|
|
219
|
+
const formatted = formatDate(strValue);
|
|
220
|
+
return h("span", { id }, formatted ?? strValue);
|
|
221
|
+
}
|
|
222
|
+
if (format === "time") {
|
|
223
|
+
const formatted = formatTime(strValue);
|
|
224
|
+
return h("span", { id }, formatted ?? strValue);
|
|
225
|
+
}
|
|
226
|
+
if (format === "date-time" || format === "datetime") {
|
|
227
|
+
const formatted = formatDateTime(strValue);
|
|
228
|
+
return h("span", { id }, formatted ?? strValue);
|
|
229
|
+
}
|
|
230
|
+
return h("span", { id }, strValue);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const strValue = typeof props.value === "string" ? props.value : "";
|
|
234
|
+
const dateType = dateInputType(props.constraints.format);
|
|
235
|
+
|
|
236
|
+
const ariaAttrs = buildAriaAttrs(props.tree);
|
|
237
|
+
const hintInfo = buildHintInfo(id, props.constraints);
|
|
238
|
+
const ariaDescribedBy = hintInfo?.ariaDescribedBy;
|
|
239
|
+
|
|
240
|
+
if (dateType !== undefined) {
|
|
241
|
+
const dateInput = h("input", {
|
|
242
|
+
id,
|
|
243
|
+
type: dateType,
|
|
244
|
+
value: props.writeOnly ? "" : strValue,
|
|
245
|
+
onInput: (e: Event) => {
|
|
246
|
+
const target = inputTarget(e);
|
|
247
|
+
if (target === undefined) return;
|
|
248
|
+
props.onChange(target.value);
|
|
249
|
+
},
|
|
250
|
+
...(ariaDescribedBy !== undefined
|
|
251
|
+
? { "aria-describedby": ariaDescribedBy }
|
|
252
|
+
: {}),
|
|
253
|
+
...ariaAttrs,
|
|
254
|
+
});
|
|
255
|
+
return withHint(dateInput, hintInfo);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (props.tree.type === "enum" && props.tree.enumValues.length > 0) {
|
|
259
|
+
const enumValues = props.tree.enumValues;
|
|
260
|
+
const selectChildren: VNode[] = [
|
|
261
|
+
h("option", { value: "" }, `Select${ELLIPSIS}`),
|
|
262
|
+
...enumValues.map((v) => {
|
|
263
|
+
const display = displayJsonValue(v);
|
|
264
|
+
return h("option", { key: display, value: display }, display);
|
|
265
|
+
}),
|
|
266
|
+
];
|
|
267
|
+
const select = h(
|
|
268
|
+
"select",
|
|
269
|
+
{
|
|
270
|
+
id,
|
|
271
|
+
value: strValue,
|
|
272
|
+
onChange: (e: Event) => {
|
|
273
|
+
const target = selectTarget(e);
|
|
274
|
+
if (target === undefined) return;
|
|
275
|
+
props.onChange(target.value);
|
|
276
|
+
},
|
|
277
|
+
...(ariaDescribedBy !== undefined
|
|
278
|
+
? { "aria-describedby": ariaDescribedBy }
|
|
279
|
+
: {}),
|
|
280
|
+
...ariaAttrs,
|
|
281
|
+
},
|
|
282
|
+
selectChildren
|
|
283
|
+
);
|
|
284
|
+
return withHint(select, hintInfo);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const isCredential =
|
|
288
|
+
props.writeOnly && props.constraints.format === "password";
|
|
289
|
+
const inputType = isCredential
|
|
290
|
+
? "password"
|
|
291
|
+
: props.constraints.format === "email"
|
|
292
|
+
? "email"
|
|
293
|
+
: props.constraints.format === "uri"
|
|
294
|
+
? "url"
|
|
295
|
+
: "text";
|
|
296
|
+
const autoComplete = isCredential
|
|
297
|
+
? strValue.length > 0
|
|
298
|
+
? "current-password"
|
|
299
|
+
: "new-password"
|
|
300
|
+
: undefined;
|
|
301
|
+
|
|
302
|
+
const inputProps: Record<string, unknown> = {
|
|
303
|
+
id,
|
|
304
|
+
type: inputType,
|
|
305
|
+
value: props.writeOnly ? "" : strValue,
|
|
306
|
+
onInput: (e: Event) => {
|
|
307
|
+
const target = inputTarget(e);
|
|
308
|
+
if (target === undefined) return;
|
|
309
|
+
props.onChange(target.value);
|
|
310
|
+
},
|
|
311
|
+
...ariaAttrs,
|
|
312
|
+
};
|
|
313
|
+
if (autoComplete !== undefined) inputProps.autocomplete = autoComplete;
|
|
314
|
+
if (typeof props.meta.description === "string") {
|
|
315
|
+
inputProps.placeholder = props.meta.description;
|
|
316
|
+
}
|
|
317
|
+
if (props.constraints.minLength !== undefined) {
|
|
318
|
+
inputProps.minlength = props.constraints.minLength;
|
|
319
|
+
}
|
|
320
|
+
if (props.constraints.maxLength !== undefined) {
|
|
321
|
+
inputProps.maxlength = props.constraints.maxLength;
|
|
322
|
+
}
|
|
323
|
+
if (ariaDescribedBy !== undefined) {
|
|
324
|
+
inputProps["aria-describedby"] = ariaDescribedBy;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const input = h("input", inputProps);
|
|
328
|
+
return withHint(input, hintInfo);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Headless renderer for `NumberField` — plain `<input type="number">`. */
|
|
332
|
+
export function renderNumber(props: VueRenderProps): VNode {
|
|
333
|
+
const id = inputId(props.path);
|
|
334
|
+
|
|
335
|
+
if (props.readOnly) {
|
|
336
|
+
if (typeof props.value !== "number") return h("span", { id }, EM_DASH);
|
|
337
|
+
return h("span", { id }, props.value.toLocaleString());
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const numValue = typeof props.value === "number" ? props.value : "";
|
|
341
|
+
const ariaAttrs = buildAriaAttrs(props.tree);
|
|
342
|
+
const hintInfo = buildHintInfo(id, props.constraints);
|
|
343
|
+
|
|
344
|
+
const isInteger =
|
|
345
|
+
props.tree.type === "number" ? props.tree.isInteger : false;
|
|
346
|
+
const inputMode = isInteger ? "numeric" : "decimal";
|
|
347
|
+
const multipleOf = props.constraints.multipleOf;
|
|
348
|
+
const step =
|
|
349
|
+
multipleOf !== undefined
|
|
350
|
+
? String(multipleOf)
|
|
351
|
+
: isInteger
|
|
352
|
+
? "1"
|
|
353
|
+
: undefined;
|
|
354
|
+
|
|
355
|
+
const inputProps: Record<string, unknown> = {
|
|
356
|
+
id,
|
|
357
|
+
type: "number",
|
|
358
|
+
inputmode: inputMode,
|
|
359
|
+
value: props.writeOnly ? "" : numValue,
|
|
360
|
+
onInput: (e: Event) => {
|
|
361
|
+
const target = inputTarget(e);
|
|
362
|
+
if (target === undefined) return;
|
|
363
|
+
props.onChange(Number(target.value));
|
|
364
|
+
},
|
|
365
|
+
...ariaAttrs,
|
|
366
|
+
};
|
|
367
|
+
if (step !== undefined) inputProps.step = step;
|
|
368
|
+
if (props.constraints.minimum !== undefined) {
|
|
369
|
+
inputProps.min = props.constraints.minimum;
|
|
370
|
+
}
|
|
371
|
+
if (props.constraints.maximum !== undefined) {
|
|
372
|
+
inputProps.max = props.constraints.maximum;
|
|
373
|
+
}
|
|
374
|
+
if (hintInfo !== undefined) {
|
|
375
|
+
inputProps["aria-describedby"] = hintInfo.ariaDescribedBy;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const numberInput = h("input", inputProps);
|
|
379
|
+
return withHint(numberInput, hintInfo);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Headless renderer for `BooleanField` — plain `<input type="checkbox">`. */
|
|
383
|
+
export function renderBoolean(props: VueRenderProps): VNode {
|
|
384
|
+
const id = inputId(props.path);
|
|
385
|
+
|
|
386
|
+
if (props.readOnly) {
|
|
387
|
+
if (typeof props.value !== "boolean") return h("span", { id }, EM_DASH);
|
|
388
|
+
return h("span", { id }, props.value ? "Yes" : "No");
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const ariaAttrs = buildAriaAttrs(props.tree, props.meta.description);
|
|
392
|
+
|
|
393
|
+
return h("input", {
|
|
394
|
+
id,
|
|
395
|
+
type: "checkbox",
|
|
396
|
+
checked: props.writeOnly ? false : props.value === true,
|
|
397
|
+
onChange: (e: Event) => {
|
|
398
|
+
const target = inputTarget(e);
|
|
399
|
+
if (target === undefined) return;
|
|
400
|
+
props.onChange(target.checked);
|
|
401
|
+
},
|
|
402
|
+
...ariaAttrs,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Headless renderer for `EnumField` — plain `<select>` listing each option. */
|
|
407
|
+
export function renderEnum(props: VueRenderProps): VNode {
|
|
408
|
+
const id = inputId(props.path);
|
|
409
|
+
const enumValue = typeof props.value === "string" ? props.value : "";
|
|
410
|
+
|
|
411
|
+
if (props.readOnly) {
|
|
412
|
+
return h("span", { id }, enumValue.length > 0 ? enumValue : EM_DASH);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const ariaAttrs = buildAriaAttrs(props.tree);
|
|
416
|
+
const hintInfo = buildHintInfo(id, props.constraints);
|
|
417
|
+
const enumValues = props.tree.type === "enum" ? props.tree.enumValues : [];
|
|
418
|
+
|
|
419
|
+
const children: VNode[] = [
|
|
420
|
+
h("option", { value: "" }, `Select${ELLIPSIS}`),
|
|
421
|
+
...enumValues.map((v) => {
|
|
422
|
+
const display = displayJsonValue(v);
|
|
423
|
+
return h("option", { key: display, value: display }, display);
|
|
424
|
+
}),
|
|
425
|
+
];
|
|
426
|
+
|
|
427
|
+
const selectProps: Record<string, unknown> = {
|
|
428
|
+
id,
|
|
429
|
+
value: props.writeOnly ? "" : enumValue,
|
|
430
|
+
onChange: (e: Event) => {
|
|
431
|
+
const target = selectTarget(e);
|
|
432
|
+
if (target === undefined) return;
|
|
433
|
+
props.onChange(target.value);
|
|
434
|
+
},
|
|
435
|
+
...ariaAttrs,
|
|
436
|
+
};
|
|
437
|
+
if (hintInfo !== undefined) {
|
|
438
|
+
selectProps["aria-describedby"] = hintInfo.ariaDescribedBy;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const select = h("select", selectProps, children);
|
|
442
|
+
return withHint(select, hintInfo);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Headless renderer for `ObjectField` — `<fieldset>` per object with one
|
|
447
|
+
* child per property.
|
|
448
|
+
*/
|
|
449
|
+
export function renderObject(props: VueRenderProps): VNode {
|
|
450
|
+
if (props.tree.type !== "object") return h("span");
|
|
451
|
+
const obj = isObject(props.value) ? props.value : {};
|
|
452
|
+
const fields = props.tree.fields;
|
|
453
|
+
|
|
454
|
+
const sortedEntries = sortFieldsByOrder(fields);
|
|
455
|
+
|
|
456
|
+
const children: VNode[] = [];
|
|
457
|
+
if (typeof props.meta.description === "string") {
|
|
458
|
+
children.push(h("legend", undefined, props.meta.description));
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
for (const [key, field] of sortedEntries) {
|
|
462
|
+
if (field.meta.visible === false) continue;
|
|
463
|
+
const childValue = obj[key];
|
|
464
|
+
const childId = inputId(`${props.path}.${key}`);
|
|
465
|
+
const childOnChange = (v: unknown) => {
|
|
466
|
+
const updated: Record<string, unknown> = {};
|
|
467
|
+
for (const [k, val] of Object.entries(obj)) {
|
|
468
|
+
updated[k] = val;
|
|
469
|
+
}
|
|
470
|
+
updated[key] = v;
|
|
471
|
+
props.onChange(updated);
|
|
472
|
+
};
|
|
473
|
+
const child = props.renderChild(field, childValue, childOnChange, key);
|
|
474
|
+
const labelText =
|
|
475
|
+
typeof field.meta.description === "string"
|
|
476
|
+
? field.meta.description
|
|
477
|
+
: key;
|
|
478
|
+
const labelChildren: (VNode | string)[] = [labelText];
|
|
479
|
+
if (field.isOptional === false) {
|
|
480
|
+
labelChildren.push(
|
|
481
|
+
h(
|
|
482
|
+
"span",
|
|
483
|
+
{
|
|
484
|
+
"aria-hidden": "true",
|
|
485
|
+
style: { color: "#dc2626" },
|
|
486
|
+
},
|
|
487
|
+
" *"
|
|
488
|
+
)
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
children.push(
|
|
492
|
+
h("div", { key }, [
|
|
493
|
+
h("label", { for: childId }, labelChildren),
|
|
494
|
+
child,
|
|
495
|
+
])
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return h("fieldset", undefined, children);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Compute the default value for a freshly added record entry based on the
|
|
504
|
+
* record's value-type schema. Mirrors {@link defaultRecordValue} from the
|
|
505
|
+
* React adapter — see that function's commentary for the per-variant
|
|
506
|
+
* choices.
|
|
507
|
+
*/
|
|
508
|
+
export function defaultRecordValue(valueType: WalkedField): unknown {
|
|
509
|
+
if (valueType.defaultValue !== undefined) return valueType.defaultValue;
|
|
510
|
+
switch (valueType.type) {
|
|
511
|
+
case "string":
|
|
512
|
+
return "";
|
|
513
|
+
case "number":
|
|
514
|
+
return 0;
|
|
515
|
+
case "boolean":
|
|
516
|
+
return false;
|
|
517
|
+
case "array":
|
|
518
|
+
return [];
|
|
519
|
+
case "object":
|
|
520
|
+
case "record":
|
|
521
|
+
return {};
|
|
522
|
+
case "null":
|
|
523
|
+
return null;
|
|
524
|
+
case "unknown":
|
|
525
|
+
case "enum":
|
|
526
|
+
case "literal":
|
|
527
|
+
case "tuple":
|
|
528
|
+
case "union":
|
|
529
|
+
case "discriminatedUnion":
|
|
530
|
+
case "conditional":
|
|
531
|
+
case "negation":
|
|
532
|
+
case "file":
|
|
533
|
+
case "never":
|
|
534
|
+
return undefined;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Generate a unique, currently-unused key for a new record entry.
|
|
540
|
+
* Picks the first of `key`, `key-1`, `key-2`, … that is not in `existing`.
|
|
541
|
+
*/
|
|
542
|
+
export function nextRecordKey(
|
|
543
|
+
existing: readonly string[],
|
|
544
|
+
base = "key"
|
|
545
|
+
): string {
|
|
546
|
+
if (!existing.includes(base)) return base;
|
|
547
|
+
let i = 1;
|
|
548
|
+
while (existing.includes(`${base}-${String(i)}`)) i += 1;
|
|
549
|
+
return `${base}-${String(i)}`;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Rename a key in an object while preserving insertion order. Returns the
|
|
554
|
+
* original object reference when the rename is a no-op (`oldKey === newKey`)
|
|
555
|
+
* or when `newKey` collides with an existing key.
|
|
556
|
+
*/
|
|
557
|
+
export function renameRecordKey(
|
|
558
|
+
obj: Record<string, unknown>,
|
|
559
|
+
oldKey: string,
|
|
560
|
+
newKey: string
|
|
561
|
+
): Record<string, unknown> {
|
|
562
|
+
if (oldKey === newKey) return obj;
|
|
563
|
+
if (newKey in obj && newKey !== oldKey) return obj;
|
|
564
|
+
const renamed: Record<string, unknown> = {};
|
|
565
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
566
|
+
renamed[k === oldKey ? newKey : k] = v;
|
|
567
|
+
}
|
|
568
|
+
return renamed;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Headless renderer for `RecordField` — editable key/value rows with
|
|
573
|
+
* add/remove controls.
|
|
574
|
+
*/
|
|
575
|
+
export function renderRecord(props: VueRenderProps): VNode {
|
|
576
|
+
if (props.tree.type !== "record") return h("span");
|
|
577
|
+
const obj = isObject(props.value) ? props.value : {};
|
|
578
|
+
const valueType = props.tree.valueType;
|
|
579
|
+
|
|
580
|
+
const entries = Object.entries(obj);
|
|
581
|
+
|
|
582
|
+
if (props.readOnly) {
|
|
583
|
+
if (entries.length === 0) {
|
|
584
|
+
return h("span", undefined, EM_DASH);
|
|
585
|
+
}
|
|
586
|
+
const groupProps: Record<string, unknown> = { role: "group" };
|
|
587
|
+
const label = ariaLabel(props.meta.description);
|
|
588
|
+
if (label !== undefined) groupProps["aria-label"] = label;
|
|
589
|
+
return h(
|
|
590
|
+
"div",
|
|
591
|
+
groupProps,
|
|
592
|
+
entries.map(([key, value]) => {
|
|
593
|
+
const childId = inputId(`${props.path}.${key}`);
|
|
594
|
+
return h("div", { key }, [
|
|
595
|
+
h("label", { for: childId }, key),
|
|
596
|
+
props.renderChild(
|
|
597
|
+
valueType,
|
|
598
|
+
value,
|
|
599
|
+
() => {
|
|
600
|
+
/* read-only: noop */
|
|
601
|
+
},
|
|
602
|
+
key
|
|
603
|
+
),
|
|
604
|
+
]);
|
|
605
|
+
})
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const handleRename = (oldKey: string, newKey: string) => {
|
|
610
|
+
const renamed = renameRecordKey(obj, oldKey, newKey);
|
|
611
|
+
if (renamed === obj) return;
|
|
612
|
+
props.onChange(renamed);
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
const handleValueChange = (key: string, nextValue: unknown) => {
|
|
616
|
+
const updated: Record<string, unknown> = {};
|
|
617
|
+
for (const [k, val] of Object.entries(obj)) {
|
|
618
|
+
updated[k] = val;
|
|
619
|
+
}
|
|
620
|
+
updated[key] = nextValue;
|
|
621
|
+
props.onChange(updated);
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
const handleRemove = (key: string) => {
|
|
625
|
+
const next: Record<string, unknown> = {};
|
|
626
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
627
|
+
if (k === key) continue;
|
|
628
|
+
next[k] = v;
|
|
629
|
+
}
|
|
630
|
+
props.onChange(next);
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
const handleAdd = () => {
|
|
634
|
+
const newKey = nextRecordKey(Object.keys(obj));
|
|
635
|
+
const next: Record<string, unknown> = { ...obj };
|
|
636
|
+
next[newKey] = defaultRecordValue(valueType);
|
|
637
|
+
props.onChange(next);
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
const groupProps: Record<string, unknown> = { role: "group" };
|
|
641
|
+
const label = ariaLabel(props.meta.description);
|
|
642
|
+
if (label !== undefined) groupProps["aria-label"] = label;
|
|
643
|
+
|
|
644
|
+
return h("div", groupProps, [
|
|
645
|
+
...entries.map(([key, value]) => {
|
|
646
|
+
const childId = inputId(`${props.path}.${key}`);
|
|
647
|
+
const keyId = `${childId}-key`;
|
|
648
|
+
return h("div", { key }, [
|
|
649
|
+
h("input", {
|
|
650
|
+
id: keyId,
|
|
651
|
+
type: "text",
|
|
652
|
+
"aria-label": "Entry key",
|
|
653
|
+
value: key,
|
|
654
|
+
onBlur: (e: Event) => {
|
|
655
|
+
const target = inputTarget(e);
|
|
656
|
+
if (target === undefined) return;
|
|
657
|
+
handleRename(key, target.value);
|
|
658
|
+
},
|
|
659
|
+
}),
|
|
660
|
+
props.renderChild(
|
|
661
|
+
valueType,
|
|
662
|
+
value,
|
|
663
|
+
(nextValue: unknown) => {
|
|
664
|
+
handleValueChange(key, nextValue);
|
|
665
|
+
},
|
|
666
|
+
key
|
|
667
|
+
),
|
|
668
|
+
h(
|
|
669
|
+
"button",
|
|
670
|
+
{
|
|
671
|
+
type: "button",
|
|
672
|
+
"aria-label": `Remove entry ${key}`,
|
|
673
|
+
onClick: () => {
|
|
674
|
+
handleRemove(key);
|
|
675
|
+
},
|
|
676
|
+
},
|
|
677
|
+
"Remove"
|
|
678
|
+
),
|
|
679
|
+
]);
|
|
680
|
+
}),
|
|
681
|
+
h(
|
|
682
|
+
"button",
|
|
683
|
+
{
|
|
684
|
+
type: "button",
|
|
685
|
+
"aria-label": "Add entry",
|
|
686
|
+
onClick: handleAdd,
|
|
687
|
+
},
|
|
688
|
+
"Add"
|
|
689
|
+
),
|
|
690
|
+
]);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** Headless renderer for `ArrayField` — ordered list with add/remove controls. */
|
|
694
|
+
export function renderArray(props: VueRenderProps): VNode {
|
|
695
|
+
if (props.tree.type !== "array") return h("span");
|
|
696
|
+
const arr = Array.isArray(props.value) ? props.value : [];
|
|
697
|
+
const element = props.tree.element;
|
|
698
|
+
if (element === undefined) return h("span");
|
|
699
|
+
|
|
700
|
+
if (props.readOnly) {
|
|
701
|
+
if (arr.length === 0) return h("span", { style: { display: "none" } });
|
|
702
|
+
const groupProps: Record<string, unknown> = { role: "group" };
|
|
703
|
+
const label = ariaLabel(props.meta.description);
|
|
704
|
+
if (label !== undefined) groupProps["aria-label"] = label;
|
|
705
|
+
return h(
|
|
706
|
+
"ul",
|
|
707
|
+
groupProps,
|
|
708
|
+
arr.map((item, i) =>
|
|
709
|
+
h(
|
|
710
|
+
"li",
|
|
711
|
+
{ key: String(i) },
|
|
712
|
+
props.renderChild(
|
|
713
|
+
element,
|
|
714
|
+
item,
|
|
715
|
+
() => {
|
|
716
|
+
/* read-only: noop */
|
|
717
|
+
},
|
|
718
|
+
`[${String(i)}]`
|
|
719
|
+
)
|
|
720
|
+
)
|
|
721
|
+
)
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const handleRemove = (index: number) => {
|
|
726
|
+
const next = arr.slice();
|
|
727
|
+
next.splice(index, 1);
|
|
728
|
+
props.onChange(next);
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
const handleAdd = () => {
|
|
732
|
+
const next = arr.slice();
|
|
733
|
+
next.push(defaultRecordValue(element));
|
|
734
|
+
props.onChange(next);
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
const groupProps: Record<string, unknown> = { role: "group" };
|
|
738
|
+
const label = ariaLabel(props.meta.description);
|
|
739
|
+
if (label !== undefined) groupProps["aria-label"] = label;
|
|
740
|
+
|
|
741
|
+
return h("div", groupProps, [
|
|
742
|
+
h(
|
|
743
|
+
"ul",
|
|
744
|
+
undefined,
|
|
745
|
+
arr.map((item, i) => {
|
|
746
|
+
const childOnChange = (v: unknown) => {
|
|
747
|
+
const nextArr = arr.slice();
|
|
748
|
+
nextArr[i] = v;
|
|
749
|
+
props.onChange(nextArr);
|
|
750
|
+
};
|
|
751
|
+
return h("li", { key: String(i) }, [
|
|
752
|
+
props.renderChild(
|
|
753
|
+
element,
|
|
754
|
+
item,
|
|
755
|
+
childOnChange,
|
|
756
|
+
`[${String(i)}]`
|
|
757
|
+
),
|
|
758
|
+
h(
|
|
759
|
+
"button",
|
|
760
|
+
{
|
|
761
|
+
type: "button",
|
|
762
|
+
"aria-label": `Remove item ${String(i)}`,
|
|
763
|
+
onClick: () => {
|
|
764
|
+
handleRemove(i);
|
|
765
|
+
},
|
|
766
|
+
},
|
|
767
|
+
"Remove"
|
|
768
|
+
),
|
|
769
|
+
]);
|
|
770
|
+
})
|
|
771
|
+
),
|
|
772
|
+
h(
|
|
773
|
+
"button",
|
|
774
|
+
{
|
|
775
|
+
type: "button",
|
|
776
|
+
"aria-label": "Add item",
|
|
777
|
+
onClick: handleAdd,
|
|
778
|
+
},
|
|
779
|
+
"Add"
|
|
780
|
+
),
|
|
781
|
+
]);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Headless renderer for plain `UnionField` — picks the matching option and
|
|
786
|
+
* renders it.
|
|
787
|
+
*/
|
|
788
|
+
export function renderUnion(props: VueRenderProps): VNode {
|
|
789
|
+
const options =
|
|
790
|
+
props.tree.type === "union" || props.tree.type === "discriminatedUnion"
|
|
791
|
+
? props.tree.options
|
|
792
|
+
: undefined;
|
|
793
|
+
if (options === undefined || options.length === 0) {
|
|
794
|
+
if (props.value === undefined || props.value === null)
|
|
795
|
+
return h("span", undefined, EM_DASH);
|
|
796
|
+
return h("span", undefined, JSON.stringify(props.value));
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const matched = matchUnionOptionShared(options, props.value);
|
|
800
|
+
if (matched !== undefined) {
|
|
801
|
+
return props.renderChild(matched, props.value, props.onChange);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const firstOption = options[0];
|
|
805
|
+
if (firstOption !== undefined) {
|
|
806
|
+
return props.renderChild(firstOption, props.value, props.onChange);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
return h("span", undefined, EM_DASH);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// ---------------------------------------------------------------------------
|
|
813
|
+
// Discriminated union — WAI-ARIA tabs pattern
|
|
814
|
+
// ---------------------------------------------------------------------------
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Pure helper: convert a tab index into the new value the discriminated
|
|
818
|
+
* union should emit. Returns `undefined` when the index is out of bounds.
|
|
819
|
+
*
|
|
820
|
+
* Extracted so the contract is unit-testable without instantiating the
|
|
821
|
+
* Vue component (which relies on the Vue runtime).
|
|
822
|
+
*/
|
|
823
|
+
export function discriminatedUnionValueForTab(
|
|
824
|
+
optionLabels: readonly string[],
|
|
825
|
+
discKey: string,
|
|
826
|
+
newIndex: number
|
|
827
|
+
): Record<string, string> | undefined {
|
|
828
|
+
const label = optionLabels[newIndex];
|
|
829
|
+
if (label === undefined) return undefined;
|
|
830
|
+
return { [discKey]: label };
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* WAI-ARIA tabs component for discriminated unions, Vue edition.
|
|
835
|
+
*
|
|
836
|
+
* Implements the WAI-ARIA "Tabs with Automatic Activation" pattern in
|
|
837
|
+
* Vue 3 Composition API:
|
|
838
|
+
* - ArrowRight / ArrowLeft move between tabs, wrapping at the extremes.
|
|
839
|
+
* - Home / End jump to the first / last tab.
|
|
840
|
+
* - `aria-selected`, `aria-controls`, `role="tablist" / "tab" / "tabpanel"`.
|
|
841
|
+
* - Roving tabindex: the active tab has `tabindex=0`, the rest `-1`.
|
|
842
|
+
*
|
|
843
|
+
* "Automatic activation" means each arrow key both moves focus and
|
|
844
|
+
* activates the new tab in one step.
|
|
845
|
+
*
|
|
846
|
+
* Focus state machine: a single `pendingFocus` ref records when the
|
|
847
|
+
* activeIndex change originated from a keyboard event; a `watch` on
|
|
848
|
+
* `activeIndex` reads the flag, calls `nextTick` to await the new
|
|
849
|
+
* DOM, then focuses the matching tab. The flag is cleared whether or
|
|
850
|
+
* not the focus call succeeds so spurious re-runs cannot leave focus
|
|
851
|
+
* shifted unexpectedly.
|
|
852
|
+
*/
|
|
853
|
+
interface DiscriminatedUnionTabsProps {
|
|
854
|
+
options: readonly WalkedField[];
|
|
855
|
+
optionLabels: readonly string[];
|
|
856
|
+
activeIndex: number;
|
|
857
|
+
path: string;
|
|
858
|
+
discKey: string;
|
|
859
|
+
renderProps: VueRenderProps;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* `defineComponent` with the generic-arguments form derives the prop
|
|
864
|
+
* types from {@link DiscriminatedUnionTabsProps} so we avoid the
|
|
865
|
+
* `PropType<T>` runtime-constructor cast banned by the project lint
|
|
866
|
+
* rules. The runtime prop list enumerates every accepted prop name
|
|
867
|
+
* so Vue's prop normalisation does not warn at mount time; the
|
|
868
|
+
* generic argument supplies the TypeScript shape `setup(props)`
|
|
869
|
+
* sees.
|
|
870
|
+
*/
|
|
871
|
+
const DiscriminatedUnionTabs = defineComponent<DiscriminatedUnionTabsProps>({
|
|
872
|
+
name: "DiscriminatedUnionTabs",
|
|
873
|
+
props: [
|
|
874
|
+
"options",
|
|
875
|
+
"optionLabels",
|
|
876
|
+
"activeIndex",
|
|
877
|
+
"path",
|
|
878
|
+
"discKey",
|
|
879
|
+
"renderProps",
|
|
880
|
+
],
|
|
881
|
+
setup(props) {
|
|
882
|
+
const tabRefs = ref<(HTMLButtonElement | null)[]>([]);
|
|
883
|
+
// Set whenever a keyboard event triggers a tab change. The
|
|
884
|
+
// `watch` below reads and clears this flag so focus only
|
|
885
|
+
// follows selection when the change originated from the
|
|
886
|
+
// keyboard — never on initial mount and never after a click.
|
|
887
|
+
const pendingFocus = ref(false);
|
|
888
|
+
|
|
889
|
+
const setTabRef = (i: number) => (el: unknown) => {
|
|
890
|
+
// Vue ref callbacks receive `Element | ComponentPublicInstance |
|
|
891
|
+
// null`. The tab buttons are plain `<button>` elements, so
|
|
892
|
+
// narrow to `HTMLButtonElement`.
|
|
893
|
+
tabRefs.value[i] = el instanceof HTMLButtonElement ? el : null;
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
const handleTabChange = (newIndex: number) => {
|
|
897
|
+
const next = discriminatedUnionValueForTab(
|
|
898
|
+
props.optionLabels,
|
|
899
|
+
props.discKey,
|
|
900
|
+
newIndex
|
|
901
|
+
);
|
|
902
|
+
if (next === undefined) return;
|
|
903
|
+
props.renderProps.onChange(next);
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
const wrapIndex = (index: number): number =>
|
|
907
|
+
((index % props.options.length) + props.options.length) %
|
|
908
|
+
props.options.length;
|
|
909
|
+
|
|
910
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
911
|
+
let target: number | undefined;
|
|
912
|
+
if (e.key === "ArrowRight")
|
|
913
|
+
target = wrapIndex(props.activeIndex + 1);
|
|
914
|
+
else if (e.key === "ArrowLeft")
|
|
915
|
+
target = wrapIndex(props.activeIndex - 1);
|
|
916
|
+
else if (e.key === "Home") target = 0;
|
|
917
|
+
else if (e.key === "End") target = props.options.length - 1;
|
|
918
|
+
if (target === undefined) return;
|
|
919
|
+
e.preventDefault();
|
|
920
|
+
if (target === props.activeIndex) return;
|
|
921
|
+
pendingFocus.value = true;
|
|
922
|
+
handleTabChange(target);
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
watch(
|
|
926
|
+
() => props.activeIndex,
|
|
927
|
+
(next) => {
|
|
928
|
+
if (!pendingFocus.value) return;
|
|
929
|
+
pendingFocus.value = false;
|
|
930
|
+
void nextTick().then(() => {
|
|
931
|
+
tabRefs.value[next]?.focus();
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
);
|
|
935
|
+
|
|
936
|
+
// Ensure the tab refs array can hold one slot per option even
|
|
937
|
+
// before each child mounts. Vue calls ref callbacks during the
|
|
938
|
+
// mount pass; pre-sizing avoids a fleeting `undefined` slot
|
|
939
|
+
// visible to consumers reading `tabRefs.value`.
|
|
940
|
+
onMounted(() => {
|
|
941
|
+
tabRefs.value.length = props.options.length;
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
return () => {
|
|
945
|
+
const panelId = panelIdFor(props.path);
|
|
946
|
+
const activeOption = props.options[props.activeIndex];
|
|
947
|
+
return h("div", undefined, [
|
|
948
|
+
h(
|
|
949
|
+
"div",
|
|
950
|
+
{
|
|
951
|
+
role: "tablist",
|
|
952
|
+
"aria-label": "Select variant",
|
|
953
|
+
"aria-orientation": "horizontal",
|
|
954
|
+
style: {
|
|
955
|
+
display: "flex",
|
|
956
|
+
gap: "0.25rem",
|
|
957
|
+
marginBottom: "0.5rem",
|
|
958
|
+
},
|
|
959
|
+
onKeydown: handleKeyDown,
|
|
960
|
+
},
|
|
961
|
+
props.options.map((_opt, i) => {
|
|
962
|
+
const isActive = i === props.activeIndex;
|
|
963
|
+
return h(
|
|
964
|
+
"button",
|
|
965
|
+
{
|
|
966
|
+
key: String(i),
|
|
967
|
+
ref: setTabRef(i),
|
|
968
|
+
type: "button",
|
|
969
|
+
role: "tab",
|
|
970
|
+
id: tabIdFor(props.path, i),
|
|
971
|
+
"aria-selected": isActive ? "true" : "false",
|
|
972
|
+
"aria-controls": panelId,
|
|
973
|
+
tabindex: isActive ? 0 : -1,
|
|
974
|
+
onClick: () => {
|
|
975
|
+
handleTabChange(i);
|
|
976
|
+
},
|
|
977
|
+
style: {
|
|
978
|
+
padding: "0.25rem 0.75rem",
|
|
979
|
+
border: isActive
|
|
980
|
+
? "1px solid #3b82f6"
|
|
981
|
+
: "1px solid #d1d5db",
|
|
982
|
+
borderRadius: "0.25rem",
|
|
983
|
+
background: isActive
|
|
984
|
+
? "#eff6ff"
|
|
985
|
+
: "transparent",
|
|
986
|
+
cursor: "pointer",
|
|
987
|
+
fontSize: "0.875rem",
|
|
988
|
+
},
|
|
989
|
+
},
|
|
990
|
+
props.optionLabels[i]
|
|
991
|
+
);
|
|
992
|
+
})
|
|
993
|
+
),
|
|
994
|
+
h(
|
|
995
|
+
"div",
|
|
996
|
+
{
|
|
997
|
+
role: "tabpanel",
|
|
998
|
+
id: panelId,
|
|
999
|
+
"aria-labelledby": tabIdFor(
|
|
1000
|
+
props.path,
|
|
1001
|
+
props.activeIndex
|
|
1002
|
+
),
|
|
1003
|
+
},
|
|
1004
|
+
activeOption !== undefined
|
|
1005
|
+
? [
|
|
1006
|
+
props.renderProps.renderChild(
|
|
1007
|
+
activeOption,
|
|
1008
|
+
props.renderProps.value,
|
|
1009
|
+
props.renderProps.onChange
|
|
1010
|
+
),
|
|
1011
|
+
]
|
|
1012
|
+
: []
|
|
1013
|
+
),
|
|
1014
|
+
]);
|
|
1015
|
+
};
|
|
1016
|
+
},
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Headless renderer for `DiscriminatedUnionField` — tabbed UI driven by
|
|
1021
|
+
* the discriminator.
|
|
1022
|
+
*/
|
|
1023
|
+
export function renderDiscriminatedUnion(props: VueRenderProps): VNode {
|
|
1024
|
+
if (props.tree.type !== "discriminatedUnion") {
|
|
1025
|
+
if (props.value === undefined || props.value === null)
|
|
1026
|
+
return h("span", undefined, EM_DASH);
|
|
1027
|
+
return h("span", undefined, JSON.stringify(props.value));
|
|
1028
|
+
}
|
|
1029
|
+
const { options, discriminator: discKey } = props.tree;
|
|
1030
|
+
if (options.length === 0) {
|
|
1031
|
+
if (props.value === undefined || props.value === null)
|
|
1032
|
+
return h("span", undefined, EM_DASH);
|
|
1033
|
+
return h("span", undefined, JSON.stringify(props.value));
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
const valueObject = isObject(props.value) ? props.value : undefined;
|
|
1037
|
+
const { optionLabels, activeIndex, activeOption } =
|
|
1038
|
+
resolveDiscriminatedActive(options, discKey, valueObject);
|
|
1039
|
+
|
|
1040
|
+
if (props.readOnly) {
|
|
1041
|
+
if (activeOption !== undefined) {
|
|
1042
|
+
return props.renderChild(activeOption, props.value, props.onChange);
|
|
1043
|
+
}
|
|
1044
|
+
return h("span", undefined, EM_DASH);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
return h(DiscriminatedUnionTabs, {
|
|
1048
|
+
options,
|
|
1049
|
+
optionLabels,
|
|
1050
|
+
activeIndex,
|
|
1051
|
+
path: props.path,
|
|
1052
|
+
discKey,
|
|
1053
|
+
renderProps: props,
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/** Headless renderer for `FileField` — plain `<input type="file">`. */
|
|
1058
|
+
export function renderFile(props: VueRenderProps): VNode {
|
|
1059
|
+
const id = inputId(props.path);
|
|
1060
|
+
const accept = props.constraints.mimeTypes?.join(",");
|
|
1061
|
+
|
|
1062
|
+
if (props.readOnly) {
|
|
1063
|
+
return h("span", { id }, "File field");
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
const ariaAttrs = buildAriaAttrs(props.tree, props.meta.description);
|
|
1067
|
+
const hintInfo = buildHintInfo(id, props.constraints);
|
|
1068
|
+
|
|
1069
|
+
const inputProps: Record<string, unknown> = {
|
|
1070
|
+
id,
|
|
1071
|
+
type: "file",
|
|
1072
|
+
onChange: (e: Event) => {
|
|
1073
|
+
const target = inputTarget(e);
|
|
1074
|
+
if (target === undefined) return;
|
|
1075
|
+
const file = target.files?.[0];
|
|
1076
|
+
if (file !== undefined) {
|
|
1077
|
+
props.onChange(file);
|
|
1078
|
+
}
|
|
1079
|
+
},
|
|
1080
|
+
...ariaAttrs,
|
|
1081
|
+
};
|
|
1082
|
+
if (accept !== undefined) inputProps.accept = accept;
|
|
1083
|
+
if (hintInfo !== undefined) {
|
|
1084
|
+
inputProps["aria-describedby"] = hintInfo.ariaDescribedBy;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
const fileInput = h("input", inputProps);
|
|
1088
|
+
return withHint(fileInput, hintInfo);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Render a literal field — `z.literal("a")` or `{ const: 5 }`.
|
|
1093
|
+
*
|
|
1094
|
+
* Literals are non-editable by nature (the value is fixed at the schema
|
|
1095
|
+
* level), so both read-only and editable modes display the literal
|
|
1096
|
+
* value(s). Multiple literals (`z.literal(["a", "b"])`) render
|
|
1097
|
+
* comma-separated.
|
|
1098
|
+
*/
|
|
1099
|
+
export function renderLiteral(props: VueRenderProps): VNode {
|
|
1100
|
+
const id = inputId(props.path);
|
|
1101
|
+
if (props.tree.type !== "literal") return h("span");
|
|
1102
|
+
const values = props.tree.literalValues;
|
|
1103
|
+
if (values.length === 0) {
|
|
1104
|
+
return h("span", { id }, EM_DASH);
|
|
1105
|
+
}
|
|
1106
|
+
const display = values.map((v) => displayJsonValue(v)).join(", ");
|
|
1107
|
+
return h("span", { id }, display);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Render a null field — `z.null()` or `{ type: "null" }`.
|
|
1112
|
+
*
|
|
1113
|
+
* The only valid value is `null`, so render an em-dash placeholder
|
|
1114
|
+
* regardless of mode.
|
|
1115
|
+
*/
|
|
1116
|
+
export function renderNull(props: VueRenderProps): VNode {
|
|
1117
|
+
const id = inputId(props.path);
|
|
1118
|
+
return h("span", { id }, EM_DASH);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* Render a never field — `z.never()` or `{ not: {} }` / `false` schema.
|
|
1123
|
+
*
|
|
1124
|
+
* `never` indicates a position that cannot hold any value. We render a
|
|
1125
|
+
* visible placeholder rather than throwing because some valid schemas
|
|
1126
|
+
* intentionally contain `never` branches (e.g. exhaustive discriminated
|
|
1127
|
+
* unions), and a runtime crash on render would be worse than a visible
|
|
1128
|
+
* indicator.
|
|
1129
|
+
*/
|
|
1130
|
+
export function renderNever(props: VueRenderProps): VNode {
|
|
1131
|
+
const id = inputId(props.path);
|
|
1132
|
+
return h(
|
|
1133
|
+
"span",
|
|
1134
|
+
{ id, class: SC_CLASSES.never },
|
|
1135
|
+
h("em", undefined, "never matches")
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* Render a tuple field — `z.tuple([z.string(), z.number()])` or
|
|
1141
|
+
* `{ prefixItems: [...] }`.
|
|
1142
|
+
*
|
|
1143
|
+
* Positional rendering: each `prefixItems` entry is rendered at its
|
|
1144
|
+
* index. The structural index (e.g. `[0]`) is passed as the path suffix
|
|
1145
|
+
* so children get unique ids and labels.
|
|
1146
|
+
*/
|
|
1147
|
+
export function renderTuple(props: VueRenderProps): VNode {
|
|
1148
|
+
if (props.tree.type !== "tuple") return h("span");
|
|
1149
|
+
const prefixItems = props.tree.prefixItems;
|
|
1150
|
+
const restItems = props.tree.restItems;
|
|
1151
|
+
const arr = Array.isArray(props.value) ? props.value : [];
|
|
1152
|
+
if (
|
|
1153
|
+
prefixItems.length === 0 &&
|
|
1154
|
+
restItems === undefined &&
|
|
1155
|
+
arr.length === 0
|
|
1156
|
+
) {
|
|
1157
|
+
return h("span", { style: { display: "none" } });
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
const restCount =
|
|
1161
|
+
restItems !== undefined
|
|
1162
|
+
? Math.max(arr.length - prefixItems.length, 0)
|
|
1163
|
+
: 0;
|
|
1164
|
+
|
|
1165
|
+
const children: VNode[] = [];
|
|
1166
|
+
prefixItems.forEach((element, i) => {
|
|
1167
|
+
const itemValue: unknown = arr[i];
|
|
1168
|
+
const childOnChange = (v: unknown) => {
|
|
1169
|
+
const next = arr.slice();
|
|
1170
|
+
next[i] = v;
|
|
1171
|
+
props.onChange(next);
|
|
1172
|
+
};
|
|
1173
|
+
children.push(
|
|
1174
|
+
h(
|
|
1175
|
+
"div",
|
|
1176
|
+
{ key: String(i) },
|
|
1177
|
+
props.renderChild(
|
|
1178
|
+
element,
|
|
1179
|
+
itemValue,
|
|
1180
|
+
childOnChange,
|
|
1181
|
+
`[${String(i)}]`
|
|
1182
|
+
)
|
|
1183
|
+
)
|
|
1184
|
+
);
|
|
1185
|
+
});
|
|
1186
|
+
if (restItems !== undefined) {
|
|
1187
|
+
for (let j = 0; j < restCount; j++) {
|
|
1188
|
+
const i = prefixItems.length + j;
|
|
1189
|
+
const itemValue: unknown = arr[i];
|
|
1190
|
+
const childOnChange = (v: unknown) => {
|
|
1191
|
+
const next = arr.slice();
|
|
1192
|
+
next[i] = v;
|
|
1193
|
+
props.onChange(next);
|
|
1194
|
+
};
|
|
1195
|
+
children.push(
|
|
1196
|
+
h(
|
|
1197
|
+
"div",
|
|
1198
|
+
{ key: `rest-${String(i)}` },
|
|
1199
|
+
props.renderChild(
|
|
1200
|
+
restItems,
|
|
1201
|
+
itemValue,
|
|
1202
|
+
childOnChange,
|
|
1203
|
+
`[${String(i)}]`
|
|
1204
|
+
)
|
|
1205
|
+
)
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
const groupProps: Record<string, unknown> = { role: "group" };
|
|
1211
|
+
const label = ariaLabel(props.meta.description);
|
|
1212
|
+
if (label !== undefined) groupProps["aria-label"] = label;
|
|
1213
|
+
|
|
1214
|
+
return h("div", groupProps, children);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* Render a conditional field — JSON Schema `if`/`then`/`else`.
|
|
1219
|
+
*
|
|
1220
|
+
* Conditional schemas describe constraints rather than a single value
|
|
1221
|
+
* shape, so the renderer surfaces each clause as a labelled fieldset.
|
|
1222
|
+
*/
|
|
1223
|
+
export function renderConditional(props: VueRenderProps): VNode {
|
|
1224
|
+
if (props.tree.type !== "conditional") return h("span");
|
|
1225
|
+
const { ifClause, thenClause, elseClause } = props.tree;
|
|
1226
|
+
const children: VNode[] = [
|
|
1227
|
+
h("div", { class: SC_CLASSES.conditionalIf }, [
|
|
1228
|
+
h("strong", undefined, "if:"),
|
|
1229
|
+
" ",
|
|
1230
|
+
props.renderChild(ifClause, props.value, props.onChange),
|
|
1231
|
+
]),
|
|
1232
|
+
];
|
|
1233
|
+
if (thenClause !== undefined) {
|
|
1234
|
+
children.push(
|
|
1235
|
+
h("div", { class: SC_CLASSES.conditionalThen }, [
|
|
1236
|
+
h("strong", undefined, "then:"),
|
|
1237
|
+
" ",
|
|
1238
|
+
props.renderChild(thenClause, props.value, props.onChange),
|
|
1239
|
+
])
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
if (elseClause !== undefined) {
|
|
1243
|
+
children.push(
|
|
1244
|
+
h("div", { class: SC_CLASSES.conditionalElse }, [
|
|
1245
|
+
h("strong", undefined, "else:"),
|
|
1246
|
+
" ",
|
|
1247
|
+
props.renderChild(elseClause, props.value, props.onChange),
|
|
1248
|
+
])
|
|
1249
|
+
);
|
|
1250
|
+
}
|
|
1251
|
+
return h("fieldset", { class: SC_CLASSES.conditional }, children);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* Render a negation field — JSON Schema `{ not: { ... } }`.
|
|
1256
|
+
*
|
|
1257
|
+
* Negation describes a constraint ("value must NOT match this schema")
|
|
1258
|
+
* rather than a value shape.
|
|
1259
|
+
*/
|
|
1260
|
+
export function renderNegation(props: VueRenderProps): VNode {
|
|
1261
|
+
if (props.tree.type !== "negation") return h("span");
|
|
1262
|
+
return h("fieldset", { class: SC_CLASSES.negation }, [
|
|
1263
|
+
h("strong", undefined, "Must NOT match:"),
|
|
1264
|
+
" ",
|
|
1265
|
+
props.renderChild(props.tree.negated, props.value, props.onChange),
|
|
1266
|
+
]);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/**
|
|
1270
|
+
* Headless renderer for `UnknownField` — JSON-encoded fallback for
|
|
1271
|
+
* unconstrained values.
|
|
1272
|
+
*/
|
|
1273
|
+
export function renderUnknown(props: VueRenderProps): VNode {
|
|
1274
|
+
const id = inputId(props.path);
|
|
1275
|
+
|
|
1276
|
+
if (props.readOnly) {
|
|
1277
|
+
if (props.value === undefined || props.value === null)
|
|
1278
|
+
return h("span", { id }, EM_DASH);
|
|
1279
|
+
const display =
|
|
1280
|
+
typeof props.value === "string"
|
|
1281
|
+
? props.value
|
|
1282
|
+
: JSON.stringify(props.value);
|
|
1283
|
+
return h("span", { id }, display);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
const strValue = typeof props.value === "string" ? props.value : "";
|
|
1287
|
+
return h("input", {
|
|
1288
|
+
id,
|
|
1289
|
+
type: "text",
|
|
1290
|
+
value: props.writeOnly ? "" : strValue,
|
|
1291
|
+
onInput: (e: Event) => {
|
|
1292
|
+
const target = inputTarget(e);
|
|
1293
|
+
if (target === undefined) return;
|
|
1294
|
+
props.onChange(target.value);
|
|
1295
|
+
},
|
|
1296
|
+
});
|
|
1297
|
+
}
|