916 lines
34 KiB
Markdown
916 lines
34 KiB
Markdown
# 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
|
|
└── 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:
|
|
|
|
| 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/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
|
|
|
|
| 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**
|
|
|
|
```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:
|
|
|
|
| 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
|
|
|
|
```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):
|
|
|
|
> [!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
|
|
|
|
```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
|
|
|
|
| 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
|
|
|
|
```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
|
|
|
|
| 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
|
|
|
|
```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.
|