diff --git a/apps/web/src/apps/showcase/example/features/form-demo/components/all-fields-demo.tsx b/apps/web/src/apps/showcase/example/features/form-demo/components/all-fields-demo.tsx index b7cd86d..73f84c4 100644 --- a/apps/web/src/apps/showcase/example/features/form-demo/components/all-fields-demo.tsx +++ b/apps/web/src/apps/showcase/example/features/form-demo/components/all-fields-demo.tsx @@ -6,10 +6,52 @@ import { FieldMultiSelect, FieldNativeSelect, FieldTagsInput, FieldCheckbox, FieldRadioGroup, FieldSwitch, FieldChipGroup, FieldSegmentedControl, FieldSlider, FieldRangeSlider, FieldRating, FieldColorInput, - FieldColorPicker, FieldFileInput + 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 = 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 = 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 = 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(); @@ -37,7 +79,23 @@ export default function AllFieldsDemo() { rating: 0, themeColor: '', colorPicker: '#1c7ed6', - avatar: null + 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: "

ERP Release Notes

This is a highly important update. Please observe the following:

  • System maintenance at midnight.
  • All users must log out.

Thank you for your cooperation.

", + realPokeSelect: null, + multiRealPokeSelect: [] } }); @@ -51,7 +109,7 @@ export default function AllFieldsDemo() { {/* --- Text & Numbers --- */}
- Text & Numbers + {t.sections.textAndNumbers} @@ -73,7 +131,7 @@ export default function AllFieldsDemo() { {/* --- Selections --- */}
- Selections + {t.sections.selections} + + + `${item.name} (${item.hex})`} + clearable + /> + + + + + + + + + + + {t.sections.advancedObjectSelects} + + + `[${item.code}] ${item.name}`} + clearable + /> + `[${item.code}] ${item.name}`} + clearable + /> + + + `[${item.code}] ${item.name}`} + clearable + /> + `[${item.code}] ${item.name}`} + defaultOptions={[{ id: 999, code: 'ASYNC-99', name: 'Pre-loaded Async Vendor' }]} + clearable + /> + + + {t.sections.multiSelectEditMode} + + + `[${item.code}] ${item.name}`} + clearable + /> + `[${item.code}] ${item.name}`} + clearable + /> + + + {t.sections.richTextEditor} + + +
+ +
{/* --- Toggles & Choices --- */}
- Toggles & Choices + {t.sections.togglesAndChoices} @@ -151,11 +372,11 @@ export default function AllFieldsDemo() { {/* --- Ranges & Specialized --- */}
- Ranges & Specialized + {t.sections.rangesAndSpecialized} - + diff --git a/apps/web/src/apps/showcase/example/features/form-demo/components/reactive-watch-demo.tsx b/apps/web/src/apps/showcase/example/features/form-demo/components/reactive-watch-demo.tsx index 447d2c4..e1c3119 100644 --- a/apps/web/src/apps/showcase/example/features/form-demo/components/reactive-watch-demo.tsx +++ b/apps/web/src/apps/showcase/example/features/form-demo/components/reactive-watch-demo.tsx @@ -1,12 +1,50 @@ import { useForm, useWatch } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; -import { Button, Paper, Title, Divider, Stack, Code, Alert } from '@repo/ui/components'; -import { FieldTextInput, FieldSelect, FieldSwitch } from '@repo/ui/form'; +import { 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(); @@ -14,10 +52,10 @@ export default function ReactiveWatchDemo() { // Define atomic validators for conditional fields const taxIdValidator = compose(z.string(), required(t.watch.corporateTaxId)); const spouseNameValidator = compose(z.string(), required(t.watch.spouseName)); - const newsletterEmailValidator = compose(z.string(), required('Newsletter Email'), emailValidator()); - const roleValidator = compose(z.string(), required('Role')); + const newsletterEmailValidator = compose(z.string(), required(t.fields.email), emailValidator()); + const roleValidator = compose(z.string(), required(t.fields.role)); - const reactiveSchema = z + const reactiveSchema = useMemo(() => z .object({ userType: z.enum(['PERSONAL', 'CORPORATE']), corporateTaxId: z.string().optional(), @@ -27,6 +65,9 @@ export default function ReactiveWatchDemo() { 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', [ @@ -51,7 +92,7 @@ export default function ReactiveWatchDemo() { 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({ resolver: zodResolver(reactiveSchema as any), @@ -64,6 +105,12 @@ export default function ReactiveWatchDemo() { 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: "

Start typing to see the live preview...

", }, }); @@ -73,6 +120,8 @@ export default function ReactiveWatchDemo() { 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({ @@ -133,6 +182,27 @@ export default function ReactiveWatchDemo() { defaultValue: '', }); + const isMounted = useRef(false); + const prevRegionIds = useRef(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 }); @@ -144,7 +214,7 @@ export default function ReactiveWatchDemo() {
- Dynamic Fields & Validation + {t.sections.reactiveWatchCascading} @@ -194,8 +264,8 @@ export default function ReactiveWatchDemo() { + + {t.sections.reactiveWatchCascading} + + + + + multiple + name="regions" + control={control as any} + label={t.fields.regions} + options={REGIONS} + valueKey="id" + labelKey="code" + clearable + /> + + + 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 && ( + + {t.descriptions.selectedRegionsTax} {regions.map((r: any) => `${r.code} (${(r.taxRate * 100).toFixed(0)}%)`).join(', ')} + + )} + + + {t.sections.reactiveRichTextPreview} + + + + + + + {t.sections.liveHtmlPreview} + +
+ + + diff --git a/apps/web/src/apps/showcase/example/features/form-demo/components/validation-bank-demo.tsx b/apps/web/src/apps/showcase/example/features/form-demo/components/validation-bank-demo.tsx index 06f24d6..029c089 100644 --- a/apps/web/src/apps/showcase/example/features/form-demo/components/validation-bank-demo.tsx +++ b/apps/web/src/apps/showcase/example/features/form-demo/components/validation-bank-demo.tsx @@ -1,10 +1,58 @@ +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 + 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 = 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 = 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, @@ -16,14 +64,20 @@ export default function ValidationBankDemo() { const t = useFormDemoTranslation(); // Compose the Zod schema using the atomic validators - const validationSchema = z.object({ + 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()) - }); + 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; @@ -35,7 +89,16 @@ export default function ValidationBankDemo() { complexPass: '', age: undefined as any, score: undefined as any, - phone: '' + 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: '', } }); @@ -47,7 +110,7 @@ export default function ValidationBankDemo() {
- Validation Bank (Atomic Registry) + {t.sections.validationBankTitle} @@ -87,7 +150,7 @@ export default function ValidationBankDemo() { name="score" control={control} label={t.validation.score} - description="Must be > 0" + description={t.descriptions.mustBePositive} withAsterisk /> @@ -96,10 +159,87 @@ export default function ValidationBankDemo() { name="phone" control={control} label={t.validation.phone} - description="Format: +62..." + description={t.descriptions.formatPhone} withAsterisk /> + {t.sections.objectLevelValidations} + + + + name="department" + control={control as any} + label={t.fields.department} + options={MOCK_DEPARTMENTS} + valueKey="code" + renderLabel={(item) => `[${item.code}] ${item.name}`} + clearable + withAsterisk + /> + + + multiple + name="assignees" + control={control as any} + label={t.fields.assignees} + loadOptions={mockFetchUsers} + valueKey="id" + labelKey="email" + searchable + clearable + withAsterisk + /> + + {t.sections.validatedPrefilledObjects} + + + + `[${item.code}] ${item.name}`} + clearable + withAsterisk + /> + `[${item.code}] ${item.name}`} + defaultOptions={[{ id: 'V1', code: 'VN-01', name: 'Vendor One' }]} + clearable + withAsterisk + /> + + + `[${item.code}] ${item.name}`} + clearable + withAsterisk + /> + + {t.sections.richTextValidations} + + + +
diff --git a/apps/web/src/apps/showcase/example/features/form-demo/i18n/en.json b/apps/web/src/apps/showcase/example/features/form-demo/i18n/en.json index 80bda1b..a32a1b4 100644 --- a/apps/web/src/apps/showcase/example/features/form-demo/i18n/en.json +++ b/apps/web/src/apps/showcase/example/features/form-demo/i18n/en.json @@ -6,9 +6,26 @@ }, "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", @@ -26,7 +43,60 @@ "orderType": "Order Type", "quantity": "Quantity", "fabricColor": "Fabric Color", - "pin": "Security PIN" + "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", diff --git a/apps/web/src/apps/showcase/example/features/form-demo/i18n/id.json b/apps/web/src/apps/showcase/example/features/form-demo/i18n/id.json index ae86b2d..f0261f0 100644 --- a/apps/web/src/apps/showcase/example/features/form-demo/i18n/id.json +++ b/apps/web/src/apps/showcase/example/features/form-demo/i18n/id.json @@ -6,9 +6,26 @@ }, "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", @@ -26,7 +43,60 @@ "orderType": "Tipe Pesanan", "quantity": "Jumlah", "fabricColor": "Warna Kain", - "pin": "PIN Keamanan" + "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", diff --git a/apps/web/src/apps/showcase/showcase-view.tsx b/apps/web/src/apps/showcase/showcase-view.tsx index 30537ac..b5579c7 100644 --- a/apps/web/src/apps/showcase/showcase-view.tsx +++ b/apps/web/src/apps/showcase/showcase-view.tsx @@ -23,6 +23,8 @@ import { Paper, } from '@repo/ui/components'; 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'; @@ -38,6 +40,7 @@ interface ShowcaseViewProps { export default function ShowcaseView({ colorScheme, setColorScheme, density, setDensity }: ShowcaseViewProps) { const [activeTab, setActiveTab] = useState('ui-components'); + const { i18n } = useTranslation(); // Mock data for the table const tableData = [ @@ -144,6 +147,18 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set {getSubtitle()} + ` | `T \| null` | `string \| null` | `T \| null` | +| `multiple={true}` | `` | `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 ( +
console.log(data.department))}> + + name="department" + control={control} + label="Department" + options={departments} + valueKey="id" + labelKey="name" + searchable + /> + + + ); +} +// 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` | ✅ | 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 = 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 ( +
console.log(data.user))}> + + name="user" + control={control} + label="Assign User" + loadOptions={loadUsers} + valueKey="id" + labelKey="fullName" + placeholder="Search users..." + /> + + + ); +} +``` + +#### Non-Paginated Example + +If your API returns all results at once, return `hasMore: false`: + +```tsx +const loadRoles: LoadOptionsFn = 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 ( + + 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 ( + + 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. @@ -683,7 +888,13 @@ export const FieldDatePicker = withRHF( | `FieldRating` | `Rating` | Range | Star rating | | `FieldColorInput` | `ColorInput` | Color | Color picker with text input | | `FieldColorPicker` | `ColorPicker` | Color | Color picker only (uses `Input.Wrapper`) | -| `FieldFileInput` | `FileInput` | File | File upload input | +| `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` | `` | `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). --- diff --git a/packages/ui/package.json b/packages/ui/package.json index 608a58a..06fe1aa 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -23,8 +23,15 @@ "@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", diff --git a/packages/ui/src/components/Form/__tests__/async-select.field.test.tsx b/packages/ui/src/components/Form/__tests__/async-select.field.test.tsx new file mode 100644 index 0000000..56c2353 --- /dev/null +++ b/packages/ui/src/components/Form/__tests__/async-select.field.test.tsx @@ -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>().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 ( + +
+ + +
+ ); + } + + render(); + + // 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 ( + +
{ capturedData = data; })}> + + + +
+ ); + } + + render(); + + 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 ( + +
+ + +
+ ); + } + + render(); + + 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 ( + +
+ + +
+ ); + } + + render(); + + // 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 ( + +
+ + +
+ ); + } + + render(); + + 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(); + }); +}); diff --git a/packages/ui/src/components/Form/__tests__/local-select.field.test.tsx b/packages/ui/src/components/Form/__tests__/local-select.field.test.tsx new file mode 100644 index 0000000..550c922 --- /dev/null +++ b/packages/ui/src/components/Form/__tests__/local-select.field.test.tsx @@ -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 ( + + + + ); + } + + render(); + 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 ( + +
{ capturedData = data; })}> + + + +
+ ); + } + + render(); + + // 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 ( + + `${item.code} - ${item.name}`} + placeholder="Select vendor" + /> + + ); + } + + render(); + 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 ( + + item.active === true} + /> + + ); + } + + render(); + 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 ( + +
{ capturedData = data; })}> + + + +
+ ); + } + + render(); + 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 ( + +
{ capturedData = data; })}> + + + +
+ ); + } + + const { container } = render(); + + 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 ( + + + + ); + } + + render(); + await user.click(screen.getByPlaceholderText('Select vendor')); + await user.click(screen.getByText('Vendor 2')); + + expect(handleSelect).toHaveBeenCalledWith(VENDORS[1]); + }); +}); diff --git a/packages/ui/src/components/Form/custom/index.ts b/packages/ui/src/components/Form/custom/index.ts new file mode 100644 index 0000000..22231af --- /dev/null +++ b/packages/ui/src/components/Form/custom/index.ts @@ -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'; diff --git a/packages/ui/src/components/Form/custom/selects/AsyncSelect.tsx b/packages/ui/src/components/Form/custom/selects/AsyncSelect.tsx new file mode 100644 index 0000000..2f813e8 --- /dev/null +++ b/packages/ui/src/components/Form/custom/selects/AsyncSelect.tsx @@ -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 { + /** + * 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; + + /** + * 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> = AsyncSelectBaseProps & + AsyncExtraProps & + Omit & { + multiple?: false; + value?: T | null; + onChange?: (value: T | null) => void; + }; + +/** Props for multi-select async mode */ +export type AsyncSelectMultiProps> = AsyncSelectBaseProps & + AsyncExtraProps & + Omit & { + multiple: true; + value?: T[]; + onChange?: (value: T[]) => void; + }; + +export type AsyncSelectProps> = AsyncSelectSingleProps | AsyncSelectMultiProps; + +// --------------------------------------------------------------------------- +// Helper: Resolve label for a data item +// --------------------------------------------------------------------------- + +function resolveLabel>( + 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>(props: AsyncSelectProps) { + 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({ + 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(); + + // 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(); + 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(() => { + let filtered = dataWithInjected; + + if (filterOption) { + const context: SelectFilterContext = { + 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 ? : 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 ( + )} + 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 ( + )} + 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'; diff --git a/packages/ui/src/components/Form/custom/selects/hooks/useAsyncPaginate.ts b/packages/ui/src/components/Form/custom/selects/hooks/useAsyncPaginate.ts new file mode 100644 index 0000000..6ff341f --- /dev/null +++ b/packages/ui/src/components/Form/custom/selects/hooks/useAsyncPaginate.ts @@ -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>) +// → 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> { + /** Async callback to load options. Receives (search, page, prevOptions). */ + loadOptions: LoadOptionsFn; + + /** 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 { + /** 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>( + options: UseAsyncPaginateOptions, +): UseAsyncPaginateReturn { + 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>>(() => 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(); + 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(() => { + 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(); + 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, + }; +} diff --git a/packages/ui/src/components/Form/custom/selects/types.ts b/packages/ui/src/components/Form/custom/selects/types.ts new file mode 100644 index 0000000..92ba417 --- /dev/null +++ b/packages/ui/src/components/Form/custom/selects/types.ts @@ -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 { + /** 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> { + /** 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) => 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> = Omit, 'options'>; + +/** + * Internal result of the object-to-string mapping logic. + * Used by both the standalone and RHF-connected variants. + */ +export interface SelectMappingResult { + /** Mantine-compatible ComboboxItem array for the Select/MultiSelect `data` prop */ + options: ComboboxItem[]; + + /** O(1) reverse lookup map: string value → original object */ + lookupMap: Map; +} + +// --------------------------------------------------------------------------- +// 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 { + /** 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 = 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 = async (search) => { + * const roles = await api.get('/roles', { params: { q: search } }); + * return { options: roles.data, hasMore: false }; + * }; + * ``` + */ +export type LoadOptionsFn = ( + search: string, + page: number, + prevOptions: T[], +) => Promise>; + +/** + * 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 { + /** 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; +} diff --git a/packages/ui/src/components/Form/fields/async-select.field.tsx b/packages/ui/src/components/Form/fields/async-select.field.tsx new file mode 100644 index 0000000..04ab605 --- /dev/null +++ b/packages/ui/src/components/Form/fields/async-select.field.tsx @@ -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 { + /** Async callback to load options — (search, page, prevOptions) => Promise */ + loadOptions: LoadOptionsFn; + + /** 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, + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = AsyncSelectBaseProps & + AsyncExtraProps & + UseControllerProps & + Omit & { + multiple?: false; + }; + +/** Multi-select async RHF props */ +export type FieldAsyncSelectMultiProps< + T extends Record, + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = AsyncSelectBaseProps & + AsyncExtraProps & + UseControllerProps & + Omit & { + multiple: true; + }; + +export type FieldAsyncSelectProps< + T extends Record, + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = + | FieldAsyncSelectSingleProps + | FieldAsyncSelectMultiProps; + +// --------------------------------------------------------------------------- +// Component Implementation +// --------------------------------------------------------------------------- + +function FieldAsyncSelectInner< + T extends Record, + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>(props: FieldAsyncSelectProps) { + 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({ + 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 ( + + multiple + {...engineProps} + value={field.value ?? []} + onChange={handleChange} + {...(mantineProps as any)} + /> + ); + } + + return ( + + {...engineProps} + value={field.value ?? null} + onChange={handleChange} + {...(mantineProps as any)} + /> + ); +} + +export const FieldAsyncSelect = React.memo(FieldAsyncSelectInner) as typeof FieldAsyncSelectInner; +(FieldAsyncSelect as any).displayName = 'FieldAsyncSelect'; diff --git a/packages/ui/src/components/Form/fields/local-select.field.tsx b/packages/ui/src/components/Form/fields/local-select.field.tsx new file mode 100644 index 0000000..30862fe --- /dev/null +++ b/packages/ui/src/components/Form/fields/local-select.field.tsx @@ -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, + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = LocalSelectBaseProps & + UseControllerProps & + Omit & { + multiple?: false; + }; + +/** Multi-select RHF props — stores T[] */ +export type FieldLocalSelectMultiProps< + T extends Record, + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = LocalSelectBaseProps & + UseControllerProps & + Omit & { + multiple: true; + }; + +/** Discriminated union based on `multiple` */ +export type FieldLocalSelectProps< + T extends Record, + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = + | FieldLocalSelectSingleProps + | FieldLocalSelectMultiProps; + +// --------------------------------------------------------------------------- +// Component Implementation +// --------------------------------------------------------------------------- + +function FieldLocalSelectInner< + T extends Record, + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>(props: FieldLocalSelectProps) { + 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({ + 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 ( + + 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 ( + + 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'; diff --git a/packages/ui/src/components/Form/fields/rich-text.field.tsx b/packages/ui/src/components/Form/fields/rich-text.field.tsx new file mode 100644 index 0000000..31f0450 --- /dev/null +++ b/packages/ui/src/components/Form/fields/rich-text.field.tsx @@ -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 = FieldPath, +> = UseControllerProps & { + label?: React.ReactNode; + description?: React.ReactNode; + withAsterisk?: boolean; +}; + +function FieldRichTextEditorComponent< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>(props: FieldRichTextEditorProps) { + 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 ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +// Wrap with React.memo for identical performance characteristics as withRHF components +export const FieldRichTextEditor = React.memo(FieldRichTextEditorComponent) as typeof FieldRichTextEditorComponent; diff --git a/packages/ui/src/components/Form/index.ts b/packages/ui/src/components/Form/index.ts index c668a91..7cbba4f 100644 --- a/packages/ui/src/components/Form/index.ts +++ b/packages/ui/src/components/Form/index.ts @@ -28,6 +28,7 @@ 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 @@ -37,6 +38,25 @@ 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 // --------------------------------------------------------------------------- diff --git a/packages/ui/src/components/Form/useTranslatedError.ts b/packages/ui/src/components/Form/useTranslatedError.ts new file mode 100644 index 0000000..8bd519a --- /dev/null +++ b/packages/ui/src/components/Form/useTranslatedError.ts @@ -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]); +} diff --git a/packages/ui/src/components/Form/withRHF.tsx b/packages/ui/src/components/Form/withRHF.tsx index 7f73a01..f282d0f 100644 --- a/packages/ui/src/components/Form/withRHF.tsx +++ b/packages/ui/src/components/Form/withRHF.tsx @@ -1,4 +1,4 @@ -import React, { type ComponentType, type Ref, useMemo } from 'react'; +import React, { type ComponentType, type Ref } from 'react'; import { useController, type FieldPath, @@ -6,83 +6,8 @@ import { type UseControllerProps, } from 'react-hook-form'; import { Input } from '@mantine/core'; -import { useTranslation } from '@repo/core-i18n'; -import type { ZodI18nPayload, WithRHFOptions } from './types'; - -// --------------------------------------------------------------------------- -// Helper: Attempt to parse a Zod error message as a JSON i18n payload -// --------------------------------------------------------------------------- - -function tryParseI18nPayload(message: string): ZodI18nPayload | null { - // Quick guard: JSON payloads always start with '{' - if (!message.startsWith('{')) return null; - - try { - const parsed: unknown = JSON.parse(message); - - if ( - typeof parsed === 'object' && - parsed !== null && - 'key' in parsed && - typeof (parsed as ZodI18nPayload).key === 'string' - ) { - return parsed as ZodI18nPayload; - } - } catch { - // Not valid JSON — this is expected for plain string error messages - } - - return null; -} - -// --------------------------------------------------------------------------- -// useTranslatedError — Hook that resolves a raw error message into a -// user-facing translated string. -// --------------------------------------------------------------------------- - -function useTranslatedError(rawMessage: string | undefined): string | undefined { - // Always call useTranslation — React hook rules require stable call order. - // The 'validation' namespace is used for Zod error keys. - // Falls back to 'common' automatically via i18next's ns resolution. - const { t, i18n } = useTranslation(); - - return useMemo(() => { - if (!rawMessage) return undefined; - - const payload = tryParseI18nPayload(rawMessage); - - if (payload) { - // Attempt to translate. If the key exists in i18n resources, we get - // the translated string. Otherwise i18next returns the key itself, - // and we fall back to the raw Zod message. - const translated = t(payload.key, { - ...payload.values, - ns: 'validation', - defaultValue: payload.key, // fallback to the key itself - }); - - // If i18next couldn't find the key (returned the key unchanged), - // try without namespace, then fall back to the raw Zod message. - if (translated === payload.key) { - const commonAttempt = t(payload.key, { - ...payload.values, - defaultValue: rawMessage, - }); - return commonAttempt; - } - - return translated; - } - - // Not a JSON payload — check if the raw message itself is a translation key - if (i18n.exists(rawMessage, { ns: 'validation' })) { - return t(rawMessage, { ns: 'validation' }); - } - - // Plain string error message — pass through as-is - return rawMessage; - }, [rawMessage, t, i18n]); -} +import type { WithRHFOptions } from './types'; +import { useTranslatedError } from './useTranslatedError'; // --------------------------------------------------------------------------- // withRHF — Higher-Order Component Factory diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 60020e1..137e26a 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -1,4 +1,8 @@ export * from '@mantine/core'; +export { + List, + TypographyStylesProvider, +} from '@mantine/core'; export * from './Form'; export * from './system-pages/coming-soon'; diff --git a/packages/ui/src/theme.css b/packages/ui/src/theme.css index 27cfea7..8a145ff 100644 --- a/packages/ui/src/theme.css +++ b/packages/ui/src/theme.css @@ -1,19 +1,35 @@ +/* ========================================= + 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` ========================================= */ - --color-brand-50: var(--mantine-color-brand-0); + + /* 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); --color-brand-300: var(--mantine-color-brand-3); @@ -24,7 +40,8 @@ --color-brand-800: var(--mantine-color-brand-8); --color-brand-900: var(--mantine-color-brand-9); - --color-error-50: var(--mantine-color-error-0); + /* 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); --color-error-300: var(--mantine-color-error-3); @@ -35,7 +52,8 @@ --color-error-800: var(--mantine-color-error-8); --color-error-900: var(--mantine-color-error-9); - --color-warning-50: var(--mantine-color-warning-0); + /* 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); --color-warning-300: var(--mantine-color-warning-3); @@ -46,7 +64,8 @@ --color-warning-800: var(--mantine-color-warning-8); --color-warning-900: var(--mantine-color-warning-9); - --color-success-50: var(--mantine-color-success-0); + /* 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); --color-success-300: var(--mantine-color-success-3); @@ -57,7 +76,8 @@ --color-success-800: var(--mantine-color-success-8); --color-success-900: var(--mantine-color-success-9); - --color-info-50: var(--mantine-color-info-0); + /* 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); --color-info-300: var(--mantine-color-info-3); @@ -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,13 +284,14 @@ } /* ========================================= - BASE RESETS + 9. BASE RESETS + Applies global typography smoothing and + sets the root font size. ========================================= */ - @layer base { body { font-size: var(--base-font-size); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index baf8113..09af81a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -245,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: @@ -442,7 +442,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/ui: dependencies: @@ -455,12 +455,33 @@ importers: '@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 @@ -555,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: @@ -1647,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: @@ -3066,6 +3105,309 @@ packages: '@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'} @@ -3370,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==} @@ -3403,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 @@ -6276,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'} @@ -7606,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: @@ -8637,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'} @@ -9068,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'} @@ -9670,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 @@ -10901,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 @@ -11241,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'}