Skip to content

Form UI Library

Architectural Foundation: React Hook Form v7 · Zod v3 · Mantine v8 · @hookform/resolvers · @mantine/tiptap

Description: 22 pre-built form field components generated via a withRHF() HOC factory, integrating Mantine inputs with React Hook Form micro-subscriptions, Zod validation, and i18n error translation for ERP-scale performance.

Package: @repo/ui · Module Path: @repo/ui/form > Dependencies: React Hook Form v7, Zod v3, Mantine v8, @repo/core-i18n


Table of Contents


Overview

The Form UI Library provides 22 pre-built form field components that integrate Mantine v8 form components with React Hook Form (RHF) and Zod validation. Each component is generated via a central withRHF() HOC factory, ensuring consistent behavior across:

  • Value binding — Two-way data flow between RHF and Mantine
  • Error display — Automatic rendering of validation errors
  • i18n translation — Zod errors can be encoded as JSON payloads for translation
  • Performance — Micro-subscriptions via useController + React.memo
  • Theme compliance — Zero hardcoded styles; all styling flows from the existing ThemeProvider

Architecture

HOC Factory Pattern

The entire library is built on a single factory function:

withRHF<MantineComponentProps>(displayName, MantineComponent, options?)
  └─► Returns a React.memo'd component that:
      ├── Uses useController() for field-level subscriptions
      ├── Maps field.value/onChange/onBlur to Mantine props
      ├── Intercepts fieldState.error?.message
      │   ├── Attempts JSON.parse for i18n payloads
      │   └── Falls back to raw string if not translatable
      ├── Passes error={translated} to Mantine component
      ├── Forwards ref to the underlying DOM element
      └── Preserves full Mantine TypeScript generics

Source: packages/ui/src/components/Form/withRHF.tsx

The factory accepts three arguments:

ArgumentTypeDescription
displayNamestringReact DevTools name (e.g., "FieldTextInput")
MantineComponentComponentTypeThe raw Mantine component
optionsWithRHFOptionsOptional config for special components

Options

OptionDefaultDescription
isCheckTypefalseUse checked instead of value (for Checkbox, Switch)
requiresWrapperfalseWrap in Input.Wrapper for error display (for ColorPicker, SegmentedControl, Chip.Group)

Naming Conventions

All wrapped components use the Field prefix to prevent naming collisions with native Mantine exports:

tsx
// ✅ Our library — RHF-connected, type-safe
import { FieldTextInput } from '@repo/ui/form';

// ✅ Native Mantine — still accessible via the same package
import { TextInput } from '@repo/ui/components';

This avoids ambiguity in large codebases where both raw Mantine and form-connected versions might be needed.

File Structure

Each component lives in its own file following the [kebab-case-name].field.tsx convention within the fields/ directory:

packages/ui/src/components/Form/
├── withRHF.tsx                    # HOC factory
├── types.ts                       # Shared TypeScript types
├── index.ts                       # Barrel exports
├── __tests__/
│   ├── withRHF.test.tsx
│   ├── text-input.field.test.tsx
│   └── checkbox.field.test.tsx
└── fields/
    ├── text-input.field.tsx           # FieldTextInput
    ├── password-input.field.tsx       # FieldPasswordInput
    ├── textarea.field.tsx             # FieldTextarea
    ├── number-input.field.tsx         # FieldNumberInput
    ├── select.field.tsx               # FieldSelect
    ├── multi-select.field.tsx         # FieldMultiSelect
    ├── native-select.field.tsx        # FieldNativeSelect
    ├── checkbox.field.tsx             # FieldCheckbox
    ├── radio-group.field.tsx          # FieldRadioGroup
    ├── switch.field.tsx               # FieldSwitch
    ├── slider.field.tsx               # FieldSlider
    ├── range-slider.field.tsx         # FieldRangeSlider
    ├── rating.field.tsx               # FieldRating
    ├── color-input.field.tsx          # FieldColorInput
    ├── color-picker.field.tsx         # FieldColorPicker
    ├── pin-input.field.tsx            # FieldPinInput
    ├── json-input.field.tsx           # FieldJsonInput
    ├── autocomplete.field.tsx         # FieldAutocomplete
    ├── tags-input.field.tsx           # FieldTagsInput
    ├── chip-group.field.tsx           # FieldChipGroup
    ├── segmented-control.field.tsx    # FieldSegmentedControl
    ├── file-input.field.tsx           # FieldFileInput
    └── rich-text.field.tsx            # FieldRichTextEditor

