Files
trackgo-fe/apps/docs-dev/src/packages/ui/FORM-COMPONENTS.md
T
shancheas f2f0be111a chore: update .gitignore and improve coding standards documentation
- Added .cursor/sessions/* to .gitignore to prevent session files from being tracked.
- Enhanced coding standards in SKILL.md by adding semicolons to TypeScript examples for consistency.
- Improved formatting in continuous learning, detail layout, and other SKILL.md files for better readability.

These changes aim to streamline development processes and maintain code quality across the project.
2026-08-25 17:50:17 +07:00

42 KiB

outline
outline
2
3

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:

Argument Type Description
displayName string React DevTools name (e.g., "FieldTextInput")
MantineComponent ComponentType The raw Mantine component
options WithRHFOptions Optional config for special components

Options

Option Default Description
isCheckType false Use checked instead of value (for Checkbox, Switch)
requiresWrapper false Wrap 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:

// ✅ 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:

// 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:

Technique What it prevents Cost
useController Global form state re-renders — each field subscribes only to its own slice ~0 (hook-level isolation)
React.memo Parent-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)

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:

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:

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:

// 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:

// 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

// 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.

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

Category Validator Target Type Description
Numeric minValue(min, field?) ZodNumber Minimum numeric value
Numeric maxValue(max, field?) ZodNumber Maximum numeric value
Numeric rangeValue(min, max, field?) ZodNumber Restricts value between min and max limits
Numeric positiveNumber(field?) ZodNumber Restricts to positive numbers
String minLength(len, field?) ZodString Minimum string character length
String maxLength(len, field?) ZodString Maximum string character length
String rangeLength(min, max, field?) ZodString Restricts string length between min and max bounds
Security simplePassword(min) ZodString Checks password string length bounds only
Security complexPassword(min) ZodString Enforces length, 1 uppercase, 1 lowercase, 1 number, and 1 special char
Technical emailValidator() ZodString Standard email format
Technical phoneValidator() ZodString Enforces 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

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:

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:

Mode Behavior Use Case
unregister Completely unmounts the field. Value is wiped. Key is removed from submission payload. Hidden fields (e.g. Spouse Name if "Single" is checked).
reset Field 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

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):

[!WARNING] > 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

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

Mode Mantine Component RHF Value Mantine value Prop onChange Payload
multiple={false} (default) <Select /> T | null string | null T | null
multiple={true} <MultiSelect /> T[] string[] T[]

FieldLocalSelect — Local Object Select

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

Props

Prop Type Required Description
options T[] Array of objects to select from
valueKey keyof T & string Property used as the unique identifier
labelKey keyof T & string Property used as the display label
renderLabel (item: T) => string Custom label renderer (overrides labelKey)
multiple boolean Enable multi-select mode
filterOption (item: T, ctx) => boolean Custom filter/exclusion logic
onSelect (value: T | T[] | null) => void Side-effect callback on selection change
name FieldPath RHF field path
control Control RHF control object
...all Mantine Select/MultiSelect props Passed through to the underlying component

Usage Example

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

Prop Type Required Description
loadOptions LoadOptionsFn<T> Async callback: (search, page, prevOptions) => Promise<{ options: T[], hasMore?: boolean }>
defaultOptions T[] Pre-loaded objects always present in dropdown (for edit forms)
debounceMs number Search debounce delay (default: 300)
valueKey keyof T & string Property used as the unique identifier
labelKey keyof T & string Property used as the display label
renderLabel (item: T) => string Custom label renderer
multiple boolean Enable multi-select mode
name FieldPath RHF field path
control Control RHF control object
...all Mantine Select/MultiSelect props Passed through to the underlying component

Paginated Example

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:

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:

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

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:

// 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)

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)

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

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

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:

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:

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.