Merge pull request 'core/form' (#17) from core/form into main

Reviewed-on: eigen/fe-monorepo-template#17
This commit is contained in:
2026-06-22 04:37:00 +00:00
69 changed files with 6474 additions and 48 deletions
+4 -1
View File
@@ -298,10 +298,13 @@ This package is intended to hold non-UI, cross-cutting logic such as date/time h
### 10. `packages/ui`
Shared UI component library (Buttons, Inputs, Cards, Layouts).
Shared UI component library (Buttons, Inputs, Cards, Layouts) with a comprehensive **Form UI Library**.
* Ensures consistent design across all applications
* Designed to be consumed by both web apps and Storybook
* **Form UI Library**: 22 RHF-connected Mantine form components with Zod validation and i18n error translation, built via a `withRHF()` HOC factory with `useController` micro-subscriptions and `React.memo` optimization for ERP-scale forms
**Documentation**: [README.md](packages/ui/README.md) · [Form Components Guide](packages/ui/docs/FORM-COMPONENTS.md)
---
+29
View File
@@ -0,0 +1,29 @@
**Role:** You are a Staff Level React Engineer and Architect managing a highly scalable Enterprise ERP monorepo.
**Context:**
We have successfully established our Form UI library in `packages/ui/src/components/Form/fields` featuring 22 Mantine form components wrapped with React Hook Form (e.g., `FieldTextInput`, `FieldSelect`, `FieldColorPicker`, etc.). We also have a Zod validation layer at `packages/ui/src/validators` that uses JSON-stringified payloads for i18n translation.
We now need to build a showcase/demo page in the main web application to test and demonstrate these components in a real-world ERP form scenario.
**Pre-Execution Analysis (READ THESE FIRST):**
Before writing any code, you MUST read and analyze:
1. The component signatures and exports in `packages/ui/src/components/Form/index.ts`.
2. The Zod validator pattern in `packages/ui/src/validators` (specifically how the JSON i18n payloads are structured).
3. The existing layout and routing patterns in `apps/web/src/apps/showcase/showcase-view.tsx` to understand how to correctly inject and mount new showcase features.
**Task Requirements:**
**Phase 1: Create the Form Showcase Component**
1. Create a new comprehensive demo component inside `apps/web/src/apps/showcase/example/features/`. Follow the existing file naming convention found in that directory.
2. The component should implement a realistic ERP form (e.g., Textile Production Order, Inventory Bulk Update, or User Registration) using `useForm`, `zodResolver`, and a custom Zod schema.
3. The Zod schema MUST utilize the JSON-stringified i18n message pattern for validation errors.
4. Utilize a diverse set of our generated UI components (text input, select, number input, color input/picker, etc.) to prove they function correctly in a unified form.
5. Include a visual output panel (e.g., using Mantine's `Code` or `Pre` component) that displays the validated JSON payload upon successful submission.
**Phase 2: Connect to Showcase View**
1. Update `apps/web/src/apps/showcase/showcase-view.tsx` to import and render the newly created Form Showcase component.
2. Integrate it seamlessly into the existing UI layout of the showcase view (e.g., adding a new Tab, Accordion, or Section dedicated to the Form UI & Validation Layer).
**Execution Rules:**
- Do not rely on hardcoded assumptions. Let your code be guided completely by the patterns, styles, and typings you discover during the Pre-Execution Analysis.
- Ensure all TypeScript typings are strict.
- Output the newly created files and the modified files cleanly.
+4 -1
View File
@@ -13,6 +13,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@repo/core-api": "workspace:*",
"@repo/core-events": "workspace:*",
"@repo/core-i18n": "workspace:*",
@@ -26,9 +27,11 @@
"lucide-react": "^1.17.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-hook-form": "^7.56.4",
"react-i18next": "^15.4.0",
"react-router-dom": "^7.11.0",
"tailwindcss": "^4.1.18"
"tailwindcss": "^4.1.18",
"zod": "^3.25.36"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
@@ -0,0 +1,408 @@
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, FieldLocalSelect, FieldAsyncSelect,
FieldRichTextEditor
} from '@repo/ui/form';
import type { LoadOptionsFn } from '@repo/ui/form';
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
const MOCK_POKEMON = Array.from({ length: 100 }, (_, i) => ({
id: i + 1,
name: `Pokemon ${i + 1}`,
}));
const loadMockPokemonOptions: LoadOptionsFn<any> = async (search, page) => {
await new Promise((resolve) => setTimeout(resolve, 500));
const filtered = MOCK_POKEMON.filter((p) => p.name.toLowerCase().includes(search.toLowerCase()));
const pageSize = 20;
const start = (page - 1) * pageSize;
const paginated = filtered.slice(start, start + pageSize);
return {
options: paginated,
hasMore: start + pageSize < filtered.length,
};
};
const loadRealPokemonOptions: LoadOptionsFn<any> = async (_search, page) => {
const limit = 20;
const offset = (page - 1) * limit;
const res = await fetch(`https://pokeapi.co/api/v2/pokemon?limit=${limit}&offset=${offset}`);
const data = await res.json();
return {
options: data.results.map((p: any, i: number) => ({ id: offset + i + 1, ...p })),
hasMore: !!data.next,
};
};
const MOCK_VENDORS = [
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' },
{ id: 'V3', code: 'VN-03', name: 'Vendor Three' },
];
const loadMockVendorsOptions: LoadOptionsFn<any> = async (search, _page) => {
await new Promise((resolve) => setTimeout(resolve, 500));
const filtered = MOCK_VENDORS.filter(v => v.name.toLowerCase().includes(search.toLowerCase()) || v.code.toLowerCase().includes(search.toLowerCase()));
return { options: filtered, hasMore: false };
};
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,
localSelectEmpty: null,
localSelectPrefilled: { id: 'V2', code: 'VN-02', name: 'Vendor Two' },
asyncSelectEmpty: null,
asyncSelectPrefilled: { id: 999, code: 'ASYNC-99', name: 'Pre-loaded Async Vendor' },
localMultiPrefilled: [
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' }
],
asyncMultiPrefilled: [
{ id: 888, code: 'ASYNC-88', name: 'Ghost Async Vendor 1' },
{ id: 999, code: 'ASYNC-99', name: 'Ghost Async Vendor 2' }
],
richTextEmpty: "",
richTextPrefilled: "<h2 style=\"text-align: center\">ERP Release Notes</h2><p>This is a <b>highly important</b> update. Please observe the following:</p><ul><li>System maintenance at <i>midnight</i>.</li><li><u style=\"text-align: justify\">All users must log out.</u></li></ul><p style=\"text-align: justify\">Thank you for your cooperation.</p>",
realPokeSelect: null,
multiRealPokeSelect: []
}
});
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">{t.sections.textAndNumbers}</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">{t.sections.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={t.fields.country}
data={['Indonesia', 'Singapore', 'Malaysia']}
/>
<FieldMultiSelect
name="categories"
control={control}
label={t.fields.categories}
data={['Electronics', 'Fashion', 'Food']}
/>
</Group>
<Group grow align="flex-start" mb="md">
<FieldLocalSelect
name="localSelect"
control={control}
label={t.fields.localSelect}
placeholder={t.placeholders.selectComplexObject}
options={[
{ id: 1, name: 'Apple', type: 'Fruit' },
{ id: 2, name: 'Carrot', type: 'Vegetable' },
{ id: 3, name: 'Banana', type: 'Fruit' }
]}
valueKey="id"
labelKey="name"
clearable
/>
<FieldLocalSelect
multiple
name="multiLocalSelect"
control={control}
label={t.fields.multiLocalSelect}
placeholder={t.placeholders.selectMultipleObjects}
options={[
{ id: 1, name: 'Red', hex: '#f00' },
{ id: 2, name: 'Green', hex: '#0f0' },
{ id: 3, name: 'Blue', hex: '#00f' }
]}
valueKey="id"
renderLabel={(item) => `${item.name} (${item.hex})`}
clearable
/>
</Group>
<Group grow align="flex-start" mb="md">
<FieldAsyncSelect
name="asyncSelect"
control={control}
label={t.fields.asyncSelectMock}
placeholder={t.placeholders.searchPokemon}
loadOptions={loadMockPokemonOptions}
valueKey="id"
labelKey="name"
clearable
/>
<FieldAsyncSelect
multiple
name="multiAsyncSelect"
control={control}
label={t.fields.multiAsyncSelect}
placeholder={t.placeholders.selectMultiplePokemon}
loadOptions={loadMockPokemonOptions}
valueKey="id"
labelKey="name"
clearable
/>
</Group>
<Group grow align="flex-start" mb="md">
<FieldAsyncSelect
name="realPokeSelect"
control={control}
label={t.fields.realPokeSingle}
placeholder={t.placeholders.scrollDeduplication}
loadOptions={loadRealPokemonOptions}
valueKey="id"
labelKey="name"
clearable
/>
<FieldAsyncSelect
multiple
name="multiRealPokeSelect"
control={control}
label={t.fields.realPokeMulti}
placeholder={t.placeholders.scrollDeduplication}
loadOptions={loadRealPokemonOptions}
valueKey="id"
labelKey="name"
clearable
/>
</Group>
<FieldTagsInput name="tags" control={control} label={t.fields.tags} />
<Title order={5} mb="sm" mt="lg" c="brand">{t.sections.advancedObjectSelects}</Title>
<Divider mb="md" />
<Group grow align="flex-start" mb="md">
<FieldLocalSelect
name="localSelectEmpty"
control={control}
label={t.fields.localEmpty}
options={MOCK_VENDORS}
valueKey="id"
renderLabel={(item) => `[${item.code}] ${item.name}`}
clearable
/>
<FieldLocalSelect
name="localSelectPrefilled"
control={control}
label={t.fields.localPrefilled}
options={MOCK_VENDORS}
valueKey="id"
renderLabel={(item) => `[${item.code}] ${item.name}`}
clearable
/>
</Group>
<Group grow align="flex-start" mb="md">
<FieldAsyncSelect
name="asyncSelectEmpty"
control={control}
label={t.fields.asyncEmpty}
loadOptions={loadMockVendorsOptions}
valueKey="id"
renderLabel={(item) => `[${item.code}] ${item.name}`}
clearable
/>
<FieldAsyncSelect
name="asyncSelectPrefilled"
control={control}
label={t.fields.asyncPrefilled}
loadOptions={loadMockVendorsOptions}
valueKey="id"
renderLabel={(item) => `[${item.code}] ${item.name}`}
defaultOptions={[{ id: 999, code: 'ASYNC-99', name: 'Pre-loaded Async Vendor' }]}
clearable
/>
</Group>
<Title order={5} mb="sm" mt="lg" c="brand">{t.sections.multiSelectEditMode}</Title>
<Divider mb="md" />
<Group grow align="flex-start" mb="md">
<FieldLocalSelect
multiple
name="localMultiPrefilled"
control={control}
label={t.fields.localMultiPrefilled}
options={MOCK_VENDORS}
valueKey="id"
renderLabel={(item) => `[${item.code}] ${item.name}`}
clearable
/>
<FieldAsyncSelect
multiple
name="asyncMultiPrefilled"
control={control}
label={t.fields.asyncMultiPrefilled}
loadOptions={loadMockVendorsOptions}
valueKey="id"
renderLabel={(item) => `[${item.code}] ${item.name}`}
clearable
/>
</Group>
<Title order={5} mb="sm" mt="lg" c="brand">{t.sections.richTextEditor}</Title>
<Divider mb="md" />
<FieldRichTextEditor
name="richTextEmpty"
control={control}
label={t.fields.richTextEmpty}
description={t.descriptions.freshTipTap}
/>
<div style={{ marginTop: '16px' }}>
<FieldRichTextEditor
name="richTextPrefilled"
control={control}
label={t.fields.richTextPrefilled}
description={t.descriptions.htmlStringLoaded}
/>
</div>
</div>
{/* --- Toggles & Choices --- */}
<div>
<Title order={5} mb="sm" c="brand">{t.sections.togglesAndChoices}</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">{t.sections.rangesAndSpecialized}</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={t.fields.priceRange} />
</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,368 @@
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, TypographyStylesProvider } from '@repo/ui/components';
import { FieldTextInput, FieldSelect, FieldSwitch, FieldLocalSelect, FieldAsyncSelect, FieldRichTextEditor } 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';
import { useEffect, useCallback, useRef, useMemo } from 'react';
interface Region {
id: string;
code: string;
taxRate: number;
}
interface Warehouse {
id: string;
regionId: string;
name: string;
}
const REGIONS: Region[] = [
{ id: 'R1', code: 'APAC', taxRate: 0.1 },
{ id: 'R2', code: 'EMEA', taxRate: 0.2 }
];
const mockFetchWarehouses = async (regionIds: string[], search: string, page: number) => {
await new Promise((resolve) => setTimeout(resolve, 500));
const allWarehouses: Warehouse[] = [
{ id: 'W1', regionId: 'R1', name: 'Singapore Hub' },
{ id: 'W2', regionId: 'R1', name: 'Tokyo Depot' },
{ id: 'W3', regionId: 'R2', name: 'London Central' },
{ id: 'W4', regionId: 'R2', name: 'Berlin Storage' },
];
const filtered = allWarehouses.filter(w => regionIds.includes(w.regionId) && w.name.toLowerCase().includes(search.toLowerCase()));
const pageSize = 10;
const start = (page - 1) * pageSize;
const paginated = filtered.slice(start, start + pageSize);
return {
options: paginated,
hasMore: start + pageSize < filtered.length
};
};
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(t.fields.email), emailValidator());
const roleValidator = compose(z.string(), required(t.fields.role));
const reactiveSchema = useMemo(() => 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(),
regions: z.array(z.object({ id: z.string(), code: z.string(), taxRate: z.number() })).optional(),
warehouses: z.array(z.object({ id: z.string(), regionId: z.string(), name: z.string() })).optional(),
richTextLive: z.string().optional(),
})
.and(
z.discriminatedUnion('userType', [
z.object({ userType: z.literal('PERSONAL') }),
z.object({ userType: z.literal('CORPORATE'), corporateTaxId: taxIdValidator }),
]),
)
.and(
z.union([
z.object({ hasSpouse: z.literal(false) }),
z.object({ hasSpouse: z.literal(true), spouseName: spouseNameValidator }),
]),
)
.and(
z.union([
z.object({ newsletter: z.literal(false) }),
z.object({ newsletter: z.literal(true), newsletterEmail: newsletterEmailValidator }),
]),
)
.and(
z.union([
z.object({ department: z.string().min(1), role: roleValidator }),
z.object({ department: z.union([z.literal(''), z.undefined(), z.null()]).optional() }),
]),
), [t, taxIdValidator, spouseNameValidator, newsletterEmailValidator, roleValidator]);
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: '',
regions: [{ id: 'R1', code: 'APAC', taxRate: 0.1 }],
warehouses: [
{ id: 'W-99', regionId: 'R1', name: 'APAC Central Hub' },
{ id: 'W-98', regionId: 'R1', name: 'APAC Backup Hub' }
],
richTextLive: "<p>Start typing to see the live preview...</p>",
},
});
// 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' });
const regions = useWatch({ control, name: 'regions' });
const watchedRichTextLive = useWatch({ control, name: 'richTextLive' });
// 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: '',
});
const isMounted = useRef(false);
const prevRegionIds = useRef<string[]>(regions?.map((r: Region) => r.id) || []);
useEffect(() => {
if (!isMounted.current) {
isMounted.current = true;
return;
}
const currentIds = regions?.map((r: Region) => r.id) || [];
const prevIds = prevRegionIds.current;
const hasChanged = currentIds.length !== prevIds.length || currentIds.some((id: string) => !prevIds.includes(id));
if (hasChanged) {
setValue('warehouses', []);
clearErrors('warehouses');
prevRegionIds.current = currentIds;
}
}, [regions, setValue, clearErrors]);
// 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">
{t.sections.reactiveWatchCascading}
</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={t.fields.department}
placeholder={t.placeholders.selectComplexObject}
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={t.fields.role}
placeholder={t.placeholders.selectComplexObject}
disabled={!department}
data={currentRoleOptions}
withAsterisk={!!department}
/>
<Title order={5} mb="sm" c="brand" mt="lg">
{t.sections.reactiveWatchCascading}
</Title>
<Divider mb="sm" />
<FieldLocalSelect<Region>
multiple
name="regions"
control={control as any}
label={t.fields.regions}
options={REGIONS}
valueKey="id"
labelKey="code"
clearable
/>
<FieldAsyncSelect<Warehouse>
multiple
key={`warehouse-select-${regions?.map((r: any) => r.id).join(',')}`}
name="warehouses"
control={control as any}
label={t.fields.warehouses}
disabled={!regions || regions.length === 0}
loadOptions={useCallback(async (search, page) => {
if (!regions || regions.length === 0) return { options: [], hasMore: false };
return mockFetchWarehouses(regions.map((r: any) => r.id), search, page);
}, [regions])}
valueKey="id"
renderLabel={(item) => `[${item.id}] ${item.name}`}
clearable
/>
{regions && regions.length > 0 && (
<Alert mt="sm" color="teal">
{t.descriptions.selectedRegionsTax} {regions.map((r: any) => `${r.code} (${(r.taxRate * 100).toFixed(0)}%)`).join(', ')}
</Alert>
)}
<Title order={5} mb="sm" c="brand" mt="lg">
{t.sections.reactiveRichTextPreview}
</Title>
<Divider mb="sm" />
<FieldRichTextEditor
name="richTextLive"
control={control as any}
label={t.fields.liveEditor}
description={t.descriptions.typeToSeePreview}
/>
<Paper p="md" withBorder radius="md" mt="sm">
<Title order={6} mb="xs">{t.sections.liveHtmlPreview}</Title>
<TypographyStylesProvider>
<div dangerouslySetInnerHTML={{ __html: watchedRichTextLive }} />
</TypographyStylesProvider>
</Paper>
<Button type="submit" mt="md">
{t.common.submitReactive}
</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,254 @@
import { useMemo } from 'react';
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,
FieldLocalSelect, FieldAsyncSelect, FieldRichTextEditor
} from '@repo/ui/form';
import type { LoadOptionsFn } from '@repo/ui/form';
interface Department {
code: string;
name: string;
costCenter: string;
}
interface Assignee {
id: number;
email: string;
}
const MOCK_DEPARTMENTS: Department[] = [
{ code: 'IT', name: 'Information Technology', costCenter: 'CC-100' },
{ code: 'HR', name: 'Human Resources', costCenter: 'CC-200' },
{ code: 'FIN', name: 'Finance', costCenter: 'CC-300' },
];
const mockFetchUsers: LoadOptionsFn<Assignee> = async (search, page) => {
await new Promise((resolve) => setTimeout(resolve, 500));
const allUsers = Array.from({ length: 20 }, (_, i) => ({
id: i + 1,
email: `user${i + 1}@company.com`
}));
const filtered = allUsers.filter(u => u.email.toLowerCase().includes(search.toLowerCase()));
const pageSize = 5;
const start = (page - 1) * pageSize;
const paginated = filtered.slice(start, start + pageSize);
return {
options: paginated,
hasMore: start + pageSize < filtered.length
};
};
const MOCK_VENDORS = [
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' },
];
const mockFetchVendors: LoadOptionsFn<any> = async (search, _page) => {
await new Promise((resolve) => setTimeout(resolve, 500));
const filtered = MOCK_VENDORS.filter(v => v.name.toLowerCase().includes(search.toLowerCase()) || v.code.toLowerCase().includes(search.toLowerCase()));
return { options: filtered, hasMore: false };
};
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 = useMemo(() => 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()),
department: z.object({ code: z.string(), name: z.string() }, { required_error: t.errors.departmentRequired }),
assignees: z.array(z.object({ id: z.number(), email: z.string() })).min(2, t.errors.min2Assignees),
prefilledVendor: z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }, { required_error: t.errors.vendorRequired }),
emptyVendor: z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }, { required_error: t.errors.vendorRequired }),
prefilledAsyncMulti: z.array(z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() })).min(1, t.errors.min1Vendor),
richTextNotes: z.string().min(15, t.errors.notesMin15),
}), [t]);
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: '',
department: null as any,
assignees: [],
prefilledVendor: { id: 'V1', code: 'VN-01', name: 'Vendor One' } as any,
emptyVendor: null as any,
prefilledAsyncMulti: [
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' }
] as any,
richTextNotes: '',
}
});
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">{t.sections.validationBankTitle}</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={t.descriptions.min6Chars}
withAsterisk
/>
<FieldPasswordInput
name="complexPass"
control={control}
label={t.validation.complexPassword}
description={t.descriptions.min8Complex}
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={t.descriptions.mustBePositive}
withAsterisk
/>
</Group>
<FieldTextInput
name="phone"
control={control}
label={t.validation.phone}
description={t.descriptions.formatPhone}
withAsterisk
/>
<Title order={5} c="brand" mt="md">{t.sections.objectLevelValidations}</Title>
<Divider mb="sm" />
<FieldLocalSelect<Department>
name="department"
control={control as any}
label={t.fields.department}
options={MOCK_DEPARTMENTS}
valueKey="code"
renderLabel={(item) => `[${item.code}] ${item.name}`}
clearable
withAsterisk
/>
<FieldAsyncSelect<Assignee>
multiple
name="assignees"
control={control as any}
label={t.fields.assignees}
loadOptions={mockFetchUsers}
valueKey="id"
labelKey="email"
searchable
clearable
withAsterisk
/>
<Title order={5} c="brand" mt="lg">{t.sections.validatedPrefilledObjects}</Title>
<Divider mb="sm" />
<Group grow align="flex-start">
<FieldAsyncSelect
name="emptyVendor"
control={control as any}
label={t.fields.emptyVendor}
loadOptions={mockFetchVendors}
valueKey="id"
renderLabel={(item) => `[${item.code}] ${item.name}`}
clearable
withAsterisk
/>
<FieldAsyncSelect
name="prefilledVendor"
control={control as any}
label={t.fields.prefilledVendor}
loadOptions={mockFetchVendors}
valueKey="id"
renderLabel={(item) => `[${item.code}] ${item.name}`}
defaultOptions={[{ id: 'V1', code: 'VN-01', name: 'Vendor One' }]}
clearable
withAsterisk
/>
</Group>
<FieldAsyncSelect
multiple
name="prefilledAsyncMulti"
control={control as any}
label={t.fields.prefilledAsyncMulti}
loadOptions={mockFetchVendors}
valueKey="id"
renderLabel={(item) => `[${item.code}] ${item.name}`}
clearable
withAsterisk
/>
<Title order={5} c="brand" mt="lg">{t.sections.richTextValidations}</Title>
<Divider mb="sm" />
<FieldRichTextEditor
name="richTextNotes"
control={control as any}
label={t.fields.importantNotes}
description={t.descriptions.zodMinLengthString}
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,117 @@
{
"tabs": {
"allFields": "All 22 Fields Demo",
"validationBank": "Validation Bank",
"reactiveWatch": "Reactive Watch (useWatch)"
},
"common": {
"submit": "Submit Data",
"submitReactive": "Submit Reactive Form",
"reset": "Reset Form",
"submittedData": "Submitted Data"
},
"sections": {
"validationBankTitle": "Validation Bank (Atomic Registry)",
"objectLevelValidations": "Object Level Validations (Local & Async)",
"validatedPrefilledObjects": "Validated Prefilled Objects",
"richTextValidations": "Rich Text Editor Validations",
"textAndNumbers": "Text & Numbers",
"selections": "Selections",
"advancedObjectSelects": "Advanced Object Selects (Custom Labels & Default Values)",
"multiSelectEditMode": "Multi-Select Edit Mode (No defaultOptions fallback)",
"richTextEditor": "Rich Text Editor (TipTap)",
"togglesAndChoices": "Toggles & Choices",
"rangesAndSpecialized": "Ranges & Specialized",
"reactiveWatchCascading": "Reactive Watch (Cascading)",
"reactiveRichTextPreview": "Reactive Rich Text Preview",
"liveHtmlPreview": "Live HTML Preview Render"
},
"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",
"department": "Department",
"assignees": "Assignees",
"emptyVendor": "Empty Vendor",
"prefilledVendor": "Prefilled Vendor",
"prefilledAsyncMulti": "Prefilled Async Multi (No Fallback)",
"importantNotes": "Important Notes",
"country": "Country",
"categories": "Categories",
"localSelect": "Local Select",
"multiLocalSelect": "Multi Local Select",
"asyncSelectMock": "Async Select (Mock API)",
"multiAsyncSelect": "Multi Async Select",
"realPokeSingle": "Real PokeAPI (Single - Tests Deduplication)",
"realPokeMulti": "Real PokeAPI (Multi - Tests Deduplication)",
"localEmpty": "Local Empty",
"localPrefilled": "Local Prefilled",
"asyncEmpty": "Async Empty",
"asyncPrefilled": "Async Prefilled (Edit Mode)",
"localMultiPrefilled": "Local Multi Prefilled",
"asyncMultiPrefilled": "Async Multi Prefilled (Ghost Items)",
"richTextEmpty": "Rich Text (Empty)",
"richTextPrefilled": "Rich Text (Prefilled / Edit Mode)",
"priceRange": "Price Range",
"role": "Role",
"regions": "Regions",
"warehouses": "Warehouses",
"liveEditor": "Live Editor"
},
"placeholders": {
"selectComplexObject": "Select a complex object",
"selectMultipleObjects": "Select multiple objects",
"searchPokemon": "Search pokemon...",
"selectMultiplePokemon": "Select multiple pokemon...",
"scrollDeduplication": "Scroll to test deduplication..."
},
"descriptions": {
"min6Chars": "Min 6 chars",
"min8Complex": "Min 8, 1 uppercase, 1 number, 1 special",
"mustBePositive": "Must be > 0",
"formatPhone": "Format: +62...",
"zodMinLengthString": "This uses Zod minimum length string validation",
"freshTipTap": "A fresh TipTap editor instance",
"htmlStringLoaded": "HTML string successfully loaded from default values",
"typeToSeePreview": "Type to see instantaneous reactive rendering below",
"selectedRegionsTax": "Selected regions tax rates:"
},
"errors": {
"departmentRequired": "Department is required",
"vendorRequired": "Vendor is required",
"min2Assignees": "Select at least 2 assignees",
"min1Vendor": "Select at least 1 vendor",
"notesMin15": "Notes must be at least 15 characters long (including HTML tags)",
"selectRegionFirst": "Select a region first to load warehouses"
},
"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,117 @@
{
"tabs": {
"allFields": "Demo 22 Field",
"validationBank": "Bank Validasi",
"reactiveWatch": "Reactive Watch (useWatch)"
},
"common": {
"submit": "Kirim Data",
"submitReactive": "Kirim Form Reaktif",
"reset": "Reset Form",
"submittedData": "Data Terkirim"
},
"sections": {
"validationBankTitle": "Bank Validasi (Registri Atomik)",
"objectLevelValidations": "Validasi Tingkat Objek (Lokal & Async)",
"validatedPrefilledObjects": "Objek Terisi yang Divalidasi",
"richTextValidations": "Validasi Rich Text Editor",
"textAndNumbers": "Teks & Angka",
"selections": "Pilihan",
"advancedObjectSelects": "Pemilihan Objek Tingkat Lanjut (Label Kustom & Nilai Default)",
"multiSelectEditMode": "Mode Edit Multi-Select (Tanpa fallback defaultOptions)",
"richTextEditor": "Rich Text Editor (TipTap)",
"togglesAndChoices": "Tombol Sakelar & Pilihan",
"rangesAndSpecialized": "Rentang & Khusus",
"reactiveWatchCascading": "Reactive Watch (Berjenjang)",
"reactiveRichTextPreview": "Pratinjau Rich Text Reaktif",
"liveHtmlPreview": "Render Pratinjau HTML Langsung"
},
"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",
"department": "Departemen",
"assignees": "Penerima Tugas",
"emptyVendor": "Vendor Kosong",
"prefilledVendor": "Vendor Terisi",
"prefilledAsyncMulti": "Multi Async Terisi (Tanpa Fallback)",
"importantNotes": "Catatan Penting",
"country": "Negara",
"categories": "Kategori",
"localSelect": "Pilihan Lokal",
"multiLocalSelect": "Pilihan Lokal Multi",
"asyncSelectMock": "Pilihan Async (Mock API)",
"multiAsyncSelect": "Pilihan Async Multi",
"realPokeSingle": "API Pokemon Asli (Tunggal - Uji Deduplikasi)",
"realPokeMulti": "API Pokemon Asli (Multi - Uji Deduplikasi)",
"localEmpty": "Lokal Kosong",
"localPrefilled": "Lokal Terisi",
"asyncEmpty": "Async Kosong",
"asyncPrefilled": "Async Terisi (Mode Edit)",
"localMultiPrefilled": "Multi Lokal Terisi",
"asyncMultiPrefilled": "Multi Async Terisi (Item Hantu)",
"richTextEmpty": "Rich Text (Kosong)",
"richTextPrefilled": "Rich Text (Terisi / Mode Edit)",
"priceRange": "Rentang Harga",
"role": "Peran",
"regions": "Wilayah",
"warehouses": "Gudang",
"liveEditor": "Editor Langsung"
},
"placeholders": {
"selectComplexObject": "Pilih objek yang kompleks",
"selectMultipleObjects": "Pilih beberapa objek",
"searchPokemon": "Cari pokemon...",
"selectMultiplePokemon": "Pilih beberapa pokemon...",
"scrollDeduplication": "Gulir untuk menguji deduplikasi..."
},
"descriptions": {
"min6Chars": "Minimal 6 karakter",
"min8Complex": "Min 8, 1 huruf besar, 1 angka, 1 karakter khusus",
"mustBePositive": "Harus > 0",
"formatPhone": "Format: +62...",
"zodMinLengthString": "Ini menggunakan validasi panjang string minimum Zod",
"freshTipTap": "Instance editor TipTap yang baru",
"htmlStringLoaded": "String HTML berhasil dimuat dari nilai default",
"typeToSeePreview": "Ketik untuk melihat render reaktif seketika di bawah",
"selectedRegionsTax": "Tarif pajak wilayah yang dipilih:"
},
"errors": {
"departmentRequired": "Departemen wajib diisi",
"vendorRequired": "Vendor wajib diisi",
"min2Assignees": "Pilih minimal 2 penerima tugas",
"min1Vendor": "Pilih minimal 1 vendor",
"notesMin15": "Catatan minimal harus terdiri dari 15 karakter (termasuk tag HTML)",
"selectRegionFirst": "Pilih wilayah terlebih dahulu untuk memuat gudang"
},
"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';
+29 -1
View File
@@ -22,11 +22,14 @@ import {
Box,
Paper,
} from '@repo/ui/components';
import { ShieldCheck, Database, Lock, Layout, Activity, Printer } from 'lucide-react';
import { ShieldCheck, Database, Lock, Layout, Activity, Printer, FileText } from 'lucide-react';
import { Globe } from 'lucide-react';
import { useTranslation } from '@repo/core-i18n';
import PrinterList from './printer-list';
import ExamplePage from './example/example.page';
import EventsDemoPage from './events-demo';
import PouchSample from './pouch-sample';
import FormDemoView from './example/features/form-demo';
interface ShowcaseViewProps {
colorScheme: ColorSchemeType;
@@ -37,6 +40,7 @@ interface ShowcaseViewProps {
export default function ShowcaseView({ colorScheme, setColorScheme, density, setDensity }: ShowcaseViewProps) {
const [activeTab, setActiveTab] = useState<string | null>('ui-components');
const { i18n } = useTranslation();
// Mock data for the table
const tableData = [
@@ -55,6 +59,8 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
return 'Authentication & Security Layers';
case 'ui-components':
return 'Theme, Typography, Forms & Data Grids';
case 'forms':
return 'Enterprise Form Engine & Zod Validation';
case 'events':
return 'Global Event Bus Synchronization';
case 'hardware':
@@ -100,6 +106,9 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
<Tabs.Tab value="ui-components" leftSection={<Layout size={18} />}>
UI Components
</Tabs.Tab>
<Tabs.Tab value="forms" leftSection={<FileText size={18} />}>
Form Engine
</Tabs.Tab>
<Tabs.Tab value="storage" leftSection={<Database size={18} />}>
Offline Storage
</Tabs.Tab>
@@ -138,6 +147,18 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
{getSubtitle()}
</Text>
</Stack>
<Select
w={180}
size="sm"
variant="filled"
leftSection={<Globe size={16} />}
data={[
{ value: 'en', label: 'English' },
{ value: 'id', label: 'Bahasa Indonesia' },
]}
value={i18n.resolvedLanguage || i18n.language}
onChange={(val) => val && i18n.changeLanguage(val)}
/>
</Group>
</Paper>
@@ -277,6 +298,13 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
</Stack>
)}
{/* --- FORMS TAB --- */}
{activeTab === 'forms' && (
<Stack gap="xl">
<FormDemoView />
</Stack>
)}
{/* --- STORAGE TAB --- */}
{activeTab === 'storage' && (
<Stack gap="xl">
@@ -0,0 +1,17 @@
{
"validation": {
"required": "{{field}} is required",
"min_length": "{{field}} must be at least {{min}} characters",
"min_len": "{{field}} must be at least {{min}} characters",
"max_len": "{{field}} must be at most {{max}} characters",
"range_len": "{{field}} must be between {{min}} and {{max}} characters",
"min_val": "{{field}} must be greater than or equal to {{min}}",
"max_val": "{{field}} must be less than or equal to {{max}}",
"range_val": "{{field}} must be between {{min}} and {{max}}",
"must_be_positive": "{{field}} must be a positive number",
"invalid_email": "Invalid email format",
"invalid_phone": "Invalid phone number format",
"invalid_password_simple": "Password must be at least {{min}} characters",
"invalid_password_complex": "Password must contain at least 1 uppercase, 1 lowercase, 1 number, and 1 special character"
}
}
@@ -0,0 +1,17 @@
{
"validation": {
"required": "{{field}} wajib diisi",
"min_length": "{{field}} minimal {{min}} karakter",
"min_len": "{{field}} minimal {{min}} karakter",
"max_len": "{{field}} maksimal {{max}} karakter",
"range_len": "{{field}} harus antara {{min}} dan {{max}} karakter",
"min_val": "{{field}} minimal bernilai {{min}}",
"max_val": "{{field}} maksimal bernilai {{max}}",
"range_val": "{{field}} harus bernilai antara {{min}} dan {{max}}",
"must_be_positive": "{{field}} harus bernilai positif",
"invalid_email": "Format email tidak valid",
"invalid_phone": "Format nomor telepon tidak valid",
"invalid_password_simple": "Kata sandi minimal {{min}} karakter",
"invalid_password_complex": "Kata sandi harus mengandung minimal 1 huruf besar, 1 huruf kecil, 1 angka, dan 1 karakter spesial"
}
}
+4 -2
View File
@@ -2,6 +2,8 @@ import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import commonEn from './locales/en/common.json';
import commonId from './locales/id/common.json';
import validationEn from './locales/en/validation.json';
import validationId from './locales/id/validation.json';
const DEFAULT_LANGUAGE = 'id';
const SUPPORTED_LANGUAGES = ['en', 'id'] as const;
@@ -9,8 +11,8 @@ const SUPPORTED_LANGUAGES = ['en', 'id'] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
export const resources = {
en: { common: commonEn.common },
id: { common: commonId.common },
en: { common: commonEn.common, validation: validationEn.validation },
id: { common: commonId.common, validation: validationId.validation },
} as const;
export interface I18nStorageAdapter {
+80
View File
@@ -0,0 +1,80 @@
# @repo/ui — Shared UI Component Library
The centralized UI component library for the monorepo. Provides consistent design primitives, system pages, and **a comprehensive Form UI Library** for building enterprise-grade forms.
## Features
- **Mantine v8** components re-exported with unified theming
- **ThemeProvider** with dark/light mode, brand colors, and density modes (compact/standard)
- **Design tokens** — Colors, typography, radius, spacing, shadows mapped between Mantine and Tailwind
- **System pages** — Pre-built 404, 403, Maintenance, and Coming Soon pages
- **Form UI Library** — 22 RHF-connected Mantine form components with Zod validation and i18n error translation
## Exports
| Entry Point | Path | Description |
|---|---|---|
| `@repo/ui/components` | `./src/components/index.ts` | All components (Mantine re-exports + system pages + Form fields) |
| `@repo/ui/form` | `./src/components/Form/index.ts` | Form field components, `withRHF` factory, RHF/Zod re-exports |
| `@repo/ui/hooks` | `./src/hooks/index.ts` | Mantine hooks re-export |
| `@repo/ui/provider` | `./src/provider/index.ts` | `ThemeProvider` with color scheme and density controls |
| `@repo/ui/theme.css` | `./src/theme.css` | Base CSS with Mantine → Tailwind token mapping |
## 📋 Form UI Library
> **Full Documentation**: [docs/FORM-COMPONENTS.md](docs/FORM-COMPONENTS.md)
The Form UI Library wraps **all 22 applicable Mantine form components** with React Hook Form via a single `withRHF()` HOC factory. Key features:
- **`useController` micro-subscriptions** — O(1) render cost per keystroke, even in 1500+ field ERP forms
- **`React.memo` wrapper** — Prevents parent-driven cascade re-renders
- **Zod + i18n error translation** — JSON error payloads are auto-parsed and translated via `@repo/core-i18n`
- **Zero hardcoded styles** — All components inherit the active `ThemeProvider` configuration
- **`Field` prefix naming** — `FieldTextInput`, `FieldSelect`, etc. to avoid collisions with native Mantine exports
### Quick Start
```tsx
import { z } from 'zod';
import { useForm, zodResolver, FieldTextInput, FieldSelect } from '@repo/ui/form';
const schema = z.object({
name: z.string().min(1, 'Name is required'),
role: z.string().min(1, 'Please select a role'),
});
function UserForm() {
const { control, handleSubmit } = useForm({
resolver: zodResolver(schema),
defaultValues: { name: '', role: '' },
});
return (
<form onSubmit={handleSubmit(console.log)}>
<FieldTextInput name="name" control={control} label="Name" />
<FieldSelect
name="role"
control={control}
label="Role"
data={['Admin', 'Editor', 'Viewer']}
/>
<button type="submit">Save</button>
</form>
);
}
```
## Scripts
| Command | Description |
|---|---|
| `pnpm test` | Run unit tests (Vitest) |
| `pnpm test:watch` | Run tests in watch mode |
| `pnpm lint` | Run ESLint |
## Dependencies
- `@mantine/core` v8, `@mantine/hooks` v8
- `react-hook-form` v7, `@hookform/resolvers` v5, `zod` v3
- `@repo/core-i18n` (workspace)
- `tailwindcss` v4, `tailwind-variants`, `tailwind-merge`
+915
View File
@@ -0,0 +1,915 @@
# 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.
+19 -2
View File
@@ -4,8 +4,10 @@
"exports": {
"./theme.css": "./src/theme.css",
"./components": "./src/components/index.ts",
"./form": "./src/components/Form/index.ts",
"./hooks": "./src/hooks/index.ts",
"./provider": "./src/provider/index.ts"
"./provider": "./src/provider/index.ts",
"./validators": "./src/validators/index.ts"
},
"license": "MIT",
"scripts": {
@@ -18,22 +20,37 @@
"react-dom": "^19.2.3"
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@mantine/core": "^8.3.15",
"@mantine/hooks": "^8.3.15",
"@mantine/tiptap": "^9.3.2",
"@repo/core-i18n": "workspace:*",
"@repo/utils": "workspace:*",
"@tiptap/extension-link": "^3.27.1",
"@tiptap/extension-text-align": "^3.27.1",
"@tiptap/extension-underline": "^3.27.1",
"@tiptap/pm": "^3.27.1",
"@tiptap/react": "^3.27.1",
"@tiptap/starter-kit": "^3.27.1",
"dayjs": "^1.11.19",
"react-hook-form": "^7.56.4",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.18"
"tailwindcss": "^4.1.18",
"zod": "^3.25.36"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@tailwindcss/vite": "^4.1.18",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^8.57.1",
"jsdom": "^26.1.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"typescript": "5.5.4",
+38
View File
@@ -0,0 +1,38 @@
import '@testing-library/jest-dom/vitest';
// ---------------------------------------------------------------------------
// Polyfill: window.matchMedia
// ---------------------------------------------------------------------------
// Mantine v8's MantineProvider calls window.matchMedia internally for
// color scheme detection. jsdom does not implement matchMedia, so we
// provide a minimal stub to prevent TypeError during test rendering.
// ---------------------------------------------------------------------------
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
// ---------------------------------------------------------------------------
// Polyfill: ResizeObserver
// ---------------------------------------------------------------------------
// Some Mantine components (Popover, Select dropdown) use ResizeObserver
// which is also not available in jsdom.
// ---------------------------------------------------------------------------
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
window.ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver;
@@ -0,0 +1,239 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import { MantineProvider } from '@mantine/core';
import { FieldAsyncSelect } from '../fields/async-select.field';
import type { LoadOptionsFn } from '../custom/selects/types';
// ---------------------------------------------------------------------------
// Mock i18n & Setup
// ---------------------------------------------------------------------------
vi.mock('@repo/core-i18n', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { exists: () => false },
}),
}));
type Vendor = { id: number; code: string; name: string };
const MOCK_VENDORS: Vendor[] = [
{ id: 1, code: 'V1', name: 'Vendor 1' },
{ id: 2, code: 'V2', name: 'Vendor 2' },
{ id: 3, code: 'V3', name: 'Vendor 3' },
];
// Reusable loadOptions mock — returns all vendors with hasMore=false
const createMockLoadOptions = (vendors: Vendor[] = MOCK_VENDORS) => {
return vi.fn<LoadOptionsFn<Vendor>>().mockResolvedValue({
options: vendors,
hasMore: false,
});
};
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('FieldAsyncSelect', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('fetches initial page on mount and renders items', async () => {
const user = userEvent.setup();
const mockLoadOptions = createMockLoadOptions();
function TestForm() {
const { control } = useForm({ defaultValues: { vendor: null } });
return (
<MantineProvider>
<form>
<FieldAsyncSelect
name="vendor"
control={control}
loadOptions={mockLoadOptions}
valueKey="id"
labelKey="name"
placeholder="Select async vendor"
/>
</form>
</MantineProvider>
);
}
render(<TestForm />);
// Wait for initial fetch (page 1, search='')
await waitFor(() => {
expect(mockLoadOptions).toHaveBeenCalledWith('', 1, []);
});
await user.click(screen.getByPlaceholderText('Select async vendor'));
// Items should be rendered from the mock response
expect(screen.getByText('Vendor 1')).toBeInTheDocument();
expect(screen.getByText('Vendor 3')).toBeInTheDocument();
});
it('stores full object in RHF from async data', async () => {
const user = userEvent.setup();
let capturedData: any = null;
const mockLoadOptions = createMockLoadOptions();
function TestForm() {
const { control, handleSubmit } = useForm({ defaultValues: { vendor: null } });
return (
<MantineProvider>
<form onSubmit={handleSubmit((data) => { capturedData = data; })}>
<FieldAsyncSelect
name="vendor"
control={control}
loadOptions={mockLoadOptions}
valueKey="id"
labelKey="name"
placeholder="Select vendor"
/>
<button type="submit">Submit</button>
</form>
</MantineProvider>
);
}
render(<TestForm />);
await waitFor(() => {
expect(mockLoadOptions).toHaveBeenCalled();
});
await user.click(screen.getByPlaceholderText('Select vendor'));
await user.click(screen.getByText('Vendor 2'));
await user.click(screen.getByText('Submit'));
expect(capturedData).toEqual({ vendor: { id: 2, code: 'V2', name: 'Vendor 2' } });
});
it('injects pre-selected value not in fetched data', async () => {
// loadOptions returns only V1 and V2
const partialLoadOptions = createMockLoadOptions([
{ id: 1, code: 'V1', name: 'Vendor 1' },
{ id: 2, code: 'V2', name: 'Vendor 2' },
]);
// The form starts with V99 pre-selected (e.g. from server hydration)
const PRESELECTED_VENDOR = { id: 99, code: 'V99', name: 'Vendor 99' };
function TestForm() {
const { control } = useForm({ defaultValues: { vendor: PRESELECTED_VENDOR } });
return (
<MantineProvider>
<form>
<FieldAsyncSelect
name="vendor"
control={control}
loadOptions={partialLoadOptions}
valueKey="id"
labelKey="name"
placeholder="Select vendor"
/>
</form>
</MantineProvider>
);
}
render(<TestForm />);
await waitFor(() => {
expect(partialLoadOptions).toHaveBeenCalled();
});
// The display value of the Select input should show the injected label
const input = screen.getByRole('textbox') as HTMLInputElement;
expect(input.value).toBe('Vendor 99');
});
it('debounces search input and refetches', async () => {
const user = userEvent.setup();
const mockLoadOptions = createMockLoadOptions();
function TestForm() {
const { control } = useForm({ defaultValues: { vendor: null } });
return (
<MantineProvider>
<form>
<FieldAsyncSelect
name="vendor"
control={control}
loadOptions={mockLoadOptions}
valueKey="id"
labelKey="name"
placeholder="Search vendor"
debounceMs={100} // fast debounce for test
/>
</form>
</MantineProvider>
);
}
render(<TestForm />);
// Wait for initial fetch
await waitFor(() => {
expect(mockLoadOptions).toHaveBeenCalledTimes(1);
});
const input = screen.getByPlaceholderText('Search vendor');
await user.type(input, 'test');
// Wait for debounced fetch — should call with search='test'
await waitFor(() => {
expect(mockLoadOptions).toHaveBeenCalledTimes(2);
expect(mockLoadOptions).toHaveBeenLastCalledWith('test', 1, []);
});
});
it('gracefully deduplicates overlapping data across API responses', async () => {
// loadOptions returns Vendor 1 twice (duplicate id=1)
const badLoadOptions = createMockLoadOptions([
{ id: 1, code: 'V1', name: 'Vendor 1' },
{ id: 1, code: 'V1', name: 'Vendor 1 (Duplicate)' },
{ id: 2, code: 'V2', name: 'Vendor 2' },
]);
function TestForm() {
const { control } = useForm({ defaultValues: { vendor: null } });
return (
<MantineProvider>
<form>
<FieldAsyncSelect
name="vendor"
control={control}
loadOptions={badLoadOptions}
valueKey="id"
labelKey="name"
placeholder="Select bad vendor"
/>
</form>
</MantineProvider>
);
}
render(<TestForm />);
await waitFor(() => {
expect(badLoadOptions).toHaveBeenCalledTimes(1);
});
const user = userEvent.setup();
await user.click(screen.getByPlaceholderText('Select bad vendor'));
// Should only render "Vendor 1" once, ignoring the duplicate with id=1
const vendor1Options = screen.getAllByText('Vendor 1');
expect(vendor1Options.length).toBe(1);
// The duplicate name should NOT be rendered
expect(screen.queryByText('Vendor 1 (Duplicate)')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { MantineProvider } from '@mantine/core';
import { FieldCheckbox } from '../fields/checkbox.field';
// ---------------------------------------------------------------------------
// Mock @repo/core-i18n
// ---------------------------------------------------------------------------
vi.mock('@repo/core-i18n', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
return (options?.defaultValue as string) ?? key;
},
i18n: {
exists: () => false,
},
}),
}));
// ---------------------------------------------------------------------------
// Test schema
// ---------------------------------------------------------------------------
const termsSchema = z.object({
acceptTerms: z.literal(true, {
errorMap: () => ({ message: 'You must accept the terms' }),
}),
});
type TermsFormValues = z.infer<typeof termsSchema>;
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('FieldCheckbox', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders with a label', () => {
function TestForm() {
const { control } = useForm({ defaultValues: { acceptTerms: false } });
return (
<MantineProvider>
<FieldCheckbox
name="acceptTerms"
control={control}
label="I accept the terms and conditions"
/>
</MantineProvider>
);
}
render(<TestForm />);
expect(screen.getByLabelText('I accept the terms and conditions')).toBeInTheDocument();
});
it('toggles checked state on click', async () => {
const user = userEvent.setup();
function TestForm() {
const { control } = useForm({ defaultValues: { acceptTerms: false } });
return (
<MantineProvider>
<FieldCheckbox
name="acceptTerms"
control={control}
label="Accept Terms"
/>
</MantineProvider>
);
}
render(<TestForm />);
const checkbox = screen.getByLabelText('Accept Terms');
expect(checkbox).not.toBeChecked();
await user.click(checkbox);
expect(checkbox).toBeChecked();
await user.click(checkbox);
expect(checkbox).not.toBeChecked();
});
it('submits the boolean value via RHF', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
function TestForm() {
const { control, handleSubmit } = useForm({
defaultValues: { acceptTerms: false },
});
return (
<MantineProvider>
<form onSubmit={handleSubmit(onSubmit)}>
<FieldCheckbox name="acceptTerms" control={control} label="Accept" />
<button type="submit">Submit</button>
</form>
</MantineProvider>
);
}
render(<TestForm />);
await user.click(screen.getByLabelText('Accept'));
await user.click(screen.getByText('Submit'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
{ acceptTerms: true },
expect.anything(),
);
});
});
it('displays Zod validation error when not checked', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
function TestForm() {
const { control, handleSubmit } = useForm<TermsFormValues>({
resolver: zodResolver(termsSchema),
defaultValues: { acceptTerms: false as unknown as true },
});
return (
<MantineProvider>
<form onSubmit={handleSubmit(onSubmit)}>
<FieldCheckbox name="acceptTerms" control={control} label="Accept" />
<button type="submit">Submit</button>
</form>
</MantineProvider>
);
}
render(<TestForm />);
// Submit without checking
await user.click(screen.getByText('Submit'));
await waitFor(() => {
expect(screen.getByText('You must accept the terms')).toBeInTheDocument();
});
expect(onSubmit).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,255 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import { MantineProvider } from '@mantine/core';
import { FieldLocalSelect } from '../fields/local-select.field';
// ---------------------------------------------------------------------------
// Mock i18n
// ---------------------------------------------------------------------------
vi.mock('@repo/core-i18n', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { exists: () => false },
}),
}));
// ---------------------------------------------------------------------------
// Test Data & Wrapper
// ---------------------------------------------------------------------------
type Vendor = { id: number; code: string; name: string; active?: boolean };
const VENDORS: Vendor[] = [
{ id: 1, code: 'V1', name: 'Vendor 1', active: true },
{ id: 2, code: 'V2', name: 'Vendor 2', active: false },
{ id: 3, code: 'V3', name: 'Vendor 3', active: true },
];
// Test wrapper removed to avoid useForm conflicts
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('FieldLocalSelect', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders with labelKey and displays correct labels', () => {
function TestForm() {
const { control } = useForm({ defaultValues: { vendor: null } });
return (
<MantineProvider>
<FieldLocalSelect
name="vendor"
control={control}
options={VENDORS}
valueKey="id"
labelKey="name"
label="Vendor"
placeholder="Select vendor"
/>
</MantineProvider>
);
}
render(<TestForm />);
expect(screen.getByText('Vendor')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Select vendor')).toBeInTheDocument();
});
it('stores the full original object in RHF on selection', async () => {
const user = userEvent.setup();
let capturedData: any = null;
function TestForm() {
const { control, handleSubmit } = useForm({ defaultValues: { vendor: null } });
return (
<MantineProvider>
<form onSubmit={handleSubmit((data) => { capturedData = data; })}>
<FieldLocalSelect
name="vendor"
control={control}
options={VENDORS}
valueKey="id"
labelKey="name"
placeholder="Select vendor"
/>
<button type="submit">Submit</button>
</form>
</MantineProvider>
);
}
render(<TestForm />);
// Open dropdown
await user.click(screen.getByPlaceholderText('Select vendor'));
// Click Vendor 2
await user.click(screen.getByText('Vendor 2'));
// Submit
await user.click(screen.getByText('Submit'));
// Should contain the full object, not just '2'
expect(capturedData).toEqual({ vendor: VENDORS[1] });
});
it('renders with renderLabel for compound labels', async () => {
const user = userEvent.setup();
function TestForm() {
const { control } = useForm({ defaultValues: { vendor: null } });
return (
<MantineProvider>
<FieldLocalSelect
name="vendor"
control={control}
options={VENDORS}
valueKey="id"
renderLabel={(item) => `${item.code} - ${item.name}`}
placeholder="Select vendor"
/>
</MantineProvider>
);
}
render(<TestForm />);
await user.click(screen.getByPlaceholderText('Select vendor'));
// Compound label should be visible
expect(screen.getByText('V1 - Vendor 1')).toBeInTheDocument();
});
it('filterOption excludes items from dropdown', async () => {
const user = userEvent.setup();
function TestForm() {
const { control } = useForm({ defaultValues: { vendor: null } });
return (
<MantineProvider>
<FieldLocalSelect
name="vendor"
control={control}
options={VENDORS}
valueKey="id"
labelKey="name"
placeholder="Select vendor"
filterOption={(item) => item.active === true}
/>
</MantineProvider>
);
}
render(<TestForm />);
await user.click(screen.getByPlaceholderText('Select vendor'));
// Active vendors should be present
expect(screen.getByText('Vendor 1')).toBeInTheDocument();
expect(screen.getByText('Vendor 3')).toBeInTheDocument();
// Inactive vendor should not be present
expect(screen.queryByText('Vendor 2')).not.toBeInTheDocument();
});
it('multi-select stores T[] in RHF', async () => {
const user = userEvent.setup();
let capturedData: any = null;
function TestForm() {
const { control, handleSubmit } = useForm({ defaultValues: { vendors: [] } });
return (
<MantineProvider>
<form onSubmit={handleSubmit((data) => { capturedData = data; })}>
<FieldLocalSelect
multiple
name="vendors"
control={control}
options={VENDORS}
valueKey="id"
labelKey="name"
placeholder="Select vendors"
/>
<button type="submit">Submit</button>
</form>
</MantineProvider>
);
}
render(<TestForm />);
await user.click(screen.getByPlaceholderText('Select vendors'));
await user.click(screen.getByText('Vendor 1'));
await user.click(screen.getByText('Vendor 3'));
await user.click(screen.getByText('Submit'));
expect(capturedData).toEqual({ vendors: [VENDORS[0], VENDORS[2]] });
});
it('multi-select clearable resets to empty array', async () => {
const user = userEvent.setup();
let capturedData: any = null;
function TestForm() {
const { control, handleSubmit } = useForm({ defaultValues: { vendors: [VENDORS[0]] } });
return (
<MantineProvider>
<form onSubmit={handleSubmit((data) => { capturedData = data; })}>
<FieldLocalSelect
multiple
name="vendors"
control={control}
options={VENDORS}
valueKey="id"
labelKey="name"
clearable
/>
<button type="submit">Submit</button>
</form>
</MantineProvider>
);
}
const { container } = render(<TestForm />);
const clearButton = container.querySelector('.mantine-CloseButton-root') || container.querySelector('button[aria-label="Clear value"]');
expect(clearButton).not.toBeNull();
await user.click(clearButton!);
await user.click(screen.getByText('Submit'));
expect(capturedData).toEqual({ vendors: [] });
});
it('onSelect callback fires with correct object', async () => {
const user = userEvent.setup();
const handleSelect = vi.fn();
function TestForm() {
const { control } = useForm({ defaultValues: { vendor: null } });
return (
<MantineProvider>
<FieldLocalSelect
name="vendor"
control={control}
options={VENDORS}
valueKey="id"
labelKey="name"
placeholder="Select vendor"
onSelect={handleSelect}
/>
</MantineProvider>
);
}
render(<TestForm />);
await user.click(screen.getByPlaceholderText('Select vendor'));
await user.click(screen.getByText('Vendor 2'));
expect(handleSelect).toHaveBeenCalledWith(VENDORS[1]);
});
});
@@ -0,0 +1,186 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { MantineProvider } from '@mantine/core';
import { FieldTextInput } from '../fields/text-input.field';
// ---------------------------------------------------------------------------
// Mock @repo/core-i18n
// ---------------------------------------------------------------------------
vi.mock('@repo/core-i18n', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
const translations: Record<string, string> = {
'validation.required': 'This field is required',
'validation.too_small': `Minimum ${options?.min ?? ''} characters required`,
};
return translations[key] ?? (options?.defaultValue as string) ?? key;
},
i18n: {
exists: (key: string) => ['validation.required', 'validation.too_small'].includes(key),
},
}),
}));
// ---------------------------------------------------------------------------
// Test schema
// ---------------------------------------------------------------------------
const loginSchema = z.object({
username: z
.string()
.min(1, 'Username cannot be empty')
.min(3, 'Username must be at least 3 characters'),
email: z.string().email('Please enter a valid email address'),
});
type LoginFormValues = z.infer<typeof loginSchema>;
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('FieldTextInput', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders with label and placeholder', () => {
function TestForm() {
const { control } = useForm<LoginFormValues>({
defaultValues: { username: '', email: '' },
});
return (
<MantineProvider>
<FieldTextInput
name="username"
control={control}
label="Username"
placeholder="Enter username"
/>
</MantineProvider>
);
}
render(<TestForm />);
expect(screen.getByLabelText('Username')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter username')).toBeInTheDocument();
});
it('integrates with Zod validation and displays errors on submit', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
function TestForm() {
const { control, handleSubmit } = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: { username: '', email: '' },
});
return (
<MantineProvider>
<form onSubmit={handleSubmit(onSubmit)}>
<FieldTextInput name="username" control={control} label="Username" />
<FieldTextInput name="email" control={control} label="Email" />
<button type="submit">Login</button>
</form>
</MantineProvider>
);
}
render(<TestForm />);
// Submit with empty fields
await user.click(screen.getByText('Login'));
// Zod should generate validation errors
await waitFor(() => {
expect(screen.getByText('Username cannot be empty')).toBeInTheDocument();
});
// onSubmit should NOT have been called
expect(onSubmit).not.toHaveBeenCalled();
});
it('clears errors when valid input is provided', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
function TestForm() {
const { control, handleSubmit } = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: { username: '', email: '' },
mode: 'onSubmit',
});
return (
<MantineProvider>
<form onSubmit={handleSubmit(onSubmit)}>
<FieldTextInput name="username" control={control} label="Username" />
<FieldTextInput name="email" control={control} label="Email" />
<button type="submit">Login</button>
</form>
</MantineProvider>
);
}
render(<TestForm />);
// Trigger validation errors
await user.click(screen.getByText('Login'));
await waitFor(() => {
expect(screen.getByText('Username cannot be empty')).toBeInTheDocument();
});
// Fill in valid data
await user.type(screen.getByLabelText('Username'), 'john');
await user.type(screen.getByLabelText('Email'), 'john@example.com');
// Re-submit with valid data
await user.click(screen.getByText('Login'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
{ username: 'john', email: 'john@example.com' },
expect.anything(),
);
});
});
it('submits successfully with valid data on first try', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
function TestForm() {
const { control, handleSubmit } = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: { username: '', email: '' },
});
return (
<MantineProvider>
<form onSubmit={handleSubmit(onSubmit)}>
<FieldTextInput name="username" control={control} label="Username" />
<FieldTextInput name="email" control={control} label="Email" />
<button type="submit">Login</button>
</form>
</MantineProvider>
);
}
render(<TestForm />);
await user.type(screen.getByLabelText('Username'), 'johndoe');
await user.type(screen.getByLabelText('Email'), 'john@example.com');
await user.click(screen.getByText('Login'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
{ username: 'johndoe', email: 'john@example.com' },
expect.anything(),
);
});
});
});
@@ -0,0 +1,248 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import { MantineProvider, TextInput } from '@mantine/core';
import { withRHF } from '../withRHF';
// ---------------------------------------------------------------------------
// Mock @repo/core-i18n — provides a controllable useTranslation hook
// ---------------------------------------------------------------------------
const mockT = vi.fn((key: string, options?: Record<string, unknown>) => {
// Simulate i18next: return translated value if key matches, else return
// the defaultValue or the key itself.
const translations: Record<string, string> = {
'validation.required': 'This field is required',
'validation.min_length': `Minimum ${options?.min ?? ''} characters`,
};
return translations[key] ?? (options?.defaultValue as string) ?? key;
});
const mockI18n = {
exists: vi.fn((key: string) => {
const knownKeys = ['validation.required', 'validation.min_length'];
return knownKeys.includes(key);
}),
};
vi.mock('@repo/core-i18n', () => ({
useTranslation: () => ({ t: mockT, i18n: mockI18n }),
}));
// ---------------------------------------------------------------------------
// Test wrapper component that provides MantineProvider + FormProvider
// ---------------------------------------------------------------------------
interface FormTestWrapperProps {
children: React.ReactNode;
defaultValues?: Record<string, unknown>;
onSubmit?: (data: Record<string, unknown>) => void;
}
function FormTestWrapper({
children,
defaultValues = {},
onSubmit = () => {},
}: FormTestWrapperProps) {
const methods = useForm({ defaultValues });
return (
<MantineProvider>
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
{children}
<button type="submit">Submit</button>
</form>
</FormProvider>
</MantineProvider>
);
}
// ---------------------------------------------------------------------------
// Create a test field component using the HOC
// ---------------------------------------------------------------------------
const TestFieldTextInput = withRHF<React.ComponentProps<typeof TextInput>>(
'TestFieldTextInput',
TextInput,
);
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('withRHF HOC', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders the wrapped Mantine component without crashing', () => {
function TestForm() {
const { control } = useForm({ defaultValues: { name: '' } });
return (
<MantineProvider>
<TestFieldTextInput name="name" control={control} label="Name" />
</MantineProvider>
);
}
render(<TestForm />);
expect(screen.getByLabelText('Name')).toBeInTheDocument();
});
it('displays the initial value from RHF form state', () => {
function TestForm() {
const { control } = useForm({ defaultValues: { name: 'John Doe' } });
return (
<MantineProvider>
<TestFieldTextInput name="name" control={control} label="Name" />
</MantineProvider>
);
}
render(<TestForm />);
expect(screen.getByLabelText('Name')).toHaveValue('John Doe');
});
it('mutates RHF state on user input', async () => {
const user = userEvent.setup();
let capturedData: Record<string, unknown> | null = null;
function TestForm() {
const { control, handleSubmit } = useForm({ defaultValues: { email: '' } });
return (
<MantineProvider>
<form onSubmit={handleSubmit((data) => { capturedData = data; })}>
<TestFieldTextInput name="email" control={control} label="Email" />
<button type="submit">Submit</button>
</form>
</MantineProvider>
);
}
render(<TestForm />);
const input = screen.getByLabelText('Email');
await user.type(input, 'test@example.com');
expect(input).toHaveValue('test@example.com');
await user.click(screen.getByText('Submit'));
expect(capturedData).toEqual({ email: 'test@example.com' });
});
it('renders raw string error messages from RHF validation', async () => {
const user = userEvent.setup();
function TestForm() {
const { control, handleSubmit } = useForm({ defaultValues: { username: '' } });
return (
<MantineProvider>
<form onSubmit={handleSubmit(() => {})}>
<TestFieldTextInput
name="username"
control={control}
rules={{ required: 'Username is required' }}
label="Username"
/>
<button type="submit">Submit</button>
</form>
</MantineProvider>
);
}
render(<TestForm />);
await user.click(screen.getByText('Submit'));
// The raw string error should appear in the DOM
expect(screen.getByText('Username is required')).toBeInTheDocument();
});
it('intercepts JSON i18n error payloads and translates them', async () => {
const user = userEvent.setup();
function TestForm() {
const { control, handleSubmit } = useForm({ defaultValues: { title: '' } });
return (
<MantineProvider>
<form onSubmit={handleSubmit(() => {})}>
<TestFieldTextInput
name="title"
control={control}
rules={{
required: JSON.stringify({ key: 'validation.required' }),
}}
label="Title"
/>
<button type="submit">Submit</button>
</form>
</MantineProvider>
);
}
render(<TestForm />);
await user.click(screen.getByText('Submit'));
// The mock translation should resolve "validation.required" → "This field is required"
expect(screen.getByText('This field is required')).toBeInTheDocument();
});
it('falls back to raw message when i18n key is not found', async () => {
const user = userEvent.setup();
function TestForm() {
const { control, handleSubmit } = useForm({ defaultValues: { code: '' } });
return (
<MantineProvider>
<form onSubmit={handleSubmit(() => {})}>
<TestFieldTextInput
name="code"
control={control}
rules={{
required: JSON.stringify({ key: 'validation.unknown_key' }),
}}
label="Code"
/>
<button type="submit">Submit</button>
</form>
</MantineProvider>
);
}
render(<TestForm />);
await user.click(screen.getByText('Submit'));
// The fallback should be the raw JSON string since neither namespace has the key.
// Our mock t() returns defaultValue when key is unknown, which is the raw JSON.
const errorElements = screen.getAllByText((content) =>
content.includes('validation.unknown_key'),
);
expect(errorElements.length).toBeGreaterThan(0);
});
it('has the correct displayName for React DevTools', () => {
expect(
(TestFieldTextInput as unknown as { displayName: string }).displayName,
).toBe('TestFieldTextInput');
});
it('forwards additional Mantine props (placeholder, etc.)', () => {
function TestForm() {
const { control } = useForm({ defaultValues: { search: '' } });
return (
<MantineProvider>
<TestFieldTextInput
name="search"
control={control}
label="Search"
placeholder="Type to search..."
/>
</MantineProvider>
);
}
render(<TestForm />);
expect(screen.getByPlaceholderText('Type to search...')).toBeInTheDocument();
});
});
@@ -0,0 +1,22 @@
// ---------------------------------------------------------------------------
// Custom Form Components — Barrel Export
// ---------------------------------------------------------------------------
// Reusable select engines that work WITHOUT React Hook Form.
// For RHF-connected versions, use `@repo/ui/form` (FieldLocalSelect, FieldAsyncSelect).
// ---------------------------------------------------------------------------
export { LocalSelect } from './selects/LocalSelect';
export type { LocalSelectProps, LocalSelectSingleProps, LocalSelectMultiProps } from './selects/LocalSelect';
export { AsyncSelect } from './selects/AsyncSelect';
export type { AsyncSelectProps, AsyncSelectSingleProps, AsyncSelectMultiProps } from './selects/AsyncSelect';
export type {
LocalSelectBaseProps,
AsyncSelectBaseProps,
SelectFilterContext,
SelectMappingResult,
LoadOptionsResponse,
LoadOptionsFn,
OptionsCacheEntry,
} from './selects/types';
@@ -0,0 +1,270 @@
import React, { useMemo, useCallback } from 'react';
import { Select, MultiSelect, Loader, type SelectProps, type MultiSelectProps, type ComboboxItem } from '@mantine/core';
import type { AsyncSelectBaseProps, SelectFilterContext, LoadOptionsFn } from './types';
import { useAsyncPaginate } from './hooks/useAsyncPaginate';
// ---------------------------------------------------------------------------
// AsyncSelect — Reusable async/paginated Select engine (no RHF dependency)
// ---------------------------------------------------------------------------
//
// Inversion of Control design: the component does NOT handle API calls.
// Instead, it accepts a `loadOptions` callback that the implementer provides.
// This supports REST, GraphQL, POST-based search, local filtering, or any
// transport mechanism.
//
// Data Mapping Contract (Single vs. Multi):
// Single: value=T|null → Mantine string|null → onChange(T|null)
// Multi: value=T[] → Mantine string[] → onChange(T[])
//
// The lookupMap includes ALL sources (fetched + default + selected values)
// to ensure deselection never produces undefined entries.
// ---------------------------------------------------------------------------
/** Mantine props we manage ourselves */
type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect';
type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect';
/** Async-specific props (IoC — no direct API coupling) */
interface AsyncExtraProps<T> {
/**
* Async callback to load options. The component calls this when:
* - The dropdown opens (page 1, search='')
* - The user types a search query (page 1, search=query)
* - The user scrolls to the bottom (page N+1, search=currentQuery)
*
* The component is completely ignorant of the transport layer.
*/
loadOptions: LoadOptionsFn<T>;
/**
* Pre-loaded objects that are always present in the dropdown.
* Use for edit forms where the default value's object may not appear
* in page 1 of the API results.
*/
defaultOptions?: T[];
/** Search debounce delay in ms (default: 300) */
debounceMs?: number;
}
/** Props for single-select async mode */
export type AsyncSelectSingleProps<T extends Record<string, any>> = AsyncSelectBaseProps<T> &
AsyncExtraProps<T> &
Omit<SelectProps, ManagedSelectProps> & {
multiple?: false;
value?: T | null;
onChange?: (value: T | null) => void;
};
/** Props for multi-select async mode */
export type AsyncSelectMultiProps<T extends Record<string, any>> = AsyncSelectBaseProps<T> &
AsyncExtraProps<T> &
Omit<MultiSelectProps, ManagedMultiSelectProps> & {
multiple: true;
value?: T[];
onChange?: (value: T[]) => void;
};
export type AsyncSelectProps<T extends Record<string, any>> = AsyncSelectSingleProps<T> | AsyncSelectMultiProps<T>;
// ---------------------------------------------------------------------------
// Helper: Resolve label for a data item
// ---------------------------------------------------------------------------
function resolveLabel<T extends Record<string, any>>(
item: T,
labelKey?: keyof T & string,
renderLabel?: (item: T) => string,
): string {
if (renderLabel) return renderLabel(item);
if (labelKey) return String(item[labelKey] ?? '');
const firstKey = Object.keys(item)[0];
return firstKey ? String(item[firstKey as keyof T] ?? '') : '';
}
// ---------------------------------------------------------------------------
// Component Implementation
// ---------------------------------------------------------------------------
function AsyncSelectInner<T extends Record<string, any>>(props: AsyncSelectProps<T>) {
const {
valueKey,
labelKey,
renderLabel,
multiple,
filterOption,
onSelect: onSelectCallback,
value,
onChange,
loadOptions,
defaultOptions,
debounceMs,
searchable,
onSearchChange: consumerOnSearchChange,
...mantineProps
} = props;
// Use the new paginate hook for data fetching
const {
data: fetchedData,
isLoading,
fetchNextPage,
search,
setSearch,
} = useAsyncPaginate<T>({
loadOptions,
valueKey,
debounceMs,
defaultOptions,
});
// -----------------------------------------------------------------------
// Merge fetched data with currently selected values.
// This ensures the lookupMap always contains all possible values,
// preventing undefined entries during deselection.
// -----------------------------------------------------------------------
const dataWithInjected = useMemo(() => {
const uniqueItems: T[] = [];
const seen = new Set<string>();
// 1. Start with fetched data (already includes defaultOptions from the hook)
for (const item of fetchedData) {
const key = String(item[valueKey]);
if (!seen.has(key)) {
seen.add(key);
uniqueItems.push(item);
}
}
// 2. Inject active selected values if they aren't in the fetched list.
// This is CRITICAL for the data mapping contract: the lookupMap
// must always be able to resolve deselected items back to objects.
if (multiple && Array.isArray(value)) {
for (const v of value) {
const key = String(v[valueKey]);
if (!seen.has(key)) {
seen.add(key);
uniqueItems.unshift(v); // Selected items at the top
}
}
} else if (!multiple && value) {
const key = String((value as T)[valueKey]);
if (!seen.has(key)) {
seen.add(key);
uniqueItems.unshift(value as T);
}
}
return uniqueItems;
}, [fetchedData, value, valueKey, multiple]);
// Build lookup map — includes ALL sources for safe reverse resolution
const lookupMap = useMemo(() => {
const map = new Map<string, T>();
for (const item of dataWithInjected) {
map.set(String(item[valueKey]), item);
}
return map;
}, [dataWithInjected, valueKey]);
// Build Mantine options from the resolved data
const baseOptions = useMemo<ComboboxItem[]>(() => {
let filtered = dataWithInjected;
if (filterOption) {
const context: SelectFilterContext<T> = {
search,
selected: value ?? (multiple ? [] : null),
};
filtered = dataWithInjected.filter((item) => filterOption(item, context));
}
return filtered.map((item) => ({
value: String(item[valueKey]),
label: resolveLabel(item, labelKey, renderLabel),
}));
}, [dataWithInjected, valueKey, labelKey, renderLabel, filterOption, value, multiple, search]);
const rightSection = isLoading ? <Loader size={16} /> : mantineProps.rightSection;
// Handle search → delegate to the hook's setSearch (debounced)
const handleSearchChange = useCallback(
(val: string) => {
setSearch(val);
consumerOnSearchChange?.(val);
},
[setSearch, consumerOnSearchChange],
);
// ScrollArea props for infinite scroll — use onBottomReached
const scrollAreaProps = useMemo(
() => ({
...(mantineProps.scrollAreaProps || {}),
onBottomReached: () => {
fetchNextPage();
},
}),
[mantineProps.scrollAreaProps, fetchNextPage],
);
// Disable Mantine's internal frontend filtering.
// The backend handles the search query, so we always display what the backend returns.
const mantineFilter = filterOption
? ({ options: opts }: any) => opts
: undefined;
// ----- Multi-select mode -----
if (multiple) {
const currentValues = Array.isArray(value) ? value.map((v) => String(v[valueKey])) : [];
const handleMultiChange = (vals: string[]) => {
// Resolve string[] → T[] via the lookup map.
// .filter(Boolean) is a safety net — if the map is complete (which it
// should be given the dataWithInjected merge), this is a no-op.
const objects = vals.map((v) => lookupMap.get(v)!).filter(Boolean);
(onChange as ((v: T[]) => void) | undefined)?.(objects);
onSelectCallback?.(objects);
};
return (
<MultiSelect
{...(mantineProps as Omit<MultiSelectProps, ManagedMultiSelectProps>)}
data={baseOptions}
value={currentValues}
onChange={handleMultiChange}
searchable={searchable ?? true}
onSearchChange={handleSearchChange}
scrollAreaProps={scrollAreaProps}
filter={mantineFilter}
rightSection={rightSection}
/>
);
}
// ----- Single-select mode -----
const currentValue = value ? String((value as T)[valueKey]) : null;
const handleSingleChange = (val: string | null) => {
const obj = val ? (lookupMap.get(val) ?? null) : null;
(onChange as ((v: T | null) => void) | undefined)?.(obj);
onSelectCallback?.(obj);
};
return (
<Select
{...(mantineProps as Omit<SelectProps, ManagedSelectProps>)}
data={baseOptions}
value={currentValue}
onChange={handleSingleChange}
searchable={searchable ?? true}
onSearchChange={handleSearchChange}
scrollAreaProps={scrollAreaProps}
filter={mantineFilter as any}
rightSection={rightSection}
/>
);
}
export const AsyncSelect = React.memo(AsyncSelectInner) as typeof AsyncSelectInner;
(AsyncSelect as any).displayName = 'AsyncSelect';
@@ -0,0 +1,189 @@
import React, { useMemo, useState, useCallback } from 'react';
import { Select, MultiSelect, type SelectProps, type MultiSelectProps, type ComboboxItem } from '@mantine/core';
import type { LocalSelectBaseProps, SelectFilterContext } from './types';
// ---------------------------------------------------------------------------
// LocalSelect — Reusable Select engine for complex object data
// ---------------------------------------------------------------------------
//
// This component is the STANDALONE (non-RHF) version. It bridges Mantine's
// string-based Select/MultiSelect with object data by:
// 1. Mapping T[] → ComboboxItem[] via valueKey + labelKey/renderLabel
// 2. Building a Map<string, T> for O(1) reverse lookups
// 3. Intercepting onChange to resolve strings back to full objects
//
// Data Mapping Contract (Single vs. Multi):
// Single: value=T|null → Mantine string|null → onChange(T|null)
// Multi: value=T[] → Mantine string[] → onChange(T[])
//
// The RHF-connected version (FieldLocalSelect) wraps this component and
// binds it to useController, following the same pattern as withRHF → FieldXxx.
// ---------------------------------------------------------------------------
/** Mantine props we manage ourselves — stripped from the pass-through */
type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect';
type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect';
/** Props for single-select mode */
export type LocalSelectSingleProps<T extends Record<string, any>> =
LocalSelectBaseProps<T> & Omit<SelectProps, ManagedSelectProps> & {
multiple?: false;
/** Controlled value — the full object or null */
value?: T | null;
/** Called when the selection changes */
onChange?: (value: T | null) => void;
};
/** Props for multi-select mode */
export type LocalSelectMultiProps<T extends Record<string, any>> =
LocalSelectBaseProps<T> & Omit<MultiSelectProps, ManagedMultiSelectProps> & {
multiple: true;
/** Controlled value — array of full objects */
value?: T[];
/** Called when the selection changes */
onChange?: (value: T[]) => void;
};
/** Discriminated union — the component narrows based on `multiple` */
export type LocalSelectProps<T extends Record<string, any>> =
| LocalSelectSingleProps<T>
| LocalSelectMultiProps<T>;
// ---------------------------------------------------------------------------
// Helper: Resolve label for a data item
// ---------------------------------------------------------------------------
function resolveLabel<T extends Record<string, any>>(
item: T,
labelKey?: keyof T & string,
renderLabel?: (item: T) => string,
): string {
if (renderLabel) return renderLabel(item);
if (labelKey) return String(item[labelKey] ?? '');
// Fail fast: if neither labelKey nor renderLabel is provided, fall back
// to the first property value. While not ideal, it prevents crashes.
const firstKey = Object.keys(item)[0];
return firstKey ? String(item[firstKey as keyof T] ?? '') : '';
}
// ---------------------------------------------------------------------------
// Component Implementation
// ---------------------------------------------------------------------------
function LocalSelectInner<T extends Record<string, any>>(
props: LocalSelectProps<T>,
) {
const {
options,
valueKey,
labelKey,
renderLabel,
multiple,
filterOption,
onSelect: onSelectCallback,
value,
onChange,
searchable,
// Extract onSearchChange BEFORE the rest spread to get a
// stable reference for the useCallback dependency array.
onSearchChange: consumerOnSearchChange,
...mantineProps
} = props;
// Track search input for filterOption
const [searchValue, setSearchValue] = useState('');
// Build lookup map: string → original object (O(1) reverse lookup)
const lookupMap = useMemo(() => {
const map = new Map<string, T>();
for (const item of options) {
map.set(String(item[valueKey]), item);
}
return map;
}, [options, valueKey]);
// Build Mantine-compatible ComboboxItem[], applying filterOption if provided
const comboboxItems = useMemo<ComboboxItem[]>(() => {
let filtered = options;
if (filterOption) {
const context: SelectFilterContext<T> = {
search: searchValue,
selected: value ?? (multiple ? [] : null),
};
filtered = options.filter((item) => filterOption(item, context));
}
return filtered.map((item) => ({
value: String(item[valueKey]),
label: resolveLabel(item, labelKey, renderLabel),
}));
}, [options, valueKey, labelKey, renderLabel, filterOption, searchValue, value, multiple]);
// Depend only on stable function references, not the
// entire mantineProps object which is a new reference every render.
const handleSearchChange = useCallback(
(val: string) => {
setSearchValue(val);
consumerOnSearchChange?.(val);
},
[consumerOnSearchChange],
);
// Passthrough filter — we handle filtering ourselves via filterOption in useMemo.
// This prevents Mantine from double-filtering.
const mantineFilter = filterOption
? ({ options: opts }: { options: ComboboxItem[] }) => opts
: undefined;
// ----- Multi-select mode -----
if (multiple) {
const currentValues = Array.isArray(value) ? value.map((v) => String(v[valueKey])) : [];
const handleMultiChange = (vals: string[]) => {
// Resolve string[] back to T[] via the lookup map.
// .filter(Boolean) guards against missing entries (defensive).
const objects = vals.map((v) => lookupMap.get(v)!).filter(Boolean);
(onChange as ((v: T[]) => void) | undefined)?.(objects);
onSelectCallback?.(objects);
};
return (
<MultiSelect
{...(mantineProps as Omit<MultiSelectProps, ManagedMultiSelectProps>)}
data={comboboxItems}
value={currentValues}
onChange={handleMultiChange}
searchable={searchable ?? false}
onSearchChange={handleSearchChange}
filter={mantineFilter as any}
/>
);
}
// ----- Single-select mode -----
const currentValue = value ? String((value as T)[valueKey]) : null;
const handleSingleChange = (val: string | null) => {
const obj = val ? lookupMap.get(val) ?? null : null;
(onChange as ((v: T | null) => void) | undefined)?.(obj);
onSelectCallback?.(obj);
};
return (
<Select
{...(mantineProps as Omit<SelectProps, ManagedSelectProps>)}
data={comboboxItems}
value={currentValue}
onChange={handleSingleChange}
searchable={searchable ?? false}
onSearchChange={handleSearchChange}
filter={mantineFilter as any}
/>
);
}
// Apply React.memo for render optimization in large forms
export const LocalSelect = React.memo(LocalSelectInner) as typeof LocalSelectInner;
(LocalSelect as any).displayName = 'LocalSelect';
@@ -0,0 +1,335 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { useDebouncedValue } from '@mantine/hooks';
import type { LoadOptionsFn, OptionsCacheEntry } from '../types';
// ---------------------------------------------------------------------------
// useAsyncPaginate — Production-grade paginated data fetching hook
// ---------------------------------------------------------------------------
//
// Architecture derived from `react-select-async-paginate` with adaptations
// for Mantine's Select/MultiSelect. Key mechanisms:
//
// 1. Search-keyed cache (Map<string, OptionsCacheEntry<T>>)
// → Switching between previously-typed searches reuses cached data
// without re-fetching from the server.
//
// 2. Request ID counter (requestIdRef)
// → Stale responses from slow or out-of-order fetches are silently
// discarded by comparing the ID captured at request start with the
// current counter value.
//
// 3. isMounted guard (mountedRef)
// → Responses arriving after the component unmounts are discarded,
// preventing React state updates on unmounted components.
//
// 4. Duplicate fetch prevention (fetchingRef boolean)
// → Guards against concurrent fetches for the same search+page combo.
//
// 5. Single consolidated effect
// → One useEffect keyed on `debouncedSearch` handles both the initial
// load (mount with search='') and subsequent search-change resets.
// This eliminates the double-initial-fetch bug from the old hook.
// ---------------------------------------------------------------------------
export interface UseAsyncPaginateOptions<T extends Record<string, any>> {
/** Async callback to load options. Receives (search, page, prevOptions). */
loadOptions: LoadOptionsFn<T>;
/** Property key used to deduplicate incoming items */
valueKey: keyof T & string;
/** Search debounce delay in ms (default: 300) */
debounceMs?: number;
/**
* Pre-loaded objects to inject into the options list.
* Used for edit-form scenarios where the RHF default value's object
* may not appear in the first page of API results.
*/
defaultOptions?: T[];
}
export interface UseAsyncPaginateReturn<T> {
/** Merged data: defaultOptions + accumulated fetched pages (deduplicated) */
data: T[];
/** True during any active fetch */
isLoading: boolean;
/** Whether the current search term has more pages available */
hasMore: boolean;
/** Current search term (raw, not debounced) */
search: string;
/** The debounced search term currently driving fetches */
debouncedSearch: string;
/** Update the search term — triggers debounce + cache lookup/fetch */
setSearch: (s: string) => void;
/** Trigger next page load for the current search term */
fetchNextPage: () => void;
/** Clear all cached pages and re-fetch from page 1 */
reset: () => void;
}
export function useAsyncPaginate<T extends Record<string, any>>(
options: UseAsyncPaginateOptions<T>,
): UseAsyncPaginateReturn<T> {
const {
loadOptions,
valueKey,
debounceMs = 300,
defaultOptions,
} = options;
// -----------------------------------------------------------------------
// State
// -----------------------------------------------------------------------
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebouncedValue(search, debounceMs);
// Search-keyed cache: each search string maps to its own pagination state
const [cache, setCache] = useState<Map<string, OptionsCacheEntry<T>>>(() => new Map());
// Loading flag — drives the UI spinner
const [isLoading, setIsLoading] = useState(false);
// -----------------------------------------------------------------------
// Refs for guards
// -----------------------------------------------------------------------
/** Monotonically increasing counter to detect stale responses */
const requestIdRef = useRef(0);
/** Guards against concurrent fetches */
const fetchingRef = useRef(false);
/** Tracks if the component is still mounted */
const mountedRef = useRef(true);
/** Stable ref for loadOptions to avoid effect re-fires on closure changes */
const loadOptionsRef = useRef(loadOptions);
loadOptionsRef.current = loadOptions;
/** Stable ref for valueKey */
const valueKeyRef = useRef(valueKey);
valueKeyRef.current = valueKey;
/** Stable ref for defaultOptions to avoid dependency churn */
const defaultOptionsRef = useRef(defaultOptions);
defaultOptionsRef.current = defaultOptions;
// -----------------------------------------------------------------------
// Cleanup on unmount
// -----------------------------------------------------------------------
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
// -----------------------------------------------------------------------
// Core fetch logic
// -----------------------------------------------------------------------
const fetchPage = useCallback(
async (searchTerm: string, page: number) => {
if (fetchingRef.current) return;
fetchingRef.current = true;
// Capture request ID — if it changes before the response arrives,
// the response is stale and should be discarded.
const capturedId = ++requestIdRef.current;
setIsLoading(true);
try {
// Get accumulated options from the cache for prevOptions
const cachedEntry = cache.get(searchTerm);
const prevOptions = cachedEntry?.options ?? [];
const response = await loadOptionsRef.current(searchTerm, page, prevOptions);
// Guard: discard if unmounted or stale
if (!mountedRef.current || requestIdRef.current !== capturedId) {
return;
}
const newItems = response.options ?? [];
const hasMore = response.hasMore ?? false;
// Deduplicate: merge prev + new, keyed by valueKey
const vk = valueKeyRef.current;
const seen = new Set<string>();
const merged: T[] = [];
// Accumulate from previous pages first
for (const item of prevOptions) {
const key = String(item[vk]);
if (!seen.has(key)) {
seen.add(key);
merged.push(item);
}
}
// Append new items (only unique ones)
let newUniqueCount = 0;
for (const item of newItems) {
const key = String(item[vk]);
if (!seen.has(key)) {
seen.add(key);
merged.push(item);
newUniqueCount++;
}
}
// If the API returned items but ALL were duplicates, treat as exhausted.
// This prevents infinite scroll loops on naive APIs that ignore pagination.
const effectiveHasMore = newItems.length > 0 && newUniqueCount === 0
? false
: hasMore;
setCache((prev) => {
const next = new Map(prev);
next.set(searchTerm, {
options: merged,
hasMore: effectiveHasMore,
page,
isLoading: false,
});
return next;
});
} catch (error) {
if (!mountedRef.current || requestIdRef.current !== capturedId) {
return;
}
console.error('[useAsyncPaginate] loadOptions failed:', error);
// Mark the cache entry as exhausted to prevent retry loops
setCache((prev) => {
const next = new Map(prev);
const existing = prev.get(searchTerm);
next.set(searchTerm, {
options: existing?.options ?? [],
hasMore: false,
page: existing?.page ?? 0,
isLoading: false,
});
return next;
});
} finally {
if (mountedRef.current) {
setIsLoading(false);
}
fetchingRef.current = false;
}
},
// We intentionally exclude `cache` from deps to avoid re-creating this callback
// on every cache update. Instead, we read cache inside via the state setter's prev.
// The `cache.get(searchTerm)` read above is for prevOptions passed to loadOptions —
// this is acceptable because the callback is only called when we're NOT already fetching.
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
// -----------------------------------------------------------------------
// Effect: Fetch on debounced search change (including initial mount)
// -----------------------------------------------------------------------
//
// This single effect replaces the old two-effect pattern that caused
// double initial fetches. On mount, debouncedSearch starts as '' and
// triggers a single page-1 fetch. On search change, it looks up the
// cache and either reuses cached data or fetches page 1 for the new term.
// -----------------------------------------------------------------------
useEffect(() => {
const cached = cache.get(debouncedSearch);
// If we already have cached data for this search term, no fetch needed
if (cached && cached.options.length > 0) {
return;
}
// No cache entry — fetch page 1
fetchPage(debouncedSearch, 1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearch]);
// -----------------------------------------------------------------------
// fetchNextPage — called by Mantine's scroll handler
// -----------------------------------------------------------------------
const fetchNextPage = useCallback(() => {
const cached = cache.get(debouncedSearch);
if (!cached || !cached.hasMore || isLoading || fetchingRef.current) return;
fetchPage(debouncedSearch, cached.page + 1);
}, [cache, debouncedSearch, isLoading, fetchPage]);
// -----------------------------------------------------------------------
// reset — clear cache and re-fetch from scratch
// -----------------------------------------------------------------------
const reset = useCallback(() => {
setCache(new Map());
requestIdRef.current++;
fetchPage(debouncedSearch, 1);
}, [debouncedSearch, fetchPage]);
// -----------------------------------------------------------------------
// Merge defaultOptions with fetched data (deduplicated)
// -----------------------------------------------------------------------
const currentCacheEntry = cache.get(debouncedSearch);
const data = useMemo<T[]>(() => {
const fetchedItems = currentCacheEntry?.options ?? [];
const defaults = defaultOptionsRef.current;
if (!defaults || defaults.length === 0) {
return fetchedItems;
}
// Merge: defaultOptions first, then fetched items (deduplicated)
const seen = new Set<string>();
const merged: T[] = [];
for (const item of defaults) {
const key = String(item[valueKeyRef.current]);
if (!seen.has(key)) {
seen.add(key);
merged.push(item);
}
}
for (const item of fetchedItems) {
const key = String(item[valueKeyRef.current]);
if (!seen.has(key)) {
seen.add(key);
merged.push(item);
}
}
return merged;
}, [currentCacheEntry?.options]);
const hasMore = currentCacheEntry?.hasMore ?? true;
const isTyping = search !== debouncedSearch;
return {
data,
isLoading: isLoading || isTyping,
hasMore,
search,
debouncedSearch,
setSearch,
fetchNextPage,
reset,
};
}
@@ -0,0 +1,143 @@
import type { ComboboxItem } from '@mantine/core';
// ---------------------------------------------------------------------------
// Select Engine — Shared types for LocalSelect and AsyncSelect
// ---------------------------------------------------------------------------
/**
* Filter context passed to the custom `filterOption` callback.
* Provides both the current search string and the currently selected value(s)
* so consumers can implement exclusion logic, compound search, or domain-specific filters.
*/
export interface SelectFilterContext<T> {
/** Current search input value */
search: string;
/** Currently selected value(s) — T | null for single, T[] for multi */
selected: T[] | T | null;
}
/**
* Core configuration for the LocalSelect engine.
* This is the "headless" API no RHF dependency.
*
* @template T - The shape of each item in the options array
*/
export interface LocalSelectBaseProps<T extends Record<string, any>> {
/** Array of complex objects to select from */
options: T[];
/** Property key to use as the unique string identifier for Mantine */
valueKey: keyof T & string;
/** Property key to use as the display label (simple mode) */
labelKey?: keyof T & string;
/** Custom label renderer — overrides `labelKey` for compound/custom labels */
renderLabel?: (item: T) => string;
/** Enable multi-select mode */
multiple?: boolean;
/**
* Custom filter function for search and exclusion logic.
* Return `true` to keep the item in the dropdown, `false` to exclude it.
*/
filterOption?: (item: T, context: SelectFilterContext<T>) => boolean;
/** Callback fired when selection changes */
onSelect?: (value: T | T[] | null) => void;
}
/**
* Core configuration for the AsyncSelect engine.
* Extends LocalSelectBaseProps but replaces `options` with `loadOptions`.
* This is the "headless" API no RHF dependency.
*
* @template T - The shape of each item in the options array
*/
export type AsyncSelectBaseProps<T extends Record<string, any>> = Omit<LocalSelectBaseProps<T>, 'options'>;
/**
* Internal result of the object-to-string mapping logic.
* Used by both the standalone and RHF-connected variants.
*/
export interface SelectMappingResult<T> {
/** Mantine-compatible ComboboxItem array for the Select/MultiSelect `data` prop */
options: ComboboxItem[];
/** O(1) reverse lookup map: string value → original object */
lookupMap: Map<string, T>;
}
// ---------------------------------------------------------------------------
// AsyncSelect — Inversion of Control types for the async paginated engine
// ---------------------------------------------------------------------------
/**
* The response shape returned by the `loadOptions` callback.
* Supports both paginated and non-paginated APIs.
*
* @template T - The shape of each option item
*/
export interface LoadOptionsResponse<T> {
/** The array of option objects for this page/batch */
options: T[];
/**
* Whether more pages are available.
* - `true` the engine will allow further scroll-triggered fetches.
* - `false` no more data; subsequent scroll events are ignored.
* - `undefined` treated as `false` (assumes non-paginated).
*/
hasMore?: boolean;
}
/**
* The callback signature for loading options asynchronously.
* This follows the Inversion of Control principle: the component is
* completely ignorant of transport (REST, GraphQL, local filter, etc.).
*
* @param search - The current search input string
* @param page - The 1-indexed page number being requested
* @param prevOptions - All options accumulated from previous pages
* @returns A promise resolving to the options for this page + pagination signal
*
* @example
* ```ts
* // REST API with cursor pagination
* const loadOptions: 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 };
* };
*
* // Non-paginated (single-shot fetch)
* const loadOptions: LoadOptionsFn<Role> = async (search) => {
* const roles = await api.get('/roles', { params: { q: search } });
* return { options: roles.data, hasMore: false };
* };
* ```
*/
export type LoadOptionsFn<T> = (
search: string,
page: number,
prevOptions: T[],
) => Promise<LoadOptionsResponse<T>>;
/**
* Internal cache entry for the search-keyed options cache.
* Each unique search string maps to one entry tracking the accumulated
* options, pagination state, and current page for that search context.
*/
export interface OptionsCacheEntry<T> {
/** Accumulated options across all fetched pages for this search term */
options: T[];
/** Whether more pages are available for this search term */
hasMore: boolean;
/** The last successfully fetched page number (1-indexed) */
page: number;
/** Whether a fetch is currently in-flight for this search term */
isLoading: boolean;
}
@@ -0,0 +1,162 @@
import React from 'react';
import {
useController,
type FieldPath,
type FieldValues,
type UseControllerProps,
} from 'react-hook-form';
import type { SelectProps, MultiSelectProps } from '@mantine/core';
import { AsyncSelect } from '../custom/selects/AsyncSelect';
import type { AsyncSelectBaseProps, LoadOptionsFn } from '../custom/selects/types';
import { useTranslatedError } from '../useTranslatedError';
// ---------------------------------------------------------------------------
// FieldAsyncSelect — RHF-connected Async Select
// ---------------------------------------------------------------------------
//
// Thin RHF wrapper around the standalone AsyncSelect engine.
// Adds useController binding + i18n error translation.
//
// Data Mapping Contract:
// Single: RHF stores T | null → maps to Mantine string | null
// Multi: RHF stores T[] → maps to Mantine string[]
//
// Inversion of Control: accepts `loadOptions` callback instead of
// hardcoded API endpoint. The component is transport-agnostic.
// ---------------------------------------------------------------------------
/** Mantine props we manage ourselves */
type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect';
type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect';
/** Async-specific props (IoC pattern) */
interface AsyncExtraProps<T> {
/** Async callback to load options — (search, page, prevOptions) => Promise */
loadOptions: LoadOptionsFn<T>;
/** Pre-loaded objects for edit forms (injected into dropdown regardless of fetch state) */
defaultOptions?: T[];
/** Search debounce delay in ms (default: 300) */
debounceMs?: number;
}
/** Single-select async RHF props */
export type FieldAsyncSelectSingleProps<
T extends Record<string, any>,
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = AsyncSelectBaseProps<T> &
AsyncExtraProps<T> &
UseControllerProps<TFieldValues, TName> &
Omit<SelectProps, ManagedSelectProps> & {
multiple?: false;
};
/** Multi-select async RHF props */
export type FieldAsyncSelectMultiProps<
T extends Record<string, any>,
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = AsyncSelectBaseProps<T> &
AsyncExtraProps<T> &
UseControllerProps<TFieldValues, TName> &
Omit<MultiSelectProps, ManagedMultiSelectProps> & {
multiple: true;
};
export type FieldAsyncSelectProps<
T extends Record<string, any>,
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> =
| FieldAsyncSelectSingleProps<T, TFieldValues, TName>
| FieldAsyncSelectMultiProps<T, TFieldValues, TName>;
// ---------------------------------------------------------------------------
// Component Implementation
// ---------------------------------------------------------------------------
function FieldAsyncSelectInner<
T extends Record<string, any>,
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(props: FieldAsyncSelectProps<T, TFieldValues, TName>) {
const {
// RHF controller props
name,
control,
rules,
shouldUnregister,
defaultValue,
disabled,
// Object/Async engine props
valueKey,
labelKey,
renderLabel,
multiple,
filterOption,
onSelect: onSelectCallback,
loadOptions,
defaultOptions,
debounceMs,
// Remaining Mantine props
...mantineProps
} = props;
const {
field,
fieldState: { error },
} = useController<TFieldValues, TName>({
name,
control,
rules,
shouldUnregister,
defaultValue,
disabled,
});
const translatedError = useTranslatedError(error?.message);
const handleChange = (value: any) => {
field.onChange(value);
onSelectCallback?.(value);
};
const engineProps = {
valueKey,
labelKey,
renderLabel,
filterOption,
loadOptions,
defaultOptions,
debounceMs,
onBlur: field.onBlur,
error: translatedError,
disabled: field.disabled,
};
if (multiple) {
return (
<AsyncSelect<T>
multiple
{...engineProps}
value={field.value ?? []}
onChange={handleChange}
{...(mantineProps as any)}
/>
);
}
return (
<AsyncSelect<T>
{...engineProps}
value={field.value ?? null}
onChange={handleChange}
{...(mantineProps as any)}
/>
);
}
export const FieldAsyncSelect = React.memo(FieldAsyncSelectInner) as typeof FieldAsyncSelectInner;
(FieldAsyncSelect as any).displayName = 'FieldAsyncSelect';
@@ -0,0 +1,4 @@
import { Autocomplete, type AutocompleteProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldAutocomplete = withRHF<AutocompleteProps>('FieldAutocomplete', Autocomplete);
@@ -0,0 +1,6 @@
import { Checkbox, type CheckboxProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldCheckbox = withRHF<CheckboxProps>('FieldCheckbox', Checkbox, {
isCheckType: true,
});
@@ -0,0 +1,13 @@
import { Chip, type ChipGroupProps } from '@mantine/core';
import { withRHF } from '../withRHF';
// Wraps Chip.Group — individual Chip items are passed as children.
// Usage:
// <FieldChipGroup name="size" control={control}>
// <Chip value="sm">Small</Chip>
// <Chip value="md">Medium</Chip>
// <Chip value="lg">Large</Chip>
// </FieldChipGroup>
export const FieldChipGroup = withRHF<ChipGroupProps>('FieldChipGroup', Chip.Group, {
requiresWrapper: true,
});
@@ -0,0 +1,4 @@
import { ColorInput, type ColorInputProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldColorInput = withRHF<ColorInputProps>('FieldColorInput', ColorInput);
@@ -0,0 +1,8 @@
import { ColorPicker, type ColorPickerProps } from '@mantine/core';
import { withRHF } from '../withRHF';
// ColorPicker does NOT have a native `error` prop.
// The HOC wraps it in Input.Wrapper to display validation errors.
export const FieldColorPicker = withRHF<ColorPickerProps>('FieldColorPicker', ColorPicker, {
requiresWrapper: true,
});
@@ -0,0 +1,4 @@
import { FileInput, type FileInputProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldFileInput = withRHF<FileInputProps>('FieldFileInput', FileInput);
@@ -0,0 +1,4 @@
import { JsonInput, type JsonInputProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldJsonInput = withRHF<JsonInputProps>('FieldJsonInput', JsonInput);
@@ -0,0 +1,153 @@
import React from 'react';
import {
useController,
type FieldPath,
type FieldValues,
type UseControllerProps,
} from 'react-hook-form';
import type { SelectProps, MultiSelectProps } from '@mantine/core';
import { LocalSelect } from '../custom/selects/LocalSelect';
import type { LocalSelectBaseProps } from '../custom/selects/types';
import { useTranslatedError } from '../useTranslatedError';
// ---------------------------------------------------------------------------
// FieldLocalSelect — RHF-connected Local Select
// ---------------------------------------------------------------------------
//
// Follows the same architectural pattern as the existing `FieldSelect`:
// Mantine Component → withRHF HOC → FieldXxx
//
// But instead of using the generic withRHF factory (which assumes string values),
// we use a manual useController binding with an object interception layer.
// The actual rendering is delegated to the standalone LocalSelect engine
// in `custom/selects/LocalSelect.tsx`.
//
// Data Mapping Contract:
// Single: RHF stores T | null → maps to Mantine string | null
// Multi: RHF stores T[] → maps to Mantine string[]
// ---------------------------------------------------------------------------
/** Mantine props we manage ourselves */
type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect';
type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect';
/** Single-select RHF props — stores T | null */
export type FieldLocalSelectSingleProps<
T extends Record<string, any>,
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = LocalSelectBaseProps<T> &
UseControllerProps<TFieldValues, TName> &
Omit<SelectProps, ManagedSelectProps> & {
multiple?: false;
};
/** Multi-select RHF props — stores T[] */
export type FieldLocalSelectMultiProps<
T extends Record<string, any>,
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = LocalSelectBaseProps<T> &
UseControllerProps<TFieldValues, TName> &
Omit<MultiSelectProps, ManagedMultiSelectProps> & {
multiple: true;
};
/** Discriminated union based on `multiple` */
export type FieldLocalSelectProps<
T extends Record<string, any>,
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> =
| FieldLocalSelectSingleProps<T, TFieldValues, TName>
| FieldLocalSelectMultiProps<T, TFieldValues, TName>;
// ---------------------------------------------------------------------------
// Component Implementation
// ---------------------------------------------------------------------------
function FieldLocalSelectInner<
T extends Record<string, any>,
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(props: FieldLocalSelectProps<T, TFieldValues, TName>) {
const {
// RHF controller props
name,
control,
rules,
shouldUnregister,
defaultValue,
disabled,
// LocalSelect engine props
options,
valueKey,
labelKey,
renderLabel,
multiple,
filterOption,
onSelect: onSelectCallback,
// Remaining Mantine props
...mantineProps
} = props;
const {
field,
fieldState: { error },
} = useController<TFieldValues, TName>({
name,
control,
rules,
shouldUnregister,
defaultValue,
disabled,
});
// Translate the error message (handles JSON i18n payloads)
const translatedError = useTranslatedError(error?.message);
// Intercept onChange to pass full objects to RHF
const handleChange = (value: any) => {
field.onChange(value);
onSelectCallback?.(value);
};
// Build engine props based on single/multi mode
if (multiple) {
return (
<LocalSelect<T>
multiple
options={options}
valueKey={valueKey}
labelKey={labelKey}
renderLabel={renderLabel}
filterOption={filterOption}
value={field.value ?? []}
onChange={handleChange}
onBlur={field.onBlur}
error={translatedError}
disabled={field.disabled}
{...(mantineProps as any)}
/>
);
}
return (
<LocalSelect<T>
options={options}
valueKey={valueKey}
labelKey={labelKey}
renderLabel={renderLabel}
filterOption={filterOption}
value={field.value ?? null}
onChange={handleChange}
onBlur={field.onBlur}
error={translatedError}
disabled={field.disabled}
{...(mantineProps as any)}
/>
);
}
export const FieldLocalSelect = React.memo(FieldLocalSelectInner) as typeof FieldLocalSelectInner;
(FieldLocalSelect as any).displayName = 'FieldLocalSelect';
@@ -0,0 +1,4 @@
import { MultiSelect, type MultiSelectProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldMultiSelect = withRHF<MultiSelectProps>('FieldMultiSelect', MultiSelect);
@@ -0,0 +1,4 @@
import { NativeSelect, type NativeSelectProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldNativeSelect = withRHF<NativeSelectProps>('FieldNativeSelect', NativeSelect);
@@ -0,0 +1,4 @@
import { NumberInput, type NumberInputProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldNumberInput = withRHF<NumberInputProps>('FieldNumberInput', NumberInput);
@@ -0,0 +1,4 @@
import { PasswordInput, type PasswordInputProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldPasswordInput = withRHF<PasswordInputProps>('FieldPasswordInput', PasswordInput);
@@ -0,0 +1,4 @@
import { PinInput, type PinInputProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldPinInput = withRHF<PinInputProps>('FieldPinInput', PinInput);
@@ -0,0 +1,10 @@
import { Radio, type RadioGroupProps } from '@mantine/core';
import { withRHF } from '../withRHF';
// Wraps Radio.Group — individual Radio items are passed as children.
// Usage:
// <FieldRadioGroup name="gender" control={control}>
// <Radio value="male" label="Male" />
// <Radio value="female" label="Female" />
// </FieldRadioGroup>
export const FieldRadioGroup = withRHF<RadioGroupProps>('FieldRadioGroup', Radio.Group);
@@ -0,0 +1,4 @@
import { RangeSlider, type RangeSliderProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldRangeSlider = withRHF<RangeSliderProps>('FieldRangeSlider', RangeSlider);
@@ -0,0 +1,4 @@
import { Rating, type RatingProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldRating = withRHF<RatingProps>('FieldRating', Rating);
@@ -0,0 +1,121 @@
import React, { useEffect } from 'react';
import { useController, type FieldPath, type FieldValues, type UseControllerProps } from 'react-hook-form';
import { useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Underline from '@tiptap/extension-underline';
import Link from '@tiptap/extension-link';
import TextAlign from '@tiptap/extension-text-align';
import { RichTextEditor } from '@mantine/tiptap';
import { Input } from '@mantine/core';
import { useTranslatedError } from '../useTranslatedError';
export type FieldRichTextEditorProps<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = UseControllerProps<TFieldValues, TName> & {
label?: React.ReactNode;
description?: React.ReactNode;
withAsterisk?: boolean;
};
function FieldRichTextEditorComponent<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(props: FieldRichTextEditorProps<TFieldValues, TName>) {
const {
name,
control,
rules,
shouldUnregister,
defaultValue,
disabled,
label,
description,
withAsterisk,
} = props;
const {
field,
fieldState: { error },
} = useController({
name,
control,
rules,
shouldUnregister,
defaultValue,
disabled,
});
const translatedError = useTranslatedError(error?.message);
const editor = useEditor({
extensions: [
StarterKit,
Underline,
Link,
TextAlign.configure({ types: ['heading', 'paragraph'], alignments: ['left', 'center', 'right', 'justify'] }),
],
content: field.value || '',
onUpdate({ editor }) {
field.onChange(editor.getHTML());
},
onBlur() {
field.onBlur();
},
});
useEffect(() => {
if (editor && field.value !== editor.getHTML()) {
editor.commands.setContent(field.value || '');
}
}, [field.value, editor]);
return (
<Input.Wrapper
label={label}
description={description}
withAsterisk={withAsterisk}
error={translatedError}
>
<RichTextEditor editor={editor}>
<RichTextEditor.Toolbar sticky stickyOffset={60}>
<RichTextEditor.ControlsGroup>
<RichTextEditor.Bold />
<RichTextEditor.Italic />
<RichTextEditor.Underline />
<RichTextEditor.Strikethrough />
<RichTextEditor.ClearFormatting />
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
<RichTextEditor.H1 />
<RichTextEditor.H2 />
<RichTextEditor.H3 />
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
<RichTextEditor.BulletList />
<RichTextEditor.OrderedList />
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
<RichTextEditor.Link />
<RichTextEditor.Unlink />
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
<RichTextEditor.AlignLeft />
<RichTextEditor.AlignCenter />
<RichTextEditor.AlignRight />
<RichTextEditor.AlignJustify />
</RichTextEditor.ControlsGroup>
</RichTextEditor.Toolbar>
<RichTextEditor.Content />
</RichTextEditor>
</Input.Wrapper>
);
}
// Wrap with React.memo for identical performance characteristics as withRHF components
export const FieldRichTextEditor = React.memo(FieldRichTextEditorComponent) as typeof FieldRichTextEditorComponent;
@@ -0,0 +1,16 @@
import { SegmentedControl, type SegmentedControlProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export interface FieldSegmentedControlProps extends SegmentedControlProps {
label?: string;
description?: string;
withAsterisk?: boolean;
}
// SegmentedControl does NOT have a native `error` prop.
// The HOC wraps it in Input.Wrapper to display validation errors.
export const FieldSegmentedControl = withRHF<FieldSegmentedControlProps>(
'FieldSegmentedControl',
SegmentedControl,
{ requiresWrapper: true },
);
@@ -0,0 +1,4 @@
import { Select, type SelectProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldSelect = withRHF<SelectProps>('FieldSelect', Select);
@@ -0,0 +1,4 @@
import { Slider, type SliderProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldSlider = withRHF<SliderProps>('FieldSlider', Slider);
@@ -0,0 +1,6 @@
import { Switch, type SwitchProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldSwitch = withRHF<SwitchProps>('FieldSwitch', Switch, {
isCheckType: true,
});
@@ -0,0 +1,4 @@
import { TagsInput, type TagsInputProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldTagsInput = withRHF<TagsInputProps>('FieldTagsInput', TagsInput);
@@ -0,0 +1,4 @@
import { TextInput, type TextInputProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldTextInput = withRHF<TextInputProps>('FieldTextInput', TextInput);
@@ -0,0 +1,4 @@
import { Textarea, type TextareaProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldTextarea = withRHF<TextareaProps>('FieldTextarea', Textarea);
+85
View File
@@ -0,0 +1,85 @@
// ---------------------------------------------------------------------------
// Form Field Components — Barrel Export
// ---------------------------------------------------------------------------
// All components are generated via the withRHF() HOC factory.
// They use the `Field` prefix to prevent naming collisions with native
// Mantine components (e.g., FieldTextInput vs TextInput).
//
// Import patterns:
// import { FieldTextInput, FieldSelect } from '@repo/ui/form';
// import { FieldTextInput, FieldSelect } from '@repo/ui/components';
// ---------------------------------------------------------------------------
// Core HOC & Types (for advanced usage / custom field creation)
export { withRHF } from './withRHF';
export type { WithRHFProps, WithRHFOptions, ZodI18nPayload, ValueTransform } from './types';
// Re-export RHF essentials so consuming apps don't need separate imports
export { useForm, useFormContext, useWatch, useFieldArray, FormProvider } from 'react-hook-form';
export { zodResolver } from '@hookform/resolvers/zod';
// ---------------------------------------------------------------------------
// Text Input Fields
// ---------------------------------------------------------------------------
export { FieldTextInput } from './fields/text-input.field';
export { FieldPasswordInput } from './fields/password-input.field';
export { FieldTextarea } from './fields/textarea.field';
export { FieldNumberInput } from './fields/number-input.field';
export { FieldJsonInput } from './fields/json-input.field';
export { FieldPinInput } from './fields/pin-input.field';
export { FieldAutocomplete } from './fields/autocomplete.field';
export { FieldRichTextEditor } from './fields/rich-text.field';
// ---------------------------------------------------------------------------
// Selection Fields
// ---------------------------------------------------------------------------
export { FieldSelect } from './fields/select.field';
export { FieldMultiSelect } from './fields/multi-select.field';
export { FieldNativeSelect } from './fields/native-select.field';
export { FieldTagsInput } from './fields/tags-input.field';
// ---------------------------------------------------------------------------
// Local & Async Selection Fields
// ---------------------------------------------------------------------------
export { FieldLocalSelect } from './fields/local-select.field';
export type { FieldLocalSelectProps } from './fields/local-select.field';
export { FieldAsyncSelect } from './fields/async-select.field';
export type { FieldAsyncSelectProps } from './fields/async-select.field';
// Standalone engines (no RHF dependency) for use outside form contexts
export { LocalSelect, AsyncSelect } from './custom';
export type {
LocalSelectProps,
AsyncSelectProps,
LocalSelectBaseProps,
SelectFilterContext,
LoadOptionsResponse,
LoadOptionsFn,
} from './custom';
// ---------------------------------------------------------------------------
// Toggle / Boolean Fields
// ---------------------------------------------------------------------------
export { FieldCheckbox } from './fields/checkbox.field';
export { FieldRadioGroup } from './fields/radio-group.field';
export { FieldSwitch } from './fields/switch.field';
export { FieldChipGroup } from './fields/chip-group.field';
export { FieldSegmentedControl } from './fields/segmented-control.field';
// ---------------------------------------------------------------------------
// Range / Numeric Fields
// ---------------------------------------------------------------------------
export { FieldSlider } from './fields/slider.field';
export { FieldRangeSlider } from './fields/range-slider.field';
export { FieldRating } from './fields/rating.field';
// ---------------------------------------------------------------------------
// Color Fields
// ---------------------------------------------------------------------------
export { FieldColorInput } from './fields/color-input.field';
export { FieldColorPicker } from './fields/color-picker.field';
// ---------------------------------------------------------------------------
// File Fields
// ---------------------------------------------------------------------------
export { FieldFileInput } from './fields/file-input.field';
+91
View File
@@ -0,0 +1,91 @@
import type { ComponentType } from 'react';
import type {
FieldPath,
FieldValues,
UseControllerProps,
} from 'react-hook-form';
// ---------------------------------------------------------------------------
// Zod i18n JSON payload shape
// ---------------------------------------------------------------------------
// When Zod errors are encoded for i18n, they follow this shape:
// { "key": "validation.required", "values": { "min": 3 } }
// The HOC will attempt JSON.parse on the error message string. If parsing
// succeeds and the shape matches, it will call t(key, values) for translation.
// ---------------------------------------------------------------------------
export interface ZodI18nPayload {
/** The i18n translation key, e.g. "validation.required" */
key: string;
/** Optional interpolation values, e.g. { min: 3, max: 255 } */
values?: Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// WithRHFProps — Props injected by the withRHF HOC
// ---------------------------------------------------------------------------
// This type removes Mantine's own value/onChange/onBlur/error props (which
// are controlled by RHF) and injects the RHF controller props instead.
// ---------------------------------------------------------------------------
/** Props that RHF will manage — stripped from the Mantine component's API */
type ManagedProps = 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error';
/**
* Final props type for a wrapped Field component.
*
* @template TComponentProps - The original Mantine component props
* @template TFieldValues - The form values shape (default: FieldValues)
* @template TName - The field path (auto-inferred from TFieldValues)
*/
export type WithRHFProps<
TComponentProps,
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Omit<TComponentProps, ManagedProps> &
UseControllerProps<TFieldValues, TName>;
// ---------------------------------------------------------------------------
// Value transform — for components with non-standard value semantics
// ---------------------------------------------------------------------------
/**
* Defines how a Mantine component's native event value maps to/from the
* RHF field value. Used for components like Checkbox (boolean checked)
* or NumberInput (number | string number).
*/
export interface ValueTransform<TFieldValue = unknown, TNativeValue = unknown> {
/** Convert RHF field value → Mantine component prop */
toComponentValue: (fieldValue: TFieldValue) => TNativeValue;
/** Convert Mantine onChange argument → RHF field value */
toFieldValue: (nativeValue: TNativeValue) => TFieldValue;
}
// ---------------------------------------------------------------------------
// HOC configuration options
// ---------------------------------------------------------------------------
export interface WithRHFOptions {
/**
* When true, the component uses `checked` instead of `value` for its
* controlled state (e.g., Checkbox, Switch).
*/
isCheckType?: boolean;
/**
* When true, the wrapped Mantine component does NOT have a native `error`
* prop. The HOC will render the component inside `Input.Wrapper` to
* display validation errors.
*/
requiresWrapper?: boolean;
}
// ---------------------------------------------------------------------------
// Utility: Extract the component's ref type for forwardRef
// ---------------------------------------------------------------------------
export type ExtractRef<T> = T extends ComponentType<infer P>
? P extends { ref?: infer R }
? R
: never
: never;
@@ -0,0 +1,78 @@
import { useMemo } from 'react';
import { useTranslation } from '@repo/core-i18n';
import type { ZodI18nPayload } from './types';
// ---------------------------------------------------------------------------
// Helper: Attempt to parse a Zod error message as a JSON i18n payload
// ---------------------------------------------------------------------------
export function tryParseI18nPayload(message: string): ZodI18nPayload | null {
// Quick guard: JSON payloads always start with '{'
if (!message.startsWith('{')) return null;
try {
const parsed: unknown = JSON.parse(message);
if (
typeof parsed === 'object' &&
parsed !== null &&
'key' in parsed &&
typeof (parsed as ZodI18nPayload).key === 'string'
) {
return parsed as ZodI18nPayload;
}
} catch {
// Not valid JSON — this is expected for plain string error messages
}
return null;
}
// ---------------------------------------------------------------------------
// useTranslatedError — Hook that resolves a raw error message into a
// user-facing translated string.
// ---------------------------------------------------------------------------
export function useTranslatedError(rawMessage: string | undefined): string | undefined {
// Always call useTranslation — React hook rules require stable call order.
// The 'validation' namespace is used for Zod error keys.
// Falls back to 'common' automatically via i18next's ns resolution.
const { t, i18n } = useTranslation();
return useMemo(() => {
if (!rawMessage) return undefined;
const payload = tryParseI18nPayload(rawMessage);
if (payload) {
// Attempt to translate. If the key exists in i18n resources, we get
// the translated string. Otherwise i18next returns the key itself,
// and we fall back to the raw Zod message.
const translated = t(payload.key, {
...payload.values,
ns: 'validation',
defaultValue: payload.key, // fallback to the key itself
});
// If i18next couldn't find the key (returned the key unchanged),
// try without namespace, then fall back to the raw Zod message.
if (translated === payload.key) {
const commonAttempt = t(payload.key, {
...payload.values,
defaultValue: rawMessage,
});
return commonAttempt;
}
return translated;
}
// Not a JSON payload — check if the raw message itself is a translation key
if (i18n.exists(rawMessage, { ns: 'validation' })) {
return t(rawMessage, { ns: 'validation' });
}
// Plain string error message — pass through as-is
return rawMessage;
}, [rawMessage, t, i18n]);
}
+165
View File
@@ -0,0 +1,165 @@
import React, { type ComponentType, type Ref } from 'react';
import {
useController,
type FieldPath,
type FieldValues,
type UseControllerProps,
} from 'react-hook-form';
import { Input } from '@mantine/core';
import type { WithRHFOptions } from './types';
import { useTranslatedError } from './useTranslatedError';
// ---------------------------------------------------------------------------
// withRHF — Higher-Order Component Factory
// ---------------------------------------------------------------------------
//
// PERFORMANCE NOTES (ERP 1500+ field forms):
// -------------------------------------------
// 1. `useController` creates a MICRO-SUBSCRIPTION for this field only.
// The component will NOT re-render when unrelated fields change.
//
// 2. `React.memo` is applied on the OUTER wrapper component. This provides
// a second defense layer: even if a parent component re-renders (e.g.,
// a layout grid reshuffles), this field will bail out of rendering if
// its own props haven't changed.
//
// 3. Together, useController + React.memo gives us O(1) render cost per
// keystroke regardless of total form size — critical for ERP-scale forms.
//
// WHY React.memo IS WARRANTED HERE:
// In smaller forms (<50 fields), React.memo's shallow comparison cost is
// negligible but unnecessary. However, in ERP forms with 1500+ fields
// rendered in virtualized grids, each wasted render cascade can add
// ~16ms of jank. The memo wrapper prevents this with near-zero overhead
// (shallow prop comparison is O(n) on prop count, typically <10 props).
// ---------------------------------------------------------------------------
/**
* Creates a React Hook Form-connected wrapper around any Mantine form component.
*
* @param displayName - The display name for the wrapped component (e.g., "FieldTextInput")
* @param MantineComponent - The Mantine component to wrap
* @param options - Configuration for special component types (checkbox, wrapper-needed, etc.)
*
* @example
* ```tsx
* import { TextInput } from '@mantine/core';
* import { withRHF } from './withRHF';
*
* export const FieldTextInput = withRHF('FieldTextInput', TextInput);
* ```
*/
export function withRHF<TComponentProps extends Record<string, any>>(
displayName: string,
MantineComponent: ComponentType<TComponentProps>,
options: WithRHFOptions = {},
) {
const { isCheckType = false, requiresWrapper = false } = options;
type Props<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Omit<TComponentProps, 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'checked'> &
UseControllerProps<TFieldValues, TName> & {
/** Optional ref forwarded to the underlying Mantine component */
ref?: Ref<unknown>;
};
// -----------------------------------------------------------------------
// The inner component — separated so React.memo can wrap it cleanly.
// -----------------------------------------------------------------------
function FieldComponent<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(props: Props<TFieldValues, TName>) {
const {
name,
control,
rules,
shouldUnregister,
defaultValue,
disabled,
ref,
...mantineProps
} = props;
const {
field,
fieldState: { error },
} = useController<TFieldValues, TName>({
name,
control,
rules,
shouldUnregister,
defaultValue,
disabled,
});
// Translate the error message (handles JSON i18n payloads)
const translatedError = useTranslatedError(error?.message);
// Build the props to spread onto the Mantine component
const componentProps: Record<string, unknown> = {
...mantineProps,
ref: ref ?? field.ref,
onBlur: field.onBlur,
disabled: field.disabled,
};
if (isCheckType) {
// Checkbox / Switch: use `checked` and boolean onChange
componentProps['checked'] = !!field.value;
componentProps['onChange'] = (event: React.ChangeEvent<HTMLInputElement> | boolean) => {
if (typeof event === 'boolean') {
field.onChange(event);
} else {
field.onChange(event.currentTarget.checked);
}
};
} else {
// Standard components: use `value` and direct onChange
componentProps['value'] = field.value ?? '';
componentProps['onChange'] = field.onChange;
}
// Components that lack a native `error` prop need Input.Wrapper
if (requiresWrapper) {
const { label, description, withAsterisk, ...innerProps } = componentProps as Record<string, unknown>;
return (
<Input.Wrapper
label={label as string}
description={description as string}
withAsterisk={withAsterisk as boolean}
error={translatedError}
>
<MantineComponent {...(innerProps as TComponentProps)} />
</Input.Wrapper>
);
}
// Standard path: pass error directly to the Mantine component
componentProps['error'] = translatedError;
return <MantineComponent {...(componentProps as TComponentProps)} />;
}
// -----------------------------------------------------------------------
// Apply React.memo for render bailout in large forms.
//
// We use the default shallow comparison. For ERP forms, this means a
// field component like <FieldTextInput name="address.city" /> will NOT
// re-render when <FieldTextInput name="address.zip" /> changes, because:
// 1. useController isolates the subscription (different field path)
// 2. React.memo catches any parent-driven re-renders where our own
// props haven't changed (e.g., a Grid layout re-render)
// -----------------------------------------------------------------------
const Memoized = React.memo(FieldComponent) as typeof FieldComponent;
// Preserve the display name for React DevTools
(Memoized as unknown as { displayName: string }).displayName = displayName;
return Memoized;
}
+5
View File
@@ -1,5 +1,10 @@
export * from '@mantine/core';
export {
List,
TypographyStylesProvider,
} from '@mantine/core';
export * from './Form';
export * from './system-pages/coming-soon';
export * from './system-pages/forbidden';
export * from './system-pages/maintenance';
+1
View File
@@ -1 +1,2 @@
export * from '@mantine/hooks';
export * from './useConditionalField';
@@ -0,0 +1,75 @@
import { useEffect, useRef } 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;
// Stabilize defaultValue using useRef to prevent infinite render loops
// if developers pass inline arrays/objects (e.g. defaultValue: [])
const defaultValueRef = useRef(defaultValue);
useEffect(() => {
defaultValueRef.current = defaultValue;
}, [defaultValue]);
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 = defaultValueRef.current !== undefined ? defaultValueRef.current : ('' 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,
mode
]);
}
+2 -6
View File
@@ -19,7 +19,7 @@ const densityMap = {
standard: standardDensity,
};
export function ThemeProvider({ children, colorScheme = 'light', density = 'standard' }: ThemeProviderProps) {
export function ThemeProvider({ children, colorScheme = 'light', density = 'compact' }: ThemeProviderProps) {
const baseTheme = createTheme({
colors: {
brand: brandColors,
@@ -39,11 +39,7 @@ export function ThemeProvider({ children, colorScheme = 'light', density = 'stan
const mergedTheme = mergeThemeOverrides(baseTheme, selectedDensity);
return (
<MantineProvider
theme={mergedTheme}
forceColorScheme={colorScheme}
defaultColorScheme={colorScheme}
>
<MantineProvider theme={mergedTheme} forceColorScheme={colorScheme} defaultColorScheme={colorScheme}>
{children}
</MantineProvider>
);
+54 -22
View File
@@ -1,18 +1,34 @@
/* =========================================
1. CORE IMPORTS & TAILWIND CONFIG
========================================= */
/* Import Mantine core and TipTap extensions */
@import '@mantine/core/styles.css';
@import '@mantine/tiptap/styles.css';
/* Initialize Tailwind CSS v4 engine */
@import 'tailwindcss';
/* Instruct Tailwind to scan the src directory for utility class usage */
@source "../src";
@theme {
/* =========================================
FONT FAMILY MAPPING (Mantine -> Tailwind)
2. FONT FAMILY MAPPING
Synchronizes Tailwind's typography utilities
with Mantine's global font configurations.
========================================= */
--base-font-size: 13px;
--font-sans: var(--mantine-font-family);
--font-mono: var(--mantine-font-family-monospace);
/* =========================================
1. COLORS (Strictly mapped to Mantine 0-9)
3. COLOR SYSTEM (Mantine to Tailwind Sync)
Maps Tailwind's 50-900 scale directly to
Mantine's 0-9 scale for seamless theming.
Usage: `bg-brand-500`, `text-error-700`
========================================= */
/* Brand Colors */
--color-brand-50: var(--mantine-color-brand-0);
--color-brand-100: var(--mantine-color-brand-1);
--color-brand-200: var(--mantine-color-brand-2);
@@ -24,6 +40,7 @@
--color-brand-800: var(--mantine-color-brand-8);
--color-brand-900: var(--mantine-color-brand-9);
/* Error Colors (Red/Danger) */
--color-error-50: var(--mantine-color-error-0);
--color-error-100: var(--mantine-color-error-1);
--color-error-200: var(--mantine-color-error-2);
@@ -35,6 +52,7 @@
--color-error-800: var(--mantine-color-error-8);
--color-error-900: var(--mantine-color-error-9);
/* Warning Colors (Yellow/Orange) */
--color-warning-50: var(--mantine-color-warning-0);
--color-warning-100: var(--mantine-color-warning-1);
--color-warning-200: var(--mantine-color-warning-2);
@@ -46,6 +64,7 @@
--color-warning-800: var(--mantine-color-warning-8);
--color-warning-900: var(--mantine-color-warning-9);
/* Success Colors (Green) */
--color-success-50: var(--mantine-color-success-0);
--color-success-100: var(--mantine-color-success-1);
--color-success-200: var(--mantine-color-success-2);
@@ -57,6 +76,7 @@
--color-success-800: var(--mantine-color-success-8);
--color-success-900: var(--mantine-color-success-9);
/* Info Colors (Blue/Cyan) */
--color-info-50: var(--mantine-color-info-0);
--color-info-100: var(--mantine-color-info-1);
--color-info-200: var(--mantine-color-info-2);
@@ -69,22 +89,26 @@
--color-info-900: var(--mantine-color-info-9);
/* =========================================
2. SPACING & CONTAINERS
4. SPACING, BREAKPOINTS & CONTAINERS
Aligns Tailwind's padding/margin scale
with Mantine's layout engine.
========================================= */
--spacing: 0.25rem;
--spacing: 0.25rem; /* Base Tailwind unit (1 = 0.25rem) */
/* Core mapped to Mantine */
/* Map core layout spacing to Mantine */
--spacing-xs: var(--mantine-spacing-xs);
--spacing-sm: var(--mantine-spacing-sm);
--spacing-md: var(--mantine-spacing-md);
--spacing-lg: var(--mantine-spacing-lg);
--spacing-xl: var(--mantine-spacing-xl);
/* Standard Tailwind responsive breakpoints and container sizes */
--breakpoint-sm: 40rem;
--breakpoint-md: 48rem;
--breakpoint-lg: 64rem;
--breakpoint-xl: 80rem;
--breakpoint-2xl: 96rem;
--container-3xs: 16rem;
--container-2xs: 18rem;
--container-xs: 20rem;
@@ -100,17 +124,22 @@
--container-7xl: 80rem;
/* =========================================
3. TYPOGRAPHY
5. TYPOGRAPHY SCALES
Base sizes (xs to xl) inherit from Mantine.
Extended sizes (2xl to 9xl) use static rems.
========================================= */
/* Core text sizes mapped to Mantine, extended kept static */
--text-xs: var(--mantine-font-size-xs);
--text-xs--line-height: calc(1 / 0.75);
--text-sm: var(--mantine-font-size-sm);
--text-sm--line-height: calc(1.25 / 0.875);
--text-base: var(--mantine-font-size-md);
--text-base--line-height: calc(1.5 / 1);
--text-lg: var(--mantine-font-size-lg);
--text-lg--line-height: calc(1.75 / 1.125);
--text-xl: var(--mantine-font-size-xl);
--text-xl--line-height: calc(1.75 / 1.25);
@@ -131,6 +160,7 @@
--text-9xl: 8rem;
--text-9xl--line-height: 1;
/* Font Weights */
--font-weight-thin: 100;
--font-weight-extralight: 200;
--font-weight-light: 300;
@@ -141,6 +171,7 @@
--font-weight-extrabold: 800;
--font-weight-black: 900;
/* Letter Spacing (Tracking) */
--tracking-tighter: -0.05em;
--tracking-tight: -0.025em;
--tracking-normal: 0em;
@@ -148,6 +179,7 @@
--tracking-wider: 0.05em;
--tracking-widest: 0.1em;
/* Line Height (Leading) */
--leading-tight: 1.25;
--leading-snug: 1.375;
--leading-normal: 1.5;
@@ -155,19 +187,23 @@
--leading-loose: 2;
/* =========================================
4. RADIUS
6. BORDER RADIUS
Inherits exact corner rounding from Mantine.
========================================= */
--radius-xs: var(--mantine-radius-xs);
--radius-sm: var(--mantine-radius-sm);
--radius-md: var(--mantine-radius-md);
--radius-lg: var(--mantine-radius-lg);
--radius-xl: var(--mantine-radius-xl);
--radius-2xl: 1rem;
--radius-3xl: 1.5rem;
--radius-4xl: 2rem;
/* =========================================
5. SHADOWS & BLURS
7. SHADOWS & BLURS
Ensures popovers, modals, and dropdowns
share identical elevation depths.
========================================= */
--shadow-2xs: 0 1px rgb(0 0 0 / 0.05);
--shadow-xs: var(--mantine-shadow-xs);
@@ -203,7 +239,7 @@
--blur-3xl: 64px;
/* =========================================
6. MISCELLANEOUS (Aspect, Anim, Perspective)
8. MISCELLANEOUS & ANIMATIONS
========================================= */
--perspective-dramatic: 100px;
--perspective-near: 300px;
@@ -217,31 +253,26 @@
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
/* Standard Tailwind Animations */
--animate-spin: spin 1s linear infinite;
--animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;
--animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
--animate-bounce: bounce 1s infinite;
@keyframes spin {
to {
transform: rotate(360deg);
}
to { transform: rotate(360deg); }
}
@keyframes ping {
75%,
100% {
75%, 100% {
transform: scale(2);
opacity: 0;
}
}
@keyframes pulse {
50% {
opacity: 0.5;
}
50% { opacity: 0.5; }
}
@keyframes bounce {
0%,
100% {
0%, 100% {
transform: translateY(-25%);
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
}
@@ -253,9 +284,10 @@
}
/* =========================================
BASE RESETS
9. BASE RESETS
Applies global typography smoothing and
sets the root font size.
========================================= */
@layer base {
body {
font-size: var(--base-font-size);
@@ -0,0 +1,153 @@
import { describe, it, expect } from 'vitest';
import { z } from 'zod';
import {
compose,
required,
emailValidator,
minValue,
maxValue,
rangeValue,
positiveNumber,
minLength,
maxLength,
rangeLength,
simplePassword,
complexPassword,
phoneValidator,
} from '../registry.validator';
describe('Validator Registry', () => {
describe('compose()', () => {
it('should compose multiple string modifiers', () => {
const schema = compose(z.string(), required('Password'), complexPassword(8));
const res = schema.safeParse('Weak');
expect(res.success).toBe(false);
const res2 = schema.safeParse('StrongPass1!');
expect(res2.success).toBe(true);
});
});
describe('General Validators', () => {
it('required() should enforce min 1 length', () => {
const schema = compose(z.string(), required('TestField'));
const res = schema.safeParse('');
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:required', values: { field: 'TestField' } })
);
});
it('emailValidator() should enforce email format', () => {
const schema = compose(z.string(), emailValidator());
const res = schema.safeParse('invalid-email');
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:invalid_email' })
);
});
});
describe('Numeric Validators', () => {
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' } })
);
});
it('maxValue() should enforce max', () => {
const schema = compose(z.number(), maxValue(100, 'Percentage'));
const res = schema.safeParse(105);
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:max_val', values: { max: 100, field: 'Percentage' } })
);
});
it('rangeValue() should enforce range', () => {
const schema = compose(z.number(), rangeValue(10, 20, 'Range'));
const res = schema.safeParse(5);
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:range_val', values: { min: 10, max: 20, field: 'Range' } })
);
});
it('positiveNumber() should enforce positive', () => {
const schema = compose(z.number(), positiveNumber('Amount'));
const res = schema.safeParse(-5);
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:must_be_positive', values: { field: 'Amount' } })
);
});
});
describe('String Length Validators', () => {
it('minLength() should enforce min length', () => {
const schema = compose(z.string(), minLength(5, 'Username'));
const res = schema.safeParse('abc');
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:min_len', values: { min: 5, field: 'Username' } })
);
});
it('maxLength() should enforce max length', () => {
const schema = compose(z.string(), maxLength(10, 'Username'));
const res = schema.safeParse('thisisaverylongusername');
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:max_len', values: { max: 10, field: 'Username' } })
);
});
it('rangeLength() should enforce range', () => {
const schema = compose(z.string(), rangeLength(3, 5, 'Code'));
const res = schema.safeParse('ab');
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:range_len', values: { min: 3, max: 5, field: 'Code' } })
);
});
});
describe('Security Validators', () => {
it('simplePassword() should enforce length only', () => {
const schema = compose(z.string(), simplePassword(6));
const res = schema.safeParse('short');
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:invalid_password_simple', values: { min: 6 } })
);
expect(schema.safeParse('longenough').success).toBe(true);
});
it('complexPassword() should enforce complex rules', () => {
const schema = compose(z.string(), complexPassword(8));
expect(schema.safeParse('weakpassword').success).toBe(false);
expect(schema.safeParse('NoSpecial1').success).toBe(false);
expect(schema.safeParse('ValidPass1!').success).toBe(true);
const res = schema.safeParse('short');
expect(res.success).toBe(false);
});
});
describe('Technical Validators', () => {
it('phoneValidator() should enforce indonesian phone pattern', () => {
const schema = compose(z.string(), phoneValidator());
expect(schema.safeParse('08123456789').success).toBe(false);
expect(schema.safeParse('+628123456789').success).toBe(true);
const res = schema.safeParse('invalid');
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:invalid_phone' })
);
});
});
});
+2
View File
@@ -0,0 +1,2 @@
export * from './sample.validator';
export * from './registry.validator';
@@ -0,0 +1,96 @@
import type { ZodString, ZodNumber, ZodTypeAny } from 'zod';
// ─── UTILITIES ─────────────────────────────────────────────────────────────
export const compose = <T extends ZodTypeAny>(
base: T,
...modifiers: ((schema: any) => any)[]
): any => {
return modifiers.reduce((acc, curr) => curr(acc), base);
};
// ─── GENERAL ───────────────────────────────────────────────────────────────
export const required = (field?: string) => (schema: ZodString) => {
return schema.min(1, {
message: JSON.stringify({ key: 'validation:required', values: { field: field || 'Field' } }),
});
};
export const emailValidator = () => (schema: ZodString) => {
return schema.email({
message: JSON.stringify({ key: 'validation:invalid_email' }),
});
};
// ─── NUMERIC ───────────────────────────────────────────────────────────────
export const minValue = (min: number, field?: string) => (schema: ZodNumber) => {
return schema.min(min, {
message: JSON.stringify({ key: 'validation:min_val', values: { min, field } }),
});
};
export const maxValue = (max: number, field?: string) => (schema: ZodNumber) => {
return schema.max(max, {
message: JSON.stringify({ key: 'validation:max_val', values: { max, field } }),
});
};
export const rangeValue = (min: number, max: number, field?: string) => (schema: ZodNumber) => {
return schema
.min(min, { message: JSON.stringify({ key: 'validation:range_val', values: { min, max, field } }) })
.max(max, { message: JSON.stringify({ key: 'validation:range_val', values: { min, max, field } }) });
};
export const positiveNumber = (field?: string) => (schema: ZodNumber) => {
return schema.positive({
message: JSON.stringify({ key: 'validation:must_be_positive', values: { field } }),
});
};
// ─── STRING LENGTH ─────────────────────────────────────────────────────────
export const minLength = (min: number, field?: string) => (schema: ZodString) => {
return schema.min(min, {
message: JSON.stringify({ key: 'validation:min_len', values: { min, field } }),
});
};
export const maxLength = (max: number, field?: string) => (schema: ZodString) => {
return schema.max(max, {
message: JSON.stringify({ key: 'validation:max_len', values: { max, field } }),
});
};
export const rangeLength = (min: number, max: number, field?: string) => (schema: ZodString) => {
return schema
.min(min, { message: JSON.stringify({ key: 'validation:range_len', values: { min, max, field } }) })
.max(max, { message: JSON.stringify({ key: 'validation:range_len', values: { min, max, field } }) });
};
// ─── SECURITY ──────────────────────────────────────────────────────────────
export const simplePassword = (min: number = 8) => (schema: ZodString) => {
return schema.min(min, {
message: JSON.stringify({ key: 'validation:invalid_password_simple', values: { min } }),
});
};
export const complexPassword = (min: number = 8) => (schema: ZodString) => {
return schema
.min(min, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) })
.regex(/[A-Z]/, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) })
.regex(/[a-z]/, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) })
.regex(/[0-9]/, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) })
.regex(/[^A-Za-z0-9]/, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) });
};
// ─── TECHNICAL ─────────────────────────────────────────────────────────────
export const phoneValidator = () => (schema: ZodString) => {
// Regex for Indonesian phone number (+62...)
return schema.regex(/^\+62\d{8,13}$/, {
message: JSON.stringify({ key: 'validation:invalid_phone' }),
});
};
@@ -0,0 +1,9 @@
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>;
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/__tests__/setup.ts'],
css: false,
},
});
+578 -7
View File
@@ -161,6 +161,9 @@ importers:
apps/web:
dependencies:
'@hookform/resolvers':
specifier: ^5.0.1
version: 5.4.0(react-hook-form@7.79.0)
'@repo/core-api':
specifier: workspace:*
version: link:../../packages/core-api
@@ -200,6 +203,9 @@ importers:
react-dom:
specifier: ^19.2.3
version: 19.2.3(react@19.2.3)
react-hook-form:
specifier: ^7.56.4
version: 7.79.0(react@19.2.3)
react-i18next:
specifier: ^15.4.0
version: 15.7.4(i18next@24.2.3)(react-dom@19.2.3)(react@19.2.3)(typescript@5.5.4)
@@ -209,6 +215,9 @@ importers:
tailwindcss:
specifier: ^4.1.18
version: 4.1.18
zod:
specifier: ^3.25.36
version: 3.25.76
devDependencies:
'@repo/eslint-config':
specifier: workspace:*
@@ -236,7 +245,7 @@ importers:
version: 5.4.17(@types/node@22.19.3)
vitest:
specifier: ^4.0.17
version: 4.0.17(jsdom@26.1.0)
version: 4.0.17(@opentelemetry/api@1.9.1)
packages/configs/eslint:
dependencies:
@@ -433,22 +442,52 @@ importers:
version: 5.5.4
vitest:
specifier: ^4.0.17
version: 4.0.17(jsdom@26.1.0)
version: 4.0.17(@opentelemetry/api@1.9.1)
packages/ui:
dependencies:
'@hookform/resolvers':
specifier: ^5.0.1
version: 5.4.0(react-hook-form@7.79.0)
'@mantine/core':
specifier: ^8.3.15
version: 8.3.15(@mantine/hooks@8.3.15)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
'@mantine/hooks':
specifier: ^8.3.15
version: 8.3.15(react@19.2.3)
'@mantine/tiptap':
specifier: ^9.3.2
version: 9.3.2(@mantine/core@8.3.15)(@mantine/hooks@8.3.15)(@tiptap/extension-link@3.27.1)(@tiptap/react@3.27.1)(react-dom@19.2.3)(react@19.2.3)
'@repo/core-i18n':
specifier: workspace:*
version: link:../core-i18n
'@repo/utils':
specifier: workspace:*
version: link:../utils
'@tiptap/extension-link':
specifier: ^3.27.1
version: 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
'@tiptap/extension-text-align':
specifier: ^3.27.1
version: 3.27.1(@tiptap/core@3.27.1)
'@tiptap/extension-underline':
specifier: ^3.27.1
version: 3.27.1(@tiptap/core@3.27.1)
'@tiptap/pm':
specifier: ^3.27.1
version: 3.27.1
'@tiptap/react':
specifier: ^3.27.1
version: 3.27.1(@floating-ui/dom@1.7.5)(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
'@tiptap/starter-kit':
specifier: ^3.27.1
version: 3.27.1
dayjs:
specifier: ^1.11.19
version: 1.11.19
react-hook-form:
specifier: ^7.56.4
version: 7.79.0(react@19.2.3)
tailwind-merge:
specifier: ^3.4.0
version: 3.4.0
@@ -458,6 +497,9 @@ importers:
tailwindcss:
specifier: ^4.1.18
version: 4.1.18
zod:
specifier: ^3.25.36
version: 3.25.76
devDependencies:
'@repo/eslint-config':
specifier: workspace:*
@@ -468,6 +510,15 @@ importers:
'@tailwindcss/vite':
specifier: ^4.1.18
version: 4.1.18(vite@5.4.17)
'@testing-library/jest-dom':
specifier: ^6.6.3
version: 6.9.1
'@testing-library/react':
specifier: ^16.3.0
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
'@testing-library/user-event':
specifier: ^14.6.1
version: 14.6.1(@testing-library/dom@10.4.1)
'@types/react':
specifier: ^19.2.7
version: 19.2.7
@@ -480,6 +531,9 @@ importers:
eslint:
specifier: ^8.57.1
version: 8.57.1
jsdom:
specifier: ^26.1.0
version: 26.1.0
react:
specifier: ^19.2.3
version: 19.2.3
@@ -522,7 +576,7 @@ importers:
version: 5.5.4
vitest:
specifier: ^4.0.17
version: 4.0.17(jsdom@26.1.0)
version: 4.0.17(@opentelemetry/api@1.9.1)
packages:
@@ -530,6 +584,10 @@ packages:
resolution: {integrity: sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==}
dev: true
/@adobe/css-tools@4.5.0:
resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==}
dev: true
/@asamuzakjp/css-color@3.2.0:
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
dependencies:
@@ -1464,6 +1522,15 @@ packages:
- supports-color
dev: false
/@hookform/resolvers@5.4.0(react-hook-form@7.79.0):
resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==}
peerDependencies:
react-hook-form: ^7.55.0
dependencies:
'@standard-schema/utils': 0.3.0
react-hook-form: 7.79.0(react@19.2.3)
dev: false
/@humanwhocodes/config-array@0.13.0:
resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==}
engines: {node: '>=10.10.0'}
@@ -1601,6 +1668,24 @@ packages:
react: 19.2.3
dev: false
/@mantine/tiptap@9.3.2(@mantine/core@8.3.15)(@mantine/hooks@8.3.15)(@tiptap/extension-link@3.27.1)(@tiptap/react@3.27.1)(react-dom@19.2.3)(react@19.2.3):
resolution: {integrity: sha512-X344wqt3eusMLPANWuNSnKoFjTDlCEOUpYq6hPWU6uBvxEHJGHuJFXjtm/jg/D6aNEvWr+P+m1+ElE7eLT/G0A==}
peerDependencies:
'@mantine/core': 9.3.2
'@mantine/hooks': 9.3.2
'@tiptap/extension-link': '>=3.3.0'
'@tiptap/react': '>=3.3.0'
react: ^19.2.0
react-dom: ^19.2.0
dependencies:
'@mantine/core': 8.3.15(@mantine/hooks@8.3.15)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
'@mantine/hooks': 8.3.15(react@19.2.3)
'@tiptap/extension-link': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
'@tiptap/react': 3.27.1(@floating-ui/dom@1.7.5)(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
dev: false
/@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.3):
resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
peerDependencies:
@@ -2452,6 +2537,10 @@ packages:
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
dev: true
/@standard-schema/utils@0.3.0:
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
dev: false
/@storybook/addon-actions@8.6.14(storybook@8.6.15):
resolution: {integrity: sha512-mDQxylxGGCQSK7tJPkD144J8jWh9IU9ziJMHfB84PKpI/V5ZgqMDnpr2bssTrUaGDqU5e1/z8KcRF+Melhs9pQ==}
peerDependencies:
@@ -2972,6 +3061,18 @@ packages:
pretty-format: 27.5.1
dev: true
/@testing-library/jest-dom@6.9.1:
resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==}
engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
dependencies:
'@adobe/css-tools': 4.5.0
aria-query: 5.3.2
css.escape: 1.5.1
dom-accessibility-api: 0.6.3
picocolors: 1.1.1
redent: 3.0.0
dev: true
/@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3):
resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
engines: {node: '>=18'}
@@ -2995,6 +3096,318 @@ packages:
react-dom: 19.2.3(react@19.2.3)
dev: true
/@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1):
resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
engines: {node: '>=12', npm: '>=6'}
peerDependencies:
'@testing-library/dom': '>=7.21.4'
dependencies:
'@testing-library/dom': 10.4.1
dev: true
/@tiptap/core@3.27.1(@tiptap/pm@3.27.1):
resolution: {integrity: sha512-rV6Qn4wmC6BxfF+4mu6bqGWj9vA4oXXhsrpXaJL2uhjxeHAGofjwcHof2X84VYzeyXgdlsGmqKie4TAppVXZUQ==}
peerDependencies:
'@tiptap/pm': 3.27.1
dependencies:
'@tiptap/pm': 3.27.1
dev: false
/@tiptap/extension-blockquote@3.27.1(@tiptap/core@3.27.1):
resolution: {integrity: sha512-VMF7xJx6qEGiX6DTKNiL31NLqypOcd/4sNjFSe8rb41PwejBJh/nOqVIbBvWkiT6NMGFLxMhj7zJ8/zPo1hXeg==}
peerDependencies:
'@tiptap/core': 3.27.1
dependencies:
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
dev: false
/@tiptap/extension-bold@3.27.1(@tiptap/core@3.27.1):
resolution: {integrity: sha512-TlC5bsS+pqETTrlz4CZz9RO/cKBYtELGIxwtKeivUn3eNfnOxQbbu4WDsiwIfzRFyd0OMnKl6BPM2KnYEehoEQ==}
peerDependencies:
'@tiptap/core': 3.27.1
dependencies:
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
dev: false
/@tiptap/extension-bubble-menu@3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1):
resolution: {integrity: sha512-j/j8Qp9Z5nViade2m7zjrO/CYH/Ca80Qj7aqo0eUaei6FZQ5izlF9o4XQU5EFMAutV6mwynsPUp8FVo5sCuYfw==}
requiresBuild: true
peerDependencies:
'@tiptap/core': 3.27.1
'@tiptap/pm': 3.27.1
dependencies:
'@floating-ui/dom': 1.7.5
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
'@tiptap/pm': 3.27.1
dev: false
optional: true
/@tiptap/extension-bullet-list@3.27.1(@tiptap/extension-list@3.27.1):
resolution: {integrity: sha512-faCUHnRP47o9Zh9VZZX6EX/569udw9Vopm2PgEKPWuKLE2qaS5WBuUVU0iItdJmKUqaWiOZkpoW4jvnDmj0dfg==}
peerDependencies:
'@tiptap/extension-list': 3.27.1
dependencies:
'@tiptap/extension-list': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
dev: false
/@tiptap/extension-code-block@3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1):
resolution: {integrity: sha512-pHlzmZx2OlHfyQ0yRlT5UL4mGokz947DthZuYefN1OleVqOkHpWBG+2JQwqoNq6bmzMne92zbH32rhcJUEYSjA==}
peerDependencies:
'@tiptap/core': 3.27.1
'@tiptap/pm': 3.27.1
dependencies:
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
'@tiptap/pm': 3.27.1
dev: false
/@tiptap/extension-code@3.27.1(@tiptap/core@3.27.1):
resolution: {integrity: sha512-epOUpFfEmBzjvnqvjv2qHX7NAuLo5dlOGV690lWu+sAYMjibuJBeVvAiKPyFCfRCCTUxdbDB3jbaOA1yEcEJ7w==}
peerDependencies:
'@tiptap/core': 3.27.1
dependencies:
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
dev: false
/@tiptap/extension-document@3.27.1(@tiptap/core@3.27.1):
resolution: {integrity: sha512-8FbBTkfnRP4iVaoj+2h3iWa+H0eGDD3yTyVCwrmue/sQTkqUNUoSuAZa3GDG4Sd41xdPwTJxl9nUWGgM1qDCnw==}
peerDependencies:
'@tiptap/core': 3.27.1
dependencies:
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
dev: false
/@tiptap/extension-dropcursor@3.27.1(@tiptap/extensions@3.27.1):
resolution: {integrity: sha512-blFf9x9RG0Qr7P3FoAH/033ffa+mMLZn34trVs8Vi0Ppk6FmJAg5HpYFOtmYoeREdNDJ5rHJKV7SoACbOHgskQ==}
peerDependencies:
'@tiptap/extensions': 3.27.1
dependencies:
'@tiptap/extensions': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
dev: false
/@tiptap/extension-floating-menu@3.27.1(@floating-ui/dom@1.7.5)(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1):
resolution: {integrity: sha512-BmJF1VqB7dSJkgAalrpVFj88WLhxKjcWPuWHOqf2ITrUU2832BhKLXKmxjWUy1gqV8PfNNVWtGfIERy7I0y0+Q==}
requiresBuild: true
peerDependencies:
'@floating-ui/dom': ^1.0.0
'@tiptap/core': 3.27.1
'@tiptap/pm': 3.27.1
dependencies:
'@floating-ui/dom': 1.7.5
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
'@tiptap/pm': 3.27.1
dev: false
optional: true
/@tiptap/extension-gapcursor@3.27.1(@tiptap/extensions@3.27.1):
resolution: {integrity: sha512-QoezN0wdvXIwLQ4ee2ccWDaX3RG0lzgQpIMpMz55oPDhpUVax1+19ApsS53LkcktpS4EbnPL4xO4DaJk0Vp7PQ==}
peerDependencies:
'@tiptap/extensions': 3.27.1
dependencies:
'@tiptap/extensions': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
dev: false
/@tiptap/extension-hard-break@3.27.1(@tiptap/core@3.27.1):
resolution: {integrity: sha512-iv/m9hzl6jfSj9Q8UEjAxONvCoUDaP7M9SRCPx3PaLNxA230TTD6RE0Ye4zFJ8ze7ZVoJJMAqg9Qpq1iYg2JOQ==}
peerDependencies:
'@tiptap/core': 3.27.1
dependencies:
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
dev: false
/@tiptap/extension-heading@3.27.1(@tiptap/core@3.27.1):
resolution: {integrity: sha512-SrC4l1kEIyv9ZXFaI/8LQqU2MyMmjczw7XXsWUQOTN4YXv0JyVgMNR3cI/wz0d2xsTfBdZ1N85Tdng+Ga1t0Sg==}
peerDependencies:
'@tiptap/core': 3.27.1
dependencies:
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
dev: false
/@tiptap/extension-horizontal-rule@3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1):
resolution: {integrity: sha512-QlKE7qn5qMnIGVGhXQlvYedvLtNJ9z0dmit5w8vPb8tKzW4Spk6M7N2kruprrDA8GBwHfeR5wmF+njfUm34qxg==}
peerDependencies:
'@tiptap/core': 3.27.1
'@tiptap/pm': 3.27.1
dependencies:
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
'@tiptap/pm': 3.27.1
dev: false
/@tiptap/extension-italic@3.27.1(@tiptap/core@3.27.1):
resolution: {integrity: sha512-jGGeyn9uRUnNjSTHpbqhiGsp6KaYTSbV09jDXPJI9cDwfV9hpugLvpaCZd0BMBbhU1B1W6kOfX0BE15qX/HQfA==}
peerDependencies:
'@tiptap/core': 3.27.1
dependencies:
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
dev: false
/@tiptap/extension-link@3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1):
resolution: {integrity: sha512-/2jBfsxBZUDGJmpZifqRQPz7f1E5qpS1BckTZ39TADzUJX+feKy7RJ3DtQ02+8y6SSMzvP9loGVjrk6zEMTk4g==}
peerDependencies:
'@tiptap/core': 3.27.1
'@tiptap/pm': 3.27.1
dependencies:
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
'@tiptap/pm': 3.27.1
linkifyjs: 4.3.3
dev: false
/@tiptap/extension-list-item@3.27.1(@tiptap/extension-list@3.27.1):
resolution: {integrity: sha512-zwRl01ETfCkWUvtvK5fw9bXtAajMPkvlkE3Cq6JvH3LF7XXJwDtNj5Tj7exacMpCaSZmlNc43vFb2rAYnrnwMA==}
peerDependencies:
'@tiptap/extension-list': 3.27.1
dependencies:
'@tiptap/extension-list': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
dev: false
/@tiptap/extension-list-keymap@3.27.1(@tiptap/extension-list@3.27.1):
resolution: {integrity: sha512-OIMZNlzPSO8WRd4ic73Fxckzl4N1tesjjLL2XApaNA/uMpO0LoF6WSRPAWv+Z24Wp92ARRJAnRP7iZoI5+Jxig==}
peerDependencies:
'@tiptap/extension-list': 3.27.1
dependencies:
'@tiptap/extension-list': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
dev: false
/@tiptap/extension-list@3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1):
resolution: {integrity: sha512-c2Upru7lj0/ZV/Ibww6cNz6sUS8m6Dp/9uygFhYcZOd3X8M0xBIEk42c6m6SQehkPziVA8QOgNJz7sMqsbz1OQ==}
peerDependencies:
'@tiptap/core': 3.27.1
'@tiptap/pm': 3.27.1
dependencies:
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
'@tiptap/pm': 3.27.1
dev: false
/@tiptap/extension-ordered-list@3.27.1(@tiptap/extension-list@3.27.1):
resolution: {integrity: sha512-GYrKqD//9nHJ2r80uXqbDMzRnFpGzbaEQRTSGaO/SH7DvXWFMow8evkOdjQ7PCQO07jNjJo75+A85Jwu3Ov3AA==}
peerDependencies:
'@tiptap/extension-list': 3.27.1
dependencies:
'@tiptap/extension-list': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
dev: false
/@tiptap/extension-paragraph@3.27.1(@tiptap/core@3.27.1):
resolution: {integrity: sha512-7K7eo1gruOgAsnbK+GCV23AUVUI0cL1bTig8HaPneoFMVbig7vddk8jNLKBWO8TXVbG7TuHdnDN4F98vdtwh5Q==}
peerDependencies:
'@tiptap/core': 3.27.1
dependencies:
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
dev: false
/@tiptap/extension-strike@3.27.1(@tiptap/core@3.27.1):
resolution: {integrity: sha512-Y3DW1jlSlCNCyMGHP3+3qBNNPS83wuFz4RTYGjZtvRRTCRh7apZme9XRWMq1rN5mJ2Cr7fKocA2/5Bs13KgN6Q==}
peerDependencies:
'@tiptap/core': 3.27.1
dependencies:
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
dev: false
/@tiptap/extension-text-align@3.27.1(@tiptap/core@3.27.1):
resolution: {integrity: sha512-EXawuJBO55wd8WcTbHTMoPhv0CGQxza4yCCPB5Hqz4ZPQwahIr3ej+8yp/kimIl0xokabwZ0/Fu8STQ4AkZv5g==}
peerDependencies:
'@tiptap/core': 3.27.1
dependencies:
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
dev: false
/@tiptap/extension-text@3.27.1(@tiptap/core@3.27.1):
resolution: {integrity: sha512-6ZwaZwSrDh+KFFv6V1J79oO37yPs7y1bFxvk1/9Ih2rn3Xr5AWz+eMS+n8RpH3djBVVAQpdIAeYQgcn+VCSsTg==}
peerDependencies:
'@tiptap/core': 3.27.1
dependencies:
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
dev: false
/@tiptap/extension-underline@3.27.1(@tiptap/core@3.27.1):
resolution: {integrity: sha512-N889J4nXN/TPfVt8uF9N1A0SY82E90zwc1y26lqOcw6KWNLmQrlhMh/9OD4ikLDbekmFpOBq/UicpHf/6S8hbQ==}
peerDependencies:
'@tiptap/core': 3.27.1
dependencies:
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
dev: false
/@tiptap/extensions@3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1):
resolution: {integrity: sha512-1Tdx9faw8k0/83V6X+xCDVhV8yElGt95JxeW3YMkKQJI56QdlPz0xOdJPlMiSGJKinPyVier+x9LJD/YZUZIaw==}
peerDependencies:
'@tiptap/core': 3.27.1
'@tiptap/pm': 3.27.1
dependencies:
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
'@tiptap/pm': 3.27.1
dev: false
/@tiptap/pm@3.27.1:
resolution: {integrity: sha512-Ffjx+vimmBU7zH/KrpXzJid3+pziCe/VL2aexSTP63cyQwKQ65LkFkCKaIsSpFdQQuakVZBGWjCA5RoBV852pw==}
dependencies:
prosemirror-changeset: 2.4.1
prosemirror-commands: 1.7.1
prosemirror-dropcursor: 1.8.2
prosemirror-gapcursor: 1.4.1
prosemirror-history: 1.5.0
prosemirror-inputrules: 1.5.1
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.9
prosemirror-schema-list: 1.5.1
prosemirror-state: 1.4.4
prosemirror-tables: 1.8.5
prosemirror-transform: 1.12.0
prosemirror-view: 1.41.9
dev: false
/@tiptap/react@3.27.1(@floating-ui/dom@1.7.5)(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3):
resolution: {integrity: sha512-/Wn2fc9zMtX08MXYScDFsm4wJ8lzfhfPEdbtls7WCDlbtrop48PWlkHDBBJrywARfAQTB2mFs9KiFy9yrQm5Lg==}
peerDependencies:
'@tiptap/core': 3.27.1
'@tiptap/pm': 3.27.1
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
'@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
dependencies:
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
'@tiptap/pm': 3.27.1
'@types/react': 19.2.7
'@types/react-dom': 19.2.3(@types/react@19.2.7)
'@types/use-sync-external-store': 0.0.6
fast-equals: 5.4.0
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
use-sync-external-store: 1.6.0(react@19.2.3)
optionalDependencies:
'@tiptap/extension-bubble-menu': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
'@tiptap/extension-floating-menu': 3.27.1(@floating-ui/dom@1.7.5)(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
transitivePeerDependencies:
- '@floating-ui/dom'
dev: false
/@tiptap/starter-kit@3.27.1:
resolution: {integrity: sha512-vfxRsqW8rCc0k4pzo0ilU3wobVi2wqVj88VZI2SlgZlNnUAkrDGDIAph7CTa9k9fshV+O1ivpEgPC5yC046jow==}
dependencies:
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
'@tiptap/extension-blockquote': 3.27.1(@tiptap/core@3.27.1)
'@tiptap/extension-bold': 3.27.1(@tiptap/core@3.27.1)
'@tiptap/extension-bullet-list': 3.27.1(@tiptap/extension-list@3.27.1)
'@tiptap/extension-code': 3.27.1(@tiptap/core@3.27.1)
'@tiptap/extension-code-block': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
'@tiptap/extension-document': 3.27.1(@tiptap/core@3.27.1)
'@tiptap/extension-dropcursor': 3.27.1(@tiptap/extensions@3.27.1)
'@tiptap/extension-gapcursor': 3.27.1(@tiptap/extensions@3.27.1)
'@tiptap/extension-hard-break': 3.27.1(@tiptap/core@3.27.1)
'@tiptap/extension-heading': 3.27.1(@tiptap/core@3.27.1)
'@tiptap/extension-horizontal-rule': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
'@tiptap/extension-italic': 3.27.1(@tiptap/core@3.27.1)
'@tiptap/extension-link': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
'@tiptap/extension-list': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
'@tiptap/extension-list-item': 3.27.1(@tiptap/extension-list@3.27.1)
'@tiptap/extension-list-keymap': 3.27.1(@tiptap/extension-list@3.27.1)
'@tiptap/extension-ordered-list': 3.27.1(@tiptap/extension-list@3.27.1)
'@tiptap/extension-paragraph': 3.27.1(@tiptap/core@3.27.1)
'@tiptap/extension-strike': 3.27.1(@tiptap/core@3.27.1)
'@tiptap/extension-text': 3.27.1(@tiptap/core@3.27.1)
'@tiptap/extension-underline': 3.27.1(@tiptap/core@3.27.1)
'@tiptap/extensions': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
'@tiptap/pm': 3.27.1
dev: false
/@tootallnate/once@2.0.0:
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
engines: {node: '>= 10'}
@@ -3299,7 +3712,6 @@ packages:
'@types/react': ^19.2.0
dependencies:
'@types/react': 19.2.7
dev: true
/@types/react@19.2.7:
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
@@ -3332,6 +3744,10 @@ packages:
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
dev: false
/@types/use-sync-external-store@0.0.6:
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
dev: false
/@types/uuid@9.0.8:
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
dev: true
@@ -4232,7 +4648,6 @@ packages:
/aria-query@5.3.2:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'}
dev: false
/array-buffer-byte-length@1.0.2:
resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
@@ -4955,6 +5370,10 @@ packages:
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
dev: false
/css.escape@1.5.1:
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
dev: true
/cssstyle@4.6.0:
resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
engines: {node: '>=18'}
@@ -5232,6 +5651,10 @@ packages:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
dev: true
/dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
dev: true
/dotenv-expand@11.0.7:
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
engines: {node: '>=12'}
@@ -6198,6 +6621,11 @@ packages:
/fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
/fast-equals@5.4.0:
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
engines: {node: '>=6.0.0'}
dev: false
/fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
@@ -7528,6 +7956,10 @@ packages:
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dev: false
/linkifyjs@4.3.3:
resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==}
dev: false
/load-plugin@6.0.3:
resolution: {integrity: sha512-kc0X2FEUZr145odl68frm+lMJuQ23+rTXYmR6TImqPtbpmXC4vVXbWKDQ9IzndA0HfyQamWfKLhzsqGSTxE63w==}
dependencies:
@@ -8143,7 +8575,6 @@ packages:
/min-indent@1.0.1:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'}
dev: false
/minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
@@ -8560,6 +8991,10 @@ packages:
wcwidth: 1.0.1
dev: true
/orderedmap@2.1.1:
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
dev: false
/own-keys@1.0.1:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}
@@ -8991,6 +9426,106 @@ packages:
react-is: 16.13.1
dev: false
/prosemirror-changeset@2.4.1:
resolution: {integrity: sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==}
dependencies:
prosemirror-transform: 1.12.0
dev: false
/prosemirror-commands@1.7.1:
resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==}
dependencies:
prosemirror-model: 1.25.9
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
dev: false
/prosemirror-dropcursor@1.8.2:
resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==}
dependencies:
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
prosemirror-view: 1.41.9
dev: false
/prosemirror-gapcursor@1.4.1:
resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==}
dependencies:
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.9
prosemirror-state: 1.4.4
prosemirror-view: 1.41.9
dev: false
/prosemirror-history@1.5.0:
resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==}
dependencies:
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
prosemirror-view: 1.41.9
rope-sequence: 1.3.4
dev: false
/prosemirror-inputrules@1.5.1:
resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==}
dependencies:
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
dev: false
/prosemirror-keymap@1.2.3:
resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
dependencies:
prosemirror-state: 1.4.4
w3c-keyname: 2.2.8
dev: false
/prosemirror-model@1.25.9:
resolution: {integrity: sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA==}
dependencies:
orderedmap: 2.1.1
dev: false
/prosemirror-schema-list@1.5.1:
resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
dependencies:
prosemirror-model: 1.25.9
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
dev: false
/prosemirror-state@1.4.4:
resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==}
dependencies:
prosemirror-model: 1.25.9
prosemirror-transform: 1.12.0
prosemirror-view: 1.41.9
dev: false
/prosemirror-tables@1.8.5:
resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==}
dependencies:
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.9
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
prosemirror-view: 1.41.9
dev: false
/prosemirror-transform@1.12.0:
resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==}
dependencies:
prosemirror-model: 1.25.9
dev: false
/prosemirror-view@1.41.9:
resolution: {integrity: sha512-clTunTX+eaLbr87L1V1QPheRlEQJyTlL3gXe9x3jQIk3rL0RVWxviDGz8tFaydwIVm+hKhYCyr+R/zBtWr9s6A==}
dependencies:
prosemirror-model: 1.25.9
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
dev: false
/protobufjs@7.6.1:
resolution: {integrity: sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==}
engines: {node: '>=12.0.0'}
@@ -9095,6 +9630,15 @@ packages:
react: 19.2.3
scheduler: 0.27.0
/react-hook-form@7.79.0(react@19.2.3):
resolution: {integrity: sha512-mhYp/MTmXvzYX6AJcJVko0rktoIhhmRnEouObj4wF5i/tCttgJvnp1+9wRkpITZjDTqpo4IOSJqu0dBlPlV/Lw==}
engines: {node: '>=18.0.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
dependencies:
react: 19.2.3
dev: false
/react-i18next@15.7.4(i18next@24.2.3)(react-dom@19.2.3)(react@19.2.3)(typescript@5.5.4):
resolution: {integrity: sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==}
peerDependencies:
@@ -9321,6 +9865,14 @@ packages:
tslib: 2.8.1
dev: true
/redent@3.0.0:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'}
dependencies:
indent-string: 4.0.0
strip-indent: 3.0.0
dev: true
/reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -9576,6 +10128,10 @@ packages:
fsevents: 2.3.3
dev: true
/rope-sequence@1.3.4:
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
dev: false
/rrweb-cssom@0.8.0:
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
dev: true
@@ -10117,7 +10673,6 @@ packages:
engines: {node: '>=8'}
dependencies:
min-indent: 1.0.1
dev: false
/strip-indent@4.1.1:
resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==}
@@ -10808,6 +11363,14 @@ packages:
tslib: 2.8.1
dev: false
/use-sync-external-store@1.6.0(react@19.2.3):
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
dependencies:
react: 19.2.3
dev: false
/utf8-byte-length@1.0.5:
resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==}
dev: true
@@ -11148,6 +11711,10 @@ packages:
/vuvuzela@1.0.3:
resolution: {integrity: sha512-Tm7jR1xTzBbPW+6y1tknKiEhz04Wf/1iZkcTJjSFcpNko43+dFW6+OOeQe9taJIug3NdfUAjFKgUSyQrIKaDvQ==}
/w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
dev: false
/w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
@@ -11408,6 +11975,10 @@ packages:
readable-stream: 3.6.2
dev: true
/zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
dev: false
/zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
dev: false