diff --git a/apps/web/src/apps/showcase/example/features/form-demo/components/all-fields-demo.tsx b/apps/web/src/apps/showcase/example/features/form-demo/components/all-fields-demo.tsx new file mode 100644 index 0000000..b7cd86d --- /dev/null +++ b/apps/web/src/apps/showcase/example/features/form-demo/components/all-fields-demo.tsx @@ -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({ + 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 ( + + +
+ + {/* --- Text & Numbers --- */} +
+ Text & Numbers + + + + + + + + + + + + + +
+ {t.fields.pin} + +
+
+ + {/* --- Selections --- */} +
+ Selections + + + + + + + + + + +
+ + {/* --- Toggles & Choices --- */} +
+ Toggles & Choices + + + + + + + + + + + + +
+ Chip Selection + + + Option 1 + Option 2 + + +
+
+ + {/* --- Ranges & Specialized --- */} +
+ Ranges & Specialized + + + + + + + + + + +
+ {t.fields.themeColor} Picker + +
+
+ {t.fields.rating} + +
+
+
+ + +
+
+
+ + + {t.common.submittedData} + {JSON.stringify(data, null, 2)} + +
+ ); +} diff --git a/apps/web/src/apps/showcase/example/features/form-demo/components/reactive-watch-demo.tsx b/apps/web/src/apps/showcase/example/features/form-demo/components/reactive-watch-demo.tsx new file mode 100644 index 0000000..df010e3 --- /dev/null +++ b/apps/web/src/apps/showcase/example/features/form-demo/components/reactive-watch-demo.tsx @@ -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({ + 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 = { + 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 ( + + +
+ + + Dynamic Fields & Validation + + + + } title="Micro-subscription Pattern" color="blue" variant="light"> + This form demonstrates isolated re-rendering using useWatch. Instead of re-rendering the + entire form root when typing, only the specific conditional fields update their display states. + + + + + + + {userType === 'CORPORATE' && ( + + )} + + + + {hasSpouse && ( + + )} + + + + + + + + + + + + {/* * ⚠️ 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. + */} + + + + +
+
+ + + + {t.common?.submittedData || 'Submitted Data'} + + {JSON.stringify(allValues, null, 2)} + +
+ ); +} diff --git a/apps/web/src/apps/showcase/example/features/form-demo/components/validation-bank-demo.tsx b/apps/web/src/apps/showcase/example/features/form-demo/components/validation-bank-demo.tsx new file mode 100644 index 0000000..06f24d6 --- /dev/null +++ b/apps/web/src/apps/showcase/example/features/form-demo/components/validation-bank-demo.tsx @@ -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; + + const { control, handleSubmit, watch } = useForm({ + 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 ( + + +
+ + Validation Bank (Atomic Registry) + + + + + + + + + + + + + + + + + + +
+
+ + + {t.common.submittedData} + {JSON.stringify(data, null, 2)} + +
+ ); +} diff --git a/apps/web/src/apps/showcase/example/features/form-demo/form-demo-view.tsx b/apps/web/src/apps/showcase/example/features/form-demo/form-demo-view.tsx new file mode 100644 index 0000000..9581fa1 --- /dev/null +++ b/apps/web/src/apps/showcase/example/features/form-demo/form-demo-view.tsx @@ -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 ( + + + {t.tabs.allFields} + {t.tabs.validationBank} + {t.tabs.reactiveWatch} + + + + + + + + + + + + + + + ); +} diff --git a/apps/web/src/apps/showcase/example/features/form-demo/i18n/en.json b/apps/web/src/apps/showcase/example/features/form-demo/i18n/en.json new file mode 100644 index 0000000..80bda1b --- /dev/null +++ b/apps/web/src/apps/showcase/example/features/form-demo/i18n/en.json @@ -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" + } +} diff --git a/apps/web/src/apps/showcase/example/features/form-demo/i18n/id.json b/apps/web/src/apps/showcase/example/features/form-demo/i18n/id.json new file mode 100644 index 0000000..ae86b2d --- /dev/null +++ b/apps/web/src/apps/showcase/example/features/form-demo/i18n/id.json @@ -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" + } +} diff --git a/apps/web/src/apps/showcase/example/features/form-demo/i18n/useFormDemoTranslation.ts b/apps/web/src/apps/showcase/example/features/form-demo/i18n/useFormDemoTranslation.ts new file mode 100644 index 0000000..0c84d67 --- /dev/null +++ b/apps/web/src/apps/showcase/example/features/form-demo/i18n/useFormDemoTranslation.ts @@ -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; +} diff --git a/apps/web/src/apps/showcase/example/features/form-demo/index.ts b/apps/web/src/apps/showcase/example/features/form-demo/index.ts new file mode 100644 index 0000000..9582b1e --- /dev/null +++ b/apps/web/src/apps/showcase/example/features/form-demo/index.ts @@ -0,0 +1 @@ +export { default } from './form-demo-view'; diff --git a/apps/web/src/apps/showcase/example/features/form-showcase.tsx b/apps/web/src/apps/showcase/example/features/form-showcase.tsx deleted file mode 100644 index 0bb0936..0000000 --- a/apps/web/src/apps/showcase/example/features/form-showcase.tsx +++ /dev/null @@ -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; - -export default function FormShowcase() { - const [submittedData, setSubmittedData] = useState(null); - - // ─── 2. INITIALIZE REACT HOOK FORM WITH ZOD RESOLVER ───────────── - const methods = useForm({ - 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 = (data) => { - setSubmittedData(data); - }; - - return ( - - - 📦 RHF + Zod + i18n Enterprise Demo - - This form demonstrates the integration of our generated Mantine UI wrappers, React Hook Form micro-subscriptions, and Zod validation using JSON i18n payloads. - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
- - {/* Output Panel untuk pembuktian Payload akhir */} - {submittedData && ( - - ✅ Validated Payload Output (PATCH/POST Ready): - - {JSON.stringify(submittedData, null, 2)} - - - )} -
- ); -} diff --git a/apps/web/src/apps/showcase/showcase-view.tsx b/apps/web/src/apps/showcase/showcase-view.tsx index 75ce9a3..30537ac 100644 --- a/apps/web/src/apps/showcase/showcase-view.tsx +++ b/apps/web/src/apps/showcase/showcase-view.tsx @@ -27,7 +27,7 @@ import PrinterList from './printer-list'; import ExamplePage from './example/example.page'; import EventsDemoPage from './events-demo'; import PouchSample from './pouch-sample'; -import FormShowcase from './example/features/form-showcase'; +import FormDemoView from './example/features/form-demo'; interface ShowcaseViewProps { colorScheme: ColorSchemeType; @@ -286,7 +286,7 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set {/* --- FORMS TAB --- */} {activeTab === 'forms' && ( - + )} diff --git a/packages/ui/docs/FORM-COMPONENTS.md b/packages/ui/docs/FORM-COMPONENTS.md index ab768df..6c7c558 100644 --- a/packages/ui/docs/FORM-COMPONENTS.md +++ b/packages/ui/docs/FORM-COMPONENTS.md @@ -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
...
; +} +``` + +### 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 ( +
+ + + {/* CRITICAL: We bind the department string to the key prop to force remounts on change */} + + + ); +} +``` + +--- + ## Usage Examples ### Basic Form diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts index 57bbcc5..0dd2aa5 100644 --- a/packages/ui/src/hooks/index.ts +++ b/packages/ui/src/hooks/index.ts @@ -1 +1,2 @@ export * from '@mantine/hooks'; +export * from './useConditionalField'; diff --git a/packages/ui/src/hooks/useConditionalField.ts b/packages/ui/src/hooks/useConditionalField.ts new file mode 100644 index 0000000..d05a511 --- /dev/null +++ b/packages/ui/src/hooks/useConditionalField.ts @@ -0,0 +1,69 @@ +import { useEffect } from 'react'; +import type { UseFormSetValue, UseFormUnregister, UseFormClearErrors, FieldValues, Path } from 'react-hook-form'; + +export interface UseConditionalFieldOptions { + condition: boolean; + name: Path; + setValue: UseFormSetValue; + unregister?: UseFormUnregister; + clearErrors?: UseFormClearErrors; + 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( + options: UseConditionalFieldOptions +) { + // 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 + ]); +} \ No newline at end of file diff --git a/packages/ui/src/validators/registry.validator.ts b/packages/ui/src/validators/registry.validator.ts index f793215..7d377cb 100644 --- a/packages/ui/src/validators/registry.validator.ts +++ b/packages/ui/src/validators/registry.validator.ts @@ -1,4 +1,4 @@ -import { z, type ZodString, type ZodNumber, type ZodTypeAny } from 'zod'; +import type { ZodString, ZodNumber, ZodTypeAny } from 'zod'; // ─── UTILITIES ─────────────────────────────────────────────────────────────