feat: implement comprehensive Form UI library with React Hook Form integration, Zod validation, and i18n support.
This commit is contained in:
@@ -0,0 +1,460 @@
|
||||
# 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](#overview)
|
||||
- [Architecture](#architecture)
|
||||
- [HOC Factory Pattern](#hoc-factory-pattern)
|
||||
- [Naming Conventions](#naming-conventions)
|
||||
- [File Structure](#file-structure)
|
||||
- [Performance & Memoization](#performance--memoization)
|
||||
- [i18n Error Translation](#i18n-error-translation)
|
||||
- [Theme & Style Inheritance](#theme--style-inheritance)
|
||||
- [Validation Layer](#validation-layer)
|
||||
- [Usage Examples](#usage-examples)
|
||||
- [Basic Form](#basic-form)
|
||||
- [With Zod Validation](#with-zod-validation)
|
||||
- [Custom Field Component](#custom-field-component)
|
||||
- [Component Reference](#component-reference)
|
||||
- [Testing](#testing)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Form UI Library provides **22 pre-built form field components** that integrate [Mantine v8](https://mantine.dev/) form components with [React Hook Form (RHF)](https://react-hook-form.com/) and [Zod](https://zod.dev/) 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`](../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:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
| 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)
|
||||
|
||||
```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 tokens** — `compactDensity` / `standardDensity` set default `size` props on all inputs (e.g., `TextInput: { defaultProps: { size: 'sm' } }`)
|
||||
3. **Color scheme** — `forceColorScheme` on `MantineProvider` handles dark/light mode
|
||||
4. **CSS variables** — `theme.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/base.validator.ts
|
||||
import { z } from 'zod';
|
||||
|
||||
export const baseValidator = z.object({
|
||||
email: z.string().email({
|
||||
message: JSON.stringify({ key: 'validation:invalid_email' }),
|
||||
}),
|
||||
name: z.string().min(3, {
|
||||
message: JSON.stringify({
|
||||
key: 'validation:min_length',
|
||||
values: { field: 'Nama', min: 3 },
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export type BaseValidatorType = z.infer<typeof baseValidator>;
|
||||
```
|
||||
|
||||
### 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 { baseValidator, type BaseValidatorType } from '@repo/ui/validators';
|
||||
|
||||
function ExampleForm() {
|
||||
const { control, handleSubmit } = useForm<BaseValidatorType>({
|
||||
resolver: zodResolver(baseValidator),
|
||||
defaultValues: { email: '', name: '' },
|
||||
});
|
||||
|
||||
const onSubmit: SubmitHandler<BaseValidatorType> = (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>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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`) |
|
||||
| `FieldFileInput` | `FileInput` | File | File upload input |
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user