Each field file is a thin one-liner:

tsx
// fields/text-input.field.tsx
import { TextInput, type TextInputProps } from '@mantine/core';
import { withRHF } from '../withRHF';

export const FieldTextInput = withRHF<TextInputProps>('FieldTextInput', TextInput);

Performance & Memoization

Why React.memo + useController?

In enterprise ERP forms with 1500+ fields, performance is critical:

TechniqueWhat it preventsCost
useControllerGlobal form state re-renders — each field subscribes only to its own slice~0 (hook-level isolation)
React.memoParent-driven re-renders (e.g., grid layout changes, tab switches)O(n) shallow prop comparison (typically n < 10)

Together, they achieve O(1) render cost per keystroke regardless of form size.

When React.memo is NOT needed

For simple forms (< 50 fields), React.memo adds negligible overhead but provides no measurable benefit. However, since the HOC is used across the entire organization, the default-on strategy ensures correctness at scale without requiring per-form tuning.


i18n Error Translation

The HOC supports three error message formats:

1. Plain String (default Zod behavior)

tsx
const schema = z.object({
  name: z.string().min(1, 'Name is required'),
});
// Error displayed: "Name is required"

2. JSON i18n Payload (structured translation)

Encode Zod errors as JSON with a translation key:

tsx
const schema = z.object({
  name: z.string().min(
    3,
    JSON.stringify({
      key: 'validation:min_length',
      values: { min: 3 },
    }),
  ),
});
// Error displayed: t('validation:min_length', { min: 3 })
// → "Minimum 3 characters" (from validation namespace)

3. Translation Key String

If the raw error string matches a key in the validation namespace:

tsx
const schema = z.object({
  email: z.string().email('validation:invalid_email'),
});
// Error displayed: t('validation:invalid_email')
// → "Please enter a valid email address"

Translation Resolution Chain

error.message
  ├── JSON.parse → { key, values }
  │   ├── t(key, { ...values, ns: 'validation' }) → translated ✓
  │   └── t(key, { ...values, ns: 'common' })     → translated ✓
  │       └── raw error.message (fallback)         → displayed as-is
  ├── i18n.exists(message, { ns: 'validation' })
  │   └── t(message, { ns: 'validation' })         → translated ✓
  └── raw string                                   → displayed as-is

Setting up the validation namespace

Add validation translations to your locale files:

json
// packages/core-i18n/src/locales/en/validation.json
{
  "validation": {
    "required": "This field is required",
    "min_length": "Minimum {{min}} characters",
    "max_length": "Maximum {{max}} characters",
    "invalid_email": "Please enter a valid email address"
  }
}

Theme & Style Inheritance

The Form components do NOT hardcode any styles. All visual appearance flows from:

  1. ThemeProvider — Wraps MantineProvider with brand colors, density tokens, and color scheme
  2. Density tokenscompactDensity / standardDensity set default size props on all inputs (e.g., TextInput: { defaultProps: { size: 'sm' } })
  3. Color schemeforceColorScheme on MantineProvider handles dark/light mode
  4. CSS variablestheme.css maps Mantine CSS variables to Tailwind tokens

This means:

tsx
// The FieldTextInput inherits compact sizing, brand colors, and dark mode
// automatically — no additional configuration needed.
<ThemeProvider colorScheme="dark" density="compact">
  <form>
    <FieldTextInput name="email" control={control} label="Email" />
  </form>
</ThemeProvider>

Validation Layer

To prevent over-engineering and package fatigue, we house the validation layer directly inside the UI package at packages/ui/src/validators rather than creating a separate @repo/validation package. This layer defines centralized Zod schemas that are pre-configured to output JSON-stringified i18n payloads.

Writing a Centralized Validator

tsx
// packages/ui/src/validators/sample.validator.ts
import { z } from 'zod';
import { compose, emailValidator, minLength } from './registry.validator';

export const sampleValidator = z.object({
  email: compose(z.string(), emailValidator()),
  name: compose(z.string(), minLength(3, 'Nama')),
});

export type SampleValidatorType = z.infer<typeof sampleValidator>;

Applying the Validator

When consuming these validators, use the zodResolver exported from @repo/ui/form and the validator from @repo/ui/validators. The Form components will automatically intercept the JSON payload, translate it using the validation namespace, and display the correct language to the user.

