/**
* Vue-flavoured wrapper around the framework-agnostic
* `dispatchRenderField` from `core/renderField.ts`.
*
* Constructs a Vue-shaped {@link DispatchConfig} (widget lookup against
* the instance → context → global chain, recursion sentinel as a Vue
* `
` {@link VNode}, fallback as a ``-wrapped value) and
* forwards the call. Used by the `` and ``
* SFCs and exported so other Vue surfaces (future API operation
* components, etc.) can dispatch into the same fallback chain.
*
* The widget-lookup contract matches the React adapter exactly:
* instance map first, then context map, then global registry. The
* dispatcher itself remains agnostic to how widget maps are scoped —
* the resolution chain is expressed here in the `lookupWidget`
* closure.
*/
import { h, isVNode, type VNode } from "vue";
import { dispatchRenderField } from "../core/renderField.ts";
import type { WalkedField } from "../core/types.ts";
import { EM_DASH } from "../core/cssClasses.ts";
import { getVueRenderFunction, mergeVueResolvers } from "./resolver.ts";
import { headlessVueResolver } from "./headless.ts";
import type {
VueComponentResolver,
VueRenderProps,
VueWidgetMap,
} from "./types.ts";
import { lookupGlobalWidget } from "./widget.ts";
/**
* Build the {@link VueRenderProps} object handed to a Vue render
* function or widget. Mirrors `buildRenderProps` in `core/renderer.ts`
* but emits Vue's `VNode`-returning `renderChild` signature directly so
* the dispatcher does not need to cross-cast between React and Vue
* shapes at the resolver boundary.
*/
function buildVueRenderProps(
tree: WalkedField,
value: unknown,
onChange: (next: unknown) => void,
renderChild: VueRenderProps["renderChild"],
path: string
): VueRenderProps {
const isReadOnly = tree.editability === "presentation";
const isWriteOnly = tree.editability === "input";
const props: VueRenderProps = {
value,
onChange,
readOnly: isReadOnly,
writeOnly: isWriteOnly,
meta: tree.meta,
constraints: tree.constraints,
path,
tree,
renderChild,
};
if (tree.examples !== undefined) props.examples = tree.examples;
return props;
}
/**
* Render a single walked field through the resolved widget / resolver
* / headless pipeline.
*
* Thin Vue-flavoured wrapper around {@link dispatchRenderField}: it
* constructs a Vue-shaped {@link DispatchConfig} and returns the
* dispatcher's {@link VNode} output.
*
* @param tree - The walked field tree node to render.
* @param value - The current value at this position.
* @param onChange - Callback invoked when the field emits a change. For
* read-only renders (e.g. ``) pass a noop.
* @param userResolver - User-supplied resolver, or `undefined` to use
* the headless resolver alone.
* @param renderChild - Recursive child renderer threaded through
* the {@link VueRenderProps} `renderChild` field.
* @param path - Dot-separated structural path; non-empty.
* @param instanceWidgets - Per-instance widget map (highest priority).
* @param contextWidgets - Context-scoped widget map (middle priority).
* @param depth - Recursion depth used by the depth cap in
* {@link dispatchRenderField}.
*/
export function vueRenderField(
tree: WalkedField,
value: unknown,
onChange: (v: unknown) => void,
userResolver: VueComponentResolver | undefined,
renderChild: VueRenderProps["renderChild"],
path: string,
instanceWidgets?: VueWidgetMap,
contextWidgets?: VueWidgetMap,
depth = 0
): VNode {
if (path.length === 0) {
throw new Error(
"vueRenderField requires a non-empty path. Pass the root path " +
"(derived from `idPrefix` or `useId()`) for the root field, " +
"and use renderChild's pathSuffix to derive child paths."
);
}
// Build the merged resolver once per dispatch — user overrides on
// top of the headless fallback, mirroring the historic React
// behaviour.
const resolver: VueComponentResolver =
userResolver !== undefined
? mergeVueResolvers(userResolver, headlessVueResolver)
: headlessVueResolver;
return dispatchRenderField({
tree,
value,
path,
depth,
resolver,
config: {
buildProps: (fieldTree, fieldPath) =>
buildVueRenderProps(
fieldTree,
value,
onChange,
renderChild,
fieldPath
),
lookupRenderFn: (type, mergedResolver) =>
getVueRenderFunction(type, mergedResolver),
// Widget lookup follows the canonical Vue resolution
// order: instance → context → global. Pulled out as a
// closure so the dispatcher remains agnostic to how
// widget maps are scoped.
lookupWidget: (name) =>
instanceWidgets?.get(name) ??
contextWidgets?.get(name) ??
lookupGlobalWidget(name),
recursionSentinel: (fieldTree) => {
const label =
typeof fieldTree.meta.description === "string"
? fieldTree.meta.description
: "schema";
return h("fieldset", undefined, [
h("em", undefined, `↻ ${label} (recursive)`),
]);
},
fallback: (_fieldTree, fieldValue) => {
if (fieldValue === undefined || fieldValue === null)
return h("span", undefined, EM_DASH);
return h(
"span",
undefined,
typeof fieldValue === "string"
? fieldValue
: JSON.stringify(fieldValue)
);
},
coerceResult: (result, step) => {
if (step === "widget") {
if (result === undefined || result === null)
return undefined;
if (isVNode(result)) return result;
// Widget returned a value but not in a Vue-renderable
// shape — wrap it in a span so the output remains a
// valid VNode rather than falling through to the
// resolver.
if (
typeof result === "string" ||
typeof result === "number"
)
return h("span", undefined, String(result));
return h("span");
}
if (result === undefined || result === null)
return h("span", { style: { display: "none" } });
if (isVNode(result)) return result;
if (typeof result === "string" || typeof result === "number")
return h("span", undefined, String(result));
return undefined;
},
},
});
}