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.
@@ -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
+ }