tsx
import { useForm, type SubmitHandler } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { FieldTextInput } from '@repo/ui/form';
import { sampleValidator, type SampleValidatorType } from '@repo/ui/validators';

function ExampleForm() {
  const { control, handleSubmit } = useForm<SampleValidatorType>({
    resolver: zodResolver(sampleValidator),
    defaultValues: { email: '', name: '' },
  });

  const onSubmit: SubmitHandler<SampleValidatorType> = (data) => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <FieldTextInput name="email" control={control} label="Email" />
      <FieldTextInput name="name" control={control} label="Name" />
      <button type="submit">Submit</button>
    </form>
  );
}

Validator Bank Reference

The registry.validator.ts provides a set of pre-configured atomic validators returning modified Zod schemas that automatically emit translated JSON payloads.

Available Atomic Validators

CategoryValidatorTarget TypeDescription
NumericminValue(min, field?)ZodNumberMinimum numeric value
NumericmaxValue(max, field?)ZodNumberMaximum numeric value
NumericrangeValue(min, max, field?)ZodNumberRestricts value between min and max limits
NumericpositiveNumber(field?)ZodNumberRestricts to positive numbers
StringminLength(len, field?)ZodStringMinimum string character length
StringmaxLength(len, field?)ZodStringMaximum string character length
StringrangeLength(min, max, field?)ZodStringRestricts string length between min and max bounds
SecuritysimplePassword(min)ZodStringChecks password string length bounds only
SecuritycomplexPassword(min)ZodStringEnforces length, 1 uppercase, 1 lowercase, 1 number, and 1 special char
TechnicalemailValidator()ZodStringStandard email format
TechnicalphoneValidator()ZodStringEnforces Indonesian (+62) phone number format

WARNING

Always distinguish between rangeValue (which bounds the actual numeric integer/float) and rangeLength (which bounds the amount of characters in a string).

Composition Guide

Instead of manually chaining long .min().max().regex() methods, use the compose() helper utility to elegantly stack atomic validators onto a base primitive.

Example: User Registration Password Field

tsx
import { z } from 'zod';
import { compose, required, minLength, complexPassword } from '@repo/ui/validators';

export const userRegistrationSchema = z.object({
  password: compose(z.string(), required('Password'), complexPassword(8)),
});

Testing Validators

We enforce strict test coverage for our Validation Bank. If you add a new atomic validator to registry.validator.ts, you MUST add corresponding tests to __tests__/registry.validator.test.ts.

Tests must explicitly verify the JSON stringified i18n payload:

typescript
it('minValue() should enforce min', () => {
  const schema = compose(z.number(), minValue(10, 'Age'));
  const res = schema.safeParse(5);

  expect(res.success).toBe(false);
  expect(res.error?.issues[0].message).toBe(
    JSON.stringify({ key: 'validation:min_val', values: { min: 10, field: 'Age' } }),
  );
});

Reactive Form Logic: useConditionalField

To decouple complex rendering side-effects from your component's root render function, the @repo/ui/hooks module provides useConditionalField. This hook automatically cleans up React Hook Form fields based on dynamic boolean conditions, enabling efficient micro-subscription architectures via useWatch.

IMPORTANT

The hook exclusively uses a strict UseConditionalFieldOptions object signature. Legacy positional parameters are no longer supported to ensure strict typing and predictability across the monorepo.

Core Modes

The hook supports two cleanup strategies defined by the mode parameter:

ModeBehaviorUse Case
unregisterCompletely unmounts the field. Value is wiped. Key is removed from submission payload.Hidden fields (e.g. Spouse Name if "Single" is checked).
resetField stays active/disabled. Value is wiped. Error state is cleared. Key is sent in payload as empty/default.Disabled or Cascading fields (e.g. Email Input if "Subscribe" is false, or resetting City when Province changes).

Hook Configuration

tsx
import { useForm, useWatch } from 'react-hook-form';
import { useConditionalField } from '@repo/ui/hooks';

export function ExampleForm() {
  const { control, setValue, unregister, clearErrors } = useForm();

  const userType = useWatch({ control, name: 'userType' });
  const newsletter = useWatch({ control, name: 'newsletter' });

  // 1. Unregister Mode (Hidden Field)
  useConditionalField({
    condition: userType === 'CORPORATE',
    name: 'corporateTaxId',
    setValue,
    unregister,
    mode: 'unregister',
  });

  // 2. Reset Mode (Visible but Disabled)
  useConditionalField({
    condition: newsletter === true,
    name: 'newsletterEmail',
    setValue,
    clearErrors,
    mode: 'reset',
  });

  return <form>...</form>;
}

