feat: add useConditionalField hook and implement reactive form showcase examples

This commit is contained in:
Firman Ramdhani
2026-06-15 22:03:11 +07:00
parent f7c7bc6907
commit 50cce32ada
14 changed files with 862 additions and 172 deletions
@@ -0,0 +1,187 @@
import { useForm } from 'react-hook-form';
import { Button, Paper, Title, Group, Stack, Code, Divider, Text, Chip, Radio } from '@repo/ui/components';
import {
FieldTextInput, FieldPasswordInput, FieldTextarea, FieldNumberInput,
FieldJsonInput, FieldPinInput, FieldAutocomplete, FieldSelect,
FieldMultiSelect, FieldNativeSelect, FieldTagsInput, FieldCheckbox,
FieldRadioGroup, FieldSwitch, FieldChipGroup, FieldSegmentedControl,
FieldSlider, FieldRangeSlider, FieldRating, FieldColorInput,
FieldColorPicker, FieldFileInput
} from '@repo/ui/form';
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
export default function AllFieldsDemo() {
const t = useFormDemoTranslation();
const { control, handleSubmit, watch } = useForm<any>({
defaultValues: {
customerName: '',
email: '',
password: '',
description: '',
age: undefined,
jsonConfig: '',
pin: '',
country: '',
orderType: '',
categories: [],
nativeOrderType: '',
tags: [],
terms: false,
priority: '',
receiveEmails: false,
chipSelection: '',
segmentedPriority: 'normal',
satisfaction: 5,
priceRange: [0, 100],
rating: 0,
themeColor: '',
colorPicker: '#1c7ed6',
avatar: null
}
});
const onSubmit = (data: any) => console.log('All Fields Submitted:', data);
const data = watch();
return (
<Stack gap="xl">
<Paper p="xl" withBorder radius="md">
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="xl">
{/* --- Text & Numbers --- */}
<div>
<Title order={5} mb="sm" c="brand">Text & Numbers</Title>
<Divider mb="md" />
<Group grow align="flex-start" mb="md">
<FieldTextInput name="customerName" control={control} label={t.fields.customerName} />
<FieldTextInput name="email" control={control} label={t.fields.email} />
</Group>
<Group grow align="flex-start" mb="md">
<FieldPasswordInput name="password" control={control} label={t.fields.password} />
<FieldNumberInput name="age" control={control} label={t.fields.age} />
</Group>
<Group grow align="flex-start" mb="md">
<FieldTextarea name="description" control={control} label={t.fields.description} minRows={3} />
<FieldJsonInput name="jsonConfig" control={control} label={t.fields.jsonConfig} formatOnBlur />
</Group>
<div>
<Text size="sm" fw={500} mb={3}>{t.fields.pin}</Text>
<FieldPinInput name="pin" control={control} length={6} />
</div>
</div>
{/* --- Selections --- */}
<div>
<Title order={5} mb="sm" c="brand">Selections</Title>
<Divider mb="md" />
<Group grow align="flex-start" mb="md">
<FieldSelect
name="orderType"
control={control}
label={t.fields.orderType}
data={['BULK', 'RETAIL']}
/>
<FieldNativeSelect
name="nativeOrderType"
control={control}
label={`Native ${t.fields.orderType}`}
data={['BULK', 'RETAIL']}
/>
</Group>
<Group grow align="flex-start" mb="md">
<FieldAutocomplete
name="country"
control={control}
label="Country"
data={['Indonesia', 'Singapore', 'Malaysia']}
/>
<FieldMultiSelect
name="categories"
control={control}
label="Categories"
data={['Electronics', 'Fashion', 'Food']}
/>
</Group>
<FieldTagsInput name="tags" control={control} label={t.fields.tags} />
</div>
{/* --- Toggles & Choices --- */}
<div>
<Title order={5} mb="sm" c="brand">Toggles & Choices</Title>
<Divider mb="md" />
<Group mb="md">
<FieldCheckbox name="terms" control={control} label={t.fields.terms} />
<FieldSwitch name="receiveEmails" control={control} label={t.fields.receiveEmails} />
</Group>
<FieldRadioGroup
name="priority"
control={control}
label={t.fields.priority}
mb="md"
>
<Group mt="xs">
<Radio value="low" label="Low" />
<Radio value="high" label="High" />
</Group>
</FieldRadioGroup>
<FieldSegmentedControl
name="segmentedPriority"
control={control}
label={t.fields.priority}
data={[
{ label: 'Normal', value: 'normal' },
{ label: 'Urgent', value: 'urgent' }
]}
mb="md"
/>
<div>
<Text size="sm" fw={500} mb={3}>Chip Selection</Text>
<FieldChipGroup
name="chipSelection"
control={control}
>
<Group>
<Chip value="1">Option 1</Chip>
<Chip value="2">Option 2</Chip>
</Group>
</FieldChipGroup>
</div>
</div>
{/* --- Ranges & Specialized --- */}
<div>
<Title order={5} mb="sm" c="brand">Ranges & Specialized</Title>
<Divider mb="md" />
<Group grow align="flex-start" mb="md">
<FieldSlider name="satisfaction" control={control} label={t.fields.rating} />
<FieldRangeSlider name="priceRange" control={control} label="Price Range" />
</Group>
<Group grow align="flex-start" mb="md">
<FieldColorInput name="themeColor" control={control} label={t.fields.themeColor} />
<FieldFileInput name="avatar" control={control} label={t.fields.avatar} />
</Group>
<Group grow align="flex-start" mb="md">
<div>
<Text size="sm" fw={500} mb={3}>{t.fields.themeColor} Picker</Text>
<FieldColorPicker name="colorPicker" control={control} />
</div>
<div>
<Text size="sm" fw={500} mb={3}>{t.fields.rating}</Text>
<FieldRating name="rating" control={control} />
</div>
</Group>
</div>
<Button type="submit" mt="md">{t.common.submit}</Button>
</Stack>
</form>
</Paper>
<Paper p="md" withBorder radius="md" bg="var(--mantine-color-gray-0)">
<Title order={6} mb="xs">{t.common.submittedData}</Title>
<Code block>{JSON.stringify(data, null, 2)}</Code>
</Paper>
</Stack>
);
}
@@ -0,0 +1,235 @@
import { useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button, Paper, Title, Divider, Stack, Code, Alert } from '@repo/ui/components';
import { FieldTextInput, FieldSelect, FieldSwitch } from '@repo/ui/form';
import { useConditionalField } from '@repo/ui/hooks';
import { compose, required, emailValidator } from '@repo/ui/validators';
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
import { Info } from 'lucide-react';
export default function ReactiveWatchDemo() {
const t = useFormDemoTranslation();
// Define atomic validators for conditional fields
const taxIdValidator = compose(z.string(), required(t.watch.corporateTaxId));
const spouseNameValidator = compose(z.string(), required(t.watch.spouseName));
const newsletterEmailValidator = compose(z.string(), required('Newsletter Email'), emailValidator());
const roleValidator = compose(z.string(), required('Role'));
const reactiveSchema = z
.object({
userType: z.enum(['PERSONAL', 'CORPORATE']),
corporateTaxId: z.string().optional(),
hasSpouse: z.boolean(),
spouseName: z.string().optional(),
newsletter: z.boolean(),
newsletterEmail: z.string().optional(),
department: z.string().optional(),
role: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.userType === 'CORPORATE') {
const res = taxIdValidator.safeParse(data.corporateTaxId || '');
if (!res.success) res.error.issues.forEach((i: any) => ctx.addIssue({ ...i, path: ['corporateTaxId'] }));
}
if (data.hasSpouse) {
const res = spouseNameValidator.safeParse(data.spouseName || '');
if (!res.success) res.error.issues.forEach((i: any) => ctx.addIssue({ ...i, path: ['spouseName'] }));
}
if (data.newsletter) {
const res = newsletterEmailValidator.safeParse(data.newsletterEmail || '');
if (!res.success) res.error.issues.forEach((i: any) => ctx.addIssue({ ...i, path: ['newsletterEmail'] }));
}
if (data.department) {
const res = roleValidator.safeParse(data.role || '');
if (!res.success) res.error.issues.forEach((i: any) => ctx.addIssue({ ...i, path: ['role'] }));
}
});
const { control, handleSubmit, setValue, unregister, clearErrors } = useForm<any>({
resolver: zodResolver(reactiveSchema as any),
defaultValues: {
userType: 'PERSONAL',
corporateTaxId: '',
hasSpouse: false,
spouseName: '',
newsletter: false,
newsletterEmail: '',
department: '',
role: '',
},
});
// Micro-subscriptions via useWatch
const userType = useWatch({ control, name: 'userType' });
const hasSpouse = useWatch({ control, name: 'hasSpouse' });
const newsletter = useWatch({ control, name: 'newsletter' });
const department = useWatch({ control, name: 'department' });
const role = useWatch({ control, name: 'role' });
// Use the custom hook to cleanly unregister and reset fields when hidden
useConditionalField({
condition: userType === 'CORPORATE',
name: 'corporateTaxId',
setValue,
unregister,
mode: 'unregister',
defaultValue: '',
});
useConditionalField({
condition: hasSpouse === true,
name: 'spouseName',
setValue,
unregister,
mode: 'unregister',
defaultValue: '',
});
// Use the reset mode to clear values and errors without unregistering the field
useConditionalField({
condition: newsletter === true,
name: 'newsletterEmail',
setValue,
clearErrors,
mode: 'reset',
defaultValue: '',
});
// Cascading Dropdown Logic: Department -> Role
const roleOptions: Record<string, { value: string; label: string }[]> = {
IT: [
{ value: 'FRONTEND', label: 'Frontend Engineer' },
{ value: 'BACKEND', label: 'Backend Engineer' },
],
HR: [
{ value: 'RECRUITER', label: 'Technical Recruiter' },
{ value: 'MANAGER', label: 'HR Manager' },
],
FINANCE: [
{ value: 'ACCOUNTANT', label: 'Accountant' },
{ value: 'ANALYST', label: 'Financial Analyst' },
],
};
const currentRoleOptions = department ? roleOptions[department] : [];
const isRoleValid = !role || (!!department && currentRoleOptions.some((opt) => opt.value === role));
// Reset Mode: Automatically clears the 'role' field value and errors if the department changes
// and the currently selected role is no longer valid for the new department.
useConditionalField({
condition: isRoleValid,
name: 'role',
setValue,
clearErrors,
mode: 'reset',
defaultValue: '',
});
// Use watch only to display the JSON output at the bottom
const allValues = useWatch({ control });
const onSubmit = (data: any) => console.log('Reactive Passed:', data);
return (
<Stack gap="xl">
<Paper p="xl" withBorder radius="md">
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<Title order={5} mb="sm" c="brand">
Dynamic Fields & Validation
</Title>
<Divider mb="sm" />
<Alert icon={<Info size={16} />} title="Micro-subscription Pattern" color="blue" variant="light">
This form demonstrates isolated re-rendering using <code>useWatch</code>. Instead of re-rendering the
entire form root when typing, only the specific conditional fields update their display states.
</Alert>
<Divider label="Hidden/Unmounted Pattern" labelPosition="center" my="sm" />
<FieldSelect
name="userType"
control={control}
label={t.watch.userType}
data={[
{ value: 'PERSONAL', label: t.watch.typePersonal || 'Personal' },
{ value: 'CORPORATE', label: t.watch.typeCorporate || 'Corporate' },
]}
withAsterisk
/>
{userType === 'CORPORATE' && (
<FieldTextInput name="corporateTaxId" control={control} label={t.watch.corporateTaxId} withAsterisk />
)}
<FieldSwitch name="hasSpouse" control={control} label={t.watch.hasSpouse} mt="md" />
{hasSpouse && (
<FieldTextInput name="spouseName" control={control} label={t.watch.spouseName} withAsterisk />
)}
<Divider label="Visible but Disabled Pattern" labelPosition="center" my="md" />
<FieldSwitch name="newsletter" control={control} label="Subscribe to Newsletter" />
<FieldTextInput
name="newsletterEmail"
control={control}
label="Newsletter Email"
disabled={!newsletter}
placeholder="Enter your email to subscribe"
withAsterisk={newsletter}
/>
<Divider label="Reset Mode (Cascading Dependencies)" labelPosition="center" my="md" />
<FieldSelect
name="department"
control={control}
label="Department"
placeholder="Select a department"
data={[
{ value: 'IT', label: 'Information Technology' },
{ value: 'HR', label: 'Human Resources' },
{ value: 'FINANCE', label: 'Finance' },
]}
/>
{/* * ⚠️ CRITICAL UI FIX: DYNAMIC KEY
* Why bind the 'key' to the parent dependency (department)?
* * Mantine's Select component caches its internal visual state. When the parent
* 'department' changes, our useConditionalField hook successfully clears the RHF
* payload state, but Mantine might visually retain the old text on the screen.
* * By changing the 'key' whenever the department changes, we force React to
* completely unmount and remount this component. This destroys Mantine's old
* internal cache and guarantees a perfectly clean UI sync.
*/}
<FieldSelect
key={`role-select-${department}`}
name="role"
control={control}
label="Role"
placeholder="Select a role"
disabled={!department}
data={currentRoleOptions}
withAsterisk={!!department}
/>
<Button type="submit" mt="md">
{t.common?.submit || 'Submit Reactive Form'}
</Button>
</Stack>
</form>
</Paper>
<Paper p="md" withBorder radius="md" bg="var(--mantine-color-gray-0)">
<Title order={6} mb="xs">
{t.common?.submittedData || 'Submitted Data'}
</Title>
<Code block>{JSON.stringify(allValues, null, 2)}</Code>
</Paper>
</Stack>
);
}
@@ -0,0 +1,114 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button, Paper, Title, Group, Stack, Code, Divider } from '@repo/ui/components';
import {
FieldTextInput, FieldPasswordInput, FieldNumberInput
} from '@repo/ui/form';
import {
compose, required, rangeLength,
positiveNumber, simplePassword,
complexPassword, phoneValidator, rangeValue
} from '@repo/ui/validators';
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
export default function ValidationBankDemo() {
const t = useFormDemoTranslation();
// Compose the Zod schema using the atomic validators
const validationSchema = z.object({
username: compose(z.string(), required(t.fields.customerName), rangeLength(3, 15, t.fields.customerName)),
simplePass: compose(z.string(), required(t.validation.simplePassword), simplePassword(6)),
complexPass: compose(z.string(), required(t.validation.complexPassword), complexPassword(8)),
age: compose(z.number(), required(t.fields.age), rangeValue(18, 65, t.fields.age)),
score: compose(z.number(), required(t.validation.score), positiveNumber(t.validation.score)),
phone: compose(z.string(), required(t.validation.phone), phoneValidator())
});
type ValidationFormValues = z.infer<typeof validationSchema>;
const { control, handleSubmit, watch } = useForm<ValidationFormValues>({
resolver: zodResolver(validationSchema),
defaultValues: {
username: '',
simplePass: '',
complexPass: '',
age: undefined as any,
score: undefined as any,
phone: ''
}
});
const onSubmit = (data: ValidationFormValues) => console.log('Validation Passed:', data);
const data = watch();
return (
<Stack gap="xl">
<Paper p="xl" withBorder radius="md">
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<Title order={5} c="brand">Validation Bank (Atomic Registry)</Title>
<Divider mb="sm" />
<FieldTextInput
name="username"
control={control}
label={t.fields.customerName}
description={t.validation.usernameRange}
withAsterisk
/>
<Group grow align="flex-start">
<FieldPasswordInput
name="simplePass"
control={control}
label={t.validation.simplePassword}
description="Min 6 chars"
withAsterisk
/>
<FieldPasswordInput
name="complexPass"
control={control}
label={t.validation.complexPassword}
description="Min 8, 1 uppercase, 1 number, 1 special"
withAsterisk
/>
</Group>
<Group grow align="flex-start">
<FieldNumberInput
name="age"
control={control}
label={t.fields.age}
description={t.validation.ageRange}
withAsterisk
/>
<FieldNumberInput
name="score"
control={control}
label={t.validation.score}
description="Must be > 0"
withAsterisk
/>
</Group>
<FieldTextInput
name="phone"
control={control}
label={t.validation.phone}
description="Format: +62..."
withAsterisk
/>
<Button type="submit" mt="md">{t.common.submit}</Button>
</Stack>
</form>
</Paper>
<Paper p="md" withBorder radius="md" bg="var(--mantine-color-gray-0)">
<Title order={6} mb="xs">{t.common.submittedData}</Title>
<Code block>{JSON.stringify(data, null, 2)}</Code>
</Paper>
</Stack>
);
}
@@ -0,0 +1,31 @@
import { Tabs } from '@repo/ui/components';
import AllFieldsDemo from './components/all-fields-demo';
import ValidationBankDemo from './components/validation-bank-demo';
import ReactiveWatchDemo from './components/reactive-watch-demo';
import { useFormDemoTranslation } from './i18n/useFormDemoTranslation';
export default function FormDemoView() {
const t = useFormDemoTranslation();
return (
<Tabs defaultValue="all-fields" variant="outline" radius="md">
<Tabs.List mb="md">
<Tabs.Tab value="all-fields">{t.tabs.allFields}</Tabs.Tab>
<Tabs.Tab value="validation-bank">{t.tabs.validationBank}</Tabs.Tab>
<Tabs.Tab value="reactive-watch">{t.tabs.reactiveWatch}</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="all-fields">
<AllFieldsDemo />
</Tabs.Panel>
<Tabs.Panel value="validation-bank">
<ValidationBankDemo />
</Tabs.Panel>
<Tabs.Panel value="reactive-watch">
<ReactiveWatchDemo />
</Tabs.Panel>
</Tabs>
);
}
@@ -0,0 +1,47 @@
{
"tabs": {
"allFields": "All 22 Fields Demo",
"validationBank": "Validation Bank",
"reactiveWatch": "Reactive Watch (useWatch)"
},
"common": {
"submit": "Submit Data",
"reset": "Reset Form",
"submittedData": "Submitted Data"
},
"fields": {
"customerName": "Customer Name",
"email": "Email Address",
"priority": "Production Priority",
"password": "Password",
"description": "Description",
"age": "Age",
"jsonConfig": "JSON Config",
"tags": "Tags",
"terms": "I agree to the terms and conditions",
"receiveEmails": "Receive marketing emails",
"rating": "Satisfaction Rating",
"themeColor": "Theme Color",
"avatar": "Avatar Upload",
"orderType": "Order Type",
"quantity": "Quantity",
"fabricColor": "Fabric Color",
"pin": "Security PIN"
},
"validation": {
"simplePassword": "Simple Password",
"complexPassword": "Complex Password",
"score": "Score (Positive)",
"ageRange": "Age Range (18-65)",
"usernameRange": "Username Length (3-15)",
"phone": "Phone Number (+62)"
},
"watch": {
"userType": "User Type",
"typePersonal": "Personal",
"typeCorporate": "Corporate",
"corporateTaxId": "Corporate Tax ID",
"hasSpouse": "Do you have a spouse?",
"spouseName": "Spouse Name"
}
}
@@ -0,0 +1,47 @@
{
"tabs": {
"allFields": "Demo 22 Field",
"validationBank": "Bank Validasi",
"reactiveWatch": "Reactive Watch (useWatch)"
},
"common": {
"submit": "Kirim Data",
"reset": "Reset Form",
"submittedData": "Data Terkirim"
},
"fields": {
"customerName": "Nama Pelanggan",
"email": "Alamat Email",
"priority": "Prioritas Produksi",
"password": "Kata Sandi",
"description": "Deskripsi",
"age": "Usia",
"jsonConfig": "Konfigurasi JSON",
"tags": "Label (Tags)",
"terms": "Saya setuju dengan syarat dan ketentuan",
"receiveEmails": "Terima email pemasaran",
"rating": "Peringkat Kepuasan",
"themeColor": "Warna Tema",
"avatar": "Unggah Avatar",
"orderType": "Tipe Pesanan",
"quantity": "Jumlah",
"fabricColor": "Warna Kain",
"pin": "PIN Keamanan"
},
"validation": {
"simplePassword": "Sandi Sederhana",
"complexPassword": "Sandi Kompleks",
"score": "Skor (Positif)",
"ageRange": "Rentang Usia (18-65)",
"usernameRange": "Panjang Username (3-15)",
"phone": "Nomor Telepon (+62)"
},
"watch": {
"userType": "Tipe Pengguna",
"typePersonal": "Personal",
"typeCorporate": "Perusahaan",
"corporateTaxId": "NPWP Perusahaan",
"hasSpouse": "Apakah Anda memiliki pasangan?",
"spouseName": "Nama Pasangan"
}
}
@@ -0,0 +1,10 @@
import { useTranslation } from 'react-i18next';
import en from './en.json';
import id from './id.json';
export type FormDemoI18n = typeof en;
export function useFormDemoTranslation(): FormDemoI18n {
const { i18n } = useTranslation();
return (i18n.language === 'id' ? id : en) as FormDemoI18n;
}
@@ -0,0 +1 @@
export { default } from './form-demo-view';
@@ -1,169 +0,0 @@
import { useState } from 'react';
import { z } from 'zod';
import { useForm, FormProvider, type SubmitHandler } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button, Paper, Title, Group, Stack, Code, Text } from '@repo/ui/components';
// Import our RHF-connected Field components from packages/ui
import {
FieldTextInput,
FieldNumberInput,
FieldSelect,
FieldColorInput,
FieldCheckbox,
FieldSegmentedControl
} from '@repo/ui/form';
// ─── 1. VALIDATOR SCHEMA WITH JSON I18N PAYLOAD ───────────────────
const erpOrderSchema = z.object({
customerName: z.string().min(3, {
message: JSON.stringify({ key: 'validation:min_length', values: { field: 'Nama Pelanggan', min: 3 } })
}),
email: z.string().email({
message: JSON.stringify({ key: 'validation:invalid_email' })
}),
orderType: z.enum(['BULK', 'RETAIL'], {
required_error: JSON.stringify({ key: 'validation:required', values: { field: 'Tipe Pesanan' } })
}),
quantity: z.number({
required_error: JSON.stringify({ key: 'validation:required', values: { field: 'Jumlah Roll' } })
}).min(5, {
message: JSON.stringify({ key: 'validation:min_length', values: { field: 'Jumlah Roll', min: 5 } })
}),
fabricColor: z.string().min(1, {
message: JSON.stringify({ key: 'validation:required', values: { field: 'Kode Warna Kain' } })
}),
priority: z.string(),
termsAccepted: z.literal(true, {
errorMap: () => ({ message: JSON.stringify({ key: 'validation:required', values: { field: 'Persetujuan Syarat & Ketentuan' } }) })
})
});
type ErpOrderPayload = z.infer<typeof erpOrderSchema>;
export default function FormShowcase() {
const [submittedData, setSubmittedData] = useState<ErpOrderPayload | null>(null);
// ─── 2. INITIALIZE REACT HOOK FORM WITH ZOD RESOLVER ─────────────
const methods = useForm<ErpOrderPayload>({
resolver: zodResolver(erpOrderSchema),
defaultValues: {
customerName: '',
email: '',
orderType: 'BULK',
quantity: 5,
fabricColor: '#228be6',
priority: 'normal',
// @ts-ignore - literal true is required but we start with false
termsAccepted: false,
},
});
const onSubmit: SubmitHandler<ErpOrderPayload> = (data) => {
setSubmittedData(data);
};
return (
<Stack gap="md" style={{ maxWidth: 800, margin: '0 auto', width: '100%' }}>
<Paper withBorder p="xl" radius="md" bg="var(--mantine-color-body)">
<Title order={3} mb="xs">📦 RHF + Zod + i18n Enterprise Demo</Title>
<Text c="dimmed" size="sm" mb="xl">
This form demonstrates the integration of our generated Mantine UI wrappers, React Hook Form micro-subscriptions, and Zod validation using JSON i18n payloads.
</Text>
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit as any)}>
<Stack gap="md">
<Group grow align="flex-start">
<FieldTextInput
name="customerName"
label="Nama Pelanggan"
placeholder="Masukkan nama perusahaan atau perorangan"
withAsterisk
/>
<FieldTextInput
name="email"
label="Email Korespondensi"
placeholder="billing@company.com"
withAsterisk
/>
</Group>
<FieldSegmentedControl
name="priority"
label="Prioritas Produksi"
data={[
{ label: 'Normal', value: 'normal' },
{ label: 'Urgent', value: 'urgent' },
{ label: 'Critical', value: 'critical' }
]}
/>
<Group grow align="flex-start">
<FieldSelect
name="orderType"
label="Tipe Pesanan"
data={[
{ value: 'BULK', label: 'Grosir (Bulk)' },
{ value: 'RETAIL', label: 'Eceran (Retail)' }
]}
withAsterisk
/>
<FieldNumberInput
name="quantity"
label="Jumlah Produksi (Roll)"
placeholder="Minimal 5 roll"
withAsterisk
/>
</Group>
<FieldColorInput
name="fabricColor"
label="Spesifikasi Warna Bahan"
placeholder="Pilih atau masukkan kode HEX warna"
withAsterisk
/>
<FieldCheckbox
name="termsAccepted"
label="Saya menyetujui syarat & ketentuan produksi"
mt="md"
/>
<Group justify="flex-end" mt="xl">
<Button
type="button"
variant="subtle"
color="gray"
onClick={() => {
methods.reset();
setSubmittedData(null);
}}
>
Reset Form
</Button>
<Button type="submit" color="brand">
Submit Payload
</Button>
</Group>
</Stack>
</form>
</FormProvider>
</Paper>
{/* Output Panel untuk pembuktian Payload akhir */}
{submittedData && (
<Paper withBorder p="md" radius="md" bg="dark.8">
<Title order={5} c="green.4" mb="xs"> Validated Payload Output (PATCH/POST Ready):</Title>
<Code block color="dark" style={{ fontSize: '13px' }}>
{JSON.stringify(submittedData, null, 2)}
</Code>
</Paper>
)}
</Stack>
);
}
+2 -2
View File
@@ -27,7 +27,7 @@ import PrinterList from './printer-list';
import ExamplePage from './example/example.page'; import ExamplePage from './example/example.page';
import EventsDemoPage from './events-demo'; import EventsDemoPage from './events-demo';
import PouchSample from './pouch-sample'; import PouchSample from './pouch-sample';
import FormShowcase from './example/features/form-showcase'; import FormDemoView from './example/features/form-demo';
interface ShowcaseViewProps { interface ShowcaseViewProps {
colorScheme: ColorSchemeType; colorScheme: ColorSchemeType;
@@ -286,7 +286,7 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
{/* --- FORMS TAB --- */} {/* --- FORMS TAB --- */}
{activeTab === 'forms' && ( {activeTab === 'forms' && (
<Stack gap="xl"> <Stack gap="xl">
<FormShowcase /> <FormDemoView />
</Stack> </Stack>
)} )}
+117
View File
@@ -362,6 +362,123 @@ it('minValue() should enforce min', () => {
--- ---
## 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>
);
}
```
---
## Usage Examples ## Usage Examples
### Basic Form ### Basic Form
+1
View File
@@ -1 +1,2 @@
export * from '@mantine/hooks'; export * from '@mantine/hooks';
export * from './useConditionalField';
@@ -0,0 +1,69 @@
import { useEffect } from 'react';
import type { UseFormSetValue, UseFormUnregister, UseFormClearErrors, FieldValues, Path } from 'react-hook-form';
export interface UseConditionalFieldOptions<TFieldValues extends FieldValues> {
condition: boolean;
name: Path<TFieldValues>;
setValue: UseFormSetValue<TFieldValues>;
unregister?: UseFormUnregister<TFieldValues>;
clearErrors?: UseFormClearErrors<TFieldValues>;
defaultValue?: any;
mode?: 'unregister' | 'reset';
}
/**
* Automatically cleans up a conditionally rendered React Hook Form field
* when its parent condition becomes false.
*
* @param options Configuration object for the conditional field behavior
*/
export function useConditionalField<TFieldValues extends FieldValues>(
options: UseConditionalFieldOptions<TFieldValues>
) {
// Destructure with default values
const {
condition,
name,
setValue,
unregister,
clearErrors,
defaultValue,
mode = 'unregister'
} = options;
const config = options;
useEffect(() => {
// When the condition evaluates to false, we execute the cleanup logic
if (!condition) {
// 1. Reset the field value. We use a stable empty state (like '' instead of undefined)
// to prevent uncontrolled component fallback in the React UI. We also force RHF to sync.
const targetValue = config.defaultValue !== undefined ? config.defaultValue : ('' as any);
config.setValue(config.name, targetValue, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true
});
// 2. Execute the appropriate side-effect strategy based on the active mode
if (mode === 'unregister' && config.unregister) {
// Unregister Mode: Completely unmounts the field from React Hook Form.
// The value is removed from the payload and validation is entirely bypassed.
config.unregister(config.name);
} else if (mode === 'reset' && config.clearErrors) {
// Reset Mode: Keeps the field active in the DOM (e.g. cascading or disabled dependencies).
// Wipes the value and clears active validation errors so the user can interact
// with a fresh state, but keeps the property in the payload.
config.clearErrors(config.name);
}
}
}, [
condition,
name,
setValue,
unregister,
clearErrors,
defaultValue,
mode
]);
}
@@ -1,4 +1,4 @@
import { z, type ZodString, type ZodNumber, type ZodTypeAny } from 'zod'; import type { ZodString, ZodNumber, ZodTypeAny } from 'zod';
// ─── UTILITIES ───────────────────────────────────────────────────────────── // ─── UTILITIES ─────────────────────────────────────────────────────────────