refactor: migrate docs-dev from storybook to vitepress config and update devcontainer configuration

This commit is contained in:
Firman Ramdhani
2026-06-24 13:14:44 +07:00
parent 385452bf36
commit ecb16c759d
26 changed files with 1892 additions and 1414 deletions
-80
View File
@@ -1,80 +0,0 @@
# @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`
-530
View File
@@ -1,530 +0,0 @@
# Core App Shell — Layout Engine Architecture & Usage Guide
> **Package**: `@repo/ui` · **Module Path**: `@repo/ui/components`
> **Dependencies**: React 18+, Mantine v8 (`AppShell`), `@mantine/hooks`
---
## Table of Contents
- [Overview](#overview)
- [Architecture](#architecture)
- [Composition Model](#composition-model)
- [File Structure](#file-structure)
- [API Reference](#api-reference)
- [CoreAppShellConfig](#coreappshellconfig)
- [Layout Variants](#layout-variants)
- [Features](#features)
- [Dimensions](#dimensions)
- [Slots](#slots)
- [Context API](#context-api)
- [Usage Examples](#usage-examples)
- [Minimal Setup](#minimal-setup)
- [Header-First with Utility Bar](#header-first-with-utility-bar)
- [Double Sidebar (Rail + Panel)](#double-sidebar-rail--panel)
- [Interactive Config Builder](#interactive-config-builder)
- [CorePageContainer](#corepagecontainer)
- [Design Decisions & Caveats](#design-decisions--caveats)
---
## Overview
The **Core App Shell** is a configuration-driven layout engine that wraps Mantine's `AppShell` component. It provides a single `<CoreAppShell>` component that renders enterprise-grade application frames — complete with headers, sidebars, aside panels, utility bars, and footers — controlled entirely through a declarative `config` object and slot-based content injection.
Key capabilities:
- **Three layout variants** — `header-first`, `sidebar-first`, and `top-nav` — covering the most common enterprise SaaS patterns
- **Double sidebar** — Google-style rail + contextual panel navigation
- **Smart defaults** — Slots auto-detect presence; no explicit feature flags needed for basic layouts
- **Responsive out-of-the-box** — Mobile drawer, desktop collapse, and mini-sidebar are all built-in
- **State persistence** — Optional `localStorage`-backed sidebar state via `@mantine/hooks`
- **Context API** — All layout toggle methods (`toggleMobile`, `toggleDesktop`, `setSidebarVariant`, etc.) are available to any descendant component via `useCoreAppShell()`
---
## Architecture
### Composition Model
The layout engine uses a **Provider → Inner** composition pattern:
```
CoreAppShell (Public API)
└── CoreAppShellProvider (Context — state management)
└── CoreAppShellInner (Layout rendering — consumes context)
└── Mantine <AppShell> (CSS Grid engine)
├── AppShell.Header ← slots.utilityBar + slots.header
├── AppShell.Navbar ← slots.sidebar | slots.sidebarRail + slots.sidebarPanel
├── AppShell.Main ← children
├── AppShell.Aside ← slots.aside
└── AppShell.Footer ← slots.footer
```
The outer `CoreAppShell` is a thin wrapper that instantiates the provider and passes config down. The inner component subscribes to context and derives all layout calculations (navbar width, header height, collapse states) from the live config + user interactions.
### File Structure
```
packages/ui/src/components/core-app-shell/
├── types.ts # All TypeScript interfaces and union types
├── core-app-shell-context.tsx # Context provider + useCoreAppShell hook
├── core-app-shell.tsx # Main component (Public API + Inner renderer)
├── core-page-container.tsx # Companion page-level content wrapper
└── index.ts # Barrel exports
```
**Source**: [`core-app-shell/`](../src/components/core-app-shell/)
---
## API Reference
### CoreAppShellConfig
The top-level configuration object that controls the entire layout:
```tsx
interface CoreAppShellConfig {
variant: LayoutVariant;
dimensions?: CoreAppShellDimensions;
features?: CoreAppShellFeatures;
}
```
| Property | Type | Required | Description |
|---|---|---|---|
| `variant` | `LayoutVariant` | ✅ | Determines the structural layout mode |
| `dimensions` | `CoreAppShellDimensions` | — | Override default pixel dimensions |
| `features` | `CoreAppShellFeatures` | — | Toggle optional layout regions and behaviors |
---
### Layout Variants
```tsx
type LayoutVariant = 'header-first' | 'sidebar-first' | 'top-nav';
```
| Variant | Mantine `layout` | Visual Description |
|---|---|---|
| `header-first` | `default` | Header spans the full viewport width. Sidebar and aside sit **below** the header, stretching to the bottom of the screen. Footer is inset between the sidebar and aside. This is the most common enterprise/dashboard pattern (e.g., Azure Portal, Jira). |
| `sidebar-first` | `alt` | Sidebar spans the full viewport height. Header sits **to the right** of the sidebar. Produces a "desktop application" feel (e.g., VS Code, Slack). Footer spans full width beneath the sidebar. |
| `top-nav` | `default` | Header-only layout with **no visible desktop sidebar**. The sidebar is hidden on desktop but remains accessible as a mobile drawer on small screens. Ideal for documentation sites or marketing pages. |
> [!IMPORTANT]
> When `variant` is set to `top-nav`, the desktop navbar is visually hidden via `collapsed.desktop: true` and width `0`. However, the `<AppShell.Navbar>` DOM element remains mounted with responsive width props so the mobile drawer continues to function. This is an intentional design choice to avoid conditional DOM removal.
---
### Features
```tsx
interface CoreAppShellFeatures {
desktopCollapseVariant?: DesktopCollapseVariant;
withUtilityBar?: boolean;
withAside?: boolean;
withFooter?: boolean;
withDoubleSidebar?: boolean;
persistState?: boolean;
zIndex?: number;
disabled?: boolean;
}
```
| Property | Type | Default | Description |
|---|---|---|---|
| `desktopCollapseVariant` | `'hide' \| 'mini'` | `'hide'` | **`hide`**: Sidebar slides out completely (collapsed width = 0). **`mini`**: Sidebar shrinks to `sidebarMiniWidth` showing only icons. |
| `withUtilityBar` | `boolean` | Auto-detected | Show the utility bar above the header. If omitted, the bar renders when a `utilityBar` slot is provided. Set explicitly to `false` to suppress. |
| `withAside` | `boolean` | Auto-detected | Show the right-hand aside panel. Same auto-detection logic as `withUtilityBar`. |
| `withFooter` | `boolean` | Auto-detected | Show the bottom footer. Same auto-detection logic. |
| `withDoubleSidebar` | `boolean` | `false` | Enable the **Rail + Panel** double sidebar mode. When `true`, the navbar renders `sidebarRail` and `sidebarPanel` slots instead of the single `sidebar` slot. |
| `persistState` | `boolean` | `true` (implied) | Persist sidebar variant (`expanded`/`mini`/`hidden`) to `localStorage` via `useLocalStorage`. Set to `false` for demos or ephemeral layouts. |
| `zIndex` | `number` | `200` | Base z-index passed to Mantine's `AppShell`. |
| `disabled` | `boolean` | `false` | Disables the AppShell layout entirely (renders children without structural chrome). |
> [!TIP]
> **Smart defaults**: You rarely need to set `withUtilityBar`, `withAside`, or `withFooter` explicitly. The engine auto-detects presence by checking if the corresponding slot is provided and truthy. Only set them to `false` when you want to **suppress** a slot that is being passed.
---
### Dimensions
```tsx
interface CoreAppShellDimensions {
utilityBarHeight?: number | string;
headerHeight?: number | string;
sidebarWidth?: number | string;
sidebarMiniWidth?: number | string;
sidebarRailWidth?: number | string;
asideWidth?: number | string;
}
```
| Property | Type | Default | Description |
|---|---|---|---|
| `utilityBarHeight` | `number \| string` | `32` | Height of the utility bar strip above the header |
| `headerHeight` | `number \| string` | `60` | Height of the main header |
| `sidebarWidth` | `number \| string` | `260` | Width of the expanded sidebar |
| `sidebarMiniWidth` | `number \| string` | `80` | Width of the sidebar in `mini` collapse mode |
| `sidebarRailWidth` | `number \| string` | `54` | Width of the icon rail in double-sidebar mode |
| `asideWidth` | `number \| string` | `260` | Width of the right-hand aside panel |
> [!NOTE]
> All dimension values accept both pixel numbers (e.g., `260`) and CSS strings (e.g., `'20rem'`). When both `headerHeight` and `utilityBarHeight` are numbers, they are summed directly. When either is a string, the engine wraps them in a `calc()` expression automatically.
---
### Slots
Content is injected via the `slots` prop — a flat object of named `ReactNode` values:
```tsx
interface CoreAppShellSlots {
utilityBar?: ReactNode;
header?: ReactNode;
sidebar?: ReactNode;
sidebarMobile?: ReactNode;
sidebarRail?: ReactNode;
sidebarPanel?: ReactNode;
aside?: ReactNode;
footer?: ReactNode;
}
```
| Slot | Location | Notes |
|---|---|---|
| `utilityBar` | Above the header, hidden on mobile (`display: none` below `sm`) | Typically used for environment banners, announcements, or top-level links. |
| `header` | Main application header | Must contain its own `<Burger>` for mobile toggle (use `useCoreAppShell()` context). |
| `sidebar` | Desktop navbar body (single-sidebar mode) | Ignored when `withDoubleSidebar` is `true` — use `sidebarRail` + `sidebarPanel` instead. |
| `sidebarMobile` | Mobile drawer content | Falls back to `sidebar` if not provided. Use this to render a simplified mobile-specific navigation. |
| `sidebarRail` | Narrow icon rail (double-sidebar mode) | Only rendered when `withDoubleSidebar` is `true`. Separated from `sidebarPanel` by a 1px border. |
| `sidebarPanel` | Contextual panel beside the rail (double-sidebar mode) | Collapsible via `toggleNavbarPanel()`. Only rendered when `withDoubleSidebar` is `true` and `navbarPanelOpened` is `true`. |
| `aside` | Right-hand panel | Collapsible via `toggleAside()`. Only rendered when `withAside` is enabled. |
| `footer` | Bottom application footer | In `header-first` mode, the footer is inset between sidebar and aside. In `sidebar-first` mode, it spans the full width. |
---
### Context API
The `useCoreAppShell()` hook provides access to all layout state and toggle methods from any descendant component:
```tsx
import { useCoreAppShell } from '@repo/ui/components';
```
| Property / Method | Type | Description |
|---|---|---|
| `mobileOpened` | `boolean` | Whether the mobile drawer is currently open |
| `desktopOpened` | `boolean` | Whether the desktop sidebar is expanded (only applies when `desktopCollapseVariant` is `'hide'`) |
| `sidebarVariant` | `SidebarVariant` | Current sidebar mode: `'expanded'` \| `'mini'` \| `'hidden'` |
| `asideOpened` | `boolean` | Whether the aside panel is currently visible |
| `navbarPanelOpened` | `boolean` | Whether the secondary panel in double-sidebar mode is expanded |
| `config` | `CoreAppShellConfig` | Read-only access to the current layout configuration |
| `toggleMobile()` | `() => void` | Toggle the mobile drawer open/closed |
| `toggleDesktop()` | `() => void` | Toggle the desktop sidebar open/closed |
| `toggleAside()` | `() => void` | Toggle the aside panel visibility |
| `toggleNavbarPanel()` | `() => void` | Toggle the double-sidebar panel open/closed |
| `setSidebarVariant()` | `(variant: SidebarVariant) => void` | Programmatically set the sidebar to `'expanded'`, `'mini'`, or `'hidden'` |
> [!WARNING]
> `useCoreAppShell()` **must** be called from within a `<CoreAppShell>` subtree. Calling it outside the provider will throw: `"useCoreAppShell must be used within CoreAppShellProvider"`. If you need context access in the header slot, pass a component (not inline JSX) so it mounts inside the provider tree.
---
## Usage Examples
### Minimal Setup
The simplest possible layout — a header and sidebar with all defaults:
```tsx
import { CoreAppShell, CoreAppShellConfig, useCoreAppShell } from '@repo/ui/components';
import { Group, Text, Box, Stack, Button, Burger } from '@repo/ui/components';
function MyHeader() {
const { mobileOpened, toggleMobile } = useCoreAppShell();
return (
<Group h="100%" px="md">
<Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
<Text fw={700}>My Application</Text>
</Group>
);
}
const config: CoreAppShellConfig = {
variant: 'header-first',
};
function App() {
return (
<CoreAppShell
config={config}
slots={{
header: <MyHeader />,
sidebar: (
<Stack p="md" gap="xs">
<Button variant="subtle" fullWidth>Dashboard</Button>
<Button variant="subtle" fullWidth>Settings</Button>
</Stack>
),
}}
>
<Text>Main content area</Text>
</CoreAppShell>
);
}
```
---
### Header-First with Utility Bar
A full enterprise layout with utility bar, aside, and footer:
```tsx
import { CoreAppShell, CoreAppShellConfig, useCoreAppShell } from '@repo/ui/components';
import { Group, Text, Box, Burger } from '@repo/ui/components';
function AppHeader() {
const { mobileOpened, toggleMobile } = useCoreAppShell();
return (
<Group h="100%" px="md" justify="space-between">
<Group>
<Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
<Text fw={700} size="lg">Enterprise Dashboard</Text>
</Group>
</Group>
);
}
const config: CoreAppShellConfig = {
variant: 'header-first',
features: {
desktopCollapseVariant: 'hide',
persistState: true,
},
dimensions: {
headerHeight: 60,
utilityBarHeight: 32,
sidebarWidth: 280,
asideWidth: 300,
},
};
function App() {
return (
<CoreAppShell
config={config}
slots={{
utilityBar: (
<Group h="100%" px="md" justify="flex-end">
<Text size="xs">v2.4.1 · Production</Text>
</Group>
),
header: <AppHeader />,
sidebar: <MySidebar />,
aside: <MyAside />,
footer: (
<Group h="100%" px="md">
<Text size="sm">© 2026 Acme Corp</Text>
</Group>
),
}}
>
<MyPageContent />
</CoreAppShell>
);
}
```
---
### Double Sidebar (Rail + Panel)
Google-style navigation with an icon rail and a collapsible contextual panel:
```tsx
import { CoreAppShell, CoreAppShellConfig, useCoreAppShell } from '@repo/ui/components';
import { Stack, Box, Text, Burger, Group } from '@repo/ui/components';
import { Home, Settings, BarChart2 } from 'lucide-react';
function AppHeader() {
const { mobileOpened, toggleMobile } = useCoreAppShell();
return (
<Group h="100%" px="md">
<Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
<Text fw={700}>Admin Panel</Text>
</Group>
);
}
const config: CoreAppShellConfig = {
variant: 'sidebar-first',
features: {
withDoubleSidebar: true,
},
dimensions: {
sidebarRailWidth: 54,
sidebarWidth: 260,
},
};
function App() {
return (
<CoreAppShell
config={config}
slots={{
header: <AppHeader />,
sidebarRail: (
<Stack align="center" gap="lg" pt="md">
<Home size={24} />
<BarChart2 size={24} />
<Settings size={24} />
</Stack>
),
sidebarPanel: (
<Box p="md">
<Text fw={700} mb="sm">Navigation</Text>
{/* Contextual links based on active rail icon */}
</Box>
),
sidebarMobile: (
<Box p="md">
<Text fw={700}>Mobile Nav</Text>
{/* Simplified mobile navigation */}
</Box>
),
}}
>
<Text>Main content</Text>
</CoreAppShell>
);
}
```
> [!NOTE]
> When `withDoubleSidebar` is `true`, the `sidebar` slot is ignored on desktop. The navbar renders `sidebarRail` (fixed-width icon column) and `sidebarPanel` (collapsible contextual panel) side-by-side. On mobile, `sidebarMobile` takes priority, falling back to `sidebar` if not provided.
---
### Interactive Config Builder
The showcase demo at `apps/web/src/apps/showcase/shell-demo/` demonstrates a live, interactive config builder where every feature toggle and variant switch updates the layout in real-time. The key pattern is managing `config` state externally and passing it as a prop:
```tsx
import { useState, useMemo } from 'react';
import { CoreAppShell, CoreAppShellConfig, LayoutVariant, DesktopCollapseVariant } from '@repo/ui/components';
function ShellDemo() {
const [layoutVariant, setLayoutVariant] = useState<LayoutVariant>('header-first');
const [collapseVariant, setCollapseVariant] = useState<DesktopCollapseVariant>('hide');
const [withDoubleSidebar, setWithDoubleSidebar] = useState(false);
const config: CoreAppShellConfig = useMemo(() => ({
variant: layoutVariant,
features: {
desktopCollapseVariant: collapseVariant,
withDoubleSidebar,
persistState: false,
},
}), [layoutVariant, collapseVariant, withDoubleSidebar]);
return (
<CoreAppShell config={config} slots={{ header: <MyHeader />, sidebar: <MySidebar /> }}>
{/* Config controls live here — they can use useCoreAppShell() for toggle methods */}
</CoreAppShell>
);
}
```
---
## CorePageContainer
A companion component for structuring page-level content within the `<AppShell.Main>` area. It provides a sticky page header and a contained, padded content region.
```tsx
import { CorePageContainer } from '@repo/ui/components';
```
### Props
```tsx
interface CorePageContainerProps extends ContainerProps {
headerSlot?: ReactNode;
children: ReactNode;
stickyHeader?: boolean;
}
```
| Prop | Type | Default | Description |
|---|---|---|---|
| `headerSlot` | `ReactNode` | — | Page-level header content (title, breadcrumbs, action buttons). Rendered above the main content with a bottom border. |
| `stickyHeader` | `boolean` | `false` | When `true`, the page header sticks to the top of the scroll area, offset by the AppShell header height via `var(--app-shell-header-offset)`. |
| `px` | `MantineSpacing` | `'md'` | Horizontal padding for both the header and content areas |
| `py` | `MantineSpacing` | `'md'` | Vertical padding for both the header and content areas |
| _...rest_ | `ContainerProps` | — | All other Mantine `Container` props are forwarded to the content region |
### Usage
```tsx
<CoreAppShell config={config} slots={slots}>
<CorePageContainer
stickyHeader
headerSlot={
<Group justify="space-between">
<Text component="h1" size="xl" fw={700}>Users</Text>
<Button>Add User</Button>
</Group>
}
>
<UserTable />
</CorePageContainer>
</CoreAppShell>
```
---
## Design Decisions & Caveats
### Mobile Navbar Lifecycle
The `<AppShell.Navbar>` DOM element is **always mounted**, even when the layout variant is `top-nav`. The desktop content is hidden via `visibleFrom="sm"` and mobile content via `hiddenFrom="sm"`. This ensures Mantine's native drawer engine works correctly on mobile without conditional DOM removal breaking the transition animations.
### Footer Positioning in `header-first` Mode
In `header-first` mode, the footer is **inset** between the sidebar and aside using CSS custom properties:
```css
left: var(--app-shell-navbar-offset, 0px);
right: var(--app-shell-aside-offset, 0px);
```
In `sidebar-first` mode, the footer spans the full viewport width (`left: 0; right: 0`).
### Z-Index Strategy
| Element | `header-first` | `sidebar-first` |
|---|---|---|
| AppShell (base) | `200` (default) | `200` (default) |
| Navbar | `105` | `100` |
| Aside | `105` | `100` |
| Footer | `100` | `100` |
The elevated `105` z-index for navbar/aside in `header-first` mode ensures they render above the footer, which is positioned at `100`.
### Sidebar Width Calculation
The navbar width is dynamically computed based on multiple state variables:
```
navbarWidth.sm =
isTopNav → 0
isDoubleSidebar + panelOpen → sidebarWidth
isDoubleSidebar + panelClosed → sidebarRailWidth
sidebarVariant === 'mini' → sidebarMiniWidth
default → sidebarWidth
```
On mobile and `xs` breakpoints, the width is always `100%` and `sidebarWidth` respectively, regardless of variant.
-915
View File
@@ -1,915 +0,0 @@
# 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.