Cascading Dropdowns & Reactivity

When dealing with cascading dependencies (e.g., Department -> Role), changing the parent dropdown should invalidate and reset the child dropdown.

You can accomplish this easily by supplying mode: 'reset' to useConditionalField. However, there is a critical rendering caveat with Mantine's Select (and similar complex visual inputs):

> **The Dynamic Key Trick:** Mantine components aggressively cache their internal visual text state. Even if `useConditionalField` perfectly resets the React Hook Form payload state to `''`, Mantine may still visually display the old, stale text on the screen.

To fix this UI desync, you must bind the parent dependency to the child component's key prop. This forces React's reconciliation engine to completely unmount and remount the child DOM node, flushing Mantine's internal cache and guaranteeing perfect UI synchronization.

Master Example: Department to Role Cascade

tsx
import { useForm, useWatch } from 'react-hook-form';
import { useConditionalField } from '@repo/ui/hooks';
import { FieldSelect } from '@repo/ui/form';

export function DepartmentForm() {
  const { control, setValue, clearErrors } = useForm();

  const department = useWatch({ control, name: 'department' });
  const role = useWatch({ control, name: 'role' });

  // Derive available options based on the parent state
  const currentRoleOptions =
    department === 'IT'
      ? [
          { value: 'FRONTEND', label: 'Frontend' },
          { value: 'BACKEND', label: 'Backend' },
        ]
      : [];

  // Determine if the currently selected role is still mathematically valid
  const isRoleValid = !role || (!!department && currentRoleOptions.some((opt) => opt.value === role));

  // 3. Reset Mode: Automatically wipes the field value in the RHF Payload if it becomes invalid
  useConditionalField({
    condition: isRoleValid,
    name: 'role',
    setValue,
    clearErrors,
    mode: 'reset',
    defaultValue: '',
  });

  return (
    <form>
      <FieldSelect
        name="department"
        control={control}
        label="Department"
        data={[{ value: 'IT', label: 'Information Technology' }]}
      />

      {/* CRITICAL: We bind the department string to the key prop to force remounts on change */}
      <FieldSelect
        key={`role-select-${department}`}
        name="role"
        control={control}
        label="Role"
        disabled={!department}
        data={currentRoleOptions}
      />
    </form>
  );
}

Object & Async Select Components

Mantine's native Select and MultiSelect are string-based: they store string | null and string[] respectively. In enterprise applications, we often need to store full objects (T | null or T[]) in RHF state — for example, a user object { id: '1', name: 'Alice', email: 'alice@co.com' } rather than just '1'.

The LocalSelect and AsyncSelect engines bridge this gap by:

  1. Mapping T[]ComboboxItem[] for Mantine rendering (via valueKey + labelKey/renderLabel)
  2. Building an O(1) reverse lookup map (Map<string, T>) for resolving string changes back to full objects
  3. Intercepting onChange to pass resolved objects to RHF

IMPORTANT

These components are separate from the native FieldSelect and FieldMultiSelect, which continue to work as simple string-based Mantine wrappers. Use FieldLocalSelect/FieldAsyncSelect only when you need to store full objects in RHF state.

Single vs. Multi-Select Data Mapping

ModeMantine ComponentRHF ValueMantine value ProponChange Payload
multiple={false} (default)<Select />T | nullstring | nullT | null
multiple={true}<MultiSelect />T[]string[]T[]

FieldLocalSelect — Local Object Select

Accepts a static data array of objects. No async fetching.

Props

PropTypeRequiredDescription
optionsT[]Array of objects to select from
valueKeykeyof T & stringProperty used as the unique identifier
labelKeykeyof T & stringProperty used as the display label
renderLabel(item: T) => stringCustom label renderer (overrides labelKey)
multiplebooleanEnable multi-select mode
filterOption(item: T, ctx) => booleanCustom filter/exclusion logic
onSelect(value: T | T[] | null) => voidSide-effect callback on selection change
nameFieldPathRHF field path
controlControlRHF control object
...all Mantine Select/MultiSelect propsPassed through to the underlying component

Usage Example

