feat: implement comprehensive Form UI library with React Hook Form integration, Zod validation, and i18n support.

This commit is contained in:
Firman Ramdhani
2026-06-15 18:40:22 +07:00
parent 00a2f218cc
commit a72b73bac4
45 changed files with 2064 additions and 10 deletions
+80
View File
@@ -0,0 +1,80 @@
# @repo/ui — Shared UI Component Library
The centralized UI component library for the monorepo. Provides consistent design primitives, system pages, and **a comprehensive Form UI Library** for building enterprise-grade forms.
## Features
- **Mantine v8** components re-exported with unified theming
- **ThemeProvider** with dark/light mode, brand colors, and density modes (compact/standard)
- **Design tokens** — Colors, typography, radius, spacing, shadows mapped between Mantine and Tailwind
- **System pages** — Pre-built 404, 403, Maintenance, and Coming Soon pages
- **Form UI Library** — 22 RHF-connected Mantine form components with Zod validation and i18n error translation
## Exports
| Entry Point | Path | Description |
|---|---|---|
| `@repo/ui/components` | `./src/components/index.ts` | All components (Mantine re-exports + system pages + Form fields) |
| `@repo/ui/form` | `./src/components/Form/index.ts` | Form field components, `withRHF` factory, RHF/Zod re-exports |
| `@repo/ui/hooks` | `./src/hooks/index.ts` | Mantine hooks re-export |
| `@repo/ui/provider` | `./src/provider/index.ts` | `ThemeProvider` with color scheme and density controls |
| `@repo/ui/theme.css` | `./src/theme.css` | Base CSS with Mantine → Tailwind token mapping |
## 📋 Form UI Library
> **Full Documentation**: [docs/FORM-COMPONENTS.md](docs/FORM-COMPONENTS.md)
The Form UI Library wraps **all 22 applicable Mantine form components** with React Hook Form via a single `withRHF()` HOC factory. Key features:
- **`useController` micro-subscriptions** — O(1) render cost per keystroke, even in 1500+ field ERP forms
- **`React.memo` wrapper** — Prevents parent-driven cascade re-renders
- **Zod + i18n error translation** — JSON error payloads are auto-parsed and translated via `@repo/core-i18n`
- **Zero hardcoded styles** — All components inherit the active `ThemeProvider` configuration
- **`Field` prefix naming** — `FieldTextInput`, `FieldSelect`, etc. to avoid collisions with native Mantine exports
### Quick Start
```tsx
import { z } from 'zod';
import { useForm, zodResolver, FieldTextInput, FieldSelect } from '@repo/ui/form';
const schema = z.object({
name: z.string().min(1, 'Name is required'),
role: z.string().min(1, 'Please select a role'),
});
function UserForm() {
const { control, handleSubmit } = useForm({
resolver: zodResolver(schema),
defaultValues: { name: '', role: '' },
});
return (
<form onSubmit={handleSubmit(console.log)}>
<FieldTextInput name="name" control={control} label="Name" />
<FieldSelect
name="role"
control={control}
label="Role"
data={['Admin', 'Editor', 'Viewer']}
/>
<button type="submit">Save</button>
</form>
);
}
```
## Scripts
| Command | Description |
|---|---|
| `pnpm test` | Run unit tests (Vitest) |
| `pnpm test:watch` | Run tests in watch mode |
| `pnpm lint` | Run ESLint |
## Dependencies
- `@mantine/core` v8, `@mantine/hooks` v8
- `react-hook-form` v7, `@hookform/resolvers` v5, `zod` v3
- `@repo/core-i18n` (workspace)
- `tailwindcss` v4, `tailwind-variants`, `tailwind-merge`
+460
View File
@@ -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.
+12 -2
View File
@@ -4,8 +4,10 @@
"exports": {
"./theme.css": "./src/theme.css",
"./components": "./src/components/index.ts",
"./form": "./src/components/Form/index.ts",
"./hooks": "./src/hooks/index.ts",
"./provider": "./src/provider/index.ts"
"./provider": "./src/provider/index.ts",
"./validators": "./src/validators/index.ts"
},
"license": "MIT",
"scripts": {
@@ -18,22 +20,30 @@
"react-dom": "^19.2.3"
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@mantine/core": "^8.3.15",
"@mantine/hooks": "^8.3.15",
"@repo/core-i18n": "workspace:*",
"@repo/utils": "workspace:*",
"dayjs": "^1.11.19",
"react-hook-form": "^7.56.4",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.18"
"tailwindcss": "^4.1.18",
"zod": "^3.25.36"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@tailwindcss/vite": "^4.1.18",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^8.57.1",
"jsdom": "^26.1.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"typescript": "5.5.4",
+38
View File
@@ -0,0 +1,38 @@
import '@testing-library/jest-dom/vitest';
// ---------------------------------------------------------------------------
// Polyfill: window.matchMedia
// ---------------------------------------------------------------------------
// Mantine v8's MantineProvider calls window.matchMedia internally for
// color scheme detection. jsdom does not implement matchMedia, so we
// provide a minimal stub to prevent TypeError during test rendering.
// ---------------------------------------------------------------------------
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
// ---------------------------------------------------------------------------
// Polyfill: ResizeObserver
// ---------------------------------------------------------------------------
// Some Mantine components (Popover, Select dropdown) use ResizeObserver
// which is also not available in jsdom.
// ---------------------------------------------------------------------------
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
window.ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver;
@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { MantineProvider } from '@mantine/core';
import { FieldCheckbox } from '../fields/checkbox.field';
// ---------------------------------------------------------------------------
// Mock @repo/core-i18n
// ---------------------------------------------------------------------------
vi.mock('@repo/core-i18n', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
return (options?.defaultValue as string) ?? key;
},
i18n: {
exists: () => false,
},
}),
}));
// ---------------------------------------------------------------------------
// Test schema
// ---------------------------------------------------------------------------
const termsSchema = z.object({
acceptTerms: z.literal(true, {
errorMap: () => ({ message: 'You must accept the terms' }),
}),
});
type TermsFormValues = z.infer<typeof termsSchema>;
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('FieldCheckbox', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders with a label', () => {
function TestForm() {
const { control } = useForm({ defaultValues: { acceptTerms: false } });
return (
<MantineProvider>
<FieldCheckbox
name="acceptTerms"
control={control}
label="I accept the terms and conditions"
/>
</MantineProvider>
);
}
render(<TestForm />);
expect(screen.getByLabelText('I accept the terms and conditions')).toBeInTheDocument();
});
it('toggles checked state on click', async () => {
const user = userEvent.setup();
function TestForm() {
const { control } = useForm({ defaultValues: { acceptTerms: false } });
return (
<MantineProvider>
<FieldCheckbox
name="acceptTerms"
control={control}
label="Accept Terms"
/>
</MantineProvider>
);
}
render(<TestForm />);
const checkbox = screen.getByLabelText('Accept Terms');
expect(checkbox).not.toBeChecked();
await user.click(checkbox);
expect(checkbox).toBeChecked();
await user.click(checkbox);
expect(checkbox).not.toBeChecked();
});
it('submits the boolean value via RHF', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
function TestForm() {
const { control, handleSubmit } = useForm({
defaultValues: { acceptTerms: false },
});
return (
<MantineProvider>
<form onSubmit={handleSubmit(onSubmit)}>
<FieldCheckbox name="acceptTerms" control={control} label="Accept" />
<button type="submit">Submit</button>
</form>
</MantineProvider>
);
}
render(<TestForm />);
await user.click(screen.getByLabelText('Accept'));
await user.click(screen.getByText('Submit'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
{ acceptTerms: true },
expect.anything(),
);
});
});
it('displays Zod validation error when not checked', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
function TestForm() {
const { control, handleSubmit } = useForm<TermsFormValues>({
resolver: zodResolver(termsSchema),
defaultValues: { acceptTerms: false as unknown as true },
});
return (
<MantineProvider>
<form onSubmit={handleSubmit(onSubmit)}>
<FieldCheckbox name="acceptTerms" control={control} label="Accept" />
<button type="submit">Submit</button>
</form>
</MantineProvider>
);
}
render(<TestForm />);
// Submit without checking
await user.click(screen.getByText('Submit'));
await waitFor(() => {
expect(screen.getByText('You must accept the terms')).toBeInTheDocument();
});
expect(onSubmit).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,186 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { MantineProvider } from '@mantine/core';
import { FieldTextInput } from '../fields/text-input.field';
// ---------------------------------------------------------------------------
// Mock @repo/core-i18n
// ---------------------------------------------------------------------------
vi.mock('@repo/core-i18n', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
const translations: Record<string, string> = {
'validation.required': 'This field is required',
'validation.too_small': `Minimum ${options?.min ?? ''} characters required`,
};
return translations[key] ?? (options?.defaultValue as string) ?? key;
},
i18n: {
exists: (key: string) => ['validation.required', 'validation.too_small'].includes(key),
},
}),
}));
// ---------------------------------------------------------------------------
// Test schema
// ---------------------------------------------------------------------------
const loginSchema = z.object({
username: z
.string()
.min(1, 'Username cannot be empty')
.min(3, 'Username must be at least 3 characters'),
email: z.string().email('Please enter a valid email address'),
});
type LoginFormValues = z.infer<typeof loginSchema>;
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('FieldTextInput', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders with label and placeholder', () => {
function TestForm() {
const { control } = useForm<LoginFormValues>({
defaultValues: { username: '', email: '' },
});
return (
<MantineProvider>
<FieldTextInput
name="username"
control={control}
label="Username"
placeholder="Enter username"
/>
</MantineProvider>
);
}
render(<TestForm />);
expect(screen.getByLabelText('Username')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter username')).toBeInTheDocument();
});
it('integrates with Zod validation and displays errors on submit', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
function TestForm() {
const { control, handleSubmit } = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: { username: '', email: '' },
});
return (
<MantineProvider>
<form onSubmit={handleSubmit(onSubmit)}>
<FieldTextInput name="username" control={control} label="Username" />
<FieldTextInput name="email" control={control} label="Email" />
<button type="submit">Login</button>
</form>
</MantineProvider>
);
}
render(<TestForm />);
// Submit with empty fields
await user.click(screen.getByText('Login'));
// Zod should generate validation errors
await waitFor(() => {
expect(screen.getByText('Username cannot be empty')).toBeInTheDocument();
});
// onSubmit should NOT have been called
expect(onSubmit).not.toHaveBeenCalled();
});
it('clears errors when valid input is provided', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
function TestForm() {
const { control, handleSubmit } = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: { username: '', email: '' },
mode: 'onSubmit',
});
return (
<MantineProvider>
<form onSubmit={handleSubmit(onSubmit)}>
<FieldTextInput name="username" control={control} label="Username" />
<FieldTextInput name="email" control={control} label="Email" />
<button type="submit">Login</button>
</form>
</MantineProvider>
);
}
render(<TestForm />);
// Trigger validation errors
await user.click(screen.getByText('Login'));
await waitFor(() => {
expect(screen.getByText('Username cannot be empty')).toBeInTheDocument();
});
// Fill in valid data
await user.type(screen.getByLabelText('Username'), 'john');
await user.type(screen.getByLabelText('Email'), 'john@example.com');
// Re-submit with valid data
await user.click(screen.getByText('Login'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
{ username: 'john', email: 'john@example.com' },
expect.anything(),
);
});
});
it('submits successfully with valid data on first try', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
function TestForm() {
const { control, handleSubmit } = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: { username: '', email: '' },
});
return (
<MantineProvider>
<form onSubmit={handleSubmit(onSubmit)}>
<FieldTextInput name="username" control={control} label="Username" />
<FieldTextInput name="email" control={control} label="Email" />
<button type="submit">Login</button>
</form>
</MantineProvider>
);
}
render(<TestForm />);
await user.type(screen.getByLabelText('Username'), 'johndoe');
await user.type(screen.getByLabelText('Email'), 'john@example.com');
await user.click(screen.getByText('Login'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
{ username: 'johndoe', email: 'john@example.com' },
expect.anything(),
);
});
});
});
@@ -0,0 +1,248 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import { MantineProvider, TextInput } from '@mantine/core';
import { withRHF } from '../withRHF';
// ---------------------------------------------------------------------------
// Mock @repo/core-i18n — provides a controllable useTranslation hook
// ---------------------------------------------------------------------------
const mockT = vi.fn((key: string, options?: Record<string, unknown>) => {
// Simulate i18next: return translated value if key matches, else return
// the defaultValue or the key itself.
const translations: Record<string, string> = {
'validation.required': 'This field is required',
'validation.min_length': `Minimum ${options?.min ?? ''} characters`,
};
return translations[key] ?? (options?.defaultValue as string) ?? key;
});
const mockI18n = {
exists: vi.fn((key: string) => {
const knownKeys = ['validation.required', 'validation.min_length'];
return knownKeys.includes(key);
}),
};
vi.mock('@repo/core-i18n', () => ({
useTranslation: () => ({ t: mockT, i18n: mockI18n }),
}));
// ---------------------------------------------------------------------------
// Test wrapper component that provides MantineProvider + FormProvider
// ---------------------------------------------------------------------------
interface FormTestWrapperProps {
children: React.ReactNode;
defaultValues?: Record<string, unknown>;
onSubmit?: (data: Record<string, unknown>) => void;
}
function FormTestWrapper({
children,
defaultValues = {},
onSubmit = () => {},
}: FormTestWrapperProps) {
const methods = useForm({ defaultValues });
return (
<MantineProvider>
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
{children}
<button type="submit">Submit</button>
</form>
</FormProvider>
</MantineProvider>
);
}
// ---------------------------------------------------------------------------
// Create a test field component using the HOC
// ---------------------------------------------------------------------------
const TestFieldTextInput = withRHF<React.ComponentProps<typeof TextInput>>(
'TestFieldTextInput',
TextInput,
);
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('withRHF HOC', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders the wrapped Mantine component without crashing', () => {
function TestForm() {
const { control } = useForm({ defaultValues: { name: '' } });
return (
<MantineProvider>
<TestFieldTextInput name="name" control={control} label="Name" />
</MantineProvider>
);
}
render(<TestForm />);
expect(screen.getByLabelText('Name')).toBeInTheDocument();
});
it('displays the initial value from RHF form state', () => {
function TestForm() {
const { control } = useForm({ defaultValues: { name: 'John Doe' } });
return (
<MantineProvider>
<TestFieldTextInput name="name" control={control} label="Name" />
</MantineProvider>
);
}
render(<TestForm />);
expect(screen.getByLabelText('Name')).toHaveValue('John Doe');
});
it('mutates RHF state on user input', async () => {
const user = userEvent.setup();
let capturedData: Record<string, unknown> | null = null;
function TestForm() {
const { control, handleSubmit } = useForm({ defaultValues: { email: '' } });
return (
<MantineProvider>
<form onSubmit={handleSubmit((data) => { capturedData = data; })}>
<TestFieldTextInput name="email" control={control} label="Email" />
<button type="submit">Submit</button>
</form>
</MantineProvider>
);
}
render(<TestForm />);
const input = screen.getByLabelText('Email');
await user.type(input, 'test@example.com');
expect(input).toHaveValue('test@example.com');
await user.click(screen.getByText('Submit'));
expect(capturedData).toEqual({ email: 'test@example.com' });
});
it('renders raw string error messages from RHF validation', async () => {
const user = userEvent.setup();
function TestForm() {
const { control, handleSubmit } = useForm({ defaultValues: { username: '' } });
return (
<MantineProvider>
<form onSubmit={handleSubmit(() => {})}>
<TestFieldTextInput
name="username"
control={control}
rules={{ required: 'Username is required' }}
label="Username"
/>
<button type="submit">Submit</button>
</form>
</MantineProvider>
);
}
render(<TestForm />);
await user.click(screen.getByText('Submit'));
// The raw string error should appear in the DOM
expect(screen.getByText('Username is required')).toBeInTheDocument();
});
it('intercepts JSON i18n error payloads and translates them', async () => {
const user = userEvent.setup();
function TestForm() {
const { control, handleSubmit } = useForm({ defaultValues: { title: '' } });
return (
<MantineProvider>
<form onSubmit={handleSubmit(() => {})}>
<TestFieldTextInput
name="title"
control={control}
rules={{
required: JSON.stringify({ key: 'validation.required' }),
}}
label="Title"
/>
<button type="submit">Submit</button>
</form>
</MantineProvider>
);
}
render(<TestForm />);
await user.click(screen.getByText('Submit'));
// The mock translation should resolve "validation.required" → "This field is required"
expect(screen.getByText('This field is required')).toBeInTheDocument();
});
it('falls back to raw message when i18n key is not found', async () => {
const user = userEvent.setup();
function TestForm() {
const { control, handleSubmit } = useForm({ defaultValues: { code: '' } });
return (
<MantineProvider>
<form onSubmit={handleSubmit(() => {})}>
<TestFieldTextInput
name="code"
control={control}
rules={{
required: JSON.stringify({ key: 'validation.unknown_key' }),
}}
label="Code"
/>
<button type="submit">Submit</button>
</form>
</MantineProvider>
);
}
render(<TestForm />);
await user.click(screen.getByText('Submit'));
// The fallback should be the raw JSON string since neither namespace has the key.
// Our mock t() returns defaultValue when key is unknown, which is the raw JSON.
const errorElements = screen.getAllByText((content) =>
content.includes('validation.unknown_key'),
);
expect(errorElements.length).toBeGreaterThan(0);
});
it('has the correct displayName for React DevTools', () => {
expect(
(TestFieldTextInput as unknown as { displayName: string }).displayName,
).toBe('TestFieldTextInput');
});
it('forwards additional Mantine props (placeholder, etc.)', () => {
function TestForm() {
const { control } = useForm({ defaultValues: { search: '' } });
return (
<MantineProvider>
<TestFieldTextInput
name="search"
control={control}
label="Search"
placeholder="Type to search..."
/>
</MantineProvider>
);
}
render(<TestForm />);
expect(screen.getByPlaceholderText('Type to search...')).toBeInTheDocument();
});
});
@@ -0,0 +1,4 @@
import { Autocomplete, type AutocompleteProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldAutocomplete = withRHF<AutocompleteProps>('FieldAutocomplete', Autocomplete);
@@ -0,0 +1,6 @@
import { Checkbox, type CheckboxProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldCheckbox = withRHF<CheckboxProps>('FieldCheckbox', Checkbox, {
isCheckType: true,
});
@@ -0,0 +1,13 @@
import { Chip, type ChipGroupProps } from '@mantine/core';
import { withRHF } from '../withRHF';
// Wraps Chip.Group — individual Chip items are passed as children.
// Usage:
// <FieldChipGroup name="size" control={control}>
// <Chip value="sm">Small</Chip>
// <Chip value="md">Medium</Chip>
// <Chip value="lg">Large</Chip>
// </FieldChipGroup>
export const FieldChipGroup = withRHF<ChipGroupProps>('FieldChipGroup', Chip.Group, {
requiresWrapper: true,
});
@@ -0,0 +1,4 @@
import { ColorInput, type ColorInputProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldColorInput = withRHF<ColorInputProps>('FieldColorInput', ColorInput);
@@ -0,0 +1,8 @@
import { ColorPicker, type ColorPickerProps } from '@mantine/core';
import { withRHF } from '../withRHF';
// ColorPicker does NOT have a native `error` prop.
// The HOC wraps it in Input.Wrapper to display validation errors.
export const FieldColorPicker = withRHF<ColorPickerProps>('FieldColorPicker', ColorPicker, {
requiresWrapper: true,
});
@@ -0,0 +1,4 @@
import { FileInput, type FileInputProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldFileInput = withRHF<FileInputProps>('FieldFileInput', FileInput);
@@ -0,0 +1,4 @@
import { JsonInput, type JsonInputProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldJsonInput = withRHF<JsonInputProps>('FieldJsonInput', JsonInput);
@@ -0,0 +1,4 @@
import { MultiSelect, type MultiSelectProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldMultiSelect = withRHF<MultiSelectProps>('FieldMultiSelect', MultiSelect);
@@ -0,0 +1,4 @@
import { NativeSelect, type NativeSelectProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldNativeSelect = withRHF<NativeSelectProps>('FieldNativeSelect', NativeSelect);
@@ -0,0 +1,4 @@
import { NumberInput, type NumberInputProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldNumberInput = withRHF<NumberInputProps>('FieldNumberInput', NumberInput);
@@ -0,0 +1,4 @@
import { PasswordInput, type PasswordInputProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldPasswordInput = withRHF<PasswordInputProps>('FieldPasswordInput', PasswordInput);
@@ -0,0 +1,4 @@
import { PinInput, type PinInputProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldPinInput = withRHF<PinInputProps>('FieldPinInput', PinInput);
@@ -0,0 +1,10 @@
import { Radio, type RadioGroupProps } from '@mantine/core';
import { withRHF } from '../withRHF';
// Wraps Radio.Group — individual Radio items are passed as children.
// Usage:
// <FieldRadioGroup name="gender" control={control}>
// <Radio value="male" label="Male" />
// <Radio value="female" label="Female" />
// </FieldRadioGroup>
export const FieldRadioGroup = withRHF<RadioGroupProps>('FieldRadioGroup', Radio.Group);
@@ -0,0 +1,4 @@
import { RangeSlider, type RangeSliderProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldRangeSlider = withRHF<RangeSliderProps>('FieldRangeSlider', RangeSlider);
@@ -0,0 +1,4 @@
import { Rating, type RatingProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldRating = withRHF<RatingProps>('FieldRating', Rating);
@@ -0,0 +1,16 @@
import { SegmentedControl, type SegmentedControlProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export interface FieldSegmentedControlProps extends SegmentedControlProps {
label?: string;
description?: string;
withAsterisk?: boolean;
}
// SegmentedControl does NOT have a native `error` prop.
// The HOC wraps it in Input.Wrapper to display validation errors.
export const FieldSegmentedControl = withRHF<FieldSegmentedControlProps>(
'FieldSegmentedControl',
SegmentedControl,
{ requiresWrapper: true },
);
@@ -0,0 +1,4 @@
import { Select, type SelectProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldSelect = withRHF<SelectProps>('FieldSelect', Select);
@@ -0,0 +1,4 @@
import { Slider, type SliderProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldSlider = withRHF<SliderProps>('FieldSlider', Slider);
@@ -0,0 +1,6 @@
import { Switch, type SwitchProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldSwitch = withRHF<SwitchProps>('FieldSwitch', Switch, {
isCheckType: true,
});
@@ -0,0 +1,4 @@
import { TagsInput, type TagsInputProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldTagsInput = withRHF<TagsInputProps>('FieldTagsInput', TagsInput);
@@ -0,0 +1,4 @@
import { TextInput, type TextInputProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldTextInput = withRHF<TextInputProps>('FieldTextInput', TextInput);
@@ -0,0 +1,4 @@
import { Textarea, type TextareaProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldTextarea = withRHF<TextareaProps>('FieldTextarea', Textarea);
+65
View File
@@ -0,0 +1,65 @@
// ---------------------------------------------------------------------------
// Form Field Components — Barrel Export
// ---------------------------------------------------------------------------
// All components are generated via the withRHF() HOC factory.
// They use the `Field` prefix to prevent naming collisions with native
// Mantine components (e.g., FieldTextInput vs TextInput).
//
// Import patterns:
// import { FieldTextInput, FieldSelect } from '@repo/ui/form';
// import { FieldTextInput, FieldSelect } from '@repo/ui/components';
// ---------------------------------------------------------------------------
// Core HOC & Types (for advanced usage / custom field creation)
export { withRHF } from './withRHF';
export type { WithRHFProps, WithRHFOptions, ZodI18nPayload, ValueTransform } from './types';
// Re-export RHF essentials so consuming apps don't need separate imports
export { useForm, useFormContext, useWatch, useFieldArray, FormProvider } from 'react-hook-form';
export { zodResolver } from '@hookform/resolvers/zod';
// ---------------------------------------------------------------------------
// Text Input Fields
// ---------------------------------------------------------------------------
export { FieldTextInput } from './fields/text-input.field';
export { FieldPasswordInput } from './fields/password-input.field';
export { FieldTextarea } from './fields/textarea.field';
export { FieldNumberInput } from './fields/number-input.field';
export { FieldJsonInput } from './fields/json-input.field';
export { FieldPinInput } from './fields/pin-input.field';
export { FieldAutocomplete } from './fields/autocomplete.field';
// ---------------------------------------------------------------------------
// Selection Fields
// ---------------------------------------------------------------------------
export { FieldSelect } from './fields/select.field';
export { FieldMultiSelect } from './fields/multi-select.field';
export { FieldNativeSelect } from './fields/native-select.field';
export { FieldTagsInput } from './fields/tags-input.field';
// ---------------------------------------------------------------------------
// Toggle / Boolean Fields
// ---------------------------------------------------------------------------
export { FieldCheckbox } from './fields/checkbox.field';
export { FieldRadioGroup } from './fields/radio-group.field';
export { FieldSwitch } from './fields/switch.field';
export { FieldChipGroup } from './fields/chip-group.field';
export { FieldSegmentedControl } from './fields/segmented-control.field';
// ---------------------------------------------------------------------------
// Range / Numeric Fields
// ---------------------------------------------------------------------------
export { FieldSlider } from './fields/slider.field';
export { FieldRangeSlider } from './fields/range-slider.field';
export { FieldRating } from './fields/rating.field';
// ---------------------------------------------------------------------------
// Color Fields
// ---------------------------------------------------------------------------
export { FieldColorInput } from './fields/color-input.field';
export { FieldColorPicker } from './fields/color-picker.field';
// ---------------------------------------------------------------------------
// File Fields
// ---------------------------------------------------------------------------
export { FieldFileInput } from './fields/file-input.field';
+91
View File
@@ -0,0 +1,91 @@
import type { ComponentType } from 'react';
import type {
FieldPath,
FieldValues,
UseControllerProps,
} from 'react-hook-form';
// ---------------------------------------------------------------------------
// Zod i18n JSON payload shape
// ---------------------------------------------------------------------------
// When Zod errors are encoded for i18n, they follow this shape:
// { "key": "validation.required", "values": { "min": 3 } }
// The HOC will attempt JSON.parse on the error message string. If parsing
// succeeds and the shape matches, it will call t(key, values) for translation.
// ---------------------------------------------------------------------------
export interface ZodI18nPayload {
/** The i18n translation key, e.g. "validation.required" */
key: string;
/** Optional interpolation values, e.g. { min: 3, max: 255 } */
values?: Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// WithRHFProps — Props injected by the withRHF HOC
// ---------------------------------------------------------------------------
// This type removes Mantine's own value/onChange/onBlur/error props (which
// are controlled by RHF) and injects the RHF controller props instead.
// ---------------------------------------------------------------------------
/** Props that RHF will manage — stripped from the Mantine component's API */
type ManagedProps = 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error';
/**
* Final props type for a wrapped Field component.
*
* @template TComponentProps - The original Mantine component props
* @template TFieldValues - The form values shape (default: FieldValues)
* @template TName - The field path (auto-inferred from TFieldValues)
*/
export type WithRHFProps<
TComponentProps,
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Omit<TComponentProps, ManagedProps> &
UseControllerProps<TFieldValues, TName>;
// ---------------------------------------------------------------------------
// Value transform — for components with non-standard value semantics
// ---------------------------------------------------------------------------
/**
* Defines how a Mantine component's native event value maps to/from the
* RHF field value. Used for components like Checkbox (boolean ↔ checked)
* or NumberInput (number | string → number).
*/
export interface ValueTransform<TFieldValue = unknown, TNativeValue = unknown> {
/** Convert RHF field value → Mantine component prop */
toComponentValue: (fieldValue: TFieldValue) => TNativeValue;
/** Convert Mantine onChange argument → RHF field value */
toFieldValue: (nativeValue: TNativeValue) => TFieldValue;
}
// ---------------------------------------------------------------------------
// HOC configuration options
// ---------------------------------------------------------------------------
export interface WithRHFOptions {
/**
* When true, the component uses `checked` instead of `value` for its
* controlled state (e.g., Checkbox, Switch).
*/
isCheckType?: boolean;
/**
* When true, the wrapped Mantine component does NOT have a native `error`
* prop. The HOC will render the component inside `Input.Wrapper` to
* display validation errors.
*/
requiresWrapper?: boolean;
}
// ---------------------------------------------------------------------------
// Utility: Extract the component's ref type for forwardRef
// ---------------------------------------------------------------------------
export type ExtractRef<T> = T extends ComponentType<infer P>
? P extends { ref?: infer R }
? R
: never
: never;
+240
View File
@@ -0,0 +1,240 @@
import React, { type ComponentType, type Ref, useMemo } from 'react';
import {
useController,
type FieldPath,
type FieldValues,
type UseControllerProps,
} from 'react-hook-form';
import { Input } from '@mantine/core';
import { useTranslation } from '@repo/core-i18n';
import type { ZodI18nPayload, WithRHFOptions } from './types';
// ---------------------------------------------------------------------------
// Helper: Attempt to parse a Zod error message as a JSON i18n payload
// ---------------------------------------------------------------------------
function tryParseI18nPayload(message: string): ZodI18nPayload | null {
// Quick guard: JSON payloads always start with '{'
if (!message.startsWith('{')) return null;
try {
const parsed: unknown = JSON.parse(message);
if (
typeof parsed === 'object' &&
parsed !== null &&
'key' in parsed &&
typeof (parsed as ZodI18nPayload).key === 'string'
) {
return parsed as ZodI18nPayload;
}
} catch {
// Not valid JSON — this is expected for plain string error messages
}
return null;
}
// ---------------------------------------------------------------------------
// useTranslatedError — Hook that resolves a raw error message into a
// user-facing translated string.
// ---------------------------------------------------------------------------
function useTranslatedError(rawMessage: string | undefined): string | undefined {
// Always call useTranslation — React hook rules require stable call order.
// The 'validation' namespace is used for Zod error keys.
// Falls back to 'common' automatically via i18next's ns resolution.
const { t, i18n } = useTranslation();
return useMemo(() => {
if (!rawMessage) return undefined;
const payload = tryParseI18nPayload(rawMessage);
if (payload) {
// Attempt to translate. If the key exists in i18n resources, we get
// the translated string. Otherwise i18next returns the key itself,
// and we fall back to the raw Zod message.
const translated = t(payload.key, {
...payload.values,
ns: 'validation',
defaultValue: payload.key, // fallback to the key itself
});
// If i18next couldn't find the key (returned the key unchanged),
// try without namespace, then fall back to the raw Zod message.
if (translated === payload.key) {
const commonAttempt = t(payload.key, {
...payload.values,
defaultValue: rawMessage,
});
return commonAttempt;
}
return translated;
}
// Not a JSON payload — check if the raw message itself is a translation key
if (i18n.exists(rawMessage, { ns: 'validation' })) {
return t(rawMessage, { ns: 'validation' });
}
// Plain string error message — pass through as-is
return rawMessage;
}, [rawMessage, t, i18n]);
}
// ---------------------------------------------------------------------------
// withRHF — Higher-Order Component Factory
// ---------------------------------------------------------------------------
//
// PERFORMANCE NOTES (ERP 1500+ field forms):
// -------------------------------------------
// 1. `useController` creates a MICRO-SUBSCRIPTION for this field only.
// The component will NOT re-render when unrelated fields change.
//
// 2. `React.memo` is applied on the OUTER wrapper component. This provides
// a second defense layer: even if a parent component re-renders (e.g.,
// a layout grid reshuffles), this field will bail out of rendering if
// its own props haven't changed.
//
// 3. Together, useController + React.memo gives us O(1) render cost per
// keystroke regardless of total form size — critical for ERP-scale forms.
//
// WHY React.memo IS WARRANTED HERE:
// In smaller forms (<50 fields), React.memo's shallow comparison cost is
// negligible but unnecessary. However, in ERP forms with 1500+ fields
// rendered in virtualized grids, each wasted render cascade can add
// ~16ms of jank. The memo wrapper prevents this with near-zero overhead
// (shallow prop comparison is O(n) on prop count, typically <10 props).
// ---------------------------------------------------------------------------
/**
* Creates a React Hook Form-connected wrapper around any Mantine form component.
*
* @param displayName - The display name for the wrapped component (e.g., "FieldTextInput")
* @param MantineComponent - The Mantine component to wrap
* @param options - Configuration for special component types (checkbox, wrapper-needed, etc.)
*
* @example
* ```tsx
* import { TextInput } from '@mantine/core';
* import { withRHF } from './withRHF';
*
* export const FieldTextInput = withRHF('FieldTextInput', TextInput);
* ```
*/
export function withRHF<TComponentProps extends Record<string, any>>(
displayName: string,
MantineComponent: ComponentType<TComponentProps>,
options: WithRHFOptions = {},
) {
const { isCheckType = false, requiresWrapper = false } = options;
type Props<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Omit<TComponentProps, 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'checked'> &
UseControllerProps<TFieldValues, TName> & {
/** Optional ref forwarded to the underlying Mantine component */
ref?: Ref<unknown>;
};
// -----------------------------------------------------------------------
// The inner component — separated so React.memo can wrap it cleanly.
// -----------------------------------------------------------------------
function FieldComponent<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(props: Props<TFieldValues, TName>) {
const {
name,
control,
rules,
shouldUnregister,
defaultValue,
disabled,
ref,
...mantineProps
} = props;
const {
field,
fieldState: { error },
} = useController<TFieldValues, TName>({
name,
control,
rules,
shouldUnregister,
defaultValue,
disabled,
});
// Translate the error message (handles JSON i18n payloads)
const translatedError = useTranslatedError(error?.message);
// Build the props to spread onto the Mantine component
const componentProps: Record<string, unknown> = {
...mantineProps,
ref: ref ?? field.ref,
onBlur: field.onBlur,
disabled: field.disabled,
};
if (isCheckType) {
// Checkbox / Switch: use `checked` and boolean onChange
componentProps['checked'] = !!field.value;
componentProps['onChange'] = (event: React.ChangeEvent<HTMLInputElement> | boolean) => {
if (typeof event === 'boolean') {
field.onChange(event);
} else {
field.onChange(event.currentTarget.checked);
}
};
} else {
// Standard components: use `value` and direct onChange
componentProps['value'] = field.value ?? '';
componentProps['onChange'] = field.onChange;
}
// Components that lack a native `error` prop need Input.Wrapper
if (requiresWrapper) {
const { label, description, withAsterisk, ...innerProps } = componentProps as Record<string, unknown>;
return (
<Input.Wrapper
label={label as string}
description={description as string}
withAsterisk={withAsterisk as boolean}
error={translatedError}
>
<MantineComponent {...(innerProps as TComponentProps)} />
</Input.Wrapper>
);
}
// Standard path: pass error directly to the Mantine component
componentProps['error'] = translatedError;
return <MantineComponent {...(componentProps as TComponentProps)} />;
}
// -----------------------------------------------------------------------
// Apply React.memo for render bailout in large forms.
//
// We use the default shallow comparison. For ERP forms, this means a
// field component like <FieldTextInput name="address.city" /> will NOT
// re-render when <FieldTextInput name="address.zip" /> changes, because:
// 1. useController isolates the subscription (different field path)
// 2. React.memo catches any parent-driven re-renders where our own
// props haven't changed (e.g., a Grid layout re-render)
// -----------------------------------------------------------------------
const Memoized = React.memo(FieldComponent) as typeof FieldComponent;
// Preserve the display name for React DevTools
(Memoized as unknown as { displayName: string }).displayName = displayName;
return Memoized;
}
+1
View File
@@ -1,5 +1,6 @@
export * from '@mantine/core';
export * from './Form';
export * from './system-pages/coming-soon';
export * from './system-pages/forbidden';
export * from './system-pages/maintenance';
@@ -0,0 +1,15 @@
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>;
+1
View File
@@ -0,0 +1 @@
export * from './base.validator';
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/__tests__/setup.ts'],
css: false,
},
});