Files
trackgo-fe/packages/ui/docs/FORM-COMPONENTS.md
T

19 KiB

Form UI Library — Architecture & Usage Guide

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

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' } })
  );
});

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)
FieldFileInput FileInput File File upload input

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.