tsx
import { useForm } from 'react-hook-form';
import { FieldLocalSelect } from '@repo/ui/form';

interface Department {
  id: string;
  name: string;
  code: string;
}

const departments: Department[] = [
  { id: '1', name: 'Engineering', code: 'ENG' },
  { id: '2', name: 'Marketing', code: 'MKT' },
  { id: '3', name: 'Finance', code: 'FIN' },
];

function DepartmentForm() {
  const { control, handleSubmit } = useForm<{ department: Department | null }>({
    defaultValues: { department: null },
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data.department))}>
      <FieldLocalSelect<Department>
        name="department"
        control={control}
        label="Department"
        options={departments}
        valueKey="id"
        labelKey="name"
        searchable
      />
      <button type="submit">Submit</button>
    </form>
  );
}
// On submit: data.department = { id: '1', name: 'Engineering', code: 'ENG' }

FieldAsyncSelect — Async Paginated Object Select

Uses Inversion of Control: the component does NOT handle API calls directly. Instead, you provide a loadOptions callback. This supports REST, GraphQL, POST-based search, or any transport.

Props

PropTypeRequiredDescription
loadOptionsLoadOptionsFn<T>Async callback: (search, page, prevOptions) => Promise<{ options: T[], hasMore?: boolean }>
defaultOptionsT[]Pre-loaded objects always present in dropdown (for edit forms)
debounceMsnumberSearch debounce delay (default: 300)
valueKeykeyof T & stringProperty used as the unique identifier
labelKeykeyof T & stringProperty used as the display label
renderLabel(item: T) => stringCustom label renderer
multiplebooleanEnable multi-select mode
nameFieldPathRHF field path
controlControlRHF control object
...all Mantine Select/MultiSelect propsPassed through to the underlying component

Paginated Example

tsx
import { useForm } from 'react-hook-form';
import { FieldAsyncSelect, type LoadOptionsFn } from '@repo/ui/form';
import { api } from '@/lib/api';

interface User {
  id: string;
  fullName: string;
  email: string;
}

// The loadOptions callback is completely transport-agnostic
const loadUsers: LoadOptionsFn<User> = async (search, page) => {
  const res = await api.get('/users', {
    params: { q: search, page, limit: 20 },
  });
  return {
    options: res.data.items,
    hasMore: res.data.hasNextPage,
  };
};

function UserPickerForm() {
  const { control, handleSubmit } = useForm<{ user: User | null }>({
    defaultValues: { user: null },
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data.user))}>
      <FieldAsyncSelect<User>
        name="user"
        control={control}
        label="Assign User"
        loadOptions={loadUsers}
        valueKey="id"
        labelKey="fullName"
        placeholder="Search users..."
      />
      <button type="submit">Submit</button>
    </form>
  );
}

Non-Paginated Example

If your API returns all results at once, return hasMore: false:

tsx
const loadRoles: LoadOptionsFn<Role> = async (search) => {
  const roles = await api.get('/roles', { params: { q: search } });
  return { options: roles.data, hasMore: false };
};

Edit Form with defaultOptions

When editing an existing record, the default value's object may not appear in the first page of API results. Use defaultOptions to inject it:

tsx
function EditUserForm({ existingAssignment }: { existingAssignment: User }) {
  const { control } = useForm<{ user: User | null }>({
    defaultValues: { user: existingAssignment },
  });

  return (
    <FieldAsyncSelect<User>
      name="user"
      control={control}
      label="Reassign User"
      loadOptions={loadUsers}
      valueKey="id"
      labelKey="fullName"
      defaultOptions={[existingAssignment]}
    />
  );
}

Multi-Select Async Example

tsx
function TagPickerForm() {
  const { control } = useForm<{ tags: Tag[] }>({
    defaultValues: { tags: [] },
  });

  return (
    <FieldAsyncSelect<Tag>
      multiple
      name="tags"
      control={control}
      label="Tags"
      loadOptions={loadTags}
      valueKey="id"
      renderLabel={(tag) => `${tag.name} (${tag.count})`}
    />
  );
}
// On submit: data.tags = [{ id: '1', name: 'React', count: 42 }, ...]

Enterprise Performance Guidelines: Forms & Validation

When building large-scale ERP forms, seemingly trivial React or Zod patterns can catastrophically degrade performance at scale. Adhere strictly to the following optimizations.

The "Unstable Default Value" Trap in Hooks

