# @classytic/formkit

Headless, type-safe form generation engine for React 19. Schema-driven with full TypeScript support.

[![npm](https://img.shields.io/npm/v/@classytic/formkit.svg)](https://www.npmjs.com/package/@classytic/formkit)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue.svg)](https://www.typescriptlang.org/)

## Features

- **Minimal boilerplate** - `useFormKit` hook: 5 lines to set up a complete form
- **Headless** - Bring your own UI components (Shadcn, MUI, Chakra, etc.)
- **Schema-driven** - Define forms with JSON/TypeScript schemas, defaults extracted automatically
- **Type-safe** - Full TypeScript support with generics
- **React Hook Form** - Built on top of the best form library, referentially stable return values
- **React 19** - Uses modern React 19 patterns (Context as provider, ref as prop)
- **Server Components** - Dedicated `@classytic/formkit/server` entry point for RSC
- **Variants** - Support for multiple component variants
- **Conditional fields** - Show/hide fields based on form values (function, DSL rules, AND/OR logic)
- **Responsive layouts** - Multi-column grid layouts
- **Accessibility** - Auto-generated `fieldId`, `error`, and `fieldState` props
- **Validation helpers** - `buildValidationRules` generates RHF rules from schema props
- **Lightweight** - ~12KB gzipped (peer deps excluded), tree-shakeable

## Requirements

- **React 19.0+** (React 18 is not supported)
- **React Hook Form 7.55.0+**

## Installation

```bash
npm install @classytic/formkit react-hook-form
# or
pnpm add @classytic/formkit react-hook-form
# or
yarn add @classytic/formkit react-hook-form
```

## Quick Start

### 1. Create Field Components

Each field component receives `FieldComponentProps` including `error`, `fieldId`, and the full `field` config:

```tsx
// components/form/form-input.tsx
"use client";

import { Controller } from "react-hook-form";
import type { FieldComponentProps } from "@classytic/formkit";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

export function FormInput({
  control,
  name,
  rules,       // pre-computed RHF rules from schema (required, minLength, pattern, validate…)
  label,
  placeholder,
  required,
  fieldId,     // use as id on <input> and htmlFor on <Label>
  errorId,     // use as id on error <p> and aria-errormessage on <input>
  shouldShowError, // true only after touch/submit — mirrors :user-invalid timing
  error,
}: FieldComponentProps) {
  return (
    <Controller
      name={name}
      control={control}
      rules={rules}
      render={({ field }) => (
        <div className="space-y-2">
          {label && (
            <Label htmlFor={fieldId}>
              {label}
              {required && <span aria-hidden="true" className="text-red-500 ml-1">*</span>}
            </Label>
          )}
          <Input
            {...field}
            id={fieldId}
            placeholder={placeholder}
            aria-required={required || undefined}
            aria-invalid={shouldShowError || undefined}
            aria-errormessage={shouldShowError ? errorId : undefined}
          />
          {shouldShowError && (
            <p id={errorId} role="alert" className="text-sm text-red-500">
              {error?.message}
            </p>
          )}
        </div>
      )}
    />
  );
}
```

### 2. Create Form Adapter

Register your components and layouts:

```tsx
// lib/form-adapter.tsx
"use client";

import {
  FormSystemProvider,
  type ComponentRegistry,
  type LayoutRegistry,
} from "@classytic/formkit";
import { FormInput } from "@/components/form/form-input";

const components: ComponentRegistry = {
  text: FormInput,
  email: FormInput,
  password: FormInput,
  // Add more field types...
};

const layouts: LayoutRegistry = {
  section: ({ title, description, children }) => (
    <div className="space-y-4">
      {title && <h3 className="text-lg font-semibold">{title}</h3>}
      {description && <p className="text-muted-foreground">{description}</p>}
      {children}
    </div>
  ),
  grid: ({ children, cols = 1 }) => (
    <div className={`grid grid-cols-${cols} gap-4`}>{children}</div>
  ),
};

export function FormProvider({ children }: { children: React.ReactNode }) {
  return (
    <FormSystemProvider components={components} layouts={layouts}>
      {children}
    </FormSystemProvider>
  );
}
```

### 3. Use FormGenerator

```tsx
// app/signup/page.tsx
"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { FormGenerator, useFormKit, type FormSchema } from "@classytic/formkit";
import { FormProvider } from "@/lib/form-adapter";

const signupSchema = z.object({
  firstName: z.string().min(2),
  lastName: z.string().min(2),
  email: z.string().email(),
  password: z.string().min(8),
});

type SignupData = z.infer<typeof signupSchema>;

const formSchema: FormSchema<SignupData> = {
  sections: [
    {
      title: "Personal Information",
      cols: 2,
      fields: [
        { name: "firstName", type: "text", label: "First Name", required: true, defaultValue: "" },
        { name: "lastName", type: "text", label: "Last Name", required: true, defaultValue: "" },
      ],
    },
    {
      title: "Account",
      fields: [
        { name: "email", type: "email", label: "Email", required: true, defaultValue: "" },
        { name: "password", type: "password", label: "Password", required: true, defaultValue: "" },
      ],
    },
  ],
};

export default function SignupPage() {
  const { handleSubmit, generatorProps } = useFormKit({
    schema: formSchema,
    resolver: zodResolver(signupSchema),
  });

  return (
    <FormProvider>
      <form onSubmit={handleSubmit(console.log)} className="space-y-8">
        <FormGenerator {...generatorProps} />
        <button type="submit">Sign Up</button>
      </form>
    </FormProvider>
  );
}
```

## API Reference

### useFormKit

Convenience hook that combines schema default extraction with react-hook-form setup. Returns all `useForm` methods plus ready-to-spread `generatorProps`.

**Referentially stable** — the return value preserves the original `useForm` object identity across re-renders, so it's safe to use in `useEffect` dependency arrays.

```tsx
import { useFormKit, FormGenerator } from "@classytic/formkit";

const form = useFormKit({
  schema: formSchema,
  resolver: zodResolver(validationSchema), // optional
  defaultValues: { email: "pre@fill.com" }, // optional overrides
  disabled: false,                          // optional
  variant: "compact",                       // optional
  className: "my-form",                     // optional
  mode: "onBlur",                           // any useForm option
});

const { handleSubmit, generatorProps } = form;

// Safe to use in useEffect deps — form is referentially stable
useEffect(() => {
  if (open) form.reset(defaults);
}, [open, form]);

return (
  <form onSubmit={handleSubmit(onSubmit)}>
    <FormGenerator {...generatorProps} />
    <button type="submit">Submit</button>
  </form>
);
```

Schema `defaultValue` fields are automatically extracted and merged with any explicit `defaultValues` you provide (explicit values take priority).

`generatorProps` is memoized — it only recomputes when `schema`, `control`, `disabled`, `variant`, or `className` change.

### FormGenerator

The main component that renders forms from a schema. Supports React 19 `ref` as a regular prop.

```tsx
<FormGenerator
  schema={formSchema}       // Required: Form schema
  control={form.control}    // Optional: React Hook Form control (or wrap in <FormProvider>)
  disabled={false}           // Optional: Disable all fields
  variant="default"          // Optional: Global variant
  className="my-form"        // Optional: Root element class
  ref={formRef}              // Optional: Ref to the root <div> (React 19 ref-as-prop)
/>
```

### FormSchema

```ts
interface FormSchema<T extends FieldValues = FieldValues> {
  sections: Section<T>[];
}
```

### Section

```ts
interface Section<T> {
  id?: string;                  // Unique identifier
  title?: string;               // Section title
  description?: string;         // Section description
  icon?: ReactNode;             // Section icon
  fields?: BaseField<T>[];      // Fields in this section
  cols?: number;                // Grid columns (1-6)
  gap?: number;                 // Grid gap
  variant?: string;             // Section variant
  className?: string;           // Custom class
  collapsible?: boolean;        // Make section collapsible
  defaultCollapsed?: boolean;
  nameSpace?: string;           // Prefix for nested object fields (e.g. "address")

  // Conditional rendering (function, DSL rule, or ConditionConfig)
  condition?: Condition<T>;

  // Custom render function (bypasses grid layout)
  render?: (props: SectionRenderProps<T>) => ReactNode;
}
```

### BaseField

```ts
interface BaseField<T> {
  // name accepts Path<T> or any string.
  // Use field.for<T>() builders for call-site enforcement of valid paths.
  // Relative names are correct for nameSpace sections and itemFields children —
  // FormGenerator prefixes them at render time (e.g. "street" → "address.street").
  name: Path<T> | string;       // Field name (required)
  type: FieldType;              // Field type (required)
  label?: string;               // Field label
  placeholder?: string;         // Placeholder text
  helperText?: string;          // Helper text below field
  disabled?: boolean;           // Disable field
  required?: boolean;           // Mark as required
  readOnly?: boolean;           // Read-only field
  variant?: string;             // Field variant
  fullWidth?: boolean;          // Span full grid width
  className?: string;           // Custom class
  defaultValue?: unknown;       // Default value

  // Conditional rendering
  condition?: Condition<T>;
  watchNames?: string | string[];  // Optimize useWatch performance

  // Dynamic options loading
  loadOptions?: (formValues: Partial<T>) => Promise<FieldOption[]> | FieldOption[];
  debounceMs?: number;

  // Sub-fields for group and array types.
  // Children use relative names ("street") — FormGenerator prefixes with parent name.
  // Intentionally untyped to T because they resolve at render time, not authoring time.
  itemFields?: BaseField[];

  // For select/radio/checkbox
  options?: FieldOption[];

  // HTML input attributes
  min?: number | string;
  max?: number | string;
  step?: number;
  pattern?: string;
  minLength?: number;
  maxLength?: number;
  rows?: number;
  multiple?: boolean;
  accept?: string;
  autoComplete?: string;
  autoFocus?: boolean;

  // Custom render override
  render?: (props: FieldComponentProps<T>) => ReactNode;

  // Arbitrary extra props for custom components
  customProps?: Record<string, unknown>;
}
```

### FieldComponentProps

Props passed to your field components:

```ts
interface FieldComponentProps<T extends FieldValues = FieldValues>
  extends BaseField<T> {
  field: BaseField<T>;          // Full field config
  control: Control<T>;          // React Hook Form control
  disabled?: boolean;           // Merged disabled state
  variant?: string;             // Active variant
  error?: FieldError;           // Field error from react-hook-form
  fieldState?: {                // Field state metadata
    invalid: boolean;
    isDirty: boolean;
    isTouched: boolean;
    isValidating: boolean;
    error?: FieldError;
  };
  fieldId: string;              // Generated ID for label-input association (e.g. "formkit-field-email")
}
```

### Condition Types

Conditions can be a function, a DSL rule, an array of rules (AND), or a `ConditionConfig` (AND/OR):

```ts
// Function condition
condition: (values) => values.accountType === "business"

// Single DSL rule
condition: { watch: "country", operator: "===", value: "US" }

// Array of rules (AND - all must match)
condition: [
  { watch: "country", operator: "===", value: "US" },
  { watch: "age", operator: "truthy" },
]

// ConditionConfig with OR logic
condition: {
  rules: [
    { watch: "country", operator: "===", value: "US" },
    { watch: "country", operator: "===", value: "CA" },
  ],
  logic: "or",
}
```

**Supported operators:** `===`, `!==`, `in`, `not-in`, `truthy`, `falsy`

**Nested paths:** DSL rules support dot-notation paths like `"address.city"` for nested form values.

### ComponentRegistry

```ts
const components: ComponentRegistry = {
  // Simple mapping
  text: FormInput,
  select: FormSelect,

  // Variant-specific components
  compact: {
    text: CompactInput,
    select: CompactSelect,
  },
};
```

### LayoutRegistry

```ts
const layouts: LayoutRegistry = {
  section: SectionLayout,
  grid: GridLayout,

  // Variant-specific layouts
  compact: {
    section: CompactSection,
  },
};
```

### extractDefaultValues

Extracts default values from a schema. Server-safe (no hooks).

```ts
import { extractDefaultValues } from "@classytic/formkit"; // or /server

const defaults = extractDefaultValues(formSchema);
// { firstName: "", lastName: "", email: "", password: "" }

// Use with react-hook-form
const form = useForm({ defaultValues: defaults });
```

Respects `nameSpace` prefixes and group `itemFields` defaults.

### buildValidationRules

Generates react-hook-form validation rules from a field's schema props. Server-safe (no hooks).

```ts
import { buildValidationRules } from "@classytic/formkit"; // or /server

function FormInput({ field, control, error, fieldId }: FieldComponentProps) {
  const rules = buildValidationRules(field);
  return (
    <Controller
      name={field.name}
      control={control}
      rules={rules}
      render={({ field: rhf }) => <input {...rhf} id={fieldId} />}
    />
  );
}
```

Maps `required`, `min`, `max`, `minLength`, `maxLength`, and `pattern` from the field schema to RHF-compatible rules with auto-generated error messages.

## Server Components

The `@classytic/formkit/server` entry point exports server-safe utilities with no React hooks or client-side code:

```ts
import {
  cn,
  defineSchema,
  defineField,
  defineSection,
  evaluateCondition,
  extractWatchNames,
  extractDefaultValues,
  buildValidationRules,
} from "@classytic/formkit/server";

// Type-only imports also available
import type {
  FormSchema,
  BaseField,
  Section,
  ConditionRule,
  ConditionConfig,
} from "@classytic/formkit/server";
```

Use this entry point in React Server Components to define schemas, evaluate conditions, or use `cn` without pulling in client-side code.

## Advanced Features

### Conditional Fields (Function)

```ts
{
  name: "companyName",
  type: "text",
  label: "Company Name",
  condition: (values) => values.accountType === "business",
}
```

### Conditional Fields (DSL Rules)

```ts
{
  name: "stateField",
  type: "select",
  label: "State",
  condition: { watch: "country", operator: "===", value: "US" },
  watchNames: ["country"], // Optimizes re-renders
}
```

### Conditional Sections

```ts
{
  title: "Business Details",
  condition: (values) => values.accountType === "business",
  fields: [
    { name: "companyName", type: "text", label: "Company" },
    { name: "taxId", type: "text", label: "Tax ID" },
  ],
}
```

### OR Conditions

```ts
{
  name: "taxField",
  type: "text",
  condition: {
    rules: [
      { watch: "country", operator: "===", value: "US" },
      { watch: "country", operator: "===", value: "CA" },
    ],
    logic: "or",
  },
}
```

### Nested Path Conditions

DSL rules resolve dot-notation paths for nested form values:

```ts
{
  name: "zipCode",
  type: "text",
  condition: { watch: "address.country", operator: "===", value: "US" },
}
```

### Namespace Support

Prefix all field names in a section with a namespace for nested objects:

```ts
{
  nameSpace: "address",
  fields: [
    { name: "street", type: "text" },  // Becomes "address.street"
    { name: "city", type: "text" },    // Becomes "address.city"
  ],
}
```

### Variants

Apply different styles based on context:

```tsx
// Register variant-specific components
const components = {
  text: DefaultInput,
  compact: {
    text: CompactInput,
  },
};

// Use variant on the whole form
<FormGenerator schema={schema} variant="compact" />

// Or per-section
{ variant: "compact", fields: [...] }

// Or per-field
{ name: "notes", type: "text", variant: "compact" }
```

### Dynamic Options Loading

```ts
{
  name: "city",
  type: "select",
  watchNames: ["country"],
  loadOptions: async (values) => {
    const cities = await fetchCities(values.country);
    return cities.map(c => ({ label: c.name, value: c.id }));
  },
  debounceMs: 300,
}
```

### Custom Section Render

```ts
{
  title: "Payment",
  render: ({ control, disabled }) => (
    <StripeElements>
      <CardElement />
      <FormInput name="billingName" control={control} />
    </StripeElements>
  ),
}
```

### Custom Field Render

```ts
{
  name: "avatar",
  type: "file",
  render: ({ field, control, error, fieldId }) => (
    <AvatarUploader fieldId={fieldId} error={error} />
  ),
}
```

### Custom Props

Pass arbitrary props to your field components via `customProps`:

```ts
{
  name: "bio",
  type: "textarea",
  label: "Biography",
  customProps: {
    maxCharacters: 500,
    showCounter: true,
  },
}
```

Access in your component:

```tsx
function FormTextarea({ field, customProps, ...props }: FieldComponentProps) {
  const maxChars = customProps?.maxCharacters as number;
  // ...
}
```

### Grouped Select Options

```ts
{
  name: "country",
  type: "select",
  options: [
    {
      label: "North America",
      options: [
        { value: "us", label: "United States" },
        { value: "ca", label: "Canada" },
      ],
    },
    {
      label: "Europe",
      options: [
        { value: "uk", label: "United Kingdom" },
        { value: "de", label: "Germany" },
      ],
    },
  ],
}
```

### Schema Builder Utilities

Type-safe helpers for defining schemas outside of components:

```ts
import { defineSchema, defineField, defineSection } from "@classytic/formkit/server";

const emailField = defineField<MyFormData>({
  name: "email",
  type: "email",
  label: "Email Address",
  required: true,
});

const personalSection = defineSection<MyFormData>({
  title: "Personal Info",
  cols: 2,
  fields: [emailField],
});

const schema = defineSchema<MyFormData>({
  sections: [personalSection],
});
```

## Type Exports

```ts
import type {
  // Core
  FormSchema,
  FormGeneratorProps,
  BaseField,
  Section,

  // Components
  FieldComponentProps,
  FieldComponent,
  ComponentRegistry,

  // Layouts
  SectionLayoutProps,
  GridLayoutProps,
  LayoutComponent,
  LayoutRegistry,

  // Options
  FieldOption,
  FieldOptionGroup,

  // Conditions
  ConditionRule,
  ConditionConfig,
  Condition,

  // Hook types
  UseFormKitOptions,
  UseFormKitReturn,

  // Utility types
  FieldType,
  LayoutType,
  Variant,
  DefineField,
  InferSchemaValues,
  SchemaFieldNames,
  FormElement,
} from "@classytic/formkit";
```

## Browser Support

- React 19.0+
- All modern browsers

## License

MIT © [Classytic](https://github.com/classytic)

## Links

- [GitHub](https://github.com/classytic/formkit)
- [npm](https://www.npmjs.com/package/@classytic/formkit)
- [Examples](https://github.com/classytic/formkit/tree/main/example/shadcn)
- [Issues](https://github.com/classytic/formkit/issues)
