shadimage
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 · light
forms@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">&lt;JsonFormDevtools /&gt;</code>{" "}
            inside a <code className="rounded bg-muted px-1">&lt;JsonForm&gt;</code> or{" "}
            <code className="rounded bg-muted px-1">&lt;JsonFormProvider&gt;</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";
}

More from ilinxa

Browse all components in this library →