When creating custom form hooks (like useConditionalField), you often need to provide a fallback or default value. Passing an inline array or object as a defaultValue can trigger infinite render loops if it is included in a useEffect dependency array, because React's referential equality check fails on every render.

Solution: The useRef Stabilization Pattern

We resolve this by storing the defaultValue in a useRef. This allows the hook's cleanup logic to access the latest value without triggering the effect again:

tsx
// Inside useConditionalField.ts
const defaultValueRef = useRef(defaultValue);

// Update ref on every render without triggering dependencies
useEffect(() => {
  defaultValueRef.current = defaultValue;
}, [defaultValue]);

// The main effect no longer depends on defaultValue
useEffect(() => {
  if (!condition) {
    const targetValue = defaultValueRef.current !== undefined ? defaultValueRef.current : '';
    setValue(name, targetValue);
  }
}, [condition, name, setValue]);

Zod Schema Performance: Avoid superRefine for Conditionals

For complex dynamic forms, developers often default to .superRefine or .refine to handle conditional validation (e.g., "Require Tax ID only if userType is Corporate").

The Problem: superRefine acts as an opaque callback. Zod cannot optimize it. In large forms, doing manual .safeParse inside a superRefine loop forces Zod to parse the entire tree continuously on every keystroke, leading to severe O(n) CPU spikes.

The Solution: Use declarative schema branching via .and(), z.discriminatedUnion, and z.union. These are statically analyzed by Zod and evaluated at native speed.

❌ Bad: Manual Parsing (O(n) CPU Spike)

tsx
const badSchema = z
  .object({
    userType: z.enum(['PERSONAL', 'CORPORATE']),
    corporateTaxId: z.string().optional(),
  })
  .superRefine((data, ctx) => {
    if (data.userType === 'CORPORATE') {
      // ⚠️ INCREDIBLY SLOW: Manual parsing inside refine loop
      const res = taxIdValidator.safeParse(data.corporateTaxId);
      if (!res.success) ctx.addIssue({ ...res.error.issues[0], path: ['corporateTaxId'] });
    }
  });

✅ Good: Declarative Unions (O(1) Evaluation)

tsx
const goodSchema = z
  .object({
    userType: z.enum(['PERSONAL', 'CORPORATE']),
    corporateTaxId: z.string().optional(),
  })
  .and(
    z.discriminatedUnion('userType', [
      z.object({ userType: z.literal('PERSONAL') }),
      z.object({ userType: z.literal('CORPORATE'), corporateTaxId: taxIdValidator }),
    ]),
  );

By stacking .and(z.union([...])) for independent conditionals (like hasSpouse, newsletter, etc.), you achieve lightning-fast, type-safe conditional validation without writing a single superRefine loop.


Usage Examples

Basic Form

tsx
import { useForm, type SubmitHandler } from 'react-hook-form';
import { FieldTextInput, FieldPasswordInput } from '@repo/ui/form';

type LoginForm = { email: string; password: string };

function LoginForm() {
  const { control, handleSubmit } = useForm<LoginForm>({
    defaultValues: { email: '', password: '' },
  });

  const onSubmit: SubmitHandler<LoginForm> = (data) => {
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <FieldTextInput name="email" control={control} label="Email" />
      <FieldPasswordInput name="password" control={control} label="Password" />
      <button type="submit">Login</button>
    </form>
  );
}

With Zod Validation

tsx
import { z } from 'zod';
import { useForm, type SubmitHandler } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { FieldTextInput, FieldNumberInput, FieldSelect, FieldCheckbox } from '@repo/ui/form';

const productSchema = z.object({
  name: z.string().min(1, {
    message: JSON.stringify({ key: 'validation:required', values: { field: 'Product Name' } }),
  }),
  sku: z.string().regex(/^[A-Z]{3}-\d{4}$/, {
    message: JSON.stringify({ key: 'validation:invalid_format', values: { format: 'AAA-0000' } }),
  }),
  price: z.number().min(0, {
    message: JSON.stringify({ key: 'validation:min_value', values: { min: 0 } }),
  }),
  category: z.string().min(1, {
    message: JSON.stringify({ key: 'validation:required', values: { field: 'Category' } }),
  }),
  isActive: z.boolean(),
});

type ProductForm = z.infer<typeof productSchema>;

