ilinxa
blockjson-formsource: ok · hit
JSON Form
Schema-driven form engine — a field DSL compiled to Zod, 25 field types including rich text, conditional and computed fields, on React Hook Form.
npx shadcn add "https://ui.ilinxa.com/r/json-form.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightforms@hookform/resolvers@^5.2.2
Registry dependencies
radio-groupsliderlabelinputtextareaselectcheckboxswitchcommandpopovercalendarbuttonseparatorbadge@ilinxa/code-block@ilinxa/rich-text-editor
Files44 · source cached 10/14/2026, 3:00:31 PM
src/registry/components/forms/json-form/json-form.tsx
"use client";
import {
Fragment,
useEffect,
useId,
useImperativeHandle,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { FormProvider, useFormState, useWatch } from "react-hook-form";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
import { useJsonForm } from "./hooks/use-json-form";
import { useDebouncedCallback } from "./hooks/use-debounced-callback";
import { mergeStrings } from "./lib/strings";
import { defaultJsonFormRegistry } from "./lib/default-registry";
import { flattenRhfErrorsToRecord } from "./lib/flatten-errors";
import { isValueField } from "./lib/validate-schema";
import { JsonFormProvider, useJsonFormContext } from "./json-form-context";
import { FieldWrapper } from "./parts/field-wrapper";
import { FieldFallback } from "./parts/field-fallback";
import { FormHeader } from "./parts/form-header";
import { JsonFormErrorSummary } from "./parts/error-summary";
import { JsonFormSubmitButton } from "./parts/submit-button";
import { JsonFormResetButton } from "./parts/reset-button";
import type {
FieldDefinition,
FieldRenderer,
JsonFormContextValue,
JsonFormProps,
} from "./types";
/**
* Top-level component. Composes our `<JsonFormProvider>` + RHF's
* `<FormProvider>` + a layout shell that walks `schema.fields` and renders
* each via the resolver chain (`renderField` → `fieldRegistry` → default).
*/
export function JsonForm<
TValues extends Record<string, unknown> = Record<string, unknown>,
>({
schema,
defaultValues,
values,
onSubmit,
onSubmitError,
onSubmitAttempt,
onChange,
onChangeDebounce = 100,
onValidationChange,
onReady,
columns = 1,
labelPosition = "top",
showSummary,
summaryStrategy = "post-submit",
showSchemaHeader = true,
submitButton,
resetButton,
fieldRegistry,
renderField,
validationMode = "onTouched",
strings: stringsOverride,
className,
style,
ref,
}: JsonFormProps<TValues>) {
const formId = useId();
const strings = useMemo(() => mergeStrings(stringsOverride), [stringsOverride]);
const [hasSubmitted, setHasSubmitted] = useState(false);
const { form, zodSchema, handle } = useJsonForm<TValues>(schema, {
defaultValues,
values,
validationMode,
strings: stringsOverride,
});
// Merge consumer registry with the default.
const mergedRegistry = useMemo<Record<string, FieldRenderer>>(
() => ({ ...defaultJsonFormRegistry, ...(fieldRegistry ?? {}) }),
[fieldRegistry],
);
// `hasSubmitted` deliberately lives on a sibling context (provided to
// `<JsonFormProvider>` as a prop) — keeping it OUT of this memo means the
// first-submit flip doesn't invalidate the main context and re-render every
// field. The compat field on the value (set to `false` here) is overwritten
// by `useJsonFormContext` reads, which merge in the narrow context.
const ctx = useMemo<JsonFormContextValue<TValues>>(
() => ({
...handle,
rhf: form,
schema,
zodSchema,
strings,
formId,
hasSubmitted: false,
fieldRegistry: mergedRegistry,
}),
[handle, form, schema, zodSchema, strings, formId, mergedRegistry],
);
useImperativeHandle(ref, () => handle, [handle]);
// T3.7 — fire `onReady` once after mount + defaults applied. Stored in a
// ref so a parent re-render that passes a fresh callback identity doesn't
// re-fire (the contract is "once per form lifetime"). Ref-write happens
// inside an effect (React Compiler bans mutation during render).
const onReadyRef = useRef(onReady);
useEffect(() => {
onReadyRef.current = onReady;
}, [onReady]);
const readyFiredRef = useRef(false);
useEffect(() => {
if (readyFiredRef.current) return;
readyFiredRef.current = true;
onReadyRef.current?.({ formApi: handle });
}, [handle]);
// Debounced onChange that subscribes to the values bag.
const debouncedChange = useDebouncedCallback((vals: Record<string, unknown>) => {
onChange?.({ values: vals as TValues, formApi: handle });
}, onChangeDebounce);
return (
<FormProvider {...form}>
<JsonFormProvider value={ctx as JsonFormContextValue} hasSubmitted={hasSubmitted}>
<form
noValidate
className={cn("flex flex-col gap-4", className)}
style={style}
onSubmit={(e) => {
e.preventDefault();
setHasSubmitted(true);
// T3.6 — fires unconditionally before validation runs.
onSubmitAttempt?.({ formApi: handle });
void form.handleSubmit(
async (vals) => {
await onSubmit({
values: vals as TValues,
formApi: handle,
});
},
(errs) => {
const flat = flattenRhfErrorsToRecord(errs);
onSubmitError?.({
errors: flat,
formApi: handle,
});
onValidationChange?.({
isValid: false,
errors: flat,
});
// T2.2 — focus the first focusable invalid field in DOM
// order (schema.fields order). Skip non-focusable types
// (`computed` is `<output>`, `hidden`/`section`/`divider`
// render no control); falling through to the next errored
// field beats a silent no-op `setFocus` call.
const focusTarget = schema.fields.find(
(f) =>
isValueField(f) &&
f.type !== "computed" &&
f.type !== "hidden" &&
flat[f.name] !== undefined,
);
if (focusTarget) {
try {
form.setFocus(focusTarget.name as never);
} catch {
// Fall through — non-registered name (rare; safer to
// skip than to throw out of a submit handler).
}
}
},
)(e);
}}
>
<ChangeBridge
onChange={onChange ? debouncedChange : undefined}
onValidationChange={onValidationChange}
/>
{showSchemaHeader ? <FormHeader /> : null}
{showSummary ? (
<JsonFormErrorSummary strategy={summaryStrategy} />
) : null}
<FieldList
fields={schema.fields}
registry={mergedRegistry}
renderField={renderField}
columns={columns}
labelPosition={labelPosition}
/>
{(submitButton !== false || resetButton) ? (
<SubmitRow
submitButton={submitButton}
resetButton={resetButton}
/>
) : null}
</form>
</JsonFormProvider>
</FormProvider>
);
}
// ─── Change + validation bridge ──────────────────────────────────────────────
function ChangeBridge({
onChange,
onValidationChange,
}: {
onChange?: (vals: Record<string, unknown>) => void;
onValidationChange?: (args: {
isValid: boolean;
errors: Record<string, string>;
}) => void;
}) {
const values = useWatch();
const { isValid, errors } = useFormState();
const rendered = useRef(0);
const lastValuesKey = useRef<string | null>(null);
const lastValidity = useRef<{ isValid: boolean; errorKey: string } | null>(null);
useEffect(() => {
// Skip first synthetic emit (initial mount) so we don't fire onChange
// before the user has touched anything.
if (rendered.current === 0) {
rendered.current++;
lastValuesKey.current = stableStringify(values);
return;
}
// Structural-equality guard breaks the controlled-mode echo loop: when
// a consumer pipes `onChange` back through the `values` prop, RHF
// re-runs `useWatch` and produces a new reference for the same payload.
// Without this guard the debounced fire-out would still leak, defeating
// defense #2 of the controlled-mode three-defenses pattern.
const next = stableStringify(values);
if (next === lastValuesKey.current) return;
lastValuesKey.current = next;
onChange?.((values ?? {}) as Record<string, unknown>);
}, [values, onChange]);
useEffect(() => {
if (!onValidationChange) return;
const flat = flattenRhfErrorsToRecord(errors as Record<string, unknown>);
const errorKey = `${Object.keys(flat).length}:${Object.keys(flat).join(",")}`;
const last = lastValidity.current;
if (last && last.isValid === isValid && last.errorKey === errorKey) return;
lastValidity.current = { isValid, errorKey };
onValidationChange({ isValid, errors: flat });
}, [isValid, errors, onValidationChange]);
return null;
}
// ─── Field-list layout (sections / dividers / rows) ──────────────────────────
interface FieldListProps {
fields: ReadonlyArray<FieldDefinition>;
registry: Record<string, FieldRenderer>;
renderField?: (args: {
field: FieldDefinition;
defaultRender: () => ReactNode;
}) => ReactNode;
columns: 1 | 2;
labelPosition: "top" | "left";
}
function FieldList({
fields,
registry,
renderField,
columns,
labelPosition,
}: FieldListProps) {
const gridClass =
columns === 2 ? "grid grid-cols-1 gap-4 md:grid-cols-2" : "flex flex-col gap-4";
// Walk fields linearly. Section/divider are inline siblings — they break the
// grid rhythm (full-width) but don't introduce nested DOM groups, keeping
// the layout simple for v0.1.0.
return (
<div className={gridClass}>
{fields.map((field) => {
if (field.type === "section") {
return (
<section
key={field.name}
data-jsonform-section={field.name}
className={cn("col-span-full mt-2 flex flex-col gap-1", columns === 2 && "md:col-span-2")}
>
{field.label ? (
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{field.label}
</h3>
) : null}
{field.description ? (
<p className="text-xs text-muted-foreground">
{field.description}
</p>
) : null}
</section>
);
}
if (field.type === "divider") {
return (
<div
key={field.name}
data-jsonform-divider={field.name}
className={cn("col-span-full", columns === 2 && "md:col-span-2")}
>
<Separator />
</div>
);
}
const renderer = registry[field.type] ?? registry["__fallback__"] ?? FieldFallback;
const defaultRender = () => (
<FieldWrapper
field={field}
renderer={renderer}
formLabelPosition={labelPosition}
/>
);
return (
<Fragment key={field.name}>
{renderField
? renderField({ field, defaultRender })
: defaultRender()}
</Fragment>
);
})}
</div>
);
}
// ─── Submit row ──────────────────────────────────────────────────────────────
function SubmitRow({
submitButton,
resetButton,
}: {
submitButton: JsonFormProps["submitButton"];
resetButton: JsonFormProps["resetButton"];
}) {
if (typeof submitButton === "function") {
return (
<SubmitFunctionRow render={submitButton} resetButton={resetButton} />
);
}
const cfg = submitButton ?? {};
const align = (cfg && typeof cfg === "object" ? cfg.align : "right") ?? "right";
const alignClass =
align === "left"
? "justify-start"
: align === "center"
? "justify-center"
: "justify-end";
return (
<div className={cn("flex items-center gap-2 pt-1", alignClass)}>
{resetButton ? (
<JsonFormResetButton
label={resetButton.label}
variant={resetButton.variant}
/>
) : null}
{submitButton !== false ? (
<JsonFormSubmitButton
label={cfg && typeof cfg === "object" ? cfg.label : undefined}
variant={cfg && typeof cfg === "object" ? cfg.variant : undefined}
disableWhenInvalid={
cfg && typeof cfg === "object"
? cfg.disableWhenInvalid
: undefined
}
/>
) : null}
</div>
);
}
function SubmitFunctionRow({
render,
resetButton,
}: {
render: Extract<
JsonFormProps["submitButton"],
(args: { isSubmitting: boolean; isValid: boolean }) => ReactNode
>;
resetButton: JsonFormProps["resetButton"];
}) {
// Read isSubmitting / isValid via context-aware atoms.
return (
<div className="flex items-center justify-end gap-2 pt-1">
{resetButton ? (
<JsonFormResetButton
label={resetButton.label}
variant={resetButton.variant}
/>
) : null}
<SubmitRenderProxy render={render} />
</div>
);
}
function SubmitRenderProxy({
render,
}: {
render: (args: { isSubmitting: boolean; isValid: boolean }) => ReactNode;
}) {
const ctx = useJsonFormContext();
return (
<>
{render({
isSubmitting: ctx.rhf.formState.isSubmitting,
isValid: ctx.rhf.formState.isValid,
})}
</>
);
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Key-sorted JSON stringify so `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` hash to
* the same key. Used by `ChangeBridge` to detect echo-loop re-emissions of a
* structurally-identical values bag.
*/
function stableStringify(input: unknown): string {
if (input === null || typeof input !== "object") return JSON.stringify(input);
if (Array.isArray(input)) return `[${input.map(stableStringify).join(",")}]`;
const keys = Object.keys(input as Record<string, unknown>).sort();
return `{${keys
.map((k) => `${JSON.stringify(k)}:${stableStringify((input as Record<string, unknown>)[k])}`)
.join(",")}}`;
}
// ─── Tail type re-exports (cross-procomp consumers) ──────────────────────────
// Procomps that compose json-form must import these types from THIS component-
// file path, not `./types`: the shadcn path rewriter preserves the component-
// file path but mangles `/types` cross-procomp imports (F-S1).
export type {
FormSchema,
Condition,
ConditionOrFn,
FieldDefinition,
FieldOptionsResolver,
FieldRenderer,
JsonFormHandle,
FieldConfig,
} from "./types";
src/registry/components/forms/json-form/json-form-context.tsx
"use client";
import { createContext, useContext, type ReactNode } from "react";
import { FormProvider } from "react-hook-form";
import type { JsonFormContextValue } from "./types";
/* eslint-disable @typescript-eslint/no-explicit-any -- `Record<string, any>` is required to satisfy react-hook-form's `FieldValues` constraint; `unknown` won't type-check there. */
const JsonFormContext = createContext<JsonFormContextValue | null>(null);
/**
* Narrow context that ONLY carries `hasSubmitted`. Split from the main
* `JsonFormContext` (v0.1.3) so that flipping `hasSubmitted` false→true on
* the first submit doesn't invalidate the memoized main context — which
* would re-render every field that consumes it. Only `<JsonFormErrorSummary>`
* actually needs to react to this flag.
*/
const JsonFormSubmittedContext = createContext<boolean>(false);
export interface JsonFormProviderProps<TValues extends Record<string, any> = Record<string, any>> {
value: JsonFormContextValue<TValues>;
/**
* Has the form attempted a submit at least once? Drives the
* `post-submit` error-summary strategy. Optional — defaults to `false`.
* Provided as a sibling prop (not on `value`) so updating it doesn't
* invalidate the memoized main context.
*/
hasSubmitted?: boolean;
children: ReactNode;
}
export function JsonFormProvider<TValues extends Record<string, any> = Record<string, any>>({
value,
hasSubmitted = false,
children,
}: JsonFormProviderProps<TValues>) {
// v0.2.4 — bridge RHF's <FormProvider> internally so the documented headless
// pattern (`<JsonFormProvider value={{ ...handle, rhf: form, ... }}>`)
// works end-to-end without consumers having to also remember to wrap
// children in `<FormProvider {...form}>`. `<JsonFormField>` /
// `<FieldWrapper>` and the narrow-deps hooks need RHF's context to call
// `useFormContext()` and `useWatch({ control })`; before v0.2.4 the
// headless example in `demo.tsx` (and any consumer copying it) silently
// mounted a tree where every FieldWrapper crashed with "Cannot destructure
// 'control' of useFormContext() as it is null". `<JsonForm>` already wraps
// with `<FormProvider {...form}>` at the top level — nesting a second copy
// here is a no-op since both share the same `form` instance (the inner
// wins, identical values).
return (
<FormProvider {...value.rhf}>
<JsonFormContext.Provider value={value as JsonFormContextValue}>
<JsonFormSubmittedContext.Provider value={hasSubmitted}>
{children}
</JsonFormSubmittedContext.Provider>
</JsonFormContext.Provider>
</FormProvider>
);
}
/**
* Accessor hook for the JsonForm context. Use inside standalone parts
* (`<JsonFormField>`, `<JsonFormErrorSummary>`, etc.) or to build a fully
* headless layout while still using our context.
*
* v0.1.3 — the returned context's `hasSubmitted` is a read-time snapshot
* from the narrow `JsonFormSubmittedContext` so consumers that depend on it
* re-render only when it flips. Most consumers should not need this; reach
* for `useJsonFormHasSubmitted()` if you only need the flag.
*/
export function useJsonFormContext<TValues extends Record<string, any> = Record<string, any>>(): JsonFormContextValue<TValues> {
const ctx = useContext(JsonFormContext);
const hasSubmitted = useContext(JsonFormSubmittedContext);
if (!ctx) {
throw new Error(
"[json-form] useJsonFormContext must be used inside a <JsonForm> or <JsonFormProvider>.",
);
}
// Reattach `hasSubmitted` so callers reading `ctx.hasSubmitted` still work,
// even though it lives on a separate context internally.
return { ...ctx, hasSubmitted } as JsonFormContextValue<TValues>;
}
/**
* Narrow accessor — returns just the `hasSubmitted` flag. Use this in
* standalone parts that ONLY care about post-submit state (e.g.,
* `<JsonFormErrorSummary>`) so they don't re-render on every main-context
* change.
*/
export function useJsonFormHasSubmitted(): boolean {
return useContext(JsonFormSubmittedContext);
}
/**
* v0.2.1 — non-throwing variant of `useJsonFormContext`. Returns `null`
* when no `<JsonFormProvider>` is in the tree. Internal-only — exported
* for `<JsonFormDevtools>` to render a soft-warning fallback panel
* instead of throwing when consumers forget the Provider wrap.
*/
export function useJsonFormContextOptional<TValues extends Record<string, any> = Record<string, any>>(): JsonFormContextValue<TValues> | null {
const ctx = useContext(JsonFormContext);
const hasSubmitted = useContext(JsonFormSubmittedContext);
if (!ctx) return null;
return { ...ctx, hasSubmitted } as JsonFormContextValue<TValues>;
}
/* eslint-enable @typescript-eslint/no-explicit-any */
src/registry/components/forms/json-form/index.ts
// Top-level component + standalone parts
export { JsonForm } from "./json-form";
export {
JsonFormProvider,
useJsonFormContext,
useJsonFormHasSubmitted,
} from "./json-form-context";
export { JsonFormField } from "./parts/json-form-field";
export { JsonFormErrorSummary } from "./parts/error-summary";
export { JsonFormSubmitButton } from "./parts/submit-button";
export { JsonFormResetButton } from "./parts/reset-button";
export { FormHeader as JsonFormHeader } from "./parts/form-header";
export { FieldWrapper as JsonFormFieldWrapper } from "./parts/field-wrapper";
export {
JsonFormDevtools,
type JsonFormDevtoolsProps,
} from "./parts/json-form-devtools";
// Headless factory + default registry (consumer can spread + extend)
export { useJsonForm } from "./hooks/use-json-form";
export {
useJsonFormFieldValue,
useJsonFormFieldsValue,
} from "./hooks/use-json-form-field-value";
export { defaultJsonFormRegistry } from "./lib/default-registry";
export { defineFieldRenderer } from "./lib/define-field-renderer";
// String dictionary + helpers
export { defaultStrings as defaultJsonFormStrings, mergeStrings as mergeJsonFormStrings } from "./lib/strings";
// Note: `RICH_TEXT_EMPTY_VALUE` (the canonical Plate "empty paragraph"
// default) is intentionally NOT re-exported from this barrel. Earlier
// attempts (v0.1.3 + v0.1.4) hit a shadcn path-rewriter inconsistency
// where cross-slug imports from `index.ts` (and `lib/*.ts`) didn't get
// the same flattening as imports from `parts/*.tsx`. Consumers using
// `richtext` fields should import the empty default directly from
// `@ilinxa/rich-text-editor`:
//
// import { RICH_TEXT_EMPTY_VALUE } from "@/components/rich-text-editor";
//
// (or whatever the consumer-side path is for their setup). Documented
// in usage.tsx.
// Public types
export type {
ButtonVariant,
Condition,
ConditionOrFn,
FieldConfig,
FieldAriaProps,
FieldDefinition,
FieldOption,
FieldOptionsResolver,
FieldRenderer,
FieldRendererArgs,
FieldType,
FieldValidators,
FormSchema,
JsonFormContextValue,
JsonFormHandle,
JsonFormProps,
JsonFormStrings,
JsonFormSubmitErrorArgs,
JsonFormValidationChangeArgs,
JsonFormValuesArgs,
UseJsonFormOptions,
UseJsonFormReturn,
} from "./types";
src/registry/components/forms/json-form/types.ts
import type { CSSProperties, ReactNode, Ref } from "react";
import type { UseFormReturn } from "react-hook-form";
import type { ZodObject, ZodTypeAny } from "zod";
/* eslint-disable @typescript-eslint/no-explicit-any -- `Record<string, any>` is required to satisfy react-hook-form's `FieldValues` constraint; `unknown` won't type-check there. */
// ─── Field DSL ───────────────────────────────────────────────────────────────
/** All built-in v0.1.x field types, plus string for consumer-registered ones. */
export type FieldType =
// text family
| "text" | "email" | "password" | "url" | "tel" | "textarea" | "number"
// choice family
| "select" | "multi-select" | "radio-group" | "checkbox" | "checkbox-group" | "switch"
// date/time family
| "date" | "date-range" | "time" | "datetime"
// rich / composite (v0.1.0 + v0.1.1 `richtext`)
| "code" | "slider" | "rating" | "richtext"
// special
| "computed" | "hidden" | "section" | "divider"
// consumer-extended (registry lookup); also covers v0.1.x types ('file', 'color', 'array') before they ship
| (string & {});
/** Static-array form of options. */
export interface FieldOption {
value: unknown;
label: string;
description?: string;
disabled?: boolean;
}
/** Async resolver form of options. */
export type FieldOptionsResolver = (args: {
query?: string;
allValues: Record<string, unknown>;
/**
* v0.1.6 — abort signal. Fires when the resolver run is superseded by a
* newer query/dep change or the field unmounts. Fetch-based resolvers
* should pass this to `fetch(url, { signal })`; ignoring it is harmless
* (the result is still discarded by the generation guard).
*/
signal: AbortSignal;
}) => Promise<FieldOption[]>;
/** Per-field type-specific config (hybrid shape — A1 resolution). */
export interface FieldConfig {
code?: { editorExtensions?: unknown[]; readOnly?: boolean };
date?: { firstDayOfWeek?: 0 | 1 | 2 | 3 | 4 | 5 | 6; minYear?: number; maxYear?: number };
rating?: { stars?: number };
/** v0.1.1 — Plate-based richtext field config. `hideToolbar` collapses both top and floating toolbars. */
richText?: { hideToolbar?: boolean; autoFocus?: boolean };
// v0.1.x additions land here non-breakingly: file?, color?
}
/** Declarative validators block. Compiled to Zod chain at schema-compile time. */
export interface FieldValidators {
required?: boolean | string;
min?: number | string | { value: number | string; message: string };
max?: number | string | { value: number | string; message: string };
minLength?: number | { value: number; message: string };
maxLength?: number | { value: number; message: string };
pattern?: string | { value: string; message: string };
email?: boolean | { message: string };
url?: boolean | { message: string };
}
/** Condition DSL — 11 operators + function escape hatch. */
export type Condition =
| { field: string; equals: unknown }
| { field: string; notEquals: unknown }
| { field: string; in: ReadonlyArray<unknown> }
| { field: string; notIn: ReadonlyArray<unknown> }
| { field: string; matches: string }
| { field: string; truthy: boolean }
| { field: string; greaterThan: number }
| { field: string; lessThan: number }
| { all: ReadonlyArray<Condition> }
| { any: ReadonlyArray<Condition> }
| { not: Condition };
export type ConditionOrFn =
| Condition
| ((args: { values: Record<string, unknown> }) => boolean);
/**
* A single field definition in the JSON schema.
*
* `name` is REQUIRED for all field types — including `section` and `divider`,
* which produce no value but use `name` as a stable React key. Consumer
* convention: prefix non-value-carrying names with `_` (e.g., `'_personal'`,
* `'_divider1'`). Compiler ignores `name` for `section`/`divider` when
* building the ZodObject.
*/
export interface FieldDefinition {
name: string;
type: FieldType;
label?: string;
description?: string;
placeholder?: string;
defaultValue?: unknown;
validators?: FieldValidators;
validate?: (args: { value: unknown; allValues: Record<string, unknown> }) => string | undefined;
validateAsync?: (args: { value: unknown; allValues: Record<string, unknown> }) => Promise<string | undefined>;
validateAsyncDebounce?: number;
visibleWhen?: ConditionOrFn;
enabledWhen?: ConditionOrFn;
requiredWhen?: ConditionOrFn;
keepValueWhenHidden?: boolean;
options?: ReadonlyArray<FieldOption> | FieldOptionsResolver;
searchable?: boolean;
optionsDebounce?: number;
min?: number; max?: number; step?: number;
rows?: number;
lang?: string;
config?: FieldConfig;
expression?: string;
compute?: (args: { values: Record<string, unknown> }) => unknown;
editable?: boolean;
row?: string;
colSpan?: "full" | 1 | 2;
width?: "full" | "half" | "third" | "quarter" | number;
labelPosition?: "top" | "left";
disabled?: boolean;
readOnly?: boolean;
autoComplete?: string;
autoFocus?: boolean;
/**
* v0.1.6 — typed metadata for downstream per-locale renderers (T3.8).
* Upstream `JsonForm` itself does NOT change behavior based on this flag;
* the contract is purely "this field's value is locale-aware." A
* consumer-side HOC composed into the registry (e.g., `withPerLocale`)
* reads this flag and renders a locale-tab strip around the underlying
* renderer. See the docs for the canonical HOC pattern.
*/
translatable?: boolean;
/**
* Declares which field paths this renderer's `allValues` access depends
* on. The FieldWrapper resolves a per-field subscription mode against
* this flag (active as of v0.2.0):
*
* - `undefined` (default) → full-bag subscription; preserves v0.1.x
* behavior for unmigrated custom renderers.
* - `[]` → no subscription; renderer receives a `getValues()` snapshot
* under `allValues`. Use this when the renderer doesn't read
* `allValues` at all (the most common case for custom renderers).
* - `['a', 'b']` → narrow subscription; `allValues` is rebuilt from the
* watched paths only via `setByPath`.
*
* Built-in default-registry renderers (`text`, `radio-group`, `checkbox`,
* etc.) auto-skip the subscription per an internal whitelist — declare
* `dependsOn` only on custom renderers, or to override the whitelist for
* a specific field. The whitelist check resolves on the FINAL renderer
* identity (not on `field.type` alone), so a consumer-registered renderer
* replacing `text` correctly opts back into full-bag unless it sets
* `dependsOn`.
*
* **Stable identity:** the array reference should be stable across renders
* (module-scope const or `useMemo`-cached). RHF de-dupes subscription
* targets by content, so an unstable reference doesn't break correctness,
* but stable refs avoid wasted reconciles.
*
* `validateSchemaDev` warns when `dependsOn` references a field name that
* doesn't exist in `schema.fields[].name`.
*/
dependsOn?: ReadonlyArray<string>;
}
/** Top-level form schema. */
export interface FormSchema {
fields: ReadonlyArray<FieldDefinition>;
validate?: (args: { values: Record<string, unknown> }) => Record<string, string> | undefined;
zodSchema?: ZodObject<Record<string, ZodTypeAny>>;
meta?: { id?: string; version?: string; title?: string; description?: string };
}
// ─── Callback arg shapes (object-shape per F-cross-12) ───────────────────────
export interface JsonFormValuesArgs<TValues> {
values: TValues;
formApi: JsonFormHandle<TValues>;
}
export interface JsonFormSubmitErrorArgs<TValues> {
errors: Record<string, string>;
formApi: JsonFormHandle<TValues>;
}
export interface JsonFormValidationChangeArgs {
isValid: boolean;
errors: Record<string, string>;
}
// ─── FieldRenderer (T1 lock — single canonical name) ─────────────────────────
/**
* ARIA attributes the field-wrapper computes once per render and passes to
* the renderer. Renderers MUST attach these to the appropriate interactive
* element:
*
* - **Native form controls** (`<input>`, `<textarea>`, `<button>`, etc.):
* spread `id` + `aria-required` + `aria-invalid` + `aria-disabled` +
* `aria-describedby` onto the element. The wrapper's `<label htmlFor>`
* targets the same `id`.
* - **Group-style controls** (`role="radiogroup"`, `role="group"`,
* custom widgets): attach `aria-labelledby={labelledBy}` to the group root
* instead of `id` (HTML `label[for]` doesn't bind to non-form controls).
* Still spread the remaining `aria-*` attributes.
*
* v0.1.2 — `ariaProps` replaces the v0.1.0 `Slot.Root` forwarding strategy.
* That approach silently failed for Popover-wrapped controls and ARIA groups
* because Radix `Popover.Root` is a context-only component (no DOM node).
*/
export interface FieldAriaProps {
/** Use on native form controls so `label[htmlFor]` binds correctly. */
id: string;
/** Use on group-style controls via `aria-labelledby`. */
labelledBy: string;
"aria-required"?: true;
"aria-invalid"?: true;
"aria-disabled"?: true;
"aria-describedby"?: string;
}
export interface FieldRendererArgs {
field: FieldDefinition;
value: unknown;
onChange: (next: unknown) => void;
onBlur: () => void;
error: string | undefined;
disabled: boolean;
readOnly: boolean;
/**
* The form's current values bag. **Freshness depends on the field's
* subscription mode** (resolved per-field as of v0.2.0):
*
* - **Snapshot mode** (built-in default renderers OR `dependsOn: []`):
* captured via `getValues()` at render time. Stays fresh on the field's
* own value changes (Controller re-renders), but DOES NOT reflect
* changes to other fields between renders. If a custom renderer in
* snapshot mode needs live values, subscribe via
* `useJsonFormFieldValue` / `useJsonFormFieldsValue` instead of reading
* from `allValues`.
* - **Narrow mode** (`dependsOn: ['a','b']`): live for the declared paths;
* `setByPath`-rebuilt so dot-paths read correctly.
* - **Full-bag mode** (`dependsOn` omitted on a custom renderer): live
* across the whole form. Preserves v0.1.x behavior.
*/
allValues: Record<string, unknown>;
formApi: JsonFormHandle<Record<string, unknown>>;
/** v0.1.2 — ARIA bridge. See `FieldAriaProps` for usage rules. */
ariaProps: FieldAriaProps;
}
export type FieldRenderer = (args: FieldRendererArgs) => ReactNode;
// ─── Imperative handle ────────────────────────────────────────────────────────
export interface JsonFormHandle<TValues = Record<string, unknown>> {
submit: () => Promise<{ ok: boolean; values?: TValues; errors?: Record<string, string> }>;
reset: (values?: Partial<TValues>) => void;
setValue: (name: string, value: unknown) => void;
getValue: (name: string) => unknown;
getValues: () => TValues;
setError: (name: string, message: string) => void;
clearErrors: (name?: string) => void;
trigger: (name?: string | string[]) => Promise<boolean>;
focus: (name: string) => void;
isDirty: () => boolean;
isValid: () => boolean;
isSubmitting: () => boolean;
}
// ─── Strings dictionary ───────────────────────────────────────────────────────
export interface JsonFormStrings {
submit: string;
reset: string;
requiredIndicator: string;
optionalIndicator: string;
loadingOptions: string;
optionsError: string;
optionsRetry: string;
noOptions: string;
summaryHeading: string;
submitFailed: string;
errorTemplates: {
required: string;
minLength: string;
maxLength: string;
min: string;
max: string;
pattern: string;
email: string;
url: string;
};
}
// ─── Component props ──────────────────────────────────────────────────────────
export type ButtonVariant = "default" | "outline" | "ghost" | "secondary" | "destructive";
export interface JsonFormProps<TValues extends Record<string, any> = Record<string, unknown>> {
schema: FormSchema;
defaultValues?: Partial<TValues>;
values?: Partial<TValues>;
onSubmit: (args: JsonFormValuesArgs<TValues>) => void | Promise<void>;
onSubmitError?: (args: JsonFormSubmitErrorArgs<TValues>) => void;
/**
* v0.1.6 (T3.6) — fires on every submit press, BEFORE validation runs.
* Use for "user attempted submit" telemetry / submit-counter UX hints
* that need to fire even when validation rejects the attempt. `onSubmit`
* still gates on validation passing.
*/
onSubmitAttempt?: (args: { formApi: JsonFormHandle<TValues> }) => void;
onChange?: (args: JsonFormValuesArgs<TValues>) => void;
onChangeDebounce?: number;
onValidationChange?: (args: JsonFormValidationChangeArgs) => void;
/**
* v0.1.6 (T3.7) — fires once after mount + defaults applied. Async-schema
* consumers (load schema → mount → wire callbacks) can ditch the manual
* `useEffect(...)` they wrote to capture the imperative handle.
*/
onReady?: (args: { formApi: JsonFormHandle<TValues> }) => void;
columns?: 1 | 2;
labelPosition?: "top" | "left";
showSummary?: boolean;
summaryStrategy?: "always" | "post-submit";
showSchemaHeader?: boolean;
submitButton?:
| {
label?: string;
variant?: ButtonVariant;
align?: "left" | "right" | "center";
disableWhenInvalid?: boolean;
}
| ((args: { isSubmitting: boolean; isValid: boolean }) => ReactNode)
| false;
resetButton?: { label?: string; variant?: ButtonVariant };
fieldRegistry?: Record<string, FieldRenderer>;
renderField?: (args: { field: FieldDefinition; defaultRender: () => ReactNode }) => ReactNode;
validationMode?: "onChange" | "onBlur" | "onTouched" | "onSubmit" | "all";
strings?: Partial<JsonFormStrings>;
className?: string;
style?: CSSProperties;
ref?: Ref<JsonFormHandle<TValues>>;
}
// ─── Standalone-parts contexts ────────────────────────────────────────────────
export type JsonFormContextValue<TValues extends Record<string, any> = Record<string, any>> = JsonFormHandle<TValues> & {
/**
* Escape hatch — the raw react-hook-form return. Standalone parts read
* `formState`, register custom controllers, etc. through this. NOT part of
* the json-form stability contract: if RHF renames or relocates API
* surface, this type follows. Prefer the typed handle methods
* (`setValue` / `getValue` / `trigger` / …) for anything reachable that
* way.
*/
rhf: UseFormReturn<TValues>;
schema: FormSchema;
zodSchema: ZodObject<Record<string, ZodTypeAny>>;
strings: JsonFormStrings;
formId: string;
hasSubmitted: boolean;
/** Merged field registry (default ⨯ consumer overrides). Exposed for standalone parts that resolve renderers. */
fieldRegistry: Record<string, FieldRenderer>;
};
export interface UseJsonFormReturn<TValues extends Record<string, any>> {
form: UseFormReturn<TValues>;
zodSchema: ZodObject<Record<string, ZodTypeAny>>;
fieldList: ReadonlyArray<FieldDefinition>;
isValid: boolean;
isSubmitting: boolean;
handle: JsonFormHandle<TValues>;
}
export interface UseJsonFormOptions<TValues extends Record<string, any>> {
defaultValues?: Partial<TValues>;
values?: Partial<TValues>;
validationMode?: "onChange" | "onBlur" | "onTouched" | "onSubmit" | "all";
strings?: Partial<JsonFormStrings>;
}
/* eslint-enable @typescript-eslint/no-explicit-any */
src/registry/components/forms/json-form/parts/field-wrapper.tsx
"use client";
import { Suspense, useEffect, useRef, type ReactNode } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import type { FieldValues, Path } from "react-hook-form";
import { cn } from "@/lib/utils";
import type { FieldAriaProps, FieldDefinition, FieldRenderer } from "../types";
import { useConditional } from "../hooks/use-conditional";
import { useJsonFormContext } from "../json-form-context";
import {
BUILTIN_RENDERER_TYPES_SKIPPING_ALL_VALUES,
defaultJsonFormRegistry,
} from "../lib/default-registry";
import { setByPath } from "../lib/path";
/**
* Stable, SSR-friendly id slug for a field. Dots are escaped (HTML id can't
* contain `.` reliably across browsers + CSS queries).
*/
export function fieldIdSlug(formId: string, name: string): string {
return `${formId}-${name.replace(/\./g, "_")}`;
}
function widthToClass(w: FieldDefinition["width"]): string | undefined {
if (w == null || w === "full") return undefined;
if (w === "half" || w === "third" || w === "quarter") return "md:col-span-1";
return undefined;
}
export interface FieldWrapperProps {
field: FieldDefinition;
renderer: FieldRenderer;
formLabelPosition?: "top" | "left";
loadingFallback?: ReactNode;
}
/**
* The single layout shell every field renderer flows through.
* Owns: label, helper text, error message, ARIA wiring (via `ariaProps`),
* visibility unmount, disabled merging, RHF Controller binding.
*
* v0.1.2 — ARIA is now passed through `FieldRendererArgs.ariaProps` instead
* of via `Slot.Root` (which silently failed for Popover-wrapped controls and
* non-form group roots).
*/
export function FieldWrapper({
field,
renderer,
formLabelPosition,
loadingFallback,
}: FieldWrapperProps) {
const { control, register, unregister, trigger, getFieldState, getValues } =
useFormContext();
const ctx = useJsonFormContext();
// v0.2.0 (A2) — three-mode subscription gate. The default v0.1.x behavior
// was a full-bag `useWatch({ control })` on every field, re-rendering the
// whole form on every keystroke. Now resolved per-field:
//
// • snapshot — built-in default renderer (audited not to read
// `allValues`) OR explicit opt-out via `dependsOn: []`. Skip the
// watch; `allValues` is a `getValues()` snapshot at render.
// • narrow — `dependsOn: ['a','b']`. Subscribe only to those paths;
// rebuild a path-keyed bag via `setByPath`.
// • fullbag — fallback. Preserves v0.1.x behavior for custom renderers
// that didn't opt in.
//
// The whitelist check resolves on the FINAL renderer identity (not just
// `field.type`) so a consumer-registered renderer replacing `text`
// correctly opts back into full-bag unless it sets `dependsOn`.
const dependsOn = field.dependsOn;
const hasDependsOn = Array.isArray(dependsOn);
const isBuiltinDefault =
BUILTIN_RENDERER_TYPES_SKIPPING_ALL_VALUES.has(field.type) &&
renderer === defaultJsonFormRegistry[field.type];
const subscriptionMode: "snapshot" | "narrow" | "fullbag" =
isBuiltinDefault || (hasDependsOn && dependsOn.length === 0)
? "snapshot"
: hasDependsOn && dependsOn.length > 0
? "narrow"
: "fullbag";
// Always call `useWatch` once with mode-conditional inputs to keep hook
// order stable. `disabled: true` skips the subscription in snapshot mode.
const watched = useWatch(
(subscriptionMode === "snapshot"
? { control, disabled: true }
: subscriptionMode === "narrow"
? { control, name: dependsOn as ReadonlyArray<string> }
: { control }) as never,
);
let allValues: Record<string, unknown>;
if (subscriptionMode === "snapshot") {
allValues = (getValues() ?? {}) as Record<string, unknown>;
} else if (subscriptionMode === "narrow") {
const arr = Array.isArray(watched) ? watched : [];
const bag: Record<string, unknown> = {};
const names = dependsOn as ReadonlyArray<string>;
for (let i = 0; i < names.length; i++) {
setByPath(bag, names[i], arr[i]);
}
allValues = bag;
} else {
allValues = (watched ?? {}) as Record<string, unknown>;
}
const { visible, enabled, required } = useConditional(field);
// Unmount-clean: when `visibleWhen` flips a field invisible and the consumer
// hasn't set `keepValueWhenHidden`, drop the registered key so submission
// strips it (C1 lock).
useEffect(() => {
if (field.type === "hidden") return;
if (!visible && !field.keepValueWhenHidden) {
unregister(field.name as Path<FieldValues>, { keepDefaultValue: true });
}
}, [visible, field.name, field.type, field.keepValueWhenHidden, unregister]);
// T2.6 — when `requiredWhen` flips a field's required state false→true on
// a field the user has already touched, trigger validation so the
// "required" error surfaces immediately instead of waiting for the next
// blur / submit. The first-render skip prevents pre-interaction errors.
const prevRequiredRef = useRef(required);
useEffect(() => {
if (!field.requiredWhen) return;
const prev = prevRequiredRef.current;
prevRequiredRef.current = required;
if (prev === required) return;
if (!required) return;
const state = getFieldState(field.name as Path<FieldValues>);
if (state.isTouched || state.isDirty) {
void trigger(field.name as Path<FieldValues>);
}
}, [required, field.requiredWhen, field.name, trigger, getFieldState]);
// `hidden` fields are tracked but render nothing — register imperatively so
// they always submit.
useEffect(() => {
if (field.type !== "hidden") return;
register(field.name as Path<FieldValues>);
}, [field.type, field.name, register]);
if (!visible) return null;
if (field.type === "hidden") return null;
const labelPosition = field.labelPosition ?? formLabelPosition ?? "top";
const controlId = fieldIdSlug(ctx.formId, field.name);
const labelId = `${controlId}-label`;
const descriptionId = `${controlId}-description`;
const errorId = `${controlId}-error`;
const isDisabled = !enabled || !!field.disabled;
const isReadOnly = !!field.readOnly;
const wrapperLayout =
labelPosition === "left"
? "grid grid-cols-[10rem_1fr] items-start gap-x-3 gap-y-1"
: "flex flex-col gap-1.5";
return (
<Controller
control={control}
name={field.name as Path<FieldValues>}
render={({ field: controller, fieldState }) => {
const errorMessage = fieldState.error?.message
? String(fieldState.error.message)
: undefined;
const describedBy = buildDescribedBy(
field.description ? descriptionId : undefined,
errorMessage ? errorId : undefined,
);
const ariaProps: FieldAriaProps = {
id: controlId,
labelledBy: labelId,
"aria-required": required ? true : undefined,
"aria-invalid": errorMessage ? true : undefined,
"aria-disabled": isDisabled ? true : undefined,
"aria-describedby": describedBy,
};
const rendered = renderer({
field,
value: controller.value,
onChange: (next: unknown) => controller.onChange(next),
onBlur: () => controller.onBlur(),
error: errorMessage,
disabled: isDisabled,
readOnly: isReadOnly,
allValues,
formApi: ctx,
ariaProps,
});
return (
<div
data-jsonform-field={field.name}
data-jsonform-type={field.type}
data-disabled={isDisabled ? "true" : undefined}
className={cn(
"group/field",
wrapperLayout,
widthToClass(field.width),
)}
>
{field.label ? (
<label
id={labelId}
htmlFor={controlId}
className={cn(
"text-sm font-medium text-foreground",
labelPosition === "left" ? "pt-1.5" : undefined,
isDisabled ? "opacity-60" : undefined,
)}
>
{field.label}
{required ? (
<span aria-hidden="true" className="ml-0.5 text-destructive">
{ctx.strings.requiredIndicator}
</span>
) : null}
</label>
) : null}
<div
className={cn(
"flex flex-col gap-1",
labelPosition === "left" ? "min-w-0" : undefined,
)}
>
<Suspense
fallback={
loadingFallback ?? (
<div className="h-9 w-full animate-pulse rounded-md bg-muted/40" />
)
}
>
{rendered}
</Suspense>
{field.description ? (
<p id={descriptionId} className="text-xs text-muted-foreground">
{field.description}
</p>
) : null}
{errorMessage ? (
<p
id={errorId}
role="alert"
className="text-xs text-destructive"
>
{errorMessage}
</p>
) : null}
</div>
</div>
);
}}
/>
);
}
function buildDescribedBy(
descriptionId: string | undefined,
errorId: string | undefined,
): string | undefined {
const ids: string[] = [];
if (descriptionId) ids.push(descriptionId);
if (errorId) ids.push(errorId);
return ids.length ? ids.join(" ") : undefined;
}
src/registry/components/forms/json-form/parts/field-text.tsx
"use client";
import { Input } from "@/components/ui/input";
import type { FieldRenderer } from "../types";
const TYPE_TO_INPUT_TYPE: Record<string, string> = {
text: "text",
email: "email",
password: "password",
url: "url",
tel: "tel",
number: "number",
};
export const FieldText: FieldRenderer = ({
field,
value,
onChange,
onBlur,
disabled,
readOnly,
ariaProps,
}) => {
const inputType = TYPE_TO_INPUT_TYPE[field.type] ?? "text";
const isNumberField = field.type === "number";
return (
<Input
id={ariaProps.id}
type={inputType}
value={value == null ? "" : String(value)}
onChange={(e) => {
const raw = e.target.value;
// T2.5 — for `number` fields, surface an empty input as `undefined`
// rather than `""`. `z.coerce.number()` turns `""` into `0`, which
// produces surprising "value is 0 but the box is empty" UX. With
// `undefined`, optional fields validate cleanly and required
// fields surface the right "this field is required" error.
if (isNumberField && raw === "") {
onChange(undefined);
return;
}
onChange(raw);
}}
onBlur={onBlur}
placeholder={field.placeholder}
disabled={disabled}
readOnly={readOnly}
autoComplete={field.autoComplete}
autoFocus={field.autoFocus}
min={field.min}
max={field.max}
step={field.step}
aria-required={ariaProps["aria-required"]}
aria-invalid={ariaProps["aria-invalid"]}
aria-disabled={ariaProps["aria-disabled"]}
aria-describedby={ariaProps["aria-describedby"]}
/>
);
};
export default FieldText;
src/registry/components/forms/json-form/parts/field-textarea.tsx
"use client";
import { Textarea } from "@/components/ui/textarea";
import type { FieldRenderer } from "../types";
export const FieldTextarea: FieldRenderer = ({
field,
value,
onChange,
onBlur,
disabled,
readOnly,
ariaProps,
}) => {
return (
<Textarea
id={ariaProps.id}
value={value == null ? "" : String(value)}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
placeholder={field.placeholder}
disabled={disabled}
readOnly={readOnly}
rows={field.rows ?? 4}
autoComplete={field.autoComplete}
autoFocus={field.autoFocus}
aria-required={ariaProps["aria-required"]}
aria-invalid={ariaProps["aria-invalid"]}
aria-disabled={ariaProps["aria-disabled"]}
aria-describedby={ariaProps["aria-describedby"]}
/>
);
};
export default FieldTextarea;
src/registry/components/forms/json-form/parts/field-select.tsx
"use client";
import { useState } from "react";
import { Button, buttonVariants } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { ChevronDownIcon, XIcon } from "lucide-react";
import type { FieldAriaProps, FieldOption, FieldRenderer } from "../types";
import { useAsyncOptions } from "../hooks/use-async-options";
import { useJsonFormContext } from "../json-form-context";
/**
* Combined renderer for `select` + `multi-select`. Static-option `select`
* uses shadcn `Select`; `searchable: true` OR `multi-select` switches to a
* `Command + Popover` combobox.
*/
export const FieldSelect: FieldRenderer = ({
field,
value,
onChange,
onBlur,
disabled,
ariaProps,
}) => {
const isMulti = field.type === "multi-select";
const useCombobox = isMulti || !!field.searchable;
const { options, loading, error, retry, setQuery } = useAsyncOptions(field);
const ctx = useJsonFormContext();
if (!useCombobox) {
return (
<Select
value={value == null ? "" : String(value)}
onValueChange={(v) => onChange(v == null ? null : coerceOptionValue(v, options))}
disabled={disabled}
>
<SelectTrigger
id={ariaProps.id}
className="w-full"
onBlur={onBlur}
aria-required={ariaProps["aria-required"]}
aria-invalid={ariaProps["aria-invalid"]}
aria-disabled={ariaProps["aria-disabled"]}
aria-describedby={ariaProps["aria-describedby"]}
>
<SelectValue placeholder={field.placeholder ?? "Select…"} />
</SelectTrigger>
<SelectContent>
{loading ? (
<div className="px-2 py-1 text-xs text-muted-foreground">
{ctx.strings.loadingOptions}
</div>
) : null}
{error ? (
<div className="flex items-center justify-between px-2 py-1 text-xs text-destructive">
<span>{ctx.strings.optionsError}</span>
<Button size="xs" variant="ghost" onClick={retry}>
{ctx.strings.optionsRetry}
</Button>
</div>
) : null}
{!loading && options.length === 0 && !error ? (
<div className="px-2 py-1 text-xs text-muted-foreground">
{ctx.strings.noOptions}
</div>
) : null}
{options.map((opt) => (
<SelectItem
key={String(opt.value)}
value={String(opt.value)}
disabled={opt.disabled}
>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
return (
<ComboboxSelect
isMulti={isMulti}
value={value}
onChange={onChange}
onBlur={onBlur}
disabled={disabled}
options={options}
loading={loading}
error={error}
retry={retry}
setQuery={setQuery}
placeholder={field.placeholder}
ariaProps={ariaProps}
/>
);
};
interface ComboboxProps {
isMulti: boolean;
value: unknown;
onChange: (next: unknown) => void;
onBlur: () => void;
disabled: boolean;
options: FieldOption[];
loading: boolean;
error: string | null;
retry: () => void;
setQuery: (q: string) => void;
placeholder?: string;
ariaProps: FieldAriaProps;
}
function ComboboxSelect({
isMulti,
value,
onChange,
onBlur,
disabled,
options,
loading,
error,
retry,
setQuery,
placeholder,
ariaProps,
}: ComboboxProps) {
const ctx = useJsonFormContext();
const [open, setOpen] = useState(false);
const selected = normalizeSelected(value, isMulti);
function toggle(opt: FieldOption) {
if (isMulti) {
const next = selected.includes(opt.value)
? selected.filter((v) => v !== opt.value)
: [...selected, opt.value];
onChange(next);
return;
}
onChange(opt.value);
setOpen(false);
}
function removeChip(v: unknown, e: React.MouseEvent) {
e.stopPropagation();
if (!isMulti) return;
onChange(selected.filter((x) => x !== v));
}
return (
<Popover
open={open}
onOpenChange={(o) => {
setOpen(o);
if (!o) onBlur();
}}
>
{/* F-cross-13: no `asChild` — render the trigger as the combobox button
directly (native props only; buttonVariants supplies the styling). */}
<PopoverTrigger
id={ariaProps.id}
type="button"
role="combobox"
aria-expanded={open}
aria-required={ariaProps["aria-required"]}
aria-invalid={ariaProps["aria-invalid"]}
aria-disabled={ariaProps["aria-disabled"]}
aria-describedby={ariaProps["aria-describedby"]}
disabled={disabled}
className={cn(
buttonVariants({ variant: "outline" }),
"h-auto min-h-9 w-full justify-between gap-1.5 px-2.5 py-1.5 font-normal",
)}
>
<span className="flex flex-1 flex-wrap items-center gap-1">
{selected.length === 0 ? (
<span className="text-muted-foreground">
{placeholder ?? "Select…"}
</span>
) : isMulti ? (
selected.map((v) => {
const opt = options.find((o) => o.value === v);
return (
<Badge
key={String(v)}
variant="secondary"
className={cn("gap-1 pr-1")}
>
<span>{opt?.label ?? String(v)}</span>
<span
role="button"
aria-label={`Remove ${opt?.label ?? String(v)}`}
tabIndex={0}
onClick={(e) => removeChip(v, e)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
onChange(selected.filter((x) => x !== v));
}
}}
className="inline-flex size-3.5 cursor-pointer items-center justify-center rounded hover:bg-foreground/10"
>
<XIcon className="size-3" />
</span>
</Badge>
);
})
) : (
<span>
{options.find((o) => o.value === selected[0])?.label ??
String(selected[0])}
</span>
)}
</span>
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground" />
</PopoverTrigger>
{/* Radix: --radix-popover-trigger-width · Base UI: --anchor-width (F-cross-13) */}
<PopoverContent className="w-(--radix-popover-trigger-width,var(--anchor-width)) p-0">
<Command shouldFilter={false}>
<CommandInput
placeholder="Search…"
onValueChange={setQuery}
/>
<CommandList>
{loading ? (
<div className="px-3 py-2 text-xs text-muted-foreground">
{ctx.strings.loadingOptions}
</div>
) : null}
{error ? (
<div className="flex items-center justify-between px-3 py-2 text-xs text-destructive">
<span>{ctx.strings.optionsError}</span>
<Button size="xs" variant="ghost" onClick={retry}>
{ctx.strings.optionsRetry}
</Button>
</div>
) : null}
{!loading && options.length === 0 && !error ? (
<CommandEmpty>{ctx.strings.noOptions}</CommandEmpty>
) : null}
<CommandGroup>
{options.map((opt) => {
const isSel = selected.includes(opt.value);
return (
<CommandItem
key={String(opt.value)}
value={String(opt.label)}
disabled={opt.disabled}
data-checked={isSel || undefined}
onSelect={() => toggle(opt)}
>
<span>{opt.label}</span>
{opt.description ? (
<span className="ml-auto text-xs text-muted-foreground">
{opt.description}
</span>
) : null}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
function normalizeSelected(value: unknown, isMulti: boolean): unknown[] {
if (isMulti) {
if (Array.isArray(value)) return value;
return value == null ? [] : [value];
}
if (value == null || value === "") return [];
return [value];
}
/**
* Preserve non-string option.value types through the shadcn Select string
* round-trip. shadcn Select only accepts string values; if the consumer's
* option had `{ value: 42, label: "..." }`, we'd otherwise submit `"42"`
* instead of `42`. Look up the original `option.value` by stringified key.
*/
function coerceOptionValue(raw: string, options: FieldOption[]): unknown {
const match = options.find((o) => String(o.value) === raw);
return match ? match.value : raw;
}
export default FieldSelect;
src/registry/components/forms/json-form/parts/field-radio-group.tsx
"use client";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label";
import type { FieldOption, FieldRenderer } from "../types";
import { useAsyncOptions } from "../hooks/use-async-options";
import { fieldIdSlug } from "./field-wrapper";
import { useJsonFormContext } from "../json-form-context";
export const FieldRadioGroup: FieldRenderer = ({
field,
value,
onChange,
onBlur,
disabled,
ariaProps,
}) => {
const ctx = useJsonFormContext();
const { options } = useAsyncOptions(field);
const baseId = fieldIdSlug(ctx.formId, field.name);
return (
<RadioGroup
value={value == null ? "" : String(value)}
onValueChange={(v) => onChange(coerceOptionValue(v, options))}
disabled={disabled}
onBlur={onBlur}
aria-labelledby={ariaProps.labelledBy}
aria-required={ariaProps["aria-required"]}
aria-invalid={ariaProps["aria-invalid"]}
aria-disabled={ariaProps["aria-disabled"]}
aria-describedby={ariaProps["aria-describedby"]}
className="gap-1.5"
>
{options.map((opt) => {
const optId = `${baseId}-opt-${String(opt.value).replace(/[^a-z0-9_-]/gi, "_")}`;
return (
<div key={String(opt.value)} className="flex items-start gap-2">
<RadioGroupItem
id={optId}
value={String(opt.value)}
disabled={opt.disabled}
/>
<div className="flex flex-col">
<Label htmlFor={optId} className="font-normal">
{opt.label}
</Label>
{opt.description ? (
<span className="text-xs text-muted-foreground">
{opt.description}
</span>
) : null}
</div>
</div>
);
})}
</RadioGroup>
);
};
function coerceOptionValue(raw: string, options: FieldOption[]): unknown {
// Preserve non-string option.value types by looking up the original.
const match = options.find((o) => String(o.value) === raw);
return match ? match.value : raw;
}
export default FieldRadioGroup;
src/registry/components/forms/json-form/parts/field-checkbox.tsx
"use client";
import { Checkbox } from "@/components/ui/checkbox";
import type { FieldRenderer } from "../types";
export const FieldCheckbox: FieldRenderer = ({
value,
onChange,
onBlur,
disabled,
ariaProps,
}) => {
return (
<Checkbox
id={ariaProps.id}
checked={value === true}
onCheckedChange={(c) => onChange(c === true)}
onBlur={onBlur}
disabled={disabled}
aria-required={ariaProps["aria-required"]}
aria-invalid={ariaProps["aria-invalid"]}
aria-disabled={ariaProps["aria-disabled"]}
aria-describedby={ariaProps["aria-describedby"]}
/>
);
};
export default FieldCheckbox;
src/registry/components/forms/json-form/parts/field-checkbox-group.tsx
"use client";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import type { FieldRenderer } from "../types";
import { useAsyncOptions } from "../hooks/use-async-options";
import { fieldIdSlug } from "./field-wrapper";
import { useJsonFormContext } from "../json-form-context";
export const FieldCheckboxGroup: FieldRenderer = ({
field,
value,
onChange,
onBlur,
disabled,
ariaProps,
}) => {
const ctx = useJsonFormContext();
const { options } = useAsyncOptions(field);
const selected = Array.isArray(value) ? value : [];
const baseId = fieldIdSlug(ctx.formId, field.name);
function toggle(optValue: unknown, checked: boolean) {
if (checked) {
if (!selected.includes(optValue)) onChange([...selected, optValue]);
} else {
onChange(selected.filter((v) => v !== optValue));
}
}
return (
<div
role="group"
aria-labelledby={ariaProps.labelledBy}
aria-disabled={ariaProps["aria-disabled"]}
aria-describedby={ariaProps["aria-describedby"]}
className="flex flex-col gap-1.5"
onBlur={onBlur}
>
{options.map((opt) => {
const optId = `${baseId}-opt-${String(opt.value).replace(/[^a-z0-9_-]/gi, "_")}`;
const isChecked = selected.includes(opt.value);
return (
<div key={String(opt.value)} className="flex items-start gap-2">
<Checkbox
id={optId}
checked={isChecked}
onCheckedChange={(c) => toggle(opt.value, c === true)}
disabled={disabled || opt.disabled}
/>
<div className="flex flex-col">
<Label htmlFor={optId} className="font-normal">
{opt.label}
</Label>
{opt.description ? (
<span className="text-xs text-muted-foreground">
{opt.description}
</span>
) : null}
</div>
</div>
);
})}
</div>
);
};
export default FieldCheckboxGroup;
src/registry/components/forms/json-form/parts/field-switch.tsx
"use client";
import { Switch } from "@/components/ui/switch";
import type { FieldRenderer } from "../types";
export const FieldSwitch: FieldRenderer = ({
value,
onChange,
onBlur,
disabled,
ariaProps,
}) => {
return (
<Switch
id={ariaProps.id}
checked={value === true}
onCheckedChange={(c) => onChange(c === true)}
onBlur={onBlur}
disabled={disabled}
aria-required={ariaProps["aria-required"]}
aria-invalid={ariaProps["aria-invalid"]}
aria-disabled={ariaProps["aria-disabled"]}
aria-describedby={ariaProps["aria-describedby"]}
/>
);
};
export default FieldSwitch;
src/registry/components/forms/json-form/parts/field-date.tsx
"use client";
import { useState } from "react";
import { buttonVariants } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { CalendarIcon } from "lucide-react";
import type { FieldAriaProps, FieldRenderer } from "../types";
/**
* Combined renderer for date / date-range / time / datetime.
* Values are stored as ISO 8601 strings (YYYY-MM-DD for date, HH:mm for time,
* full ISO for datetime). date-range stores `{ start, end }`.
*/
export const FieldDate: FieldRenderer = (args) => {
if (args.field.type === "time") return <FieldTime {...args} />;
if (args.field.type === "date-range") return <FieldDateRange {...args} />;
if (args.field.type === "datetime") return <FieldDateTime {...args} />;
return <FieldDateSingle {...args} />;
};
function formatDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function parseDate(s: unknown): Date | undefined {
if (typeof s !== "string" || !s) return undefined;
const d = new Date(s);
return Number.isNaN(d.getTime()) ? undefined : d;
}
function ariaAttrs(p: FieldAriaProps, withId: boolean) {
return {
...(withId ? { id: p.id } : {}),
"aria-required": p["aria-required"],
"aria-invalid": p["aria-invalid"],
"aria-disabled": p["aria-disabled"],
"aria-describedby": p["aria-describedby"],
};
}
const FieldDateSingle: FieldRenderer = ({
field,
value,
onChange,
onBlur,
disabled,
ariaProps,
}) => {
const [open, setOpen] = useState(false);
const parsed = parseDate(value);
const display = parsed ? formatDate(parsed) : "";
return (
<Popover
open={open}
onOpenChange={(o) => {
setOpen(o);
if (!o) onBlur();
}}
>
{/* F-cross-13: no `asChild` — render the trigger as the button directly
(both Radix + Base UI pass native props through to a <button>). */}
<PopoverTrigger
type="button"
disabled={disabled}
className={cn(
buttonVariants({ variant: "outline" }),
"w-full justify-start gap-2 font-normal",
!display && "text-muted-foreground",
)}
{...ariaAttrs(ariaProps, true)}
>
<CalendarIcon className="size-4" />
{display || field.placeholder || "Pick a date"}
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={parsed}
onSelect={(d) => {
onChange(d ? formatDate(d) : "");
setOpen(false);
}}
/>
</PopoverContent>
</Popover>
);
};
const FieldTime: FieldRenderer = ({
field,
value,
onChange,
onBlur,
disabled,
readOnly,
ariaProps,
}) => {
return (
<Input
type="time"
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
disabled={disabled}
readOnly={readOnly}
step={field.step}
{...ariaAttrs(ariaProps, true)}
/>
);
};
const FieldDateTime: FieldRenderer = ({
field,
value,
onChange,
onBlur,
disabled,
ariaProps,
}) => {
const [open, setOpen] = useState(false);
const iso = typeof value === "string" ? value : "";
const datePart = iso.slice(0, 10);
const timePart = iso.length >= 16 ? iso.slice(11, 16) : "00:00";
const parsed = parseDate(datePart);
const display = iso ? `${datePart} ${timePart}` : "";
function update(nextDate: Date | undefined, nextTime: string) {
if (!nextDate) {
onChange("");
return;
}
const d = formatDate(nextDate);
onChange(`${d}T${nextTime}`);
}
return (
<Popover
open={open}
onOpenChange={(o) => {
setOpen(o);
if (!o) onBlur();
}}
>
<PopoverTrigger
type="button"
disabled={disabled}
className={cn(
buttonVariants({ variant: "outline" }),
"w-full justify-start gap-2 font-normal",
!display && "text-muted-foreground",
)}
{...ariaAttrs(ariaProps, true)}
>
<CalendarIcon className="size-4" />
{display || field.placeholder || "Pick a date & time"}
</PopoverTrigger>
<PopoverContent className="w-auto space-y-2 p-2">
<Calendar
mode="single"
selected={parsed}
onSelect={(d) => update(d, timePart || "00:00")}
/>
<Input
type="time"
value={timePart}
onChange={(e) => update(parsed, e.target.value)}
/>
</PopoverContent>
</Popover>
);
};
const FieldDateRange: FieldRenderer = ({
field,
value,
onChange,
onBlur,
disabled,
ariaProps,
}) => {
const [open, setOpen] = useState(false);
const range = isRange(value)
? { from: parseDate(value.start), to: parseDate(value.end) }
: { from: undefined as Date | undefined, to: undefined as Date | undefined };
const display =
range.from && range.to
? `${formatDate(range.from)} → ${formatDate(range.to)}`
: range.from
? `${formatDate(range.from)} → …`
: "";
return (
<Popover
open={open}
onOpenChange={(o) => {
setOpen(o);
if (!o) onBlur();
}}
>
<PopoverTrigger
type="button"
disabled={disabled}
className={cn(
buttonVariants({ variant: "outline" }),
"w-full justify-start gap-2 font-normal",
!display && "text-muted-foreground",
)}
{...ariaAttrs(ariaProps, true)}
>
<CalendarIcon className="size-4" />
{display || field.placeholder || "Pick a date range"}
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="range"
selected={range}
onSelect={(r) => {
onChange({
start: r?.from ? formatDate(r.from) : "",
end: r?.to ? formatDate(r.to) : "",
});
if (r?.from && r?.to) setOpen(false);
}}
/>
</PopoverContent>
</Popover>
);
};
function isRange(v: unknown): v is { start: string; end: string } {
return (
!!v &&
typeof v === "object" &&
"start" in v &&
"end" in v
);
}
export default FieldDate;
src/registry/components/forms/json-form/parts/field-code.tsx
"use client";
// Import from the specific file (not the barrel) so shadcn's path rewriter
// produces a consumer-side path that resolves. The barrel index.ts re-export
// works here by accident — code-block.tsx happens to export `CodeBlock` —
// but the explicit file path is the safe convention. See field-richtext.tsx.
import { CodeBlock } from "@/registry/components/code/code-block/code-block";
import type { FieldRenderer } from "../types";
/**
* `code` field renderer — wraps `@ilinxa/code-block` in `mode='edit'`.
*
* Loaded lazily by `default-registry.ts`
* (`React.lazy(() => import('../parts/field-code'))`) so the CodeMirror
* chunk only ships when a form actually contains a `code` field.
*
* Therefore this MUST `export default`.
*
* ARIA: `code-block` doesn't expose `id` (CodeMirror takes over the
* contenteditable element). Wrap in a `<div role="group">` with
* `aria-labelledby` so the wrapper's label still announces correctly.
*/
const FieldCode: FieldRenderer = ({
field,
value,
onChange,
disabled,
readOnly,
ariaProps,
}) => {
const cfg = field.config?.code;
const codeReadOnly = readOnly || disabled || cfg?.readOnly;
return (
<div
role="group"
aria-labelledby={ariaProps.labelledBy}
aria-disabled={ariaProps["aria-disabled"]}
aria-describedby={ariaProps["aria-describedby"]}
data-aria-required={ariaProps["aria-required"] ? "true" : undefined}
data-aria-invalid={ariaProps["aria-invalid"] ? "true" : undefined}
>
<CodeBlock
mode="edit"
value={typeof value === "string" ? value : ""}
onChange={(args) => onChange(args.value)}
readOnly={codeReadOnly}
lang={field.lang}
emptyMessage={field.placeholder}
showLineNumbers
showCopy
ariaLabel={field.label ?? field.name}
/>
</div>
);
};
export default FieldCode;
src/registry/components/forms/json-form/parts/field-richtext.tsx
"use client";
// Import everything from `rich-text-editor.tsx` (not `./types`, not the
// barrel `./index`). shadcn 4.6.0's path rewriter handles cross-procomp
// component-file paths correctly but mangles `/types` and `/index` to the
// CURRENT slug (F-S1 cross-procomp `/types` bug), which then breaks
// consumer-tsc. `rich-text-editor.tsx` re-exports the symbols we need at
// its tail, so all imports land on a path the rewriter preserves.
import {
RichTextEditor,
RICH_TEXT_EMPTY_VALUE,
type RichTextValue,
} from "@/registry/components/data/rich-text-editor/rich-text-editor";
import type { FieldRenderer } from "../types";
/**
* `richtext` field renderer — wraps `@ilinxa/rich-text-editor`'s
* `<RichTextEditor>` in a Plate-based WYSIWYG.
*
* Loaded lazily by `default-registry.ts` so the Plate bundle (~165KB gzip)
* only ships when a form actually contains a `richtext` field. Therefore
* this MUST `export default`.
*
* Submitted value: `RichTextValue` (Plate JSON — `{ type, children }[]`).
* Serialize with `serializeRichTextToHtml(value)` from
* `@ilinxa/rich-text-editor` if you need HTML at an export boundary
* (RSS / email / OG tags).
*
* ARIA: `RichTextEditor` doesn't expose `id` (Plate manages a
* contenteditable internally). Wrap in a `role="group"` with
* `aria-labelledby` so the wrapper's label binds.
*/
const FieldRichtext: FieldRenderer = ({
field,
value,
onChange,
disabled,
readOnly,
ariaProps,
}) => {
const cfg = field.config?.richText;
const safe = isRichtextValue(value) ? value : RICH_TEXT_EMPTY_VALUE;
return (
<div
role="group"
aria-labelledby={ariaProps.labelledBy}
aria-disabled={ariaProps["aria-disabled"]}
aria-describedby={ariaProps["aria-describedby"]}
data-aria-required={ariaProps["aria-required"] ? "true" : undefined}
data-aria-invalid={ariaProps["aria-invalid"] ? "true" : undefined}
>
<RichTextEditor
value={safe}
onChange={(next) => onChange(next)}
readOnly={readOnly || disabled}
placeholder={field.placeholder}
hideToolbar={cfg?.hideToolbar}
autoFocus={cfg?.autoFocus ?? field.autoFocus}
/>
</div>
);
};
function isRichtextValue(v: unknown): v is RichTextValue {
return Array.isArray(v);
}
export default FieldRichtext;
src/registry/components/forms/json-form/parts/field-slider.tsx
"use client";
import { Slider } from "@/components/ui/slider";
import type { FieldRenderer } from "../types";
export const FieldSlider: FieldRenderer = ({
field,
value,
onChange,
onBlur,
disabled,
ariaProps,
}) => {
const min = field.min ?? 0;
const max = field.max ?? 100;
const step = field.step ?? 1;
const numeric = typeof value === "number" ? value : Number(value);
const safe = Number.isFinite(numeric) ? numeric : min;
return (
<div className="flex w-full items-center gap-3" onBlur={onBlur}>
<Slider
id={ariaProps.id}
value={[safe]}
min={min}
max={max}
step={step}
disabled={disabled}
onValueChange={(v) => onChange(firstNumber(v, min))}
aria-labelledby={ariaProps.labelledBy}
aria-required={ariaProps["aria-required"]}
aria-invalid={ariaProps["aria-invalid"]}
aria-disabled={ariaProps["aria-disabled"]}
aria-describedby={ariaProps["aria-describedby"]}
className="flex-1"
/>
<span className="w-10 text-right text-xs tabular-nums text-muted-foreground">
{safe}
</span>
</div>
);
};
/** Slider's onValueChange emits `number | readonly number[]` depending on
* the installed shadcn version. Normalize to a single number. */
function firstNumber(v: number | readonly number[], fallback: number): number {
if (typeof v === "number") return v;
if (Array.isArray(v) && v.length > 0) return v[0];
return fallback;
}
export default FieldSlider;
src/registry/components/forms/json-form/parts/field-rating.tsx
"use client";
import { useRef, type KeyboardEvent } from "react";
import { StarIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { FieldRenderer } from "../types";
export const FieldRating: FieldRenderer = ({
field,
value,
onChange,
onBlur,
disabled,
ariaProps,
}) => {
const stars = field.config?.rating?.stars ?? 5;
const current = typeof value === "number" ? value : Number(value) || 0;
const groupRef = useRef<HTMLDivElement | null>(null);
function setValue(n: number) {
onChange(Math.max(0, Math.min(stars, n)));
}
function handleKey(e: KeyboardEvent<HTMLDivElement>) {
if (disabled) return;
if (e.key === "ArrowRight" || e.key === "ArrowUp") {
e.preventDefault();
setValue(current + 1);
return;
}
if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
e.preventDefault();
setValue(current - 1);
return;
}
if (e.key === "Home") {
e.preventDefault();
setValue(1);
return;
}
if (e.key === "End") {
e.preventDefault();
setValue(stars);
return;
}
const n = Number(e.key);
if (Number.isFinite(n) && n >= 1 && n <= stars) {
e.preventDefault();
setValue(n);
}
}
return (
<div
ref={groupRef}
role="radiogroup"
aria-labelledby={ariaProps.labelledBy}
aria-required={ariaProps["aria-required"]}
aria-invalid={ariaProps["aria-invalid"]}
aria-disabled={ariaProps["aria-disabled"]}
aria-describedby={ariaProps["aria-describedby"]}
tabIndex={disabled ? -1 : 0}
onKeyDown={handleKey}
onBlur={onBlur}
className={cn(
"inline-flex w-fit items-center gap-1 rounded-md p-0.5 outline-none ring-ring/50 focus-visible:ring-3",
disabled && "opacity-60",
)}
>
{Array.from({ length: stars }, (_, i) => {
const n = i + 1;
const filled = n <= current;
return (
<button
key={n}
type="button"
role="radio"
aria-checked={n === current}
aria-label={`${n} star${n === 1 ? "" : "s"}`}
disabled={disabled}
onClick={() => setValue(n)}
tabIndex={-1}
className={cn(
"p-0.5 transition-colors",
filled ? "text-primary" : "text-muted-foreground/40",
!disabled && "hover:text-primary",
)}
>
<StarIcon
className="size-5"
fill={filled ? "currentColor" : "none"}
/>
</button>
);
})}
</div>
);
};
export default FieldRating;
src/registry/components/forms/json-form/parts/field-computed.tsx
"use client";
import { useEffect } from "react";
import { Input } from "@/components/ui/input";
import type { FieldRenderer } from "../types";
import { useComputed } from "../hooks/use-computed";
export const FieldComputed: FieldRenderer = ({
field,
value,
onChange,
onBlur,
disabled,
ariaProps,
}) => {
const computed = useComputed(field);
const display = computed == null ? "" : String(computed);
// Push computed value into RHF state so consumers can read it via
// `formApi.getValues()`. Skipped in editable mode (consumer types over).
useEffect(() => {
if (field.editable) return;
if (value !== computed) onChange(computed);
}, [computed, field.editable, value, onChange]);
if (field.editable) {
return (
<Input
id={ariaProps.id}
type="text"
value={typeof value === "string" ? value : display}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
disabled={disabled}
placeholder={field.placeholder}
aria-required={ariaProps["aria-required"]}
aria-invalid={ariaProps["aria-invalid"]}
aria-disabled={ariaProps["aria-disabled"]}
aria-describedby={ariaProps["aria-describedby"]}
/>
);
}
return (
<output
id={ariaProps.id}
aria-labelledby={ariaProps.labelledBy}
aria-describedby={ariaProps["aria-describedby"]}
data-jsonform-computed
className="inline-flex h-8 w-full items-center rounded-lg border border-dashed border-input bg-muted/30 px-2.5 text-sm text-muted-foreground"
>
{display || (
<span className="text-muted-foreground/60">—</span>
)}
</output>
);
};
export default FieldComputed;
src/registry/components/forms/json-form/parts/field-hidden.tsx
"use client";
import type { FieldRenderer } from "../types";
/**
* Hidden field — renders `null`. Registration + value tracking happens in
* `FieldWrapper`'s effect; FieldWrapper returns null for `type: 'hidden'`
* before this renderer is reached. This renderer exists for completeness
* (and consumer registry lookups) but is effectively unused at runtime.
*/
export const FieldHidden: FieldRenderer = () => null;
export default FieldHidden;
src/registry/components/forms/json-form/parts/field-section.tsx
"use client";
import type { FieldRenderer } from "../types";
/**
* `section` — renders `<fieldset>` chrome via the wrapper. Returns null here
* because section is handled at the form layout level (it groups subsequent
* fields visually, it doesn't render an input). The resolver in `<JsonForm>`
* routes `section`/`divider` types through dedicated layout primitives,
* bypassing this renderer. Kept in the registry for completeness so the
* fallback path doesn't trip.
*/
export const FieldSection: FieldRenderer = () => null;
export default FieldSection;
src/registry/components/forms/json-form/parts/field-divider.tsx
"use client";
import type { FieldRenderer } from "../types";
/**
* `divider` — see `field-section.tsx`. Routed through the layout primitive
* in `<JsonForm>`. This renderer is kept for symmetry; never invoked at
* runtime under normal flow.
*/
export const FieldDivider: FieldRenderer = () => null;
export default FieldDivider;
src/registry/components/forms/json-form/parts/field-fallback.tsx
"use client";
import { useEffect } from "react";
import type { FieldRenderer } from "../types";
/**
* Fallback renderer for unknown field types. Renders an inline error
* placeholder and warns once per mount in dev.
*/
export const FieldFallback: FieldRenderer = ({ field, ariaProps }) => {
useEffect(() => {
if (process.env.NODE_ENV === "production") return;
console.warn(
`[json-form] No renderer registered for field type "${field.type}" ` +
`(field "${field.name}"). Register one via the \`fieldRegistry\` prop ` +
`or use one of the built-in types.`,
);
}, [field.type, field.name]);
return (
<div
id={ariaProps.id}
role="alert"
aria-labelledby={ariaProps.labelledBy}
aria-describedby={ariaProps["aria-describedby"]}
className="inline-flex w-full items-center gap-2 rounded-md border border-dashed border-destructive/40 bg-destructive/5 px-2.5 py-1.5 text-xs text-destructive"
>
<span aria-hidden>⚠</span>
<span>
Unknown field type <code className="font-mono">{field.type}</code>
</span>
</div>
);
};
export default FieldFallback;
src/registry/components/forms/json-form/parts/form-header.tsx
"use client";
import { useJsonFormContext } from "../json-form-context";
/**
* Renders the form's `meta.title` + `meta.description` chrome at the top of
* the form. Hidden by default if `meta` is absent or `showSchemaHeader` is
* `false` (handled by `<JsonForm>`). Exported as a standalone atom so
* consumers building fully-headless layouts can place it themselves.
*/
export function FormHeader() {
const ctx = useJsonFormContext();
const meta = ctx.schema.meta;
if (!meta?.title && !meta?.description) return null;
return (
<header className="flex flex-col gap-1 pb-2">
{meta.title ? (
<h2 className="text-base font-semibold text-foreground">
{meta.title}
</h2>
) : null}
{meta.description ? (
<p className="text-sm text-muted-foreground">{meta.description}</p>
) : null}
</header>
);
}
export default FormHeader;
src/registry/components/forms/json-form/parts/error-summary.tsx
"use client";
import { useFormState } from "react-hook-form";
import {
useJsonFormContext,
useJsonFormHasSubmitted,
} from "../json-form-context";
import { flattenRhfErrorsToList } from "../lib/flatten-errors";
import { fieldIdSlug } from "./field-wrapper";
export interface JsonFormErrorSummaryProps {
/** Override the form-level `summaryStrategy`. */
strategy?: "always" | "post-submit";
className?: string;
}
/**
* Standalone error-summary atom. `<div role="alert" aria-live="polite">`
* with anchor-linked errors. Per default, only renders post-submit.
* Doubles as the form's live region for validation state changes.
*
* v0.1.3 — reads `hasSubmitted` via the narrow `useJsonFormHasSubmitted`
* accessor so the main context's identity doesn't churn when the flag
* flips (F-R4 fix).
*/
export function JsonFormErrorSummary({
strategy,
className,
}: JsonFormErrorSummaryProps) {
const ctx = useJsonFormContext();
const hasSubmitted = useJsonFormHasSubmitted();
const { errors } = useFormState({ control: ctx.rhf.control });
const effective = strategy ?? "post-submit";
const flat = flattenRhfErrorsToList(errors as Record<string, unknown>);
if (flat.length === 0) return null;
if (effective === "post-submit" && !hasSubmitted) return null;
return (
<div
role="alert"
aria-live="polite"
className={
"rounded-md border border-destructive/30 bg-destructive/5 p-3 " +
(className ?? "")
}
>
<h3 className="mb-1.5 text-sm font-medium text-destructive">
{ctx.strings.summaryHeading}
</h3>
<ul className="ml-4 list-disc space-y-0.5 text-xs text-destructive">
{flat.map(({ name, message }) => (
<li key={name}>
<a
href={`#${fieldIdSlug(ctx.formId, name)}`}
className="underline-offset-2 hover:underline"
onClick={(e) => {
// Browser default scrolls to `id`, but does NOT focus the
// control (only focusable elements get focus on `href`-jump
// and group-style controls have id on the wrapper). Force
// focus via RHF so keyboard users land on the offending
// input, not just near it.
e.preventDefault();
const slug = fieldIdSlug(ctx.formId, name);
const target = typeof document !== "undefined"
? document.getElementById(slug)
: null;
target?.scrollIntoView({ behavior: "smooth", block: "center" });
try {
ctx.rhf.setFocus(name as never);
} catch {
// setFocus throws on non-registered names; fall through to
// letting the scroll-into-view land where it can.
}
}}
>
<span className="font-medium">{name}</span>
<span className="px-1">—</span>
<span>{message}</span>
</a>
</li>
))}
</ul>
</div>
);
}
export default JsonFormErrorSummary;
src/registry/components/forms/json-form/parts/submit-button.tsx
"use client";
import { Button } from "@/components/ui/button";
import { useJsonFormContext } from "../json-form-context";
import type { ButtonVariant } from "../types";
export interface JsonFormSubmitButtonProps {
label?: string;
variant?: ButtonVariant;
disableWhenInvalid?: boolean;
className?: string;
}
/**
* Standalone submit button. Reads `isSubmitting` + `isValid` from context.
* Opt-in `disableWhenInvalid` (the description Q19 / Q40 anti-pattern is
* disabled by default).
*/
export function JsonFormSubmitButton({
label,
variant = "default",
disableWhenInvalid = false,
className,
}: JsonFormSubmitButtonProps) {
const ctx = useJsonFormContext();
const isSubmitting = ctx.rhf.formState.isSubmitting;
const isValid = ctx.rhf.formState.isValid;
const disabled = isSubmitting || (disableWhenInvalid && !isValid);
return (
<Button
type="submit"
variant={variant}
disabled={disabled}
data-jsonform-submit
className={className}
>
{label ?? ctx.strings.submit}
</Button>
);
}
export default JsonFormSubmitButton;
src/registry/components/forms/json-form/parts/reset-button.tsx
"use client";
import { Button } from "@/components/ui/button";
import { useJsonFormContext } from "../json-form-context";
import type { ButtonVariant } from "../types";
export interface JsonFormResetButtonProps {
label?: string;
variant?: ButtonVariant;
className?: string;
}
/**
* Standalone reset button. Calls `formApi.reset()` — does NOT fire the
* native form reset (which would clear values outside RHF's tracking).
*/
export function JsonFormResetButton({
label,
variant = "outline",
className,
}: JsonFormResetButtonProps) {
const ctx = useJsonFormContext();
return (
<Button
type="button"
variant={variant}
onClick={(e) => {
e.preventDefault();
ctx.reset();
}}
data-jsonform-reset
className={className}
>
{label ?? ctx.strings.reset}
</Button>
);
}
export default JsonFormResetButton;
src/registry/components/forms/json-form/parts/json-form-field.tsx
"use client";
import { useMemo } from "react";
import type { FieldRenderer } from "../types";
import { useJsonFormContext } from "../json-form-context";
import { FieldWrapper } from "./field-wrapper";
import { FieldFallback } from "./field-fallback";
export interface JsonFormFieldProps {
/** Field name to look up in the active schema. */
name: string;
/**
* Optional override registry for this single field. Falls back to the
* form-level merged registry (built from `<JsonForm fieldRegistry>`
* spread over the default registry).
*/
registry?: Record<string, FieldRenderer>;
}
/**
* Standalone field renderer for fully-headless layouts. Looks up the field
* by `name` in the current schema, resolves a renderer from the registry,
* wraps it in `<FieldWrapper>`. Throws (dev) if the field isn't found.
*
* The form-level merged registry is exposed via `ctx.fieldRegistry`.
*/
export function JsonFormField({ name, registry }: JsonFormFieldProps) {
const ctx = useJsonFormContext();
const field = useMemo(
() => ctx.schema.fields.find((f) => f.name === name),
[ctx.schema.fields, name],
);
if (!field) {
if (process.env.NODE_ENV !== "production") {
console.warn(
`[json-form] <JsonFormField name="${name}"> — no field with this name in the current schema.`,
);
}
return null;
}
const merged = registry ?? ctx.fieldRegistry;
const renderer = merged[field.type] ?? FieldFallback;
return <FieldWrapper field={field} renderer={renderer} />;
}
export default JsonFormField;
src/registry/components/forms/json-form/hooks/use-json-form.ts
"use client";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useForm, type UseFormReturn } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import type {
FormSchema,
JsonFormHandle,
JsonFormStrings,
UseJsonFormOptions,
UseJsonFormReturn,
} from "../types";
import { flattenRhfErrorsToRecord } from "../lib/flatten-errors";
import { compileStructural, injectStrings } from "../lib/schema-compiler";
import { mergeStrings } from "../lib/strings";
import { validateSchemaDev } from "../lib/validate-schema";
/**
* Headless factory hook. Builds the Zod schema, instantiates `useForm`
* with the resolver, returns everything needed to render the form
* yourself (or for `<JsonForm>` to render it for you).
*/
export function useJsonForm<TValues extends Record<string, unknown> = Record<string, unknown>>(
schema: FormSchema,
options: UseJsonFormOptions<TValues> = {},
): UseJsonFormReturn<TValues> {
const strings: JsonFormStrings = useMemo(() => mergeStrings(options.strings), [options.strings]);
// Run dev-only schema sanity checks once per schema identity
useMemo(() => {
validateSchemaDev(schema);
}, [schema]);
// v0.1.7 (C1) — two-memo compile pipeline. The structural step (field-list
// extraction + schema reference) caches on schema identity; the strings
// injection (Zod chain construction + outer refinements) caches on the
// structural + strings pair. Strings-only changes (locale switch, error
// template overrides) skip the schema re-walk.
const structural = useMemo(() => compileStructural(schema), [schema]);
const zodSchema = useMemo(
() => injectStrings(structural, strings),
[structural, strings],
);
const defaultValues = useMemo(() => {
return mergeDefaultValues<TValues>(schema, options.defaultValues);
}, [schema, options.defaultValues]);
const form = useForm<TValues>({
// `zodSchema as never` bypasses zodResolver's overload matching.
// The resolver has 4 overloads (v3/v4 × raw/non-raw); consumer-side
// overload-resolution behavior depends on which sub-zod the bundler
// picks for the resolver's `zod/v4/core` import, and that can vary
// between producer and consumer trees even with identical pinned
// versions (path-b smoke 2026-05-13 surfaced this). Casting the
// argument removes the choice; runtime behavior is identical
// (resolver dispatches at runtime via instanceof checks).
resolver: zodResolver(zodSchema as never) as never,
mode: options.validationMode ?? "onTouched",
defaultValues: defaultValues as never,
values: options.values as never,
});
const handle = useFormHandle(form);
return {
form,
zodSchema,
fieldList: schema.fields,
isValid: form.formState.isValid,
isSubmitting: form.formState.isSubmitting,
handle,
};
}
/**
* Merge per-field `defaultValue` with form-level `defaultValues`. Form-level
* wins on conflict (P-09).
*
* v0.2.0 (B1): form-level `defaultValues` deep-merges per leaf via
* `setByPath`/`walkLeaves` instead of shallow per-top-level-key replacement.
* `{ address: { city: 'X' } }` at form-level no longer drops sibling per-field
* defaults at `address.country`, `address.zip`, etc.
*/
function mergeDefaultValues<TValues extends Record<string, unknown>>(
schema: FormSchema,
formLevel: Partial<TValues> | undefined,
): Partial<TValues> {
const out: Record<string, unknown> = {};
for (const field of schema.fields) {
if (field.defaultValue !== undefined) {
setByPath(out, field.name.split("."), field.defaultValue);
}
}
if (formLevel) {
walkLeaves(formLevel as Record<string, unknown>, [], (path, value) => {
setByPath(out, path, value);
});
}
return out as Partial<TValues>;
}
/** True for `{}` / `Object.create(null)` shapes. Arrays / Dates / class
* instances return false so they're treated as terminal leaves. */
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== "object") return false;
if (Array.isArray(value)) return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
/** Write `value` at dot-segments `path` into `obj`, lazily creating
* intermediate plain-object containers when missing or non-object. */
function setByPath(
obj: Record<string, unknown>,
path: ReadonlyArray<string>,
value: unknown,
): void {
let cur = obj;
for (let i = 0; i < path.length - 1; i++) {
const seg = path[i];
if (!isPlainObject(cur[seg])) {
cur[seg] = {};
}
cur = cur[seg] as Record<string, unknown>;
}
cur[path[path.length - 1]] = value;
}
/** Depth-first walk: invokes `fn(path, leafValue)` for every non-plain-object
* value reachable from `obj`. Empty objects emit no callback. */
function walkLeaves(
obj: Record<string, unknown>,
prefix: ReadonlyArray<string>,
fn: (path: ReadonlyArray<string>, value: unknown) => void,
): void {
for (const key of Object.keys(obj)) {
const value = obj[key];
const nextPath = [...prefix, key];
if (isPlainObject(value)) {
walkLeaves(value, nextPath, fn);
} else {
fn(nextPath, value);
}
}
}
/** Stable imperative-handle factory; methods read fresh RHF state per call. */
function useFormHandle<TValues extends Record<string, unknown>>(
form: UseFormReturn<TValues>,
): JsonFormHandle<TValues> {
const formRef = useRef(form);
useEffect(() => {
formRef.current = form;
}, [form]);
const submit = useCallback(async () => {
let result: { ok: boolean; values?: TValues; errors?: Record<string, string> } = { ok: false };
await formRef.current.handleSubmit(
(values) => {
result = { ok: true, values: values as TValues };
},
(errors) => {
// v0.2.6 (review §6 medium) — use the shared recursive flattener: the
// previous inline loop only read TOP-LEVEL `message` fields, so a
// nested error tree (e.g. `address.city`) came back as `errors: {}`.
result = {
ok: false,
errors: flattenRhfErrorsToRecord(errors as Record<string, unknown>),
};
},
)();
return result;
}, []);
const reset = useCallback((values?: Partial<TValues>) => {
formRef.current.reset(values as never);
}, []);
const setValue = useCallback((name: string, value: unknown) => {
formRef.current.setValue(name as never, value as never);
}, []);
const getValue = useCallback((name: string) => {
return formRef.current.getValues(name as never);
}, []);
const getValues = useCallback(() => formRef.current.getValues() as TValues, []);
const setError = useCallback((name: string, message: string) => {
formRef.current.setError(name as never, { message });
}, []);
const clearErrors = useCallback((name?: string) => {
formRef.current.clearErrors(name as never);
}, []);
const trigger = useCallback((name?: string | string[]) => {
return formRef.current.trigger(name as never);
}, []);
const focus = useCallback((name: string) => {
formRef.current.setFocus(name as never);
}, []);
const isDirty = useCallback(() => formRef.current.formState.isDirty, []);
const isValid = useCallback(() => formRef.current.formState.isValid, []);
const isSubmitting = useCallback(() => formRef.current.formState.isSubmitting, []);
return useMemo<JsonFormHandle<TValues>>(
() => ({
submit, reset, setValue, getValue, getValues,
setError, clearErrors, trigger, focus,
isDirty, isValid, isSubmitting,
}),
[submit, reset, setValue, getValue, getValues, setError, clearErrors, trigger, focus, isDirty, isValid, isSubmitting],
);
}
src/registry/components/forms/json-form/hooks/use-conditional.ts
"use client";
import { useMemo } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import type { FieldDefinition } from "../types";
import {
extractConditionOrFnDeps,
resolveConditionOrFn,
} from "../lib/condition-evaluator";
import { setByPath } from "../lib/path";
export interface ConditionalState {
visible: boolean;
enabled: boolean;
required: boolean;
}
/**
* Evaluates `visibleWhen` / `enabledWhen` / `requiredWhen` for a single
* field.
*
* v0.1.6 — narrow-deps subscription (T1.2). The union of static deps across
* all three conditions drives `useWatch({ name })`, so a conditional that
* only depends on `country` only re-renders when `country` changes — not on
* every keystroke anywhere in the form. Function-form conditions can't be
* statically analyzed (`extractConditionOrFnDeps` returns `null`); we fall
* back to the full-bag subscription for those.
*
* v0.1.3 — memoization on the boolean shape (F-R5). The returned object has
* stable identity when nothing changed.
*/
export function useConditional(field: FieldDefinition): ConditionalState {
const { control } = useFormContext();
const watchTarget = useMemo(() => {
if (!field.visibleWhen && !field.enabledWhen && !field.requiredWhen) {
return { mode: "none" as const };
}
const allDeps = new Set<string>();
let anyFnForm = false;
for (const key of ["visibleWhen", "enabledWhen", "requiredWhen"] as const) {
const c = field[key];
if (!c) continue;
const deps = extractConditionOrFnDeps(c);
if (deps == null) {
anyFnForm = true;
break;
}
deps.forEach((d) => allDeps.add(d));
}
if (anyFnForm) return { mode: "full" as const };
return { mode: "narrow" as const, names: Array.from(allDeps) };
}, [field.visibleWhen, field.enabledWhen, field.requiredWhen]);
// `useWatch` is called UNCONDITIONALLY (hook rules); the `name` arg drives
// narrow vs full subscription. RHF returns `[]` for `name: []`, which is
// fine — we never read it in that case. The `as never` cast matches the
// codebase's existing RHF-overload-disambiguation pattern (see
// `use-json-form.ts` resolver cast comment for the precedent rationale);
// here the union of `{ control, name }` vs `{ control }` ambiguates v7.76
// overload resolution against the new `compute` overload.
const watched = useWatch(
(watchTarget.mode === "narrow"
? { control, name: watchTarget.names }
: { control }) as never,
);
const values = useMemo<Record<string, unknown>>(() => {
if (watchTarget.mode === "narrow") {
// RHF returns an array (positional) when `name` is an array. Rebuild
// a nested bag via `setByPath` so the condition evaluator's
// `getByPath('address.city')` walks into the right shape.
const arr = Array.isArray(watched) ? watched : [];
const bag: Record<string, unknown> = {};
watchTarget.names.forEach((n, i) => {
setByPath(bag, n, arr[i]);
});
return bag;
}
return (watched ?? {}) as Record<string, unknown>;
}, [watched, watchTarget]);
const visible = field.visibleWhen
? resolveConditionOrFn(field.visibleWhen, values)
: true;
const enabled = field.enabledWhen
? resolveConditionOrFn(field.enabledWhen, values)
: true;
const required = field.requiredWhen
? resolveConditionOrFn(field.requiredWhen, values)
: false;
return useMemo<ConditionalState>(
() => ({ visible, enabled, required }),
[visible, enabled, required],
);
}
src/registry/components/forms/json-form/hooks/use-computed.ts
"use client";
import { useMemo } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import type { FieldDefinition } from "../types";
import { interpolate, parseExpression } from "../lib/expression-parser";
import { setByPath } from "../lib/path";
/**
* Evaluate a `computed` field's value. Two forms:
* - `expression: '{firstName} {lastName}'` — pure interpolation
* - `compute: (args) => unknown` — function escape hatch
*
* v0.1.6 — narrow-deps subscription (T1.2). For the `expression` form we
* statically extract the referenced field paths from the parsed template and
* scope the `useWatch` to those names; the `compute` form is opaque and
* falls back to a full-bag subscription.
*
* v0.1.3 — inline evaluation (F-R5). The body is cheap (string interpolation
* OR a single function call) and `field-computed.tsx` has an outer
* value-equality guard before propagating to RHF, so referential instability
* of the return value is not a correctness concern.
*/
export function useComputed(field: FieldDefinition): unknown {
const { control } = useFormContext();
const parsed = useMemo(() => {
if (!field.expression) return null;
return parseExpression(field.expression);
}, [field.expression]);
// Static deps when we have an `expression`; full-bag fallback for the
// `compute` function form (or when neither is set — short-circuit later).
const watchTarget = useMemo(() => {
if (parsed) {
return { mode: "narrow" as const, names: Array.from(parsed.deps) };
}
return { mode: "full" as const };
}, [parsed]);
// `as never` matches the codebase's RHF-overload pattern; v7.76 added a
// `compute` overload that ambiguates union-arg call sites here.
const watched = useWatch(
(watchTarget.mode === "narrow"
? { control, name: watchTarget.names }
: { control }) as never,
);
const values = useMemo<Record<string, unknown>>(() => {
if (watchTarget.mode === "narrow") {
const arr = Array.isArray(watched) ? watched : [];
const bag: Record<string, unknown> = {};
watchTarget.names.forEach((n, i) => {
setByPath(bag, n, arr[i]);
});
return bag;
}
return (watched ?? {}) as Record<string, unknown>;
}, [watched, watchTarget]);
if (field.expression && parsed) {
return interpolate(parsed, values);
}
if (field.compute) {
return field.compute({ values });
}
return undefined;
}
src/registry/components/forms/json-form/hooks/use-debounced-callback.ts
"use client";
import { useEffect, useMemo, useRef } from "react";
/**
* Returns a debounced version of `fn` that delays invocation by `delay` ms.
* Hand-rolled — avoiding `use-debounce` npm dep for ~15 lines of logic.
*/
export function useDebouncedCallback<TArgs extends unknown[]>(
fn: (...args: TArgs) => void,
delay: number,
): (...args: TArgs) => void {
const fnRef = useRef(fn);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Keep the latest fn reference without re-creating the debounced wrapper.
useEffect(() => {
fnRef.current = fn;
}, [fn]);
// Clear on unmount.
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
return useMemo(() => {
return (...args: TArgs) => {
if (timerRef.current) clearTimeout(timerRef.current);
if (delay <= 0) {
fnRef.current(...args);
return;
}
timerRef.current = setTimeout(() => {
fnRef.current(...args);
}, delay);
};
}, [delay]);
}
src/registry/components/forms/json-form/hooks/use-async-options.ts
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useFormContext } from "react-hook-form";
import type { FieldDefinition, FieldOption, FieldOptionsResolver } from "../types";
export interface AsyncOptionsState {
options: FieldOption[];
loading: boolean;
error: string | null;
/** Triggers a re-fetch with the same query (used by the Retry button). */
retry: () => void;
/** Update the search query (combobox typing). */
setQuery: (q: string) => void;
}
const DEFAULT_DEBOUNCE = 200;
/**
* Manages an async options resolver: debounce, loading state, error+retry.
* Static options pass through directly (no state — derived on render).
*/
export function useAsyncOptions(field: FieldDefinition): AsyncOptionsState {
const { getValues } = useFormContext();
const [asyncOptions, setAsyncOptions] = useState<FieldOption[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [query, setQueryState] = useState("");
const [tick, setTick] = useState(0);
const generation = useRef(0);
const isAsync = typeof field.options === "function";
const staticOptions = useMemo<FieldOption[]>(
() => (Array.isArray(field.options) ? (field.options as FieldOption[]) : []),
[field.options],
);
const debounce = field.optionsDebounce ?? DEFAULT_DEBOUNCE;
useEffect(() => {
if (!isAsync) return;
const resolver = field.options as FieldOptionsResolver;
// Loading + error reset before the network call is intentional —
// ergonomic "spinner before fetch" UX. Suppress the React Compiler
// cascading-render lint; the subsequent then/catch are stable.
// eslint-disable-next-line react-hooks/set-state-in-effect
setLoading(true);
setError(null);
const myGen = ++generation.current;
const controller = new AbortController();
const handle = setTimeout(() => {
resolver({
query,
allValues: getValues() as Record<string, unknown>,
signal: controller.signal,
})
.then((next) => {
if (myGen !== generation.current) return;
setAsyncOptions(next);
setLoading(false);
})
.catch((err: unknown) => {
if (myGen !== generation.current) return;
// AbortError is expected on supersede/unmount — don't surface it.
if (err instanceof DOMException && err.name === "AbortError") return;
setAsyncOptions([]);
setLoading(false);
setError(err instanceof Error ? err.message : String(err));
});
}, debounce);
return () => {
clearTimeout(handle);
controller.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAsync, query, debounce, tick]);
const retry = useCallback(() => setTick((t) => t + 1), []);
const setQuery = useCallback((q: string) => setQueryState(q), []);
return {
options: isAsync ? asyncOptions : staticOptions,
loading: isAsync ? loading : false,
error: isAsync ? error : null,
retry,
setQuery,
};
}
src/registry/components/forms/json-form/lib/schema-compiler.ts
import { z } from "zod";
import type { ZodObject, ZodTypeAny } from "zod";
import type { FieldDefinition, FieldValidators, FormSchema, JsonFormStrings } from "../types";
import { resolveConditionOrFn } from "./condition-evaluator";
import { formatErrorTemplate } from "./strings";
import { isValueField } from "./validate-schema";
import { getByPath } from "./path";
/**
* v0.1.7 (C1) — compile pipeline split into two stages for cache granularity:
*
* compileStructural(schema) → intermediate representation, schema-keyed
* injectStrings(structural, strings) → final ZodObject, strings-keyed
* compileSchema(schema, strings) → composition wrapper (internal-only — not
* exported from index.ts; kept for code
* readability and any direct internal call)
*
* Why the split: in `useJsonForm`, `strings` identity changes (locale switch,
* error-message overrides) shouldn't re-walk the schema's field list. The
* two-memo pattern caches the structural step on schema identity, and only
* the string-injection step runs on strings change. For typical schemas the
* absolute saving is microseconds, but the cleanly-separated stages make the
* cache reasoning obvious and free us from RHF re-initialization on
* strings-only updates (the resolver wraps the new Zod object — RHF detects
* the resolver identity change but reuses prior form state).
*
* Pipeline (per the v0.1.0 plan):
* 1. Walk value-carrying fields, build a per-field Zod chain from
* `field.validators` + base type.
* 2. Group fields by dot-path roots, build a nested ZodObject via path-trie.
* 3. Apply `field.validate` (sync) + `field.validateAsync` via per-field
* `.superRefine()` (resolver evaluates after structural validators).
* 4. Apply form-level `schema.validate` via an outer `.superRefine()`.
* 5. Apply `requiredWhen` via another outer `.superRefine()` that checks
* visibility-aware required-ness against the runtime values bag.
* 6. Merge `schema.zodSchema` LAST — consumer-provided keys overwrite
* DSL-generated ones (M-03 / T8 lock).
*
* Steps 1 + 2 + 5 + 6 reference `strings` (via `errorTemplates.*` messages
* baked into the constraint chains); step 1 also runs `buildFieldZod` per
* field which is the bulk of the per-field work. So `injectStrings` does
* most of the heavy lifting; `compileStructural` is the cheap "field list
* collection" memo whose output is reused across strings changes.
*/
/**
* Schema-keyed intermediate. Internal-only; not exported from index.ts.
*
* Holds the value-carrying field list pre-extracted (so `injectStrings`
* doesn't re-filter on every strings change) plus a reference to the source
* `FormSchema` for the outer-refinement steps (custom validators / form-level
* validate / requiredWhen / consumer-zodSchema merge).
*/
export interface CompiledStructural {
readonly leafFields: ReadonlyArray<FieldDefinition>;
readonly schema: FormSchema;
}
/** Schema-keyed compile stage. Cheap; safe to re-run on any schema change. */
export function compileStructural(schema: FormSchema): CompiledStructural {
const leafFields: FieldDefinition[] = [];
for (const field of schema.fields) {
if (!isValueField(field)) continue;
leafFields.push(field);
}
return { leafFields, schema };
}
/** Strings-keyed compile stage. Builds the final Zod chains + outer refinements. */
export function injectStrings(
structural: CompiledStructural,
strings: JsonFormStrings,
): ZodObject<Record<string, ZodTypeAny>> {
// 1. Build flat leaf map: 'path' → ZodType (strings baked in)
const leaves = new Map<string, ZodTypeAny>();
for (const field of structural.leafFields) {
leaves.set(field.name, buildFieldZod(field, strings));
}
// 2. Nested ZodObject via path-trie
let zod = buildNestedZodObject(leaves);
// 3. Per-field custom sync + async validators via outer .superRefine
zod = applyCustomValidators(zod, structural.schema.fields);
// 4. Form-level validate
if (structural.schema.validate) {
const formValidate = structural.schema.validate;
zod = zod.superRefine((values, ctx) => {
const errors = formValidate({ values: values as Record<string, unknown> });
if (!errors) return;
for (const [path, message] of Object.entries(errors)) {
ctx.addIssue({
code: "custom",
path: path.split("."),
message,
});
}
}) as ZodObject<Record<string, ZodTypeAny>>;
}
// 5. requiredWhen — conditional required
zod = applyRequiredWhen(zod, structural.schema.fields, strings);
// 6. Merge consumer's zodSchema — consumer wins per-key
if (structural.schema.zodSchema) {
zod = mergeZodSchemas(zod, structural.schema.zodSchema);
}
return zod;
}
/**
* Composition wrapper. Same signature as v0.1.x for any internal callsite
* that wants the one-shot compile. Equivalent to
* `injectStrings(compileStructural(schema), strings)`.
*/
export function compileSchema(
schema: FormSchema,
strings: JsonFormStrings,
): ZodObject<Record<string, ZodTypeAny>> {
return injectStrings(compileStructural(schema), strings);
}
// ─── Per-field base Zod construction ─────────────────────────────────────────
function buildFieldZod(field: FieldDefinition, strings: JsonFormStrings): ZodTypeAny {
const v = field.validators ?? {};
let chain: ZodTypeAny;
switch (field.type) {
case "text":
case "email":
case "password":
case "url":
case "tel":
case "textarea":
case "code": {
let s = z.string();
// built-in format checks derived from type
if (field.type === "email") {
s = s.email(extractMessage(v.email, strings.errorTemplates.email));
}
if (field.type === "url") {
s = s.url(extractMessage(v.url, strings.errorTemplates.url));
}
// declared validators
if (v.minLength != null) {
const { value, message } = normalizeNumeric(v.minLength, strings.errorTemplates.minLength);
s = s.min(value, formatErrorTemplate(message, { n: value }));
}
if (v.maxLength != null) {
const { value, message } = normalizeNumeric(v.maxLength, strings.errorTemplates.maxLength);
s = s.max(value, formatErrorTemplate(message, { n: value }));
}
if (v.pattern != null) {
const { value, message } = normalizePattern(v.pattern, strings.errorTemplates.pattern);
try {
const re = new RegExp(value);
s = s.regex(re, message);
} catch {
// invalid regex — leave the field non-strictly-validated
}
}
if (v.email && field.type !== "email") {
s = s.email(extractMessage(v.email, strings.errorTemplates.email));
}
if (v.url && field.type !== "url") {
s = s.url(extractMessage(v.url, strings.errorTemplates.url));
}
chain = applyRequired(s, v, strings);
break;
}
case "number":
case "slider":
case "rating": {
// z.coerce.number handles the <input> string→number conversion
let n = z.coerce.number();
if (v.min != null) {
const { value, message } = normalizeNumeric(v.min, strings.errorTemplates.min);
n = n.gte(Number(value), formatErrorTemplate(message, { n: value }));
}
if (v.max != null) {
const { value, message } = normalizeNumeric(v.max, strings.errorTemplates.max);
n = n.lte(Number(value), formatErrorTemplate(message, { n: value }));
}
chain = applyRequired(n, v, strings);
break;
}
case "checkbox":
case "switch":
chain = applyRequiredBoolean(z.boolean(), v, strings);
break;
case "select":
case "radio-group":
chain = applyRequiredUnknown(z.unknown(), v, strings);
break;
case "multi-select":
case "checkbox-group":
chain = applyRequiredArray(z.array(z.unknown()), v, strings);
break;
case "date":
case "time":
case "datetime":
chain = applyRequired(z.string(), v, strings);
break;
case "richtext":
// Plate `Value` = `Array<TElement | TText>`. Required = at least one
// node that contains non-empty text (we can't strict-check the inner
// shape without pulling platejs into the compiler, so a permissive
// array check + a custom-validate hook covers it).
chain = applyRequiredRichtext(v, strings);
break;
case "date-range":
chain = applyRequiredUnknown(
z.object({ start: z.string(), end: z.string() }),
v,
strings,
);
break;
case "computed":
case "hidden":
// Carry value through; only `required` matters (e.g., CSRF token).
chain = applyRequiredUnknown(z.unknown(), v, strings);
break;
default:
// unknown/consumer-registered type → permissive default
chain = applyRequiredUnknown(z.unknown(), v, strings);
}
return chain;
}
// ─── Required-handling per base type ─────────────────────────────────────────
function applyRequired(s: ZodTypeAny, v: FieldValidators, strings: JsonFormStrings): ZodTypeAny {
if (!v.required) {
if (s instanceof z.ZodString) return s.optional().or(z.literal(""));
return s.optional();
}
const message = typeof v.required === "string" ? v.required : strings.errorTemplates.required;
if (s instanceof z.ZodString) {
return (s as z.ZodString).min(1, message);
}
// For numbers (incl. ZodCoercedNumber) and other types, the resolver
// already rejects undefined values when not made optional.
return s.refine((val) => val !== undefined && val !== null && val !== "", { message });
}
function applyRequiredBoolean(s: z.ZodBoolean, v: FieldValidators, strings: JsonFormStrings): ZodTypeAny {
if (!v.required) return s.optional();
const message = typeof v.required === "string" ? v.required : strings.errorTemplates.required;
return s.refine((val) => val === true, { message });
}
function applyRequiredUnknown<T extends ZodTypeAny>(s: T, v: FieldValidators, strings: JsonFormStrings): ZodTypeAny {
if (!v.required) return s.optional();
const message = typeof v.required === "string" ? v.required : strings.errorTemplates.required;
return s.refine((val) => val !== undefined && val !== null && val !== "", { message });
}
function applyRequiredArray(s: z.ZodArray<ZodTypeAny>, v: FieldValidators, strings: JsonFormStrings): ZodTypeAny {
if (!v.required) return s.optional();
const message = typeof v.required === "string" ? v.required : strings.errorTemplates.required;
return s.min(1, message);
}
function applyRequiredRichtext(v: FieldValidators, strings: JsonFormStrings): ZodTypeAny {
const base = z.array(z.unknown());
if (!v.required) return base.optional();
const message = typeof v.required === "string" ? v.required : strings.errorTemplates.required;
// Required = non-empty after stripping the "single empty paragraph" default.
return base.refine((nodes) => richtextHasContent(nodes as unknown[]), { message });
}
function richtextHasContent(nodes: unknown[]): boolean {
if (!Array.isArray(nodes) || nodes.length === 0) return false;
// Walk leaf `text` props; consider empty if every text node is whitespace-only.
function visit(n: unknown): boolean {
if (n == null || typeof n !== "object") return false;
const obj = n as Record<string, unknown>;
if (typeof obj.text === "string") return obj.text.trim().length > 0;
const children = obj.children;
if (Array.isArray(children)) return children.some(visit);
return false;
}
return nodes.some(visit);
}
// ─── Validator normalization helpers ─────────────────────────────────────────
type NumericValidator = number | string | { value: number | string; message: string };
function normalizeNumeric(
raw: NumericValidator,
defaultMessage: string,
): { value: number; message: string } {
if (typeof raw === "object") {
return { value: Number(raw.value), message: raw.message };
}
return { value: Number(raw), message: defaultMessage };
}
type PatternValidator = string | { value: string; message: string };
function normalizePattern(raw: PatternValidator, defaultMessage: string): { value: string; message: string } {
if (typeof raw === "object") return raw;
return { value: raw, message: defaultMessage };
}
function extractMessage(raw: boolean | { message: string } | undefined, fallback: string): string {
if (raw && typeof raw === "object") return raw.message;
return fallback;
}
// ─── Path-trie → nested ZodObject ────────────────────────────────────────────
type TrieNode = Map<string, TrieNode | ZodTypeAny>;
function isTrieNode(v: TrieNode | ZodTypeAny): v is TrieNode {
return v instanceof Map;
}
function buildNestedZodObject(leaves: Map<string, ZodTypeAny>): ZodObject<Record<string, ZodTypeAny>> {
const root: TrieNode = new Map();
for (const [path, ztype] of leaves) {
const segments = path.split(".");
let cur: TrieNode = root;
for (let i = 0; i < segments.length - 1; i++) {
const seg = segments[i];
const existing = cur.get(seg);
if (existing && isTrieNode(existing)) {
cur = existing;
} else {
const next: TrieNode = new Map();
cur.set(seg, next);
cur = next;
}
}
cur.set(segments[segments.length - 1], ztype);
}
function trieToZod(trie: TrieNode): ZodObject<Record<string, ZodTypeAny>> {
const shape: Record<string, ZodTypeAny> = {};
for (const [key, val] of trie) {
shape[key] = isTrieNode(val) ? trieToZod(val) : val;
}
return z.object(shape);
}
return trieToZod(root);
}
// ─── Per-field custom validate + validateAsync ───────────────────────────────
function applyCustomValidators(
zod: ZodObject<Record<string, ZodTypeAny>>,
fields: ReadonlyArray<FieldDefinition>,
): ZodObject<Record<string, ZodTypeAny>> {
const withCustom = fields.filter((f) => isValueField(f) && (f.validate || f.validateAsync));
if (withCustom.length === 0) return zod;
return zod.superRefine(async (values, ctx) => {
for (const f of withCustom) {
const value = getByPath(values, f.name);
const allValues = values as Record<string, unknown>;
// Sync first — short-circuit on error
if (f.validate) {
const err = f.validate({ value, allValues });
if (err) {
ctx.addIssue({
code: "custom",
path: f.name.split("."),
message: err,
});
continue;
}
}
// Async runs only if sync passed
if (f.validateAsync) {
const err = await f.validateAsync({ value, allValues });
if (err) {
ctx.addIssue({
code: "custom",
path: f.name.split("."),
message: err,
});
}
}
}
}) as ZodObject<Record<string, ZodTypeAny>>;
}
// ─── requiredWhen — conditional required ─────────────────────────────────────
function applyRequiredWhen(
zod: ZodObject<Record<string, ZodTypeAny>>,
fields: ReadonlyArray<FieldDefinition>,
strings: JsonFormStrings,
): ZodObject<Record<string, ZodTypeAny>> {
const conditional = fields.filter((f) => isValueField(f) && f.requiredWhen);
if (conditional.length === 0) return zod;
return zod.superRefine((values, ctx) => {
const allValues = values as Record<string, unknown>;
for (const f of conditional) {
const required = resolveConditionOrFn(f.requiredWhen!, allValues);
if (!required) continue;
const value = getByPath(values, f.name);
const isEmpty = value === undefined || value === null || value === "" ||
(Array.isArray(value) && value.length === 0);
if (isEmpty) {
const v = f.validators ?? {};
const message = typeof v.required === "string" ? v.required : strings.errorTemplates.required;
ctx.addIssue({
code: "custom",
path: f.name.split("."),
message,
});
}
}
}) as ZodObject<Record<string, ZodTypeAny>>;
}
// ─── Merge consumer-provided ZodSchema (consumer wins per key) ───────────────
function mergeZodSchemas(
base: ZodObject<Record<string, ZodTypeAny>>,
override: ZodObject<Record<string, ZodTypeAny>>,
): ZodObject<Record<string, ZodTypeAny>> {
const baseShape = base.shape;
const overrideShape = override.shape;
const merged: Record<string, ZodTypeAny> = { ...baseShape };
for (const key of Object.keys(overrideShape)) {
merged[key] = overrideShape[key];
}
return z.object(merged);
}
src/registry/components/forms/json-form/lib/condition-evaluator.ts
import type { Condition, ConditionOrFn } from "../types";
import { getByPath } from "./path";
/**
* Evaluate a Condition against a values bag. Returns the boolean result.
* Unknown shape (defensive — should never happen if types hold) → true
* (the "always visible / enabled / required" safe default) + a dev warn
* at the call site.
*/
export function evaluateCondition(
cond: Condition,
values: Record<string, unknown>,
): boolean {
if ("all" in cond) return cond.all.every((c) => evaluateCondition(c, values));
if ("any" in cond) return cond.any.some((c) => evaluateCondition(c, values));
if ("not" in cond) return !evaluateCondition(cond.not, values);
if ("field" in cond) {
const v = getByPath(values, cond.field);
if ("equals" in cond) return Object.is(v, cond.equals);
if ("notEquals" in cond) return !Object.is(v, cond.notEquals);
if ("in" in cond) return cond.in.includes(v);
if ("notIn" in cond) return !cond.notIn.includes(v);
if ("matches" in cond) {
if (typeof v !== "string") return false;
try {
return new RegExp(cond.matches).test(v);
} catch {
return false;
}
}
if ("truthy" in cond) return cond.truthy ? !!v : !v;
if ("greaterThan" in cond) return typeof v === "number" && v > cond.greaterThan;
if ("lessThan" in cond) return typeof v === "number" && v < cond.lessThan;
}
return true;
}
/**
* Helper: resolve a `Condition` or function form. The fn form receives the
* full values bag.
*/
export function resolveConditionOrFn(
cond: ConditionOrFn,
values: Record<string, unknown>,
): boolean {
if (typeof cond === "function") return cond({ values });
return evaluateCondition(cond, values);
}
/**
* Walk a Condition tree and collect every field path it references.
* Used to subscribe to only the relevant slice of form state via RHF
* `watch(deps)` — keeps the per-conditional cost bounded.
*/
export function extractConditionDeps(cond: Condition): Set<string> {
const deps = new Set<string>();
function walk(c: Condition): void {
if ("all" in c) { c.all.forEach(walk); return; }
if ("any" in c) { c.any.forEach(walk); return; }
if ("not" in c) { walk(c.not); return; }
if ("field" in c) { deps.add(c.field); }
}
walk(cond);
return deps;
}
/**
* For function-form conditions, we can't statically extract deps. Return
* `null` → caller subscribes to the whole values bag.
*/
export function extractConditionOrFnDeps(cond: ConditionOrFn): Set<string> | null {
if (typeof cond === "function") return null;
return extractConditionDeps(cond);
}
src/registry/components/forms/json-form/lib/expression-parser.ts
import { getByPath } from "./path";
/**
* Computed-field expression parser. Pure interpolation only:
* '{firstName} {lastName}' → "Hessam Hezaveh"
* '{address.city}, {address.country}' → "Tehran, Iran"
*
* NO operators, NO conditionals, NO function calls (per A3 lock). Consumers
* needing anything more use `compute: (values) => ...` instead.
*/
export interface ParsedExpression {
/** Ordered parts: alternating literal strings and field references. */
parts: Array<{ kind: "literal"; text: string } | { kind: "field"; path: string }>;
/** Set of all field paths referenced. */
deps: Set<string>;
}
const FIELD_RE = /\{([^{}]+)\}/g;
export function parseExpression(expr: string): ParsedExpression {
const parts: ParsedExpression["parts"] = [];
const deps = new Set<string>();
let lastIndex = 0;
FIELD_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = FIELD_RE.exec(expr)) !== null) {
if (m.index > lastIndex) {
parts.push({ kind: "literal", text: expr.slice(lastIndex, m.index) });
}
const path = m[1].trim();
parts.push({ kind: "field", path });
deps.add(path);
lastIndex = m.index + m[0].length;
}
if (lastIndex < expr.length) {
parts.push({ kind: "literal", text: expr.slice(lastIndex) });
}
return { parts, deps };
}
/**
* Render an expression against a values bag. Unknown fields → empty string
* (plus dev warn from the caller if desired).
*/
export function interpolate(
parsed: ParsedExpression,
values: Record<string, unknown>,
): string {
let out = "";
for (const part of parsed.parts) {
if (part.kind === "literal") {
out += part.text;
} else {
const v = getByPath(values, part.path);
out += v == null ? "" : String(v);
}
}
return out;
}
src/registry/components/forms/json-form/lib/default-registry.ts
import { createElement, lazy } from "react";
import type { FieldRenderer, FieldRendererArgs } from "../types";
import { FieldText } from "../parts/field-text";
import { FieldTextarea } from "../parts/field-textarea";
import { FieldSelect } from "../parts/field-select";
import { FieldRadioGroup } from "../parts/field-radio-group";
import { FieldCheckbox } from "../parts/field-checkbox";
import { FieldCheckboxGroup } from "../parts/field-checkbox-group";
import { FieldSwitch } from "../parts/field-switch";
import { FieldDate } from "../parts/field-date";
import { FieldSlider } from "../parts/field-slider";
import { FieldRating } from "../parts/field-rating";
import { FieldComputed } from "../parts/field-computed";
import { FieldHidden } from "../parts/field-hidden";
import { FieldSection } from "../parts/field-section";
import { FieldDivider } from "../parts/field-divider";
import { FieldFallback } from "../parts/field-fallback";
// The `code` and `richtext` fields are lazy-loaded so the CodeMirror and
// Plate bundles only ship when a form actually contains one of those
// fields. The Suspense boundary lives in `FieldWrapper`.
const LazyFieldCode = lazy(() => import("../parts/field-code"));
const FieldCode: FieldRenderer = (args: FieldRendererArgs) =>
createElement(LazyFieldCode, args);
const LazyFieldRichtext = lazy(() => import("../parts/field-richtext"));
const FieldRichtext: FieldRenderer = (args: FieldRendererArgs) =>
createElement(LazyFieldRichtext, args);
/**
* Built-in field renderer registry. Spread + extend in consumer code:
*
* const myRegistry = { ...defaultJsonFormRegistry, "rich-text": MyRenderer };
*/
/**
* v0.1.7 — internal whitelist of built-in field types whose renderers do NOT
* read `args.allValues` reactively. Defined in v0.1.7 (defined-but-unused);
* `field-wrapper.tsx` in v0.2.0 consults this set to skip the FieldWrapper-
* level `useWatch({ control })` for whitelisted types — `allValues` then
* comes from a `rhf.getValues()` snapshot taken at render time.
*
* Audit basis: every built-in renderer's source was checked for
* `args.allValues` access on 2026-05-21. `computed`'s renderer destructures
* only `field`, `value`, `onChange`, `disabled`, `ariaProps`; the values-
* reading happens inside `useComputed()` via its own narrow `useWatch`
* (independent of the FieldWrapper-level watch), so `computed` is in the
* whitelist. `section` + `divider` are layout-only and never hit
* FieldWrapper, but are included for completeness so the set covers every
* registry slot.
*
* Consumer-registered renderers OVERRIDING built-in types are NOT in this
* whitelist (the v0.2.0 check resolves on the FINAL renderer identity, not
* on `field.type` alone). A custom renderer replacing `text` correctly
* opts out of the watch drop unless the field also sets `dependsOn`.
*
* Drift mitigation: any future built-in renderer that adds `args.allValues`
* access MUST be removed from this set in the same commit. Enforced by
* `scripts/validate-default-registry-whitelist.mjs` (F-03), which is
* chained into `pnpm registry:build` and `pnpm vercel-build` so producer
* deploys fail fast if drift lands. Run standalone via
* `pnpm validate:default-registry-whitelist`.
*/
export const BUILTIN_RENDERER_TYPES_SKIPPING_ALL_VALUES: ReadonlySet<string> =
new Set([
// text family
"text",
"email",
"password",
"url",
"tel",
"number",
"textarea",
// choice family
"select",
"multi-select",
"radio-group",
"checkbox",
"checkbox-group",
"switch",
// date/time family
"date",
"date-range",
"time",
"datetime",
// rich / composite
"code",
"slider",
"rating",
"richtext",
// special
"computed",
"hidden",
"section",
"divider",
]);
export const defaultJsonFormRegistry: Record<string, FieldRenderer> = {
// text family
text: FieldText,
email: FieldText,
password: FieldText,
url: FieldText,
tel: FieldText,
number: FieldText,
textarea: FieldTextarea,
// choice family
select: FieldSelect,
"multi-select": FieldSelect,
"radio-group": FieldRadioGroup,
checkbox: FieldCheckbox,
"checkbox-group": FieldCheckboxGroup,
switch: FieldSwitch,
// date/time family
date: FieldDate,
"date-range": FieldDate,
time: FieldDate,
datetime: FieldDate,
// rich / composite
code: FieldCode,
slider: FieldSlider,
rating: FieldRating,
richtext: FieldRichtext,
// special
computed: FieldComputed,
hidden: FieldHidden,
section: FieldSection,
divider: FieldDivider,
// fallback
__fallback__: FieldFallback,
};
src/registry/components/forms/json-form/lib/strings.ts
import type { JsonFormStrings } from "../types";
export const defaultStrings: JsonFormStrings = {
submit: "Submit",
reset: "Reset",
requiredIndicator: "*",
optionalIndicator: "(optional)",
loadingOptions: "Loading…",
optionsError: "Failed to load options",
optionsRetry: "Retry",
noOptions: "No options",
summaryHeading: "Please fix the following errors:",
submitFailed: "Submission failed. Please try again.",
errorTemplates: {
required: "This field is required",
minLength: "Must be at least {n} characters",
maxLength: "Must be at most {n} characters",
min: "Must be at least {n}",
max: "Must be at most {n}",
pattern: "Value does not match the required pattern",
email: "Must be a valid email address",
url: "Must be a valid URL",
},
};
/** Substitute `{n}`, `{value}`, etc. in a template. */
export function formatErrorTemplate(
template: string,
params: Record<string, string | number>,
): string {
return template.replace(/\{(\w+)\}/g, (_, key) =>
key in params ? String(params[key]) : `{${key}}`,
);
}
/** Shallow-merge consumer overrides into the default strings dictionary. */
export function mergeStrings(
overrides: Partial<JsonFormStrings> | undefined,
): JsonFormStrings {
if (!overrides) return defaultStrings;
return {
...defaultStrings,
...overrides,
errorTemplates: {
...defaultStrings.errorTemplates,
...(overrides.errorTemplates ?? {}),
},
};
}
src/registry/components/forms/json-form/lib/path.ts
/**
* Dot-path helpers used by the expression interpolator + nested ZodObject
* construction. RHF handles deep-set/get internally for `register('a.b.c')`,
* but we need read-side dot-path access for `expression: '{address.city}'`.
*/
export function getByPath(obj: unknown, path: string): unknown {
if (obj == null) return undefined;
const segments = path.split(".");
let cur: unknown = obj;
for (const seg of segments) {
if (cur == null || typeof cur !== "object") return undefined;
cur = (cur as Record<string, unknown>)[seg];
}
return cur;
}
export function setByPath(obj: Record<string, unknown>, path: string, value: unknown): void {
const segments = path.split(".");
const last = segments.pop();
if (!last) return;
let cur: Record<string, unknown> = obj;
for (const seg of segments) {
if (!(seg in cur) || typeof cur[seg] !== "object" || cur[seg] === null) {
cur[seg] = {};
}
cur = cur[seg] as Record<string, unknown>;
}
cur[last] = value;
}
src/registry/components/forms/json-form/lib/flatten-errors.ts
/**
* Shared error-flattener for RHF's nested error tree.
*
* The error tree can be either:
* { fieldName: { message: '...', type: '...' } }
* or deeper:
* { address: { city: { message: '...' } } }
*
* Both flatten to dot-path keys. Two shapes are exposed because callers want
* different output: `flattenRhfErrorsToRecord` for callback payloads (record),
* `flattenRhfErrorsToList` for the summary UI (ordered list).
*/
interface FlatEntry {
path: string;
message: string;
}
function* walkRhfErrors(
errors: Record<string, unknown>,
prefix = "",
): Generator<FlatEntry> {
for (const [key, val] of Object.entries(errors)) {
if (!val) continue;
const path = prefix ? `${prefix}.${key}` : key;
if (
typeof val === "object" &&
val !== null &&
"message" in val &&
typeof (val as { message?: unknown }).message === "string"
) {
yield { path, message: String((val as { message?: unknown }).message) };
continue;
}
if (typeof val === "object" && val !== null) {
yield* walkRhfErrors(val as Record<string, unknown>, path);
}
}
}
/** Flatten errors to `{ 'path.to.field': 'message' }`. Callback-payload shape. */
export function flattenRhfErrorsToRecord(
errors: Record<string, unknown>,
): Record<string, string> {
const out: Record<string, string> = {};
for (const { path, message } of walkRhfErrors(errors)) {
out[path] = message;
}
return out;
}
/** Flatten errors to an ordered list. Summary-UI shape. */
export function flattenRhfErrorsToList(
errors: Record<string, unknown>,
): Array<{ name: string; message: string }> {
const out: Array<{ name: string; message: string }> = [];
for (const { path, message } of walkRhfErrors(errors)) {
out.push({ name: path, message });
}
return out;
}
src/registry/components/forms/json-form/lib/define-field-renderer.ts
import type { ReactNode } from "react";
import type {
FieldDefinition,
FieldRenderer,
FieldRendererArgs,
} from "../types";
/**
* v0.1.7 — narrowed argument shape passed to the typed factory's `impl`
* callback. Inherits all `FieldRendererArgs` keys except `value` (narrowed
* to `TValue`) and `field` (narrowed so `field.config[k]` reads as `TConfig`).
*
* Internal. The factory unwraps it back to a plain `FieldRenderer` for
* registry storage — registry consumers see the standard `FieldRendererArgs`
* shape.
*/
export interface NarrowedRendererArgs<TValue, TConfig>
extends Omit<FieldRendererArgs, "value" | "field"> {
value: TValue;
field: FieldDefinition & {
config?: { [key: string]: TConfig };
};
}
export interface DefineFieldRendererConfig<TValue, TConfig> {
/**
* Display name surfaced in `<JsonFormDevtools>` and dev-tools panels.
* Defaults to `"AnonymousRenderer"` when omitted. Attached as a
* non-enumerable `displayName` property on the returned `FieldRenderer`
* so registry-walker code can pick it up.
*/
displayName?: string;
/**
* The renderer body. Receives the narrowed args; returns the rendered
* React node.
*/
impl: (args: NarrowedRendererArgs<TValue, TConfig>) => ReactNode;
}
/**
* v0.1.7 — typed factory for custom field renderers.
*
* Returns a plain `FieldRenderer` (the same shape consumers use today via
* `Record<string, FieldRenderer>`) — so the result drops directly into a
* registry without any other plumbing. The factory is **type-narrowing
* only**: there's no runtime narrowing of `value` or `field.config`,
* because RHF values aren't statically known. The generics are
* consumer-asserted convenience.
*
* @example
* ```tsx
* import { defineFieldRenderer } from "@ilinxa/json-form";
*
* interface ColorConfig {
* palette?: string[];
* }
*
* const ColorSwatch = defineFieldRenderer<string, ColorConfig>({
* displayName: "ColorSwatch",
* impl: ({ value, onChange, field, disabled }) => {
* const palette = field.config?.color?.palette ?? ["#FF595E", "#FFCA3A"];
* return palette.map((c) => (
* <button key={c} onClick={() => onChange(c)} disabled={disabled}>
* {value === c ? "✓" : ""}
* </button>
* ));
* },
* });
*
* const registry = { ...defaultJsonFormRegistry, color: ColorSwatch };
* ```
*/
export function defineFieldRenderer<TValue = unknown, TConfig = unknown>(
config: DefineFieldRendererConfig<TValue, TConfig>,
): FieldRenderer {
const renderer: FieldRenderer = (args) =>
config.impl(args as unknown as NarrowedRendererArgs<TValue, TConfig>);
// Attach displayName as a non-enumerable property so registry-walker code
// (e.g., `<JsonFormDevtools>`) can read it without it leaking into
// `Object.keys` / JSON.stringify of registry maps.
Object.defineProperty(renderer, "displayName", {
value: config.displayName ?? "AnonymousRenderer",
enumerable: false,
writable: false,
configurable: false,
});
return renderer;
}
src/registry/components/forms/json-form/hooks/use-json-form-field-value.ts
"use client";
import { useMemo } from "react";
import { useWatch } from "react-hook-form";
import { useJsonFormContext } from "../json-form-context";
import { setByPath } from "../lib/path";
/**
* v0.1.7 — narrow-deps single-field watch. Pure ergonomic wrapper around
* RHF's `useWatch({ control, name })` scoped to one path, resolved against
* the active `<JsonFormProvider>` context.
*
* **Typing caveat:** the generic `<T>` is consumer-asserted. RHF values
* aren't statically known — the runtime returns whatever the form currently
* holds at `name`. Treat the generic as a typed-assertion convenience, not a
* runtime guarantee.
*
* Use this hook to build fully-custom form layouts that subscribe to one
* field's value without re-rendering on every other field's change. Mirrors
* the narrow-deps pattern shipped in `useConditional` + `useComputed`
* (v0.1.6) and exposes it to consumer code.
*
* @example
* ```tsx
* const country = useJsonFormFieldValue<string>("country");
* return <p>You selected {country}</p>;
* ```
*/
export function useJsonFormFieldValue<T = unknown>(name: string): T {
const ctx = useJsonFormContext();
// `as never` matches the codebase's RHF-overload-disambiguation pattern
// (see `use-conditional.ts` for the precedent rationale). The v7.76
// `compute` overload ambiguates single-string-name calls when the
// resolved Control's value type is unknown.
return useWatch({ control: ctx.rhf.control, name } as never) as T;
}
/**
* v0.1.7 — narrow-deps multi-field watch. Returns a path-keyed bag with
* only the named fields rehydrated via `setByPath` — so consumers can read
* `bag["address.city"]` via `getByPath` or destructure nested paths
* naturally.
*
* **Typing caveat:** same as `useJsonFormFieldValue<T>` — the generic is
* consumer-asserted. The returned shape is structurally a `Record<string,
* unknown>` enriched with whatever `setByPath` walks produce.
*
* @example
* ```tsx
* const { firstName, lastName } = useJsonFormFieldsValue<{
* firstName: string;
* lastName: string;
* }>(["firstName", "lastName"]);
* ```
*/
export function useJsonFormFieldsValue<
T extends Record<string, unknown> = Record<string, unknown>,
>(names: ReadonlyArray<string>): T {
const ctx = useJsonFormContext();
// `as never` per the same overload-disambiguation rationale above.
const watched = useWatch({ control: ctx.rhf.control, name: names } as never);
return useMemo<T>(() => {
const arr = Array.isArray(watched) ? watched : [];
const bag: Record<string, unknown> = {};
names.forEach((n, i) => {
setByPath(bag, n, arr[i]);
});
return bag as T;
}, [watched, names]);
}
src/registry/components/forms/json-form/parts/json-form-devtools.tsx
"use client";
import { Suspense, lazy } from "react";
/**
* v0.1.7 — JsonFormDevtools loader stub. The ~250 LOC panel body lives in
* `./json-form-devtools-body.tsx` and is fetched via `React.lazy()` only
* when the component actually renders. In prod (or when the consumer-side
* import is itself wrapped behind `process.env.NODE_ENV !== "production"`),
* the body chunk is never fetched.
*
* **This is NOT a bundler tree-shake** — the loader stub itself still
* ships in prod chunks unless the consumer-side import is conditional. For
* true chunk-level elimination, wrap the usage:
*
* ```tsx
* {process.env.NODE_ENV !== "production" && <JsonFormDevtools />}
* ```
*
* Override the auto-disable via the `force` prop for prod-debug builds.
*/
const LazyBody = lazy(() => import("./json-form-devtools-body"));
export interface JsonFormDevtoolsProps {
/**
* Render inline (as a block child) instead of as a floating panel
* anchored to the bottom-right viewport corner. Inline mode is always
* visible; floating mode shows a pill-shaped toggle until opened.
*/
inline?: boolean;
/**
* Force-enable in production builds. By default the component returns
* `null` (no-op) when `process.env.NODE_ENV === "production"`.
*/
force?: boolean;
/**
* Keyboard shortcut to toggle the floating panel open/closed. Ignored in
* inline mode. Format: `"Ctrl+Shift+J"` (case-insensitive modifiers).
* Default: `"Ctrl+Shift+J"`.
*/
shortcut?: string;
className?: string;
}
export function JsonFormDevtools({
inline = false,
force = false,
shortcut = "Ctrl+Shift+J",
className,
}: JsonFormDevtoolsProps) {
if (process.env.NODE_ENV === "production" && !force) return null;
return (
<Suspense fallback={null}>
<LazyBody inline={inline} shortcut={shortcut} className={className} />
</Suspense>
);
}
export default JsonFormDevtools;
src/registry/components/forms/json-form/parts/json-form-devtools-body.tsx
"use client";
import { useEffect, useMemo, useState } from "react";
import { useFormState, useWatch } from "react-hook-form";
import { cn } from "@/lib/utils";
import {
XIcon,
ChevronDownIcon,
ChevronRightIcon,
} from "lucide-react";
import { useJsonFormContext, useJsonFormContextOptional } from "../json-form-context";
import { useConditional } from "../hooks/use-conditional";
import { flattenRhfErrorsToList } from "../lib/flatten-errors";
import { isValueField } from "../lib/validate-schema";
import type { FieldDefinition } from "../types";
/**
* v0.1.7 — body of the JsonFormDevtools panel. Lives in its own module so
* the parent `<JsonFormDevtools>` can lazy-load it via `React.lazy()` — the
* ~250 LOC body chunk only loads when the panel actually renders, while the
* tiny loader stub ships in the parent module.
*
* This module is rendered exclusively from the dynamic-import boundary and
* is NOT exported from `index.ts`. Consumers reach it only via
* `<JsonFormDevtools>`.
*/
export interface JsonFormDevtoolsBodyProps {
inline: boolean;
shortcut: string;
className?: string;
}
type Tab = "schema" | "values" | "conditionals" | "errors";
export default function JsonFormDevtoolsBody({
inline,
shortcut,
className,
}: JsonFormDevtoolsBodyProps) {
const maybeCtx = useJsonFormContextOptional();
const [open, setOpen] = useState(inline);
const [tab, setTab] = useState<Tab>("schema");
// Keyboard shortcut listener (skipped in inline mode — the panel is
// always rendered then).
useEffect(() => {
if (inline) return;
const handler = (e: KeyboardEvent) => {
if (matchesShortcut(e, shortcut)) {
e.preventDefault();
setOpen((o) => !o);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [inline, shortcut]);
const wrapperClass = inline
? cn(
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
className,
)
: cn(
"fixed bottom-4 right-4 z-9999 flex w-96 max-w-[calc(100vw-2rem)] flex-col rounded-lg border border-border bg-card text-card-foreground shadow-xl",
className,
);
if (!inline && !open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="fixed bottom-4 right-4 z-9999 flex h-10 items-center gap-2 rounded-full border border-border bg-card px-4 text-xs font-medium text-foreground shadow-md hover:bg-muted"
aria-label="Open JsonForm devtools panel"
>
<span className="size-2 rounded-full bg-primary" />
json-form devtools
</button>
);
}
// v0.2.1 (F-06) — soft-warning fallback when no <JsonFormProvider> is in
// the tree. Renders an explanatory panel instead of throwing, so the
// consumer mistake is visible-but-recoverable rather than a hard crash.
if (!maybeCtx) {
return (
<div className={wrapperClass} data-jsonform-devtools data-state="no-provider">
{!inline ? (
<header className="flex items-center justify-between border-b border-border px-3 py-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
json-form devtools
</span>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Close devtools panel"
className="rounded p-1 hover:bg-muted"
>
<XIcon className="size-3.5" />
</button>
</header>
) : null}
<div className="p-3 text-xs text-muted-foreground">
<p className="mb-1 font-medium text-foreground">No JsonForm context found.</p>
<p>
Place <code className="rounded bg-muted px-1"><JsonFormDevtools /></code>{" "}
inside a <code className="rounded bg-muted px-1"><JsonForm></code> or{" "}
<code className="rounded bg-muted px-1"><JsonFormProvider></code>{" "}
tree so the panel can read schema, values, conditionals, and errors.
</p>
</div>
</div>
);
}
const ctx = maybeCtx;
return (
<div className={wrapperClass} data-jsonform-devtools>
{!inline ? (
<header className="flex items-center justify-between border-b border-border px-3 py-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
json-form devtools
</span>
<div className="flex items-center gap-1">
<span className="hidden text-[10px] text-muted-foreground/70 sm:inline">
{shortcut}
</span>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Close devtools panel"
className="rounded p-1 hover:bg-muted"
>
<XIcon className="size-3.5" />
</button>
</div>
</header>
) : null}
<nav className="flex items-center gap-1 border-b border-border px-2 py-1 text-xs">
{(["schema", "values", "conditionals", "errors"] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className={cn(
"rounded px-2 py-0.5 transition-colors",
tab === t
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted",
)}
>
{t}
</button>
))}
</nav>
<div className="max-h-100 min-h-40 flex-1 overflow-y-auto p-3 text-xs">
{tab === "schema" ? <SchemaTab /> : null}
{tab === "values" ? <ValuesTab /> : null}
{tab === "conditionals" ? <ConditionalsTab /> : null}
{tab === "errors" ? <ErrorsTab /> : null}
</div>
<footer className="flex items-center justify-between border-t border-border px-3 py-1.5 text-[10px] text-muted-foreground">
<span data-jsonform-devtools-form-id>{ctx.formId}</span>
<span>v0.2.1</span>
</footer>
</div>
);
}
// ─── Tabs ────────────────────────────────────────────────────────────────────
function SchemaTab() {
const ctx = useJsonFormContext();
const [collapsed, setCollapsed] = useState(false);
return (
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => setCollapsed((c) => !c)}
className="flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground hover:text-foreground"
>
{collapsed ? (
<ChevronRightIcon className="size-3" />
) : (
<ChevronDownIcon className="size-3" />
)}
FormSchema ({ctx.schema.fields.length} fields)
</button>
{!collapsed ? (
<pre className="overflow-x-auto rounded-md border border-border bg-muted/30 p-2 font-mono text-[11px] leading-relaxed text-foreground">
{JSON.stringify(ctx.schema, prettyReplacer, 2)}
</pre>
) : null}
</div>
);
}
function ValuesTab() {
const ctx = useJsonFormContext();
// Devtools-only subscription — paying the full-bag watch cost is OK here
// because devtools panels are excluded from normal render paths.
const values = useWatch({ control: ctx.rhf.control }) ?? {};
return (
<pre className="overflow-x-auto rounded-md border border-border bg-muted/30 p-2 font-mono text-[11px] leading-relaxed text-foreground">
{JSON.stringify(values, prettyReplacer, 2)}
</pre>
);
}
function ConditionalsTab() {
const ctx = useJsonFormContext();
const conditionalFields = useMemo(
() =>
ctx.schema.fields.filter(
(f) =>
isValueField(f) && (f.visibleWhen || f.enabledWhen || f.requiredWhen),
),
[ctx.schema.fields],
);
if (conditionalFields.length === 0) {
return (
<p className="italic text-muted-foreground">
No conditional fields in this schema.
</p>
);
}
return (
<table className="w-full border-collapse text-[11px]">
<thead>
<tr className="text-left text-muted-foreground">
<th className="py-1 pr-2 font-medium">Field</th>
<th className="py-1 pr-2 font-medium">visible</th>
<th className="py-1 pr-2 font-medium">enabled</th>
<th className="py-1 pr-2 font-medium">required</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{conditionalFields.map((f) => (
<ConditionalRow key={f.name} field={f} />
))}
</tbody>
</table>
);
}
function ConditionalRow({ field }: { field: FieldDefinition }) {
// useConditional handles the narrow-deps subscription internally; the
// devtools row re-renders only when this field's evaluated state flips.
const { visible, enabled, required } = useConditional(field);
return (
<tr>
<td className="py-1 pr-2 font-mono text-foreground">{field.name}</td>
<td className="py-1 pr-2">{visible ? "✓" : "✗"}</td>
<td className="py-1 pr-2">{enabled ? "✓" : "✗"}</td>
<td className="py-1 pr-2">{required ? "✓" : "✗"}</td>
</tr>
);
}
function ErrorsTab() {
const ctx = useJsonFormContext();
const { errors } = useFormState({ control: ctx.rhf.control });
const flat = flattenRhfErrorsToList(errors as Record<string, unknown>);
if (flat.length === 0) {
return (
<p className="italic text-muted-foreground">No errors. Form is valid.</p>
);
}
return (
<ul className="flex flex-col gap-1">
{flat.map(({ name, message }) => (
<li
key={name}
className="flex items-baseline gap-2 rounded border border-destructive/30 bg-destructive/5 p-1.5"
>
<span className="font-mono text-foreground">{name}</span>
<span className="text-destructive">{message}</span>
</li>
))}
</ul>
);
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* JSON.stringify replacer that turns function values into a placeholder
* (raw schemas often hold validate / compute / visibleWhen callbacks). Also
* serializes Map / Set / BigInt — `JSON.stringify` itself throws on bigint
* (v0.2.1 F-08), so the panel would crash if a consumer's form held a
* bigint value.
*/
function prettyReplacer(_key: string, value: unknown): unknown {
if (typeof value === "function") return "[Function]";
if (typeof value === "bigint") return `${value.toString()}n`;
if (value instanceof Map) return Object.fromEntries(value);
if (value instanceof Set) return Array.from(value);
return value;
}
/**
* Matches a KeyboardEvent against a shortcut string like "Ctrl+Shift+J".
* Modifiers are case-insensitive; the final key compares against
* `event.key` (case-insensitive too).
*/
function matchesShortcut(e: KeyboardEvent, shortcut: string): boolean {
const parts = shortcut.split("+").map((p) => p.trim().toLowerCase());
const target = parts.pop();
if (!target) return false;
if (parts.includes("ctrl") !== e.ctrlKey) return false;
if (parts.includes("shift") !== e.shiftKey) return false;
if (parts.includes("alt") !== e.altKey) return false;
if (parts.includes("meta") !== e.metaKey) return false;
return e.key.toLowerCase() === target;
}
src/registry/components/forms/json-form/lib/validate-schema.ts
import type { FieldDefinition, FormSchema } from "../types";
import { extractConditionOrFnDeps } from "./condition-evaluator";
/**
* Dev-only sanity checks. Emits warnings via `console.warn`. Does NOT throw —
* a broken schema should still render something (the offending field falls
* back to "always visible / enabled / always-true" defaults).
*
* Skipped in production builds (no-op).
*/
export function validateSchemaDev(schema: FormSchema): void {
if (process.env.NODE_ENV === "production") return;
// (1) Duplicate field names
const seen = new Set<string>();
for (const field of schema.fields) {
if (seen.has(field.name)) {
console.warn(
`[json-form] Duplicate field name "${field.name}". First occurrence renders; others are ignored.`,
);
}
seen.add(field.name);
}
// (2) Value-carrying types without a name (shouldn't be possible per TS,
// but defensive in case consumers pass loose JSON)
for (const field of schema.fields) {
if (!field.name) {
console.warn(
`[json-form] Field of type "${field.type}" has no name. All field types require a stable name.`,
);
}
}
// (3) Active-conditional count — perf ceiling (O1 lock; threshold bumped
// from 50 → 200 in v0.2.0 since per-keystroke render count no longer scales
// with conditional count under the default-registry watch drop).
let conditionalCount = 0;
for (const field of schema.fields) {
if (field.visibleWhen) conditionalCount++;
if (field.enabledWhen) conditionalCount++;
if (field.requiredWhen) conditionalCount++;
}
if (conditionalCount > 200) {
console.warn(
`[json-form] Schema has ${conditionalCount} active conditionals (visibleWhen / enabledWhen / requiredWhen). ` +
`Performance may degrade past ~200; consider hoisting conditionals into a derived schema or splitting the form.`,
);
}
// (4) Conditional dependency cycles — A.visibleWhen depends on B, B.visibleWhen depends on A, etc.
const depGraph = new Map<string, Set<string>>();
for (const field of schema.fields) {
const deps = new Set<string>();
for (const key of ["visibleWhen", "enabledWhen", "requiredWhen"] as const) {
const c = field[key];
if (!c) continue;
const fieldDeps = extractConditionOrFnDeps(c);
if (fieldDeps) fieldDeps.forEach((d) => deps.add(d));
}
if (deps.size) depGraph.set(field.name, deps);
}
function hasCycle(start: string, visited: Set<string>, stack: Set<string>): string[] | null {
if (stack.has(start)) return [start];
if (visited.has(start)) return null;
visited.add(start);
stack.add(start);
const deps = depGraph.get(start);
if (deps) {
for (const d of deps) {
const result = hasCycle(d, visited, stack);
if (result) return [start, ...result];
}
}
stack.delete(start);
return null;
}
const cycleVisited = new Set<string>();
for (const name of depGraph.keys()) {
if (cycleVisited.has(name)) continue;
const cycle = hasCycle(name, cycleVisited, new Set<string>());
if (cycle) {
console.warn(
`[json-form] Conditional dependency cycle detected: ${cycle.join(" → ")}. ` +
`Falling back to "always true" for the cyclic fields.`,
);
break; // one warn is enough; consumer can fix and re-run
}
}
// (5) v0.1.7 — dangling `dependsOn` references. Match is dot-path-as-flat
// -string against `schema.fields[i].name` exactly. Fields declared via
// nested `defaultValue` shapes (e.g., `{ name: 'address', defaultValue:
// { city: '…' } }`) WON'T satisfy this lint for `'address.city'` —
// consumers using nested-default patterns need individual leaf entries
// to declare them lint-visible. Runtime watch-gating that honors
// `dependsOn` ships in v0.2.0; the lint runs in v0.1.7 so consumers can
// author forward-compatible schemas now.
const knownNames = new Set(schema.fields.map((f) => f.name));
for (const field of schema.fields) {
if (!field.dependsOn) continue;
for (const dep of field.dependsOn) {
if (knownNames.has(dep)) continue;
console.warn(
`[json-form] Field "${field.name}" declares dependsOn: "${dep}", ` +
`but no field with that exact name exists in the schema. ` +
`Lint matches against schema.fields[].name as a flat dot-path string.`,
);
}
}
}
/** Convenience: skip `section` / `divider` types when walking value-carrying fields. */
export function isValueField(field: FieldDefinition): boolean {
return field.type !== "section" && field.type !== "divider";
}