function ProductEditor() {
  const { control, handleSubmit } = useForm<ProductForm>({
    resolver: zodResolver(productSchema),
    defaultValues: {
      name: '',
      sku: '',
      price: 0,
      category: '',
      isActive: true,
    },
  });

  const onSubmit: SubmitHandler<ProductForm> = (data) => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <FieldTextInput name="name" control={control} label="Product Name" />
      <FieldTextInput name="sku" control={control} label="SKU" placeholder="ABC-1234" />
      <FieldNumberInput name="price" control={control} label="Price" min={0} prefix="$" />
      <FieldSelect name="category" control={control} label="Category" data={['Electronics', 'Clothing', 'Food']} />
      <FieldCheckbox name="isActive" control={control} label="Active" />
      <button type="submit">Save Product</button>
    </form>
  );
}

Custom Field Component

Use withRHF directly to wrap any Mantine component not included in the library:

tsx
import { DatePickerInput, type DatePickerInputProps } from '@mantine/dates';
import { withRHF } from '@repo/ui/form';

export const FieldDatePicker = withRHF<DatePickerInputProps>('FieldDatePicker', DatePickerInput);

Component Reference

| Component | Mantine Source | Type | Notes | | ----------------------- | ---------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | | FieldTextInput | TextInput | Text | Standard text input | | FieldPasswordInput | PasswordInput | Text | Password with visibility toggle | | FieldTextarea | Textarea | Text | Multi-line text | | FieldNumberInput | NumberInput | Text | Numeric with increment/decrement | | FieldJsonInput | JsonInput | Text | JSON-formatted text | | FieldPinInput | PinInput | Text | PIN/OTP code input | | FieldAutocomplete | Autocomplete | Text | Text input with suggestions | | FieldSelect | Select | Selection | Single-value dropdown | | FieldMultiSelect | MultiSelect | Selection | Multi-value dropdown | | FieldNativeSelect | NativeSelect | Selection | Native <select> element | | FieldTagsInput | TagsInput | Selection | Free-form tag entry | | FieldCheckbox | Checkbox | Toggle | Boolean checkbox (uses checked) | | FieldRadioGroup | Radio.Group | Toggle | Radio button group | | FieldSwitch | Switch | Toggle | Boolean switch (uses checked) | | FieldChipGroup | Chip.Group | Toggle | Chip selection group (uses Input.Wrapper) | | FieldSegmentedControl | SegmentedControl | Toggle | Segmented control (uses Input.Wrapper) | | FieldSlider | Slider | Range | Single-value slider | | FieldRangeSlider | RangeSlider | Range | Dual-handle range slider | | FieldRating | Rating | Range | Star rating | | FieldColorInput | ColorInput | Color | Color picker with text input | | FieldColorPicker | ColorPicker | Color | Color picker only (uses Input.Wrapper) | | FieldLocalSelect | Select / MultiSelect | Selection | Stores full T or T[] object in RHF instead of string ID. Accepts static options array with valueKey/labelKey mapping. | | FieldAsyncSelect | Select / MultiSelect | Selection | Async paginated object select with IoC loadOptions callback. Supports search-keyed caching, defaultOptions for edit forms, and automatic pagination detection. | | FieldFileInput | <FileInput /> | File | File[] | null | | FieldRichTextEditor | @mantine/tiptap | string (HTML) |

Rich Text Editor (TipTap)

The FieldRichTextEditor component integrates @mantine/tiptap directly with React Hook Form. It safely stores the Editor's HTML output directly into the RHF state as a string. Because TipTap is an uncontrolled editor natively, this field uses a specialized useController wrapper that automatically syncs bidirectional updates (e.g., calling editor.commands.setContent(field.value) when the form is reset or async default values arrive).


Testing

Tests are located in src/components/Form/__tests__/ and can be run via:

bash
cd packages/ui && pnpm test

The test suite covers:

  • withRHF.test.tsx (8 tests) — Core HOC behavior: rendering, value binding, input mutation, error display, i18n translation, fallback behavior, displayName, prop forwarding
  • text-input.field.test.tsx (4 tests) — FieldTextInput integration with Zod validation, error display/clearing, and full submission flow
  • checkbox.field.test.tsx (4 tests) — FieldCheckbox boolean toggle, checked state, RHF submission, and Zod required validation

All tests use @testing-library/react with mocked @repo/core-i18n and a window.matchMedia polyfill for jsdom compatibility with Mantine v8.