Merge pull request 'backup/asycn-select' (#16) from backup/asycn-select into core/form
Reviewed-on: eigen/fe-monorepo-template#16
This commit is contained in:
+230
-9
@@ -6,10 +6,52 @@ import {
|
|||||||
FieldMultiSelect, FieldNativeSelect, FieldTagsInput, FieldCheckbox,
|
FieldMultiSelect, FieldNativeSelect, FieldTagsInput, FieldCheckbox,
|
||||||
FieldRadioGroup, FieldSwitch, FieldChipGroup, FieldSegmentedControl,
|
FieldRadioGroup, FieldSwitch, FieldChipGroup, FieldSegmentedControl,
|
||||||
FieldSlider, FieldRangeSlider, FieldRating, FieldColorInput,
|
FieldSlider, FieldRangeSlider, FieldRating, FieldColorInput,
|
||||||
FieldColorPicker, FieldFileInput
|
FieldColorPicker, FieldFileInput, FieldLocalSelect, FieldAsyncSelect,
|
||||||
|
FieldRichTextEditor
|
||||||
} from '@repo/ui/form';
|
} from '@repo/ui/form';
|
||||||
|
import type { LoadOptionsFn } from '@repo/ui/form';
|
||||||
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
|
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
|
||||||
|
|
||||||
|
const MOCK_POKEMON = Array.from({ length: 100 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
name: `Pokemon ${i + 1}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const loadMockPokemonOptions: LoadOptionsFn<any> = async (search, page) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
const filtered = MOCK_POKEMON.filter((p) => p.name.toLowerCase().includes(search.toLowerCase()));
|
||||||
|
const pageSize = 20;
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const paginated = filtered.slice(start, start + pageSize);
|
||||||
|
return {
|
||||||
|
options: paginated,
|
||||||
|
hasMore: start + pageSize < filtered.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRealPokemonOptions: LoadOptionsFn<any> = async (_search, page) => {
|
||||||
|
const limit = 20;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const res = await fetch(`https://pokeapi.co/api/v2/pokemon?limit=${limit}&offset=${offset}`);
|
||||||
|
const data = await res.json();
|
||||||
|
return {
|
||||||
|
options: data.results.map((p: any, i: number) => ({ id: offset + i + 1, ...p })),
|
||||||
|
hasMore: !!data.next,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOCK_VENDORS = [
|
||||||
|
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
|
||||||
|
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' },
|
||||||
|
{ id: 'V3', code: 'VN-03', name: 'Vendor Three' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const loadMockVendorsOptions: LoadOptionsFn<any> = async (search, _page) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
const filtered = MOCK_VENDORS.filter(v => v.name.toLowerCase().includes(search.toLowerCase()) || v.code.toLowerCase().includes(search.toLowerCase()));
|
||||||
|
return { options: filtered, hasMore: false };
|
||||||
|
};
|
||||||
|
|
||||||
export default function AllFieldsDemo() {
|
export default function AllFieldsDemo() {
|
||||||
const t = useFormDemoTranslation();
|
const t = useFormDemoTranslation();
|
||||||
|
|
||||||
@@ -37,7 +79,23 @@ export default function AllFieldsDemo() {
|
|||||||
rating: 0,
|
rating: 0,
|
||||||
themeColor: '',
|
themeColor: '',
|
||||||
colorPicker: '#1c7ed6',
|
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: "<h2 style=\"text-align: center\">ERP Release Notes</h2><p>This is a <b>highly important</b> update. Please observe the following:</p><ul><li>System maintenance at <i>midnight</i>.</li><li><u style=\"text-align: justify\">All users must log out.</u></li></ul><p style=\"text-align: justify\">Thank you for your cooperation.</p>",
|
||||||
|
realPokeSelect: null,
|
||||||
|
multiRealPokeSelect: []
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -51,7 +109,7 @@ export default function AllFieldsDemo() {
|
|||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
{/* --- Text & Numbers --- */}
|
{/* --- Text & Numbers --- */}
|
||||||
<div>
|
<div>
|
||||||
<Title order={5} mb="sm" c="brand">Text & Numbers</Title>
|
<Title order={5} mb="sm" c="brand">{t.sections.textAndNumbers}</Title>
|
||||||
<Divider mb="md" />
|
<Divider mb="md" />
|
||||||
<Group grow align="flex-start" mb="md">
|
<Group grow align="flex-start" mb="md">
|
||||||
<FieldTextInput name="customerName" control={control} label={t.fields.customerName} />
|
<FieldTextInput name="customerName" control={control} label={t.fields.customerName} />
|
||||||
@@ -73,7 +131,7 @@ export default function AllFieldsDemo() {
|
|||||||
|
|
||||||
{/* --- Selections --- */}
|
{/* --- Selections --- */}
|
||||||
<div>
|
<div>
|
||||||
<Title order={5} mb="sm" c="brand">Selections</Title>
|
<Title order={5} mb="sm" c="brand">{t.sections.selections}</Title>
|
||||||
<Divider mb="md" />
|
<Divider mb="md" />
|
||||||
<Group grow align="flex-start" mb="md">
|
<Group grow align="flex-start" mb="md">
|
||||||
<FieldSelect
|
<FieldSelect
|
||||||
@@ -93,22 +151,185 @@ export default function AllFieldsDemo() {
|
|||||||
<FieldAutocomplete
|
<FieldAutocomplete
|
||||||
name="country"
|
name="country"
|
||||||
control={control}
|
control={control}
|
||||||
label="Country"
|
label={t.fields.country}
|
||||||
data={['Indonesia', 'Singapore', 'Malaysia']}
|
data={['Indonesia', 'Singapore', 'Malaysia']}
|
||||||
/>
|
/>
|
||||||
<FieldMultiSelect
|
<FieldMultiSelect
|
||||||
name="categories"
|
name="categories"
|
||||||
control={control}
|
control={control}
|
||||||
label="Categories"
|
label={t.fields.categories}
|
||||||
data={['Electronics', 'Fashion', 'Food']}
|
data={['Electronics', 'Fashion', 'Food']}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
<Group grow align="flex-start" mb="md">
|
||||||
|
<FieldLocalSelect
|
||||||
|
name="localSelect"
|
||||||
|
control={control}
|
||||||
|
label={t.fields.localSelect}
|
||||||
|
placeholder={t.placeholders.selectComplexObject}
|
||||||
|
options={[
|
||||||
|
{ id: 1, name: 'Apple', type: 'Fruit' },
|
||||||
|
{ id: 2, name: 'Carrot', type: 'Vegetable' },
|
||||||
|
{ id: 3, name: 'Banana', type: 'Fruit' }
|
||||||
|
]}
|
||||||
|
valueKey="id"
|
||||||
|
labelKey="name"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<FieldLocalSelect
|
||||||
|
multiple
|
||||||
|
name="multiLocalSelect"
|
||||||
|
control={control}
|
||||||
|
label={t.fields.multiLocalSelect}
|
||||||
|
placeholder={t.placeholders.selectMultipleObjects}
|
||||||
|
options={[
|
||||||
|
{ id: 1, name: 'Red', hex: '#f00' },
|
||||||
|
{ id: 2, name: 'Green', hex: '#0f0' },
|
||||||
|
{ id: 3, name: 'Blue', hex: '#00f' }
|
||||||
|
]}
|
||||||
|
valueKey="id"
|
||||||
|
renderLabel={(item) => `${item.name} (${item.hex})`}
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Group grow align="flex-start" mb="md">
|
||||||
|
<FieldAsyncSelect
|
||||||
|
name="asyncSelect"
|
||||||
|
control={control}
|
||||||
|
label={t.fields.asyncSelectMock}
|
||||||
|
placeholder={t.placeholders.searchPokemon}
|
||||||
|
loadOptions={loadMockPokemonOptions}
|
||||||
|
valueKey="id"
|
||||||
|
labelKey="name"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<FieldAsyncSelect
|
||||||
|
multiple
|
||||||
|
name="multiAsyncSelect"
|
||||||
|
control={control}
|
||||||
|
label={t.fields.multiAsyncSelect}
|
||||||
|
placeholder={t.placeholders.selectMultiplePokemon}
|
||||||
|
loadOptions={loadMockPokemonOptions}
|
||||||
|
valueKey="id"
|
||||||
|
labelKey="name"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Group grow align="flex-start" mb="md">
|
||||||
|
<FieldAsyncSelect
|
||||||
|
name="realPokeSelect"
|
||||||
|
control={control}
|
||||||
|
label={t.fields.realPokeSingle}
|
||||||
|
placeholder={t.placeholders.scrollDeduplication}
|
||||||
|
loadOptions={loadRealPokemonOptions}
|
||||||
|
valueKey="id"
|
||||||
|
labelKey="name"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<FieldAsyncSelect
|
||||||
|
multiple
|
||||||
|
name="multiRealPokeSelect"
|
||||||
|
control={control}
|
||||||
|
label={t.fields.realPokeMulti}
|
||||||
|
placeholder={t.placeholders.scrollDeduplication}
|
||||||
|
loadOptions={loadRealPokemonOptions}
|
||||||
|
valueKey="id"
|
||||||
|
labelKey="name"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
<FieldTagsInput name="tags" control={control} label={t.fields.tags} />
|
<FieldTagsInput name="tags" control={control} label={t.fields.tags} />
|
||||||
|
|
||||||
|
<Title order={5} mb="sm" mt="lg" c="brand">{t.sections.advancedObjectSelects}</Title>
|
||||||
|
<Divider mb="md" />
|
||||||
|
<Group grow align="flex-start" mb="md">
|
||||||
|
<FieldLocalSelect
|
||||||
|
name="localSelectEmpty"
|
||||||
|
control={control}
|
||||||
|
label={t.fields.localEmpty}
|
||||||
|
options={MOCK_VENDORS}
|
||||||
|
valueKey="id"
|
||||||
|
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<FieldLocalSelect
|
||||||
|
name="localSelectPrefilled"
|
||||||
|
control={control}
|
||||||
|
label={t.fields.localPrefilled}
|
||||||
|
options={MOCK_VENDORS}
|
||||||
|
valueKey="id"
|
||||||
|
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Group grow align="flex-start" mb="md">
|
||||||
|
<FieldAsyncSelect
|
||||||
|
name="asyncSelectEmpty"
|
||||||
|
control={control}
|
||||||
|
label={t.fields.asyncEmpty}
|
||||||
|
loadOptions={loadMockVendorsOptions}
|
||||||
|
valueKey="id"
|
||||||
|
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<FieldAsyncSelect
|
||||||
|
name="asyncSelectPrefilled"
|
||||||
|
control={control}
|
||||||
|
label={t.fields.asyncPrefilled}
|
||||||
|
loadOptions={loadMockVendorsOptions}
|
||||||
|
valueKey="id"
|
||||||
|
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||||
|
defaultOptions={[{ id: 999, code: 'ASYNC-99', name: 'Pre-loaded Async Vendor' }]}
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Title order={5} mb="sm" mt="lg" c="brand">{t.sections.multiSelectEditMode}</Title>
|
||||||
|
<Divider mb="md" />
|
||||||
|
<Group grow align="flex-start" mb="md">
|
||||||
|
<FieldLocalSelect
|
||||||
|
multiple
|
||||||
|
name="localMultiPrefilled"
|
||||||
|
control={control}
|
||||||
|
label={t.fields.localMultiPrefilled}
|
||||||
|
options={MOCK_VENDORS}
|
||||||
|
valueKey="id"
|
||||||
|
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<FieldAsyncSelect
|
||||||
|
multiple
|
||||||
|
name="asyncMultiPrefilled"
|
||||||
|
control={control}
|
||||||
|
label={t.fields.asyncMultiPrefilled}
|
||||||
|
loadOptions={loadMockVendorsOptions}
|
||||||
|
valueKey="id"
|
||||||
|
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Title order={5} mb="sm" mt="lg" c="brand">{t.sections.richTextEditor}</Title>
|
||||||
|
<Divider mb="md" />
|
||||||
|
<FieldRichTextEditor
|
||||||
|
name="richTextEmpty"
|
||||||
|
control={control}
|
||||||
|
label={t.fields.richTextEmpty}
|
||||||
|
description={t.descriptions.freshTipTap}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: '16px' }}>
|
||||||
|
<FieldRichTextEditor
|
||||||
|
name="richTextPrefilled"
|
||||||
|
control={control}
|
||||||
|
label={t.fields.richTextPrefilled}
|
||||||
|
description={t.descriptions.htmlStringLoaded}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* --- Toggles & Choices --- */}
|
{/* --- Toggles & Choices --- */}
|
||||||
<div>
|
<div>
|
||||||
<Title order={5} mb="sm" c="brand">Toggles & Choices</Title>
|
<Title order={5} mb="sm" c="brand">{t.sections.togglesAndChoices}</Title>
|
||||||
<Divider mb="md" />
|
<Divider mb="md" />
|
||||||
<Group mb="md">
|
<Group mb="md">
|
||||||
<FieldCheckbox name="terms" control={control} label={t.fields.terms} />
|
<FieldCheckbox name="terms" control={control} label={t.fields.terms} />
|
||||||
@@ -151,11 +372,11 @@ export default function AllFieldsDemo() {
|
|||||||
|
|
||||||
{/* --- Ranges & Specialized --- */}
|
{/* --- Ranges & Specialized --- */}
|
||||||
<div>
|
<div>
|
||||||
<Title order={5} mb="sm" c="brand">Ranges & Specialized</Title>
|
<Title order={5} mb="sm" c="brand">{t.sections.rangesAndSpecialized}</Title>
|
||||||
<Divider mb="md" />
|
<Divider mb="md" />
|
||||||
<Group grow align="flex-start" mb="md">
|
<Group grow align="flex-start" mb="md">
|
||||||
<FieldSlider name="satisfaction" control={control} label={t.fields.rating} />
|
<FieldSlider name="satisfaction" control={control} label={t.fields.rating} />
|
||||||
<FieldRangeSlider name="priceRange" control={control} label="Price Range" />
|
<FieldRangeSlider name="priceRange" control={control} label={t.fields.priceRange} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow align="flex-start" mb="md">
|
<Group grow align="flex-start" mb="md">
|
||||||
<FieldColorInput name="themeColor" control={control} label={t.fields.themeColor} />
|
<FieldColorInput name="themeColor" control={control} label={t.fields.themeColor} />
|
||||||
|
|||||||
+139
-12
@@ -1,12 +1,50 @@
|
|||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { Button, Paper, Title, Divider, Stack, Code, Alert } from '@repo/ui/components';
|
import { Button, Paper, Title, Divider, Stack, Code, Alert, TypographyStylesProvider } from '@repo/ui/components';
|
||||||
import { FieldTextInput, FieldSelect, FieldSwitch } from '@repo/ui/form';
|
import { FieldTextInput, FieldSelect, FieldSwitch, FieldLocalSelect, FieldAsyncSelect, FieldRichTextEditor } from '@repo/ui/form';
|
||||||
import { useConditionalField } from '@repo/ui/hooks';
|
import { useConditionalField } from '@repo/ui/hooks';
|
||||||
import { compose, required, emailValidator } from '@repo/ui/validators';
|
import { compose, required, emailValidator } from '@repo/ui/validators';
|
||||||
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
|
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
|
||||||
import { Info } from 'lucide-react';
|
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() {
|
export default function ReactiveWatchDemo() {
|
||||||
const t = useFormDemoTranslation();
|
const t = useFormDemoTranslation();
|
||||||
@@ -14,10 +52,10 @@ export default function ReactiveWatchDemo() {
|
|||||||
// Define atomic validators for conditional fields
|
// Define atomic validators for conditional fields
|
||||||
const taxIdValidator = compose(z.string(), required(t.watch.corporateTaxId));
|
const taxIdValidator = compose(z.string(), required(t.watch.corporateTaxId));
|
||||||
const spouseNameValidator = compose(z.string(), required(t.watch.spouseName));
|
const spouseNameValidator = compose(z.string(), required(t.watch.spouseName));
|
||||||
const newsletterEmailValidator = compose(z.string(), required('Newsletter Email'), emailValidator());
|
const newsletterEmailValidator = compose(z.string(), required(t.fields.email), emailValidator());
|
||||||
const roleValidator = compose(z.string(), required('Role'));
|
const roleValidator = compose(z.string(), required(t.fields.role));
|
||||||
|
|
||||||
const reactiveSchema = z
|
const reactiveSchema = useMemo(() => z
|
||||||
.object({
|
.object({
|
||||||
userType: z.enum(['PERSONAL', 'CORPORATE']),
|
userType: z.enum(['PERSONAL', 'CORPORATE']),
|
||||||
corporateTaxId: z.string().optional(),
|
corporateTaxId: z.string().optional(),
|
||||||
@@ -27,6 +65,9 @@ export default function ReactiveWatchDemo() {
|
|||||||
newsletterEmail: z.string().optional(),
|
newsletterEmail: z.string().optional(),
|
||||||
department: z.string().optional(),
|
department: z.string().optional(),
|
||||||
role: 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(
|
.and(
|
||||||
z.discriminatedUnion('userType', [
|
z.discriminatedUnion('userType', [
|
||||||
@@ -51,7 +92,7 @@ export default function ReactiveWatchDemo() {
|
|||||||
z.object({ department: z.string().min(1), role: roleValidator }),
|
z.object({ department: z.string().min(1), role: roleValidator }),
|
||||||
z.object({ department: z.union([z.literal(''), z.undefined(), z.null()]).optional() }),
|
z.object({ department: z.union([z.literal(''), z.undefined(), z.null()]).optional() }),
|
||||||
]),
|
]),
|
||||||
);
|
), [t, taxIdValidator, spouseNameValidator, newsletterEmailValidator, roleValidator]);
|
||||||
|
|
||||||
const { control, handleSubmit, setValue, unregister, clearErrors } = useForm<any>({
|
const { control, handleSubmit, setValue, unregister, clearErrors } = useForm<any>({
|
||||||
resolver: zodResolver(reactiveSchema as any),
|
resolver: zodResolver(reactiveSchema as any),
|
||||||
@@ -64,6 +105,12 @@ export default function ReactiveWatchDemo() {
|
|||||||
newsletterEmail: '',
|
newsletterEmail: '',
|
||||||
department: '',
|
department: '',
|
||||||
role: '',
|
role: '',
|
||||||
|
regions: [{ id: 'R1', code: 'APAC', taxRate: 0.1 }],
|
||||||
|
warehouses: [
|
||||||
|
{ id: 'W-99', regionId: 'R1', name: 'APAC Central Hub' },
|
||||||
|
{ id: 'W-98', regionId: 'R1', name: 'APAC Backup Hub' }
|
||||||
|
],
|
||||||
|
richTextLive: "<p>Start typing to see the live preview...</p>",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,6 +120,8 @@ export default function ReactiveWatchDemo() {
|
|||||||
const newsletter = useWatch({ control, name: 'newsletter' });
|
const newsletter = useWatch({ control, name: 'newsletter' });
|
||||||
const department = useWatch({ control, name: 'department' });
|
const department = useWatch({ control, name: 'department' });
|
||||||
const role = useWatch({ control, name: 'role' });
|
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
|
// Use the custom hook to cleanly unregister and reset fields when hidden
|
||||||
useConditionalField({
|
useConditionalField({
|
||||||
@@ -133,6 +182,27 @@ export default function ReactiveWatchDemo() {
|
|||||||
defaultValue: '',
|
defaultValue: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isMounted = useRef(false);
|
||||||
|
const prevRegionIds = useRef<string[]>(regions?.map((r: Region) => r.id) || []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMounted.current) {
|
||||||
|
isMounted.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIds = regions?.map((r: Region) => r.id) || [];
|
||||||
|
const prevIds = prevRegionIds.current;
|
||||||
|
|
||||||
|
const hasChanged = currentIds.length !== prevIds.length || currentIds.some((id: string) => !prevIds.includes(id));
|
||||||
|
|
||||||
|
if (hasChanged) {
|
||||||
|
setValue('warehouses', []);
|
||||||
|
clearErrors('warehouses');
|
||||||
|
prevRegionIds.current = currentIds;
|
||||||
|
}
|
||||||
|
}, [regions, setValue, clearErrors]);
|
||||||
|
|
||||||
// Use watch only to display the JSON output at the bottom
|
// Use watch only to display the JSON output at the bottom
|
||||||
const allValues = useWatch({ control });
|
const allValues = useWatch({ control });
|
||||||
|
|
||||||
@@ -144,7 +214,7 @@ export default function ReactiveWatchDemo() {
|
|||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Title order={5} mb="sm" c="brand">
|
<Title order={5} mb="sm" c="brand">
|
||||||
Dynamic Fields & Validation
|
{t.sections.reactiveWatchCascading}
|
||||||
</Title>
|
</Title>
|
||||||
<Divider mb="sm" />
|
<Divider mb="sm" />
|
||||||
|
|
||||||
@@ -194,8 +264,8 @@ export default function ReactiveWatchDemo() {
|
|||||||
<FieldSelect
|
<FieldSelect
|
||||||
name="department"
|
name="department"
|
||||||
control={control}
|
control={control}
|
||||||
label="Department"
|
label={t.fields.department}
|
||||||
placeholder="Select a department"
|
placeholder={t.placeholders.selectComplexObject}
|
||||||
data={[
|
data={[
|
||||||
{ value: 'IT', label: 'Information Technology' },
|
{ value: 'IT', label: 'Information Technology' },
|
||||||
{ value: 'HR', label: 'Human Resources' },
|
{ value: 'HR', label: 'Human Resources' },
|
||||||
@@ -216,15 +286,72 @@ export default function ReactiveWatchDemo() {
|
|||||||
key={`role-select-${department}`}
|
key={`role-select-${department}`}
|
||||||
name="role"
|
name="role"
|
||||||
control={control}
|
control={control}
|
||||||
label="Role"
|
label={t.fields.role}
|
||||||
placeholder="Select a role"
|
placeholder={t.placeholders.selectComplexObject}
|
||||||
disabled={!department}
|
disabled={!department}
|
||||||
data={currentRoleOptions}
|
data={currentRoleOptions}
|
||||||
withAsterisk={!!department}
|
withAsterisk={!!department}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Title order={5} mb="sm" c="brand" mt="lg">
|
||||||
|
{t.sections.reactiveWatchCascading}
|
||||||
|
</Title>
|
||||||
|
<Divider mb="sm" />
|
||||||
|
|
||||||
|
<FieldLocalSelect<Region>
|
||||||
|
multiple
|
||||||
|
name="regions"
|
||||||
|
control={control as any}
|
||||||
|
label={t.fields.regions}
|
||||||
|
options={REGIONS}
|
||||||
|
valueKey="id"
|
||||||
|
labelKey="code"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldAsyncSelect<Warehouse>
|
||||||
|
multiple
|
||||||
|
key={`warehouse-select-${regions?.map((r: any) => r.id).join(',')}`}
|
||||||
|
name="warehouses"
|
||||||
|
control={control as any}
|
||||||
|
label={t.fields.warehouses}
|
||||||
|
disabled={!regions || regions.length === 0}
|
||||||
|
loadOptions={useCallback(async (search, page) => {
|
||||||
|
if (!regions || regions.length === 0) return { options: [], hasMore: false };
|
||||||
|
return mockFetchWarehouses(regions.map((r: any) => r.id), search, page);
|
||||||
|
}, [regions])}
|
||||||
|
valueKey="id"
|
||||||
|
renderLabel={(item) => `[${item.id}] ${item.name}`}
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
|
||||||
|
{regions && regions.length > 0 && (
|
||||||
|
<Alert mt="sm" color="teal">
|
||||||
|
{t.descriptions.selectedRegionsTax} {regions.map((r: any) => `${r.code} (${(r.taxRate * 100).toFixed(0)}%)`).join(', ')}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Title order={5} mb="sm" c="brand" mt="lg">
|
||||||
|
{t.sections.reactiveRichTextPreview}
|
||||||
|
</Title>
|
||||||
|
<Divider mb="sm" />
|
||||||
|
|
||||||
|
<FieldRichTextEditor
|
||||||
|
name="richTextLive"
|
||||||
|
control={control as any}
|
||||||
|
label={t.fields.liveEditor}
|
||||||
|
description={t.descriptions.typeToSeePreview}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Paper p="md" withBorder radius="md" mt="sm">
|
||||||
|
<Title order={6} mb="xs">{t.sections.liveHtmlPreview}</Title>
|
||||||
|
<TypographyStylesProvider>
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: watchedRichTextLive }} />
|
||||||
|
</TypographyStylesProvider>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
<Button type="submit" mt="md">
|
<Button type="submit" mt="md">
|
||||||
{t.common?.submit || 'Submit Reactive Form'}
|
{t.common.submitReactive}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
+150
-10
@@ -1,10 +1,58 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { Button, Paper, Title, Group, Stack, Code, Divider } from '@repo/ui/components';
|
import { Button, Paper, Title, Group, Stack, Code, Divider } from '@repo/ui/components';
|
||||||
import {
|
import {
|
||||||
FieldTextInput, FieldPasswordInput, FieldNumberInput
|
FieldTextInput, FieldPasswordInput, FieldNumberInput,
|
||||||
|
FieldLocalSelect, FieldAsyncSelect, FieldRichTextEditor
|
||||||
} from '@repo/ui/form';
|
} from '@repo/ui/form';
|
||||||
|
import type { LoadOptionsFn } from '@repo/ui/form';
|
||||||
|
|
||||||
|
interface Department {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
costCenter: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Assignee {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_DEPARTMENTS: Department[] = [
|
||||||
|
{ code: 'IT', name: 'Information Technology', costCenter: 'CC-100' },
|
||||||
|
{ code: 'HR', name: 'Human Resources', costCenter: 'CC-200' },
|
||||||
|
{ code: 'FIN', name: 'Finance', costCenter: 'CC-300' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockFetchUsers: LoadOptionsFn<Assignee> = async (search, page) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
const allUsers = Array.from({ length: 20 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
email: `user${i + 1}@company.com`
|
||||||
|
}));
|
||||||
|
const filtered = allUsers.filter(u => u.email.toLowerCase().includes(search.toLowerCase()));
|
||||||
|
const pageSize = 5;
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const paginated = filtered.slice(start, start + pageSize);
|
||||||
|
|
||||||
|
return {
|
||||||
|
options: paginated,
|
||||||
|
hasMore: start + pageSize < filtered.length
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOCK_VENDORS = [
|
||||||
|
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
|
||||||
|
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockFetchVendors: LoadOptionsFn<any> = async (search, _page) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
const filtered = MOCK_VENDORS.filter(v => v.name.toLowerCase().includes(search.toLowerCase()) || v.code.toLowerCase().includes(search.toLowerCase()));
|
||||||
|
return { options: filtered, hasMore: false };
|
||||||
|
};
|
||||||
import {
|
import {
|
||||||
compose, required, rangeLength,
|
compose, required, rangeLength,
|
||||||
positiveNumber, simplePassword,
|
positiveNumber, simplePassword,
|
||||||
@@ -16,14 +64,20 @@ export default function ValidationBankDemo() {
|
|||||||
const t = useFormDemoTranslation();
|
const t = useFormDemoTranslation();
|
||||||
|
|
||||||
// Compose the Zod schema using the atomic validators
|
// 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)),
|
username: compose(z.string(), required(t.fields.customerName), rangeLength(3, 15, t.fields.customerName)),
|
||||||
simplePass: compose(z.string(), required(t.validation.simplePassword), simplePassword(6)),
|
simplePass: compose(z.string(), required(t.validation.simplePassword), simplePassword(6)),
|
||||||
complexPass: compose(z.string(), required(t.validation.complexPassword), complexPassword(8)),
|
complexPass: compose(z.string(), required(t.validation.complexPassword), complexPassword(8)),
|
||||||
age: compose(z.number(), required(t.fields.age), rangeValue(18, 65, t.fields.age)),
|
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)),
|
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<typeof validationSchema>;
|
type ValidationFormValues = z.infer<typeof validationSchema>;
|
||||||
|
|
||||||
@@ -35,7 +89,16 @@ export default function ValidationBankDemo() {
|
|||||||
complexPass: '',
|
complexPass: '',
|
||||||
age: undefined as any,
|
age: undefined as any,
|
||||||
score: 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() {
|
|||||||
<Paper p="xl" withBorder radius="md">
|
<Paper p="xl" withBorder radius="md">
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Title order={5} c="brand">Validation Bank (Atomic Registry)</Title>
|
<Title order={5} c="brand">{t.sections.validationBankTitle}</Title>
|
||||||
<Divider mb="sm" />
|
<Divider mb="sm" />
|
||||||
|
|
||||||
<FieldTextInput
|
<FieldTextInput
|
||||||
@@ -63,14 +126,14 @@ export default function ValidationBankDemo() {
|
|||||||
name="simplePass"
|
name="simplePass"
|
||||||
control={control}
|
control={control}
|
||||||
label={t.validation.simplePassword}
|
label={t.validation.simplePassword}
|
||||||
description="Min 6 chars"
|
description={t.descriptions.min6Chars}
|
||||||
withAsterisk
|
withAsterisk
|
||||||
/>
|
/>
|
||||||
<FieldPasswordInput
|
<FieldPasswordInput
|
||||||
name="complexPass"
|
name="complexPass"
|
||||||
control={control}
|
control={control}
|
||||||
label={t.validation.complexPassword}
|
label={t.validation.complexPassword}
|
||||||
description="Min 8, 1 uppercase, 1 number, 1 special"
|
description={t.descriptions.min8Complex}
|
||||||
withAsterisk
|
withAsterisk
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -87,7 +150,7 @@ export default function ValidationBankDemo() {
|
|||||||
name="score"
|
name="score"
|
||||||
control={control}
|
control={control}
|
||||||
label={t.validation.score}
|
label={t.validation.score}
|
||||||
description="Must be > 0"
|
description={t.descriptions.mustBePositive}
|
||||||
withAsterisk
|
withAsterisk
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -96,7 +159,84 @@ export default function ValidationBankDemo() {
|
|||||||
name="phone"
|
name="phone"
|
||||||
control={control}
|
control={control}
|
||||||
label={t.validation.phone}
|
label={t.validation.phone}
|
||||||
description="Format: +62..."
|
description={t.descriptions.formatPhone}
|
||||||
|
withAsterisk
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Title order={5} c="brand" mt="md">{t.sections.objectLevelValidations}</Title>
|
||||||
|
<Divider mb="sm" />
|
||||||
|
|
||||||
|
<FieldLocalSelect<Department>
|
||||||
|
name="department"
|
||||||
|
control={control as any}
|
||||||
|
label={t.fields.department}
|
||||||
|
options={MOCK_DEPARTMENTS}
|
||||||
|
valueKey="code"
|
||||||
|
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||||
|
clearable
|
||||||
|
withAsterisk
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldAsyncSelect<Assignee>
|
||||||
|
multiple
|
||||||
|
name="assignees"
|
||||||
|
control={control as any}
|
||||||
|
label={t.fields.assignees}
|
||||||
|
loadOptions={mockFetchUsers}
|
||||||
|
valueKey="id"
|
||||||
|
labelKey="email"
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
withAsterisk
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Title order={5} c="brand" mt="lg">{t.sections.validatedPrefilledObjects}</Title>
|
||||||
|
<Divider mb="sm" />
|
||||||
|
|
||||||
|
<Group grow align="flex-start">
|
||||||
|
<FieldAsyncSelect
|
||||||
|
name="emptyVendor"
|
||||||
|
control={control as any}
|
||||||
|
label={t.fields.emptyVendor}
|
||||||
|
loadOptions={mockFetchVendors}
|
||||||
|
valueKey="id"
|
||||||
|
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||||
|
clearable
|
||||||
|
withAsterisk
|
||||||
|
/>
|
||||||
|
<FieldAsyncSelect
|
||||||
|
name="prefilledVendor"
|
||||||
|
control={control as any}
|
||||||
|
label={t.fields.prefilledVendor}
|
||||||
|
loadOptions={mockFetchVendors}
|
||||||
|
valueKey="id"
|
||||||
|
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||||
|
defaultOptions={[{ id: 'V1', code: 'VN-01', name: 'Vendor One' }]}
|
||||||
|
clearable
|
||||||
|
withAsterisk
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<FieldAsyncSelect
|
||||||
|
multiple
|
||||||
|
name="prefilledAsyncMulti"
|
||||||
|
control={control as any}
|
||||||
|
label={t.fields.prefilledAsyncMulti}
|
||||||
|
loadOptions={mockFetchVendors}
|
||||||
|
valueKey="id"
|
||||||
|
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||||
|
clearable
|
||||||
|
withAsterisk
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Title order={5} c="brand" mt="lg">{t.sections.richTextValidations}</Title>
|
||||||
|
<Divider mb="sm" />
|
||||||
|
|
||||||
|
<FieldRichTextEditor
|
||||||
|
name="richTextNotes"
|
||||||
|
control={control as any}
|
||||||
|
label={t.fields.importantNotes}
|
||||||
|
description={t.descriptions.zodMinLengthString}
|
||||||
withAsterisk
|
withAsterisk
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,26 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"submit": "Submit Data",
|
"submit": "Submit Data",
|
||||||
|
"submitReactive": "Submit Reactive Form",
|
||||||
"reset": "Reset Form",
|
"reset": "Reset Form",
|
||||||
"submittedData": "Submitted Data"
|
"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": {
|
"fields": {
|
||||||
"customerName": "Customer Name",
|
"customerName": "Customer Name",
|
||||||
"email": "Email Address",
|
"email": "Email Address",
|
||||||
@@ -26,7 +43,60 @@
|
|||||||
"orderType": "Order Type",
|
"orderType": "Order Type",
|
||||||
"quantity": "Quantity",
|
"quantity": "Quantity",
|
||||||
"fabricColor": "Fabric Color",
|
"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": {
|
"validation": {
|
||||||
"simplePassword": "Simple Password",
|
"simplePassword": "Simple Password",
|
||||||
|
|||||||
@@ -6,9 +6,26 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"submit": "Kirim Data",
|
"submit": "Kirim Data",
|
||||||
|
"submitReactive": "Kirim Form Reaktif",
|
||||||
"reset": "Reset Form",
|
"reset": "Reset Form",
|
||||||
"submittedData": "Data Terkirim"
|
"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": {
|
"fields": {
|
||||||
"customerName": "Nama Pelanggan",
|
"customerName": "Nama Pelanggan",
|
||||||
"email": "Alamat Email",
|
"email": "Alamat Email",
|
||||||
@@ -26,7 +43,60 @@
|
|||||||
"orderType": "Tipe Pesanan",
|
"orderType": "Tipe Pesanan",
|
||||||
"quantity": "Jumlah",
|
"quantity": "Jumlah",
|
||||||
"fabricColor": "Warna Kain",
|
"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": {
|
"validation": {
|
||||||
"simplePassword": "Sandi Sederhana",
|
"simplePassword": "Sandi Sederhana",
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
} from '@repo/ui/components';
|
} from '@repo/ui/components';
|
||||||
import { ShieldCheck, Database, Lock, Layout, Activity, Printer, FileText } from 'lucide-react';
|
import { ShieldCheck, Database, Lock, Layout, Activity, Printer, FileText } from 'lucide-react';
|
||||||
|
import { Globe } from 'lucide-react';
|
||||||
|
import { useTranslation } from '@repo/core-i18n';
|
||||||
import PrinterList from './printer-list';
|
import PrinterList from './printer-list';
|
||||||
import ExamplePage from './example/example.page';
|
import ExamplePage from './example/example.page';
|
||||||
import EventsDemoPage from './events-demo';
|
import EventsDemoPage from './events-demo';
|
||||||
@@ -38,6 +40,7 @@ interface ShowcaseViewProps {
|
|||||||
|
|
||||||
export default function ShowcaseView({ colorScheme, setColorScheme, density, setDensity }: ShowcaseViewProps) {
|
export default function ShowcaseView({ colorScheme, setColorScheme, density, setDensity }: ShowcaseViewProps) {
|
||||||
const [activeTab, setActiveTab] = useState<string | null>('ui-components');
|
const [activeTab, setActiveTab] = useState<string | null>('ui-components');
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
|
||||||
// Mock data for the table
|
// Mock data for the table
|
||||||
const tableData = [
|
const tableData = [
|
||||||
@@ -144,6 +147,18 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
|
|||||||
{getSubtitle()}
|
{getSubtitle()}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
<Select
|
||||||
|
w={180}
|
||||||
|
size="sm"
|
||||||
|
variant="filled"
|
||||||
|
leftSection={<Globe size={16} />}
|
||||||
|
data={[
|
||||||
|
{ value: 'en', label: 'English' },
|
||||||
|
{ value: 'id', label: 'Bahasa Indonesia' },
|
||||||
|
]}
|
||||||
|
value={i18n.resolvedLanguage || i18n.language}
|
||||||
|
onChange={(val) => val && i18n.changeLanguage(val)}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,8 @@ packages/ui/src/components/Form/
|
|||||||
├── tags-input.field.tsx # FieldTagsInput
|
├── tags-input.field.tsx # FieldTagsInput
|
||||||
├── chip-group.field.tsx # FieldChipGroup
|
├── chip-group.field.tsx # FieldChipGroup
|
||||||
├── segmented-control.field.tsx # FieldSegmentedControl
|
├── segmented-control.field.tsx # FieldSegmentedControl
|
||||||
└── file-input.field.tsx # FieldFileInput
|
├── file-input.field.tsx # FieldFileInput
|
||||||
|
└── rich-text.field.tsx # FieldRichTextEditor
|
||||||
```
|
```
|
||||||
|
|
||||||
Each field file is a thin one-liner:
|
Each field file is a thin one-liner:
|
||||||
@@ -479,6 +480,210 @@ export function DepartmentForm() {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Object & Async Select Components
|
||||||
|
|
||||||
|
Mantine's native `Select` and `MultiSelect` are string-based: they store `string | null` and `string[]` respectively. In enterprise applications, we often need to store **full objects** (`T | null` or `T[]`) in RHF state — for example, a user object `{ id: '1', name: 'Alice', email: 'alice@co.com' }` rather than just `'1'`.
|
||||||
|
|
||||||
|
The **LocalSelect** and **AsyncSelect** engines bridge this gap by:
|
||||||
|
1. Mapping `T[]` → `ComboboxItem[]` for Mantine rendering (via `valueKey` + `labelKey`/`renderLabel`)
|
||||||
|
2. Building an O(1) reverse lookup map (`Map<string, T>`) for resolving string changes back to full objects
|
||||||
|
3. Intercepting `onChange` to pass resolved objects to RHF
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> These components are **separate** from the native `FieldSelect` and `FieldMultiSelect`, which continue to work as simple string-based Mantine wrappers. Use `FieldLocalSelect`/`FieldAsyncSelect` only when you need to store full objects in RHF state.
|
||||||
|
|
||||||
|
### Single vs. Multi-Select Data Mapping
|
||||||
|
|
||||||
|
| Mode | Mantine Component | RHF Value | Mantine `value` Prop | `onChange` Payload |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `multiple={false}` (default) | `<Select />` | `T \| null` | `string \| null` | `T \| null` |
|
||||||
|
| `multiple={true}` | `<MultiSelect />` | `T[]` | `string[]` | `T[]` |
|
||||||
|
|
||||||
|
### FieldLocalSelect — Local Object Select
|
||||||
|
|
||||||
|
Accepts a static `data` array of objects. No async fetching.
|
||||||
|
|
||||||
|
#### Props
|
||||||
|
|
||||||
|
| Prop | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `options` | `T[]` | ✅ | Array of objects to select from |
|
||||||
|
| `valueKey` | `keyof T & string` | ✅ | Property used as the unique identifier |
|
||||||
|
| `labelKey` | `keyof T & string` | — | Property used as the display label |
|
||||||
|
| `renderLabel` | `(item: T) => string` | — | Custom label renderer (overrides `labelKey`) |
|
||||||
|
| `multiple` | `boolean` | — | Enable multi-select mode |
|
||||||
|
| `filterOption` | `(item: T, ctx) => boolean` | — | Custom filter/exclusion logic |
|
||||||
|
| `onSelect` | `(value: T \| T[] \| null) => void` | — | Side-effect callback on selection change |
|
||||||
|
| `name` | `FieldPath` | ✅ | RHF field path |
|
||||||
|
| `control` | `Control` | ✅ | RHF control object |
|
||||||
|
| _...all Mantine Select/MultiSelect props_ | | | Passed through to the underlying component |
|
||||||
|
|
||||||
|
#### Usage Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { FieldLocalSelect } from '@repo/ui/form';
|
||||||
|
|
||||||
|
interface Department {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const departments: Department[] = [
|
||||||
|
{ id: '1', name: 'Engineering', code: 'ENG' },
|
||||||
|
{ id: '2', name: 'Marketing', code: 'MKT' },
|
||||||
|
{ id: '3', name: 'Finance', code: 'FIN' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function DepartmentForm() {
|
||||||
|
const { control, handleSubmit } = useForm<{ department: Department | null }>({
|
||||||
|
defaultValues: { department: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit((data) => console.log(data.department))}>
|
||||||
|
<FieldLocalSelect<Department>
|
||||||
|
name="department"
|
||||||
|
control={control}
|
||||||
|
label="Department"
|
||||||
|
options={departments}
|
||||||
|
valueKey="id"
|
||||||
|
labelKey="name"
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// On submit: data.department = { id: '1', name: 'Engineering', code: 'ENG' }
|
||||||
|
```
|
||||||
|
|
||||||
|
### FieldAsyncSelect — Async Paginated Object Select
|
||||||
|
|
||||||
|
Uses **Inversion of Control**: the component does NOT handle API calls directly. Instead, you provide a `loadOptions` callback. This supports REST, GraphQL, POST-based search, or any transport.
|
||||||
|
|
||||||
|
#### Props
|
||||||
|
|
||||||
|
| Prop | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `loadOptions` | `LoadOptionsFn<T>` | ✅ | Async callback: `(search, page, prevOptions) => Promise<{ options: T[], hasMore?: boolean }>` |
|
||||||
|
| `defaultOptions` | `T[]` | — | Pre-loaded objects always present in dropdown (for edit forms) |
|
||||||
|
| `debounceMs` | `number` | — | Search debounce delay (default: 300) |
|
||||||
|
| `valueKey` | `keyof T & string` | ✅ | Property used as the unique identifier |
|
||||||
|
| `labelKey` | `keyof T & string` | — | Property used as the display label |
|
||||||
|
| `renderLabel` | `(item: T) => string` | — | Custom label renderer |
|
||||||
|
| `multiple` | `boolean` | — | Enable multi-select mode |
|
||||||
|
| `name` | `FieldPath` | ✅ | RHF field path |
|
||||||
|
| `control` | `Control` | ✅ | RHF control object |
|
||||||
|
| _...all Mantine Select/MultiSelect props_ | | | Passed through to the underlying component |
|
||||||
|
|
||||||
|
#### Paginated Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { FieldAsyncSelect, type LoadOptionsFn } from '@repo/ui/form';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
fullName: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The loadOptions callback is completely transport-agnostic
|
||||||
|
const loadUsers: LoadOptionsFn<User> = async (search, page) => {
|
||||||
|
const res = await api.get('/users', {
|
||||||
|
params: { q: search, page, limit: 20 },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
options: res.data.items,
|
||||||
|
hasMore: res.data.hasNextPage,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function UserPickerForm() {
|
||||||
|
const { control, handleSubmit } = useForm<{ user: User | null }>({
|
||||||
|
defaultValues: { user: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit((data) => console.log(data.user))}>
|
||||||
|
<FieldAsyncSelect<User>
|
||||||
|
name="user"
|
||||||
|
control={control}
|
||||||
|
label="Assign User"
|
||||||
|
loadOptions={loadUsers}
|
||||||
|
valueKey="id"
|
||||||
|
labelKey="fullName"
|
||||||
|
placeholder="Search users..."
|
||||||
|
/>
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Non-Paginated Example
|
||||||
|
|
||||||
|
If your API returns all results at once, return `hasMore: false`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const loadRoles: LoadOptionsFn<Role> = async (search) => {
|
||||||
|
const roles = await api.get('/roles', { params: { q: search } });
|
||||||
|
return { options: roles.data, hasMore: false };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Edit Form with `defaultOptions`
|
||||||
|
|
||||||
|
When editing an existing record, the default value's object may not appear in the first page of API results. Use `defaultOptions` to inject it:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function EditUserForm({ existingAssignment }: { existingAssignment: User }) {
|
||||||
|
const { control } = useForm<{ user: User | null }>({
|
||||||
|
defaultValues: { user: existingAssignment },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FieldAsyncSelect<User>
|
||||||
|
name="user"
|
||||||
|
control={control}
|
||||||
|
label="Reassign User"
|
||||||
|
loadOptions={loadUsers}
|
||||||
|
valueKey="id"
|
||||||
|
labelKey="fullName"
|
||||||
|
defaultOptions={[existingAssignment]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Multi-Select Async Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function TagPickerForm() {
|
||||||
|
const { control } = useForm<{ tags: Tag[] }>({
|
||||||
|
defaultValues: { tags: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FieldAsyncSelect<Tag>
|
||||||
|
multiple
|
||||||
|
name="tags"
|
||||||
|
control={control}
|
||||||
|
label="Tags"
|
||||||
|
loadOptions={loadTags}
|
||||||
|
valueKey="id"
|
||||||
|
renderLabel={(tag) => `${tag.name} (${tag.count})`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// On submit: data.tags = [{ id: '1', name: 'React', count: 42 }, ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Enterprise Performance Guidelines: Forms & Validation
|
## 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.
|
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<DatePickerInputProps>(
|
|||||||
| `FieldRating` | `Rating` | Range | Star rating |
|
| `FieldRating` | `Rating` | Range | Star rating |
|
||||||
| `FieldColorInput` | `ColorInput` | Color | Color picker with text input |
|
| `FieldColorInput` | `ColorInput` | Color | Color picker with text input |
|
||||||
| `FieldColorPicker` | `ColorPicker` | Color | Color picker only (uses `Input.Wrapper`) |
|
| `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` | `<FileInput />` | `File | File[] | null` |
|
||||||
|
| `FieldRichTextEditor` | `@mantine/tiptap` | `string` (HTML) |
|
||||||
|
|
||||||
|
### Rich Text Editor (TipTap)
|
||||||
|
The `FieldRichTextEditor` component integrates `@mantine/tiptap` directly with React Hook Form. It safely stores the Editor's HTML output directly into the RHF state as a `string`. Because TipTap is an uncontrolled editor natively, this field uses a specialized `useController` wrapper that automatically syncs bidirectional updates (e.g., calling `editor.commands.setContent(field.value)` when the form is reset or async default values arrive).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,15 @@
|
|||||||
"@hookform/resolvers": "^5.0.1",
|
"@hookform/resolvers": "^5.0.1",
|
||||||
"@mantine/core": "^8.3.15",
|
"@mantine/core": "^8.3.15",
|
||||||
"@mantine/hooks": "^8.3.15",
|
"@mantine/hooks": "^8.3.15",
|
||||||
|
"@mantine/tiptap": "^9.3.2",
|
||||||
"@repo/core-i18n": "workspace:*",
|
"@repo/core-i18n": "workspace:*",
|
||||||
"@repo/utils": "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",
|
"dayjs": "^1.11.19",
|
||||||
"react-hook-form": "^7.56.4",
|
"react-hook-form": "^7.56.4",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import React from 'react';
|
||||||
|
import { useForm, FormProvider } from 'react-hook-form';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import { FieldAsyncSelect } from '../fields/async-select.field';
|
||||||
|
import type { LoadOptionsFn } from '../custom/selects/types';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock i18n & Setup
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
vi.mock('@repo/core-i18n', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
i18n: { exists: () => false },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type Vendor = { id: number; code: string; name: string };
|
||||||
|
|
||||||
|
const MOCK_VENDORS: Vendor[] = [
|
||||||
|
{ id: 1, code: 'V1', name: 'Vendor 1' },
|
||||||
|
{ id: 2, code: 'V2', name: 'Vendor 2' },
|
||||||
|
{ id: 3, code: 'V3', name: 'Vendor 3' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Reusable loadOptions mock — returns all vendors with hasMore=false
|
||||||
|
const createMockLoadOptions = (vendors: Vendor[] = MOCK_VENDORS) => {
|
||||||
|
return vi.fn<LoadOptionsFn<Vendor>>().mockResolvedValue({
|
||||||
|
options: vendors,
|
||||||
|
hasMore: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('FieldAsyncSelect', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches initial page on mount and renders items', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const mockLoadOptions = createMockLoadOptions();
|
||||||
|
|
||||||
|
function TestForm() {
|
||||||
|
const { control } = useForm({ defaultValues: { vendor: null } });
|
||||||
|
return (
|
||||||
|
<MantineProvider>
|
||||||
|
<form>
|
||||||
|
<FieldAsyncSelect
|
||||||
|
name="vendor"
|
||||||
|
control={control}
|
||||||
|
loadOptions={mockLoadOptions}
|
||||||
|
valueKey="id"
|
||||||
|
labelKey="name"
|
||||||
|
placeholder="Select async vendor"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<TestForm />);
|
||||||
|
|
||||||
|
// Wait for initial fetch (page 1, search='')
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockLoadOptions).toHaveBeenCalledWith('', 1, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByPlaceholderText('Select async vendor'));
|
||||||
|
|
||||||
|
// Items should be rendered from the mock response
|
||||||
|
expect(screen.getByText('Vendor 1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Vendor 3')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores full object in RHF from async data', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let capturedData: any = null;
|
||||||
|
const mockLoadOptions = createMockLoadOptions();
|
||||||
|
|
||||||
|
function TestForm() {
|
||||||
|
const { control, handleSubmit } = useForm({ defaultValues: { vendor: null } });
|
||||||
|
return (
|
||||||
|
<MantineProvider>
|
||||||
|
<form onSubmit={handleSubmit((data) => { capturedData = data; })}>
|
||||||
|
<FieldAsyncSelect
|
||||||
|
name="vendor"
|
||||||
|
control={control}
|
||||||
|
loadOptions={mockLoadOptions}
|
||||||
|
valueKey="id"
|
||||||
|
labelKey="name"
|
||||||
|
placeholder="Select vendor"
|
||||||
|
/>
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</form>
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<TestForm />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockLoadOptions).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByPlaceholderText('Select vendor'));
|
||||||
|
await user.click(screen.getByText('Vendor 2'));
|
||||||
|
await user.click(screen.getByText('Submit'));
|
||||||
|
|
||||||
|
expect(capturedData).toEqual({ vendor: { id: 2, code: 'V2', name: 'Vendor 2' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('injects pre-selected value not in fetched data', async () => {
|
||||||
|
// loadOptions returns only V1 and V2
|
||||||
|
const partialLoadOptions = createMockLoadOptions([
|
||||||
|
{ id: 1, code: 'V1', name: 'Vendor 1' },
|
||||||
|
{ id: 2, code: 'V2', name: 'Vendor 2' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The form starts with V99 pre-selected (e.g. from server hydration)
|
||||||
|
const PRESELECTED_VENDOR = { id: 99, code: 'V99', name: 'Vendor 99' };
|
||||||
|
|
||||||
|
function TestForm() {
|
||||||
|
const { control } = useForm({ defaultValues: { vendor: PRESELECTED_VENDOR } });
|
||||||
|
return (
|
||||||
|
<MantineProvider>
|
||||||
|
<form>
|
||||||
|
<FieldAsyncSelect
|
||||||
|
name="vendor"
|
||||||
|
control={control}
|
||||||
|
loadOptions={partialLoadOptions}
|
||||||
|
valueKey="id"
|
||||||
|
labelKey="name"
|
||||||
|
placeholder="Select vendor"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<TestForm />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(partialLoadOptions).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The display value of the Select input should show the injected label
|
||||||
|
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||||
|
expect(input.value).toBe('Vendor 99');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debounces search input and refetches', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const mockLoadOptions = createMockLoadOptions();
|
||||||
|
|
||||||
|
function TestForm() {
|
||||||
|
const { control } = useForm({ defaultValues: { vendor: null } });
|
||||||
|
return (
|
||||||
|
<MantineProvider>
|
||||||
|
<form>
|
||||||
|
<FieldAsyncSelect
|
||||||
|
name="vendor"
|
||||||
|
control={control}
|
||||||
|
loadOptions={mockLoadOptions}
|
||||||
|
valueKey="id"
|
||||||
|
labelKey="name"
|
||||||
|
placeholder="Search vendor"
|
||||||
|
debounceMs={100} // fast debounce for test
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<TestForm />);
|
||||||
|
|
||||||
|
// Wait for initial fetch
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockLoadOptions).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('Search vendor');
|
||||||
|
await user.type(input, 'test');
|
||||||
|
|
||||||
|
// Wait for debounced fetch — should call with search='test'
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockLoadOptions).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockLoadOptions).toHaveBeenLastCalledWith('test', 1, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gracefully deduplicates overlapping data across API responses', async () => {
|
||||||
|
// loadOptions returns Vendor 1 twice (duplicate id=1)
|
||||||
|
const badLoadOptions = createMockLoadOptions([
|
||||||
|
{ id: 1, code: 'V1', name: 'Vendor 1' },
|
||||||
|
{ id: 1, code: 'V1', name: 'Vendor 1 (Duplicate)' },
|
||||||
|
{ id: 2, code: 'V2', name: 'Vendor 2' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
function TestForm() {
|
||||||
|
const { control } = useForm({ defaultValues: { vendor: null } });
|
||||||
|
return (
|
||||||
|
<MantineProvider>
|
||||||
|
<form>
|
||||||
|
<FieldAsyncSelect
|
||||||
|
name="vendor"
|
||||||
|
control={control}
|
||||||
|
loadOptions={badLoadOptions}
|
||||||
|
valueKey="id"
|
||||||
|
labelKey="name"
|
||||||
|
placeholder="Select bad vendor"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<TestForm />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(badLoadOptions).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.click(screen.getByPlaceholderText('Select bad vendor'));
|
||||||
|
|
||||||
|
// Should only render "Vendor 1" once, ignoring the duplicate with id=1
|
||||||
|
const vendor1Options = screen.getAllByText('Vendor 1');
|
||||||
|
expect(vendor1Options.length).toBe(1);
|
||||||
|
|
||||||
|
// The duplicate name should NOT be rendered
|
||||||
|
expect(screen.queryByText('Vendor 1 (Duplicate)')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import React from 'react';
|
||||||
|
import { useForm, FormProvider } from 'react-hook-form';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import { FieldLocalSelect } from '../fields/local-select.field';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock i18n
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
vi.mock('@repo/core-i18n', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
i18n: { exists: () => false },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test Data & Wrapper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Vendor = { id: number; code: string; name: string; active?: boolean };
|
||||||
|
|
||||||
|
const VENDORS: Vendor[] = [
|
||||||
|
{ id: 1, code: 'V1', name: 'Vendor 1', active: true },
|
||||||
|
{ id: 2, code: 'V2', name: 'Vendor 2', active: false },
|
||||||
|
{ id: 3, code: 'V3', name: 'Vendor 3', active: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Test wrapper removed to avoid useForm conflicts
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('FieldLocalSelect', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with labelKey and displays correct labels', () => {
|
||||||
|
function TestForm() {
|
||||||
|
const { control } = useForm({ defaultValues: { vendor: null } });
|
||||||
|
return (
|
||||||
|
<MantineProvider>
|
||||||
|
<FieldLocalSelect
|
||||||
|
name="vendor"
|
||||||
|
control={control}
|
||||||
|
options={VENDORS}
|
||||||
|
valueKey="id"
|
||||||
|
labelKey="name"
|
||||||
|
label="Vendor"
|
||||||
|
placeholder="Select vendor"
|
||||||
|
/>
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<TestForm />);
|
||||||
|
expect(screen.getByText('Vendor')).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText('Select vendor')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores the full original object in RHF on selection', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let capturedData: any = null;
|
||||||
|
|
||||||
|
function TestForm() {
|
||||||
|
const { control, handleSubmit } = useForm({ defaultValues: { vendor: null } });
|
||||||
|
return (
|
||||||
|
<MantineProvider>
|
||||||
|
<form onSubmit={handleSubmit((data) => { capturedData = data; })}>
|
||||||
|
<FieldLocalSelect
|
||||||
|
name="vendor"
|
||||||
|
control={control}
|
||||||
|
options={VENDORS}
|
||||||
|
valueKey="id"
|
||||||
|
labelKey="name"
|
||||||
|
placeholder="Select vendor"
|
||||||
|
/>
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</form>
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<TestForm />);
|
||||||
|
|
||||||
|
// Open dropdown
|
||||||
|
await user.click(screen.getByPlaceholderText('Select vendor'));
|
||||||
|
|
||||||
|
// Click Vendor 2
|
||||||
|
await user.click(screen.getByText('Vendor 2'));
|
||||||
|
|
||||||
|
// Submit
|
||||||
|
await user.click(screen.getByText('Submit'));
|
||||||
|
|
||||||
|
// Should contain the full object, not just '2'
|
||||||
|
expect(capturedData).toEqual({ vendor: VENDORS[1] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with renderLabel for compound labels', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
function TestForm() {
|
||||||
|
const { control } = useForm({ defaultValues: { vendor: null } });
|
||||||
|
return (
|
||||||
|
<MantineProvider>
|
||||||
|
<FieldLocalSelect
|
||||||
|
name="vendor"
|
||||||
|
control={control}
|
||||||
|
options={VENDORS}
|
||||||
|
valueKey="id"
|
||||||
|
renderLabel={(item) => `${item.code} - ${item.name}`}
|
||||||
|
placeholder="Select vendor"
|
||||||
|
/>
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<TestForm />);
|
||||||
|
await user.click(screen.getByPlaceholderText('Select vendor'));
|
||||||
|
|
||||||
|
// Compound label should be visible
|
||||||
|
expect(screen.getByText('V1 - Vendor 1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
it('filterOption excludes items from dropdown', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
function TestForm() {
|
||||||
|
const { control } = useForm({ defaultValues: { vendor: null } });
|
||||||
|
return (
|
||||||
|
<MantineProvider>
|
||||||
|
<FieldLocalSelect
|
||||||
|
name="vendor"
|
||||||
|
control={control}
|
||||||
|
options={VENDORS}
|
||||||
|
valueKey="id"
|
||||||
|
labelKey="name"
|
||||||
|
placeholder="Select vendor"
|
||||||
|
filterOption={(item) => item.active === true}
|
||||||
|
/>
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<TestForm />);
|
||||||
|
await user.click(screen.getByPlaceholderText('Select vendor'));
|
||||||
|
|
||||||
|
// Active vendors should be present
|
||||||
|
expect(screen.getByText('Vendor 1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Vendor 3')).toBeInTheDocument();
|
||||||
|
// Inactive vendor should not be present
|
||||||
|
expect(screen.queryByText('Vendor 2')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multi-select stores T[] in RHF', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let capturedData: any = null;
|
||||||
|
|
||||||
|
function TestForm() {
|
||||||
|
const { control, handleSubmit } = useForm({ defaultValues: { vendors: [] } });
|
||||||
|
return (
|
||||||
|
<MantineProvider>
|
||||||
|
<form onSubmit={handleSubmit((data) => { capturedData = data; })}>
|
||||||
|
<FieldLocalSelect
|
||||||
|
multiple
|
||||||
|
name="vendors"
|
||||||
|
control={control}
|
||||||
|
options={VENDORS}
|
||||||
|
valueKey="id"
|
||||||
|
labelKey="name"
|
||||||
|
placeholder="Select vendors"
|
||||||
|
/>
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</form>
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<TestForm />);
|
||||||
|
await user.click(screen.getByPlaceholderText('Select vendors'));
|
||||||
|
await user.click(screen.getByText('Vendor 1'));
|
||||||
|
await user.click(screen.getByText('Vendor 3'));
|
||||||
|
|
||||||
|
await user.click(screen.getByText('Submit'));
|
||||||
|
expect(capturedData).toEqual({ vendors: [VENDORS[0], VENDORS[2]] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multi-select clearable resets to empty array', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let capturedData: any = null;
|
||||||
|
|
||||||
|
function TestForm() {
|
||||||
|
const { control, handleSubmit } = useForm({ defaultValues: { vendors: [VENDORS[0]] } });
|
||||||
|
return (
|
||||||
|
<MantineProvider>
|
||||||
|
<form onSubmit={handleSubmit((data) => { capturedData = data; })}>
|
||||||
|
<FieldLocalSelect
|
||||||
|
multiple
|
||||||
|
name="vendors"
|
||||||
|
control={control}
|
||||||
|
options={VENDORS}
|
||||||
|
valueKey="id"
|
||||||
|
labelKey="name"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</form>
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { container } = render(<TestForm />);
|
||||||
|
|
||||||
|
const clearButton = container.querySelector('.mantine-CloseButton-root') || container.querySelector('button[aria-label="Clear value"]');
|
||||||
|
expect(clearButton).not.toBeNull();
|
||||||
|
await user.click(clearButton!);
|
||||||
|
|
||||||
|
await user.click(screen.getByText('Submit'));
|
||||||
|
expect(capturedData).toEqual({ vendors: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onSelect callback fires with correct object', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const handleSelect = vi.fn();
|
||||||
|
|
||||||
|
function TestForm() {
|
||||||
|
const { control } = useForm({ defaultValues: { vendor: null } });
|
||||||
|
return (
|
||||||
|
<MantineProvider>
|
||||||
|
<FieldLocalSelect
|
||||||
|
name="vendor"
|
||||||
|
control={control}
|
||||||
|
options={VENDORS}
|
||||||
|
valueKey="id"
|
||||||
|
labelKey="name"
|
||||||
|
placeholder="Select vendor"
|
||||||
|
onSelect={handleSelect}
|
||||||
|
/>
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<TestForm />);
|
||||||
|
await user.click(screen.getByPlaceholderText('Select vendor'));
|
||||||
|
await user.click(screen.getByText('Vendor 2'));
|
||||||
|
|
||||||
|
expect(handleSelect).toHaveBeenCalledWith(VENDORS[1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Custom Form Components — Barrel Export
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reusable select engines that work WITHOUT React Hook Form.
|
||||||
|
// For RHF-connected versions, use `@repo/ui/form` (FieldLocalSelect, FieldAsyncSelect).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export { LocalSelect } from './selects/LocalSelect';
|
||||||
|
export type { LocalSelectProps, LocalSelectSingleProps, LocalSelectMultiProps } from './selects/LocalSelect';
|
||||||
|
|
||||||
|
export { AsyncSelect } from './selects/AsyncSelect';
|
||||||
|
export type { AsyncSelectProps, AsyncSelectSingleProps, AsyncSelectMultiProps } from './selects/AsyncSelect';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
LocalSelectBaseProps,
|
||||||
|
AsyncSelectBaseProps,
|
||||||
|
SelectFilterContext,
|
||||||
|
SelectMappingResult,
|
||||||
|
LoadOptionsResponse,
|
||||||
|
LoadOptionsFn,
|
||||||
|
OptionsCacheEntry,
|
||||||
|
} from './selects/types';
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import React, { useMemo, useCallback } from 'react';
|
||||||
|
import { Select, MultiSelect, Loader, type SelectProps, type MultiSelectProps, type ComboboxItem } from '@mantine/core';
|
||||||
|
import type { AsyncSelectBaseProps, SelectFilterContext, LoadOptionsFn } from './types';
|
||||||
|
import { useAsyncPaginate } from './hooks/useAsyncPaginate';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AsyncSelect — Reusable async/paginated Select engine (no RHF dependency)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Inversion of Control design: the component does NOT handle API calls.
|
||||||
|
// Instead, it accepts a `loadOptions` callback that the implementer provides.
|
||||||
|
// This supports REST, GraphQL, POST-based search, local filtering, or any
|
||||||
|
// transport mechanism.
|
||||||
|
//
|
||||||
|
// Data Mapping Contract (Single vs. Multi):
|
||||||
|
// Single: value=T|null → Mantine string|null → onChange(T|null)
|
||||||
|
// Multi: value=T[] → Mantine string[] → onChange(T[])
|
||||||
|
//
|
||||||
|
// The lookupMap includes ALL sources (fetched + default + selected values)
|
||||||
|
// to ensure deselection never produces undefined entries.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Mantine props we manage ourselves */
|
||||||
|
type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect';
|
||||||
|
type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect';
|
||||||
|
|
||||||
|
/** Async-specific props (IoC — no direct API coupling) */
|
||||||
|
interface AsyncExtraProps<T> {
|
||||||
|
/**
|
||||||
|
* Async callback to load options. The component calls this when:
|
||||||
|
* - The dropdown opens (page 1, search='')
|
||||||
|
* - The user types a search query (page 1, search=query)
|
||||||
|
* - The user scrolls to the bottom (page N+1, search=currentQuery)
|
||||||
|
*
|
||||||
|
* The component is completely ignorant of the transport layer.
|
||||||
|
*/
|
||||||
|
loadOptions: LoadOptionsFn<T>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-loaded objects that are always present in the dropdown.
|
||||||
|
* Use for edit forms where the default value's object may not appear
|
||||||
|
* in page 1 of the API results.
|
||||||
|
*/
|
||||||
|
defaultOptions?: T[];
|
||||||
|
|
||||||
|
/** Search debounce delay in ms (default: 300) */
|
||||||
|
debounceMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for single-select async mode */
|
||||||
|
export type AsyncSelectSingleProps<T extends Record<string, any>> = AsyncSelectBaseProps<T> &
|
||||||
|
AsyncExtraProps<T> &
|
||||||
|
Omit<SelectProps, ManagedSelectProps> & {
|
||||||
|
multiple?: false;
|
||||||
|
value?: T | null;
|
||||||
|
onChange?: (value: T | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Props for multi-select async mode */
|
||||||
|
export type AsyncSelectMultiProps<T extends Record<string, any>> = AsyncSelectBaseProps<T> &
|
||||||
|
AsyncExtraProps<T> &
|
||||||
|
Omit<MultiSelectProps, ManagedMultiSelectProps> & {
|
||||||
|
multiple: true;
|
||||||
|
value?: T[];
|
||||||
|
onChange?: (value: T[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AsyncSelectProps<T extends Record<string, any>> = AsyncSelectSingleProps<T> | AsyncSelectMultiProps<T>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: Resolve label for a data item
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function resolveLabel<T extends Record<string, any>>(
|
||||||
|
item: T,
|
||||||
|
labelKey?: keyof T & string,
|
||||||
|
renderLabel?: (item: T) => string,
|
||||||
|
): string {
|
||||||
|
if (renderLabel) return renderLabel(item);
|
||||||
|
if (labelKey) return String(item[labelKey] ?? '');
|
||||||
|
const firstKey = Object.keys(item)[0];
|
||||||
|
return firstKey ? String(item[firstKey as keyof T] ?? '') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component Implementation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function AsyncSelectInner<T extends Record<string, any>>(props: AsyncSelectProps<T>) {
|
||||||
|
const {
|
||||||
|
valueKey,
|
||||||
|
labelKey,
|
||||||
|
renderLabel,
|
||||||
|
multiple,
|
||||||
|
filterOption,
|
||||||
|
onSelect: onSelectCallback,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
loadOptions,
|
||||||
|
defaultOptions,
|
||||||
|
debounceMs,
|
||||||
|
searchable,
|
||||||
|
onSearchChange: consumerOnSearchChange,
|
||||||
|
...mantineProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
// Use the new paginate hook for data fetching
|
||||||
|
const {
|
||||||
|
data: fetchedData,
|
||||||
|
isLoading,
|
||||||
|
fetchNextPage,
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
} = useAsyncPaginate<T>({
|
||||||
|
loadOptions,
|
||||||
|
valueKey,
|
||||||
|
debounceMs,
|
||||||
|
defaultOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Merge fetched data with currently selected values.
|
||||||
|
// This ensures the lookupMap always contains all possible values,
|
||||||
|
// preventing undefined entries during deselection.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const dataWithInjected = useMemo(() => {
|
||||||
|
const uniqueItems: T[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
// 1. Start with fetched data (already includes defaultOptions from the hook)
|
||||||
|
for (const item of fetchedData) {
|
||||||
|
const key = String(item[valueKey]);
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
uniqueItems.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Inject active selected values if they aren't in the fetched list.
|
||||||
|
// This is CRITICAL for the data mapping contract: the lookupMap
|
||||||
|
// must always be able to resolve deselected items back to objects.
|
||||||
|
if (multiple && Array.isArray(value)) {
|
||||||
|
for (const v of value) {
|
||||||
|
const key = String(v[valueKey]);
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
uniqueItems.unshift(v); // Selected items at the top
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (!multiple && value) {
|
||||||
|
const key = String((value as T)[valueKey]);
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
uniqueItems.unshift(value as T);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueItems;
|
||||||
|
}, [fetchedData, value, valueKey, multiple]);
|
||||||
|
|
||||||
|
// Build lookup map — includes ALL sources for safe reverse resolution
|
||||||
|
const lookupMap = useMemo(() => {
|
||||||
|
const map = new Map<string, T>();
|
||||||
|
for (const item of dataWithInjected) {
|
||||||
|
map.set(String(item[valueKey]), item);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [dataWithInjected, valueKey]);
|
||||||
|
|
||||||
|
// Build Mantine options from the resolved data
|
||||||
|
const baseOptions = useMemo<ComboboxItem[]>(() => {
|
||||||
|
let filtered = dataWithInjected;
|
||||||
|
|
||||||
|
if (filterOption) {
|
||||||
|
const context: SelectFilterContext<T> = {
|
||||||
|
search,
|
||||||
|
selected: value ?? (multiple ? [] : null),
|
||||||
|
};
|
||||||
|
filtered = dataWithInjected.filter((item) => filterOption(item, context));
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered.map((item) => ({
|
||||||
|
value: String(item[valueKey]),
|
||||||
|
label: resolveLabel(item, labelKey, renderLabel),
|
||||||
|
}));
|
||||||
|
}, [dataWithInjected, valueKey, labelKey, renderLabel, filterOption, value, multiple, search]);
|
||||||
|
|
||||||
|
const rightSection = isLoading ? <Loader size={16} /> : mantineProps.rightSection;
|
||||||
|
|
||||||
|
// Handle search → delegate to the hook's setSearch (debounced)
|
||||||
|
const handleSearchChange = useCallback(
|
||||||
|
(val: string) => {
|
||||||
|
setSearch(val);
|
||||||
|
consumerOnSearchChange?.(val);
|
||||||
|
},
|
||||||
|
[setSearch, consumerOnSearchChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ScrollArea props for infinite scroll — use onBottomReached
|
||||||
|
const scrollAreaProps = useMemo(
|
||||||
|
() => ({
|
||||||
|
...(mantineProps.scrollAreaProps || {}),
|
||||||
|
onBottomReached: () => {
|
||||||
|
fetchNextPage();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[mantineProps.scrollAreaProps, fetchNextPage],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Disable Mantine's internal frontend filtering.
|
||||||
|
// The backend handles the search query, so we always display what the backend returns.
|
||||||
|
const mantineFilter = filterOption
|
||||||
|
? ({ options: opts }: any) => opts
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// ----- Multi-select mode -----
|
||||||
|
if (multiple) {
|
||||||
|
const currentValues = Array.isArray(value) ? value.map((v) => String(v[valueKey])) : [];
|
||||||
|
|
||||||
|
const handleMultiChange = (vals: string[]) => {
|
||||||
|
// Resolve string[] → T[] via the lookup map.
|
||||||
|
// .filter(Boolean) is a safety net — if the map is complete (which it
|
||||||
|
// should be given the dataWithInjected merge), this is a no-op.
|
||||||
|
const objects = vals.map((v) => lookupMap.get(v)!).filter(Boolean);
|
||||||
|
(onChange as ((v: T[]) => void) | undefined)?.(objects);
|
||||||
|
onSelectCallback?.(objects);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MultiSelect
|
||||||
|
{...(mantineProps as Omit<MultiSelectProps, ManagedMultiSelectProps>)}
|
||||||
|
data={baseOptions}
|
||||||
|
value={currentValues}
|
||||||
|
onChange={handleMultiChange}
|
||||||
|
searchable={searchable ?? true}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
scrollAreaProps={scrollAreaProps}
|
||||||
|
filter={mantineFilter}
|
||||||
|
rightSection={rightSection}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Single-select mode -----
|
||||||
|
const currentValue = value ? String((value as T)[valueKey]) : null;
|
||||||
|
|
||||||
|
const handleSingleChange = (val: string | null) => {
|
||||||
|
const obj = val ? (lookupMap.get(val) ?? null) : null;
|
||||||
|
(onChange as ((v: T | null) => void) | undefined)?.(obj);
|
||||||
|
onSelectCallback?.(obj);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
{...(mantineProps as Omit<SelectProps, ManagedSelectProps>)}
|
||||||
|
data={baseOptions}
|
||||||
|
value={currentValue}
|
||||||
|
onChange={handleSingleChange}
|
||||||
|
searchable={searchable ?? true}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
scrollAreaProps={scrollAreaProps}
|
||||||
|
filter={mantineFilter as any}
|
||||||
|
rightSection={rightSection}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AsyncSelect = React.memo(AsyncSelectInner) as typeof AsyncSelectInner;
|
||||||
|
(AsyncSelect as any).displayName = 'AsyncSelect';
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import React, { useMemo, useState, useCallback } from 'react';
|
||||||
|
import { Select, MultiSelect, type SelectProps, type MultiSelectProps, type ComboboxItem } from '@mantine/core';
|
||||||
|
import type { LocalSelectBaseProps, SelectFilterContext } from './types';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// LocalSelect — Reusable Select engine for complex object data
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// This component is the STANDALONE (non-RHF) version. It bridges Mantine's
|
||||||
|
// string-based Select/MultiSelect with object data by:
|
||||||
|
// 1. Mapping T[] → ComboboxItem[] via valueKey + labelKey/renderLabel
|
||||||
|
// 2. Building a Map<string, T> for O(1) reverse lookups
|
||||||
|
// 3. Intercepting onChange to resolve strings back to full objects
|
||||||
|
//
|
||||||
|
// Data Mapping Contract (Single vs. Multi):
|
||||||
|
// Single: value=T|null → Mantine string|null → onChange(T|null)
|
||||||
|
// Multi: value=T[] → Mantine string[] → onChange(T[])
|
||||||
|
//
|
||||||
|
// The RHF-connected version (FieldLocalSelect) wraps this component and
|
||||||
|
// binds it to useController, following the same pattern as withRHF → FieldXxx.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Mantine props we manage ourselves — stripped from the pass-through */
|
||||||
|
type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect';
|
||||||
|
type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect';
|
||||||
|
|
||||||
|
/** Props for single-select mode */
|
||||||
|
export type LocalSelectSingleProps<T extends Record<string, any>> =
|
||||||
|
LocalSelectBaseProps<T> & Omit<SelectProps, ManagedSelectProps> & {
|
||||||
|
multiple?: false;
|
||||||
|
/** Controlled value — the full object or null */
|
||||||
|
value?: T | null;
|
||||||
|
/** Called when the selection changes */
|
||||||
|
onChange?: (value: T | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Props for multi-select mode */
|
||||||
|
export type LocalSelectMultiProps<T extends Record<string, any>> =
|
||||||
|
LocalSelectBaseProps<T> & Omit<MultiSelectProps, ManagedMultiSelectProps> & {
|
||||||
|
multiple: true;
|
||||||
|
/** Controlled value — array of full objects */
|
||||||
|
value?: T[];
|
||||||
|
/** Called when the selection changes */
|
||||||
|
onChange?: (value: T[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Discriminated union — the component narrows based on `multiple` */
|
||||||
|
export type LocalSelectProps<T extends Record<string, any>> =
|
||||||
|
| LocalSelectSingleProps<T>
|
||||||
|
| LocalSelectMultiProps<T>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: Resolve label for a data item
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function resolveLabel<T extends Record<string, any>>(
|
||||||
|
item: T,
|
||||||
|
labelKey?: keyof T & string,
|
||||||
|
renderLabel?: (item: T) => string,
|
||||||
|
): string {
|
||||||
|
if (renderLabel) return renderLabel(item);
|
||||||
|
if (labelKey) return String(item[labelKey] ?? '');
|
||||||
|
// Fail fast: if neither labelKey nor renderLabel is provided, fall back
|
||||||
|
// to the first property value. While not ideal, it prevents crashes.
|
||||||
|
const firstKey = Object.keys(item)[0];
|
||||||
|
return firstKey ? String(item[firstKey as keyof T] ?? '') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component Implementation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function LocalSelectInner<T extends Record<string, any>>(
|
||||||
|
props: LocalSelectProps<T>,
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
options,
|
||||||
|
valueKey,
|
||||||
|
labelKey,
|
||||||
|
renderLabel,
|
||||||
|
multiple,
|
||||||
|
filterOption,
|
||||||
|
onSelect: onSelectCallback,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
searchable,
|
||||||
|
// Extract onSearchChange BEFORE the rest spread to get a
|
||||||
|
// stable reference for the useCallback dependency array.
|
||||||
|
onSearchChange: consumerOnSearchChange,
|
||||||
|
...mantineProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
// Track search input for filterOption
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
|
||||||
|
// Build lookup map: string → original object (O(1) reverse lookup)
|
||||||
|
const lookupMap = useMemo(() => {
|
||||||
|
const map = new Map<string, T>();
|
||||||
|
for (const item of options) {
|
||||||
|
map.set(String(item[valueKey]), item);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [options, valueKey]);
|
||||||
|
|
||||||
|
// Build Mantine-compatible ComboboxItem[], applying filterOption if provided
|
||||||
|
const comboboxItems = useMemo<ComboboxItem[]>(() => {
|
||||||
|
let filtered = options;
|
||||||
|
|
||||||
|
if (filterOption) {
|
||||||
|
const context: SelectFilterContext<T> = {
|
||||||
|
search: searchValue,
|
||||||
|
selected: value ?? (multiple ? [] : null),
|
||||||
|
};
|
||||||
|
filtered = options.filter((item) => filterOption(item, context));
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered.map((item) => ({
|
||||||
|
value: String(item[valueKey]),
|
||||||
|
label: resolveLabel(item, labelKey, renderLabel),
|
||||||
|
}));
|
||||||
|
}, [options, valueKey, labelKey, renderLabel, filterOption, searchValue, value, multiple]);
|
||||||
|
|
||||||
|
// Depend only on stable function references, not the
|
||||||
|
// entire mantineProps object which is a new reference every render.
|
||||||
|
const handleSearchChange = useCallback(
|
||||||
|
(val: string) => {
|
||||||
|
setSearchValue(val);
|
||||||
|
consumerOnSearchChange?.(val);
|
||||||
|
},
|
||||||
|
[consumerOnSearchChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Passthrough filter — we handle filtering ourselves via filterOption in useMemo.
|
||||||
|
// This prevents Mantine from double-filtering.
|
||||||
|
const mantineFilter = filterOption
|
||||||
|
? ({ options: opts }: { options: ComboboxItem[] }) => opts
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
|
||||||
|
// ----- Multi-select mode -----
|
||||||
|
if (multiple) {
|
||||||
|
const currentValues = Array.isArray(value) ? value.map((v) => String(v[valueKey])) : [];
|
||||||
|
|
||||||
|
const handleMultiChange = (vals: string[]) => {
|
||||||
|
// Resolve string[] back to T[] via the lookup map.
|
||||||
|
// .filter(Boolean) guards against missing entries (defensive).
|
||||||
|
const objects = vals.map((v) => lookupMap.get(v)!).filter(Boolean);
|
||||||
|
(onChange as ((v: T[]) => void) | undefined)?.(objects);
|
||||||
|
onSelectCallback?.(objects);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MultiSelect
|
||||||
|
{...(mantineProps as Omit<MultiSelectProps, ManagedMultiSelectProps>)}
|
||||||
|
data={comboboxItems}
|
||||||
|
value={currentValues}
|
||||||
|
onChange={handleMultiChange}
|
||||||
|
searchable={searchable ?? false}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
filter={mantineFilter as any}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Single-select mode -----
|
||||||
|
const currentValue = value ? String((value as T)[valueKey]) : null;
|
||||||
|
|
||||||
|
const handleSingleChange = (val: string | null) => {
|
||||||
|
const obj = val ? lookupMap.get(val) ?? null : null;
|
||||||
|
(onChange as ((v: T | null) => void) | undefined)?.(obj);
|
||||||
|
onSelectCallback?.(obj);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
{...(mantineProps as Omit<SelectProps, ManagedSelectProps>)}
|
||||||
|
data={comboboxItems}
|
||||||
|
value={currentValue}
|
||||||
|
onChange={handleSingleChange}
|
||||||
|
searchable={searchable ?? false}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
filter={mantineFilter as any}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply React.memo for render optimization in large forms
|
||||||
|
export const LocalSelect = React.memo(LocalSelectInner) as typeof LocalSelectInner;
|
||||||
|
(LocalSelect as any).displayName = 'LocalSelect';
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { useDebouncedValue } from '@mantine/hooks';
|
||||||
|
import type { LoadOptionsFn, OptionsCacheEntry } from '../types';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// useAsyncPaginate — Production-grade paginated data fetching hook
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Architecture derived from `react-select-async-paginate` with adaptations
|
||||||
|
// for Mantine's Select/MultiSelect. Key mechanisms:
|
||||||
|
//
|
||||||
|
// 1. Search-keyed cache (Map<string, OptionsCacheEntry<T>>)
|
||||||
|
// → Switching between previously-typed searches reuses cached data
|
||||||
|
// without re-fetching from the server.
|
||||||
|
//
|
||||||
|
// 2. Request ID counter (requestIdRef)
|
||||||
|
// → Stale responses from slow or out-of-order fetches are silently
|
||||||
|
// discarded by comparing the ID captured at request start with the
|
||||||
|
// current counter value.
|
||||||
|
//
|
||||||
|
// 3. isMounted guard (mountedRef)
|
||||||
|
// → Responses arriving after the component unmounts are discarded,
|
||||||
|
// preventing React state updates on unmounted components.
|
||||||
|
//
|
||||||
|
// 4. Duplicate fetch prevention (fetchingRef boolean)
|
||||||
|
// → Guards against concurrent fetches for the same search+page combo.
|
||||||
|
//
|
||||||
|
// 5. Single consolidated effect
|
||||||
|
// → One useEffect keyed on `debouncedSearch` handles both the initial
|
||||||
|
// load (mount with search='') and subsequent search-change resets.
|
||||||
|
// This eliminates the double-initial-fetch bug from the old hook.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface UseAsyncPaginateOptions<T extends Record<string, any>> {
|
||||||
|
/** Async callback to load options. Receives (search, page, prevOptions). */
|
||||||
|
loadOptions: LoadOptionsFn<T>;
|
||||||
|
|
||||||
|
/** Property key used to deduplicate incoming items */
|
||||||
|
valueKey: keyof T & string;
|
||||||
|
|
||||||
|
/** Search debounce delay in ms (default: 300) */
|
||||||
|
debounceMs?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-loaded objects to inject into the options list.
|
||||||
|
* Used for edit-form scenarios where the RHF default value's object
|
||||||
|
* may not appear in the first page of API results.
|
||||||
|
*/
|
||||||
|
defaultOptions?: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseAsyncPaginateReturn<T> {
|
||||||
|
/** Merged data: defaultOptions + accumulated fetched pages (deduplicated) */
|
||||||
|
data: T[];
|
||||||
|
|
||||||
|
/** True during any active fetch */
|
||||||
|
isLoading: boolean;
|
||||||
|
|
||||||
|
/** Whether the current search term has more pages available */
|
||||||
|
hasMore: boolean;
|
||||||
|
|
||||||
|
/** Current search term (raw, not debounced) */
|
||||||
|
search: string;
|
||||||
|
|
||||||
|
/** The debounced search term currently driving fetches */
|
||||||
|
debouncedSearch: string;
|
||||||
|
|
||||||
|
/** Update the search term — triggers debounce + cache lookup/fetch */
|
||||||
|
setSearch: (s: string) => void;
|
||||||
|
|
||||||
|
/** Trigger next page load for the current search term */
|
||||||
|
fetchNextPage: () => void;
|
||||||
|
|
||||||
|
/** Clear all cached pages and re-fetch from page 1 */
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAsyncPaginate<T extends Record<string, any>>(
|
||||||
|
options: UseAsyncPaginateOptions<T>,
|
||||||
|
): UseAsyncPaginateReturn<T> {
|
||||||
|
const {
|
||||||
|
loadOptions,
|
||||||
|
valueKey,
|
||||||
|
debounceMs = 300,
|
||||||
|
defaultOptions,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// State
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [debouncedSearch] = useDebouncedValue(search, debounceMs);
|
||||||
|
|
||||||
|
// Search-keyed cache: each search string maps to its own pagination state
|
||||||
|
const [cache, setCache] = useState<Map<string, OptionsCacheEntry<T>>>(() => new Map());
|
||||||
|
|
||||||
|
// Loading flag — drives the UI spinner
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Refs for guards
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Monotonically increasing counter to detect stale responses */
|
||||||
|
const requestIdRef = useRef(0);
|
||||||
|
|
||||||
|
/** Guards against concurrent fetches */
|
||||||
|
const fetchingRef = useRef(false);
|
||||||
|
|
||||||
|
/** Tracks if the component is still mounted */
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
|
/** Stable ref for loadOptions to avoid effect re-fires on closure changes */
|
||||||
|
const loadOptionsRef = useRef(loadOptions);
|
||||||
|
loadOptionsRef.current = loadOptions;
|
||||||
|
|
||||||
|
/** Stable ref for valueKey */
|
||||||
|
const valueKeyRef = useRef(valueKey);
|
||||||
|
valueKeyRef.current = valueKey;
|
||||||
|
|
||||||
|
/** Stable ref for defaultOptions to avoid dependency churn */
|
||||||
|
const defaultOptionsRef = useRef(defaultOptions);
|
||||||
|
defaultOptionsRef.current = defaultOptions;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Cleanup on unmount
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Core fetch logic
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const fetchPage = useCallback(
|
||||||
|
async (searchTerm: string, page: number) => {
|
||||||
|
if (fetchingRef.current) return;
|
||||||
|
fetchingRef.current = true;
|
||||||
|
|
||||||
|
// Capture request ID — if it changes before the response arrives,
|
||||||
|
// the response is stale and should be discarded.
|
||||||
|
const capturedId = ++requestIdRef.current;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get accumulated options from the cache for prevOptions
|
||||||
|
const cachedEntry = cache.get(searchTerm);
|
||||||
|
const prevOptions = cachedEntry?.options ?? [];
|
||||||
|
|
||||||
|
const response = await loadOptionsRef.current(searchTerm, page, prevOptions);
|
||||||
|
|
||||||
|
// Guard: discard if unmounted or stale
|
||||||
|
if (!mountedRef.current || requestIdRef.current !== capturedId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newItems = response.options ?? [];
|
||||||
|
const hasMore = response.hasMore ?? false;
|
||||||
|
|
||||||
|
// Deduplicate: merge prev + new, keyed by valueKey
|
||||||
|
const vk = valueKeyRef.current;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const merged: T[] = [];
|
||||||
|
|
||||||
|
// Accumulate from previous pages first
|
||||||
|
for (const item of prevOptions) {
|
||||||
|
const key = String(item[vk]);
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
merged.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append new items (only unique ones)
|
||||||
|
let newUniqueCount = 0;
|
||||||
|
for (const item of newItems) {
|
||||||
|
const key = String(item[vk]);
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
merged.push(item);
|
||||||
|
newUniqueCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the API returned items but ALL were duplicates, treat as exhausted.
|
||||||
|
// This prevents infinite scroll loops on naive APIs that ignore pagination.
|
||||||
|
const effectiveHasMore = newItems.length > 0 && newUniqueCount === 0
|
||||||
|
? false
|
||||||
|
: hasMore;
|
||||||
|
|
||||||
|
setCache((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(searchTerm, {
|
||||||
|
options: merged,
|
||||||
|
hasMore: effectiveHasMore,
|
||||||
|
page,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!mountedRef.current || requestIdRef.current !== capturedId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('[useAsyncPaginate] loadOptions failed:', error);
|
||||||
|
|
||||||
|
// Mark the cache entry as exhausted to prevent retry loops
|
||||||
|
setCache((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const existing = prev.get(searchTerm);
|
||||||
|
next.set(searchTerm, {
|
||||||
|
options: existing?.options ?? [],
|
||||||
|
hasMore: false,
|
||||||
|
page: existing?.page ?? 0,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
fetchingRef.current = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// We intentionally exclude `cache` from deps to avoid re-creating this callback
|
||||||
|
// on every cache update. Instead, we read cache inside via the state setter's prev.
|
||||||
|
// The `cache.get(searchTerm)` read above is for prevOptions passed to loadOptions —
|
||||||
|
// this is acceptable because the callback is only called when we're NOT already fetching.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Effect: Fetch on debounced search change (including initial mount)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// This single effect replaces the old two-effect pattern that caused
|
||||||
|
// double initial fetches. On mount, debouncedSearch starts as '' and
|
||||||
|
// triggers a single page-1 fetch. On search change, it looks up the
|
||||||
|
// cache and either reuses cached data or fetches page 1 for the new term.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cached = cache.get(debouncedSearch);
|
||||||
|
|
||||||
|
// If we already have cached data for this search term, no fetch needed
|
||||||
|
if (cached && cached.options.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No cache entry — fetch page 1
|
||||||
|
fetchPage(debouncedSearch, 1);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [debouncedSearch]);
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// fetchNextPage — called by Mantine's scroll handler
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const fetchNextPage = useCallback(() => {
|
||||||
|
const cached = cache.get(debouncedSearch);
|
||||||
|
if (!cached || !cached.hasMore || isLoading || fetchingRef.current) return;
|
||||||
|
fetchPage(debouncedSearch, cached.page + 1);
|
||||||
|
}, [cache, debouncedSearch, isLoading, fetchPage]);
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// reset — clear cache and re-fetch from scratch
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setCache(new Map());
|
||||||
|
requestIdRef.current++;
|
||||||
|
fetchPage(debouncedSearch, 1);
|
||||||
|
}, [debouncedSearch, fetchPage]);
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Merge defaultOptions with fetched data (deduplicated)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const currentCacheEntry = cache.get(debouncedSearch);
|
||||||
|
|
||||||
|
const data = useMemo<T[]>(() => {
|
||||||
|
const fetchedItems = currentCacheEntry?.options ?? [];
|
||||||
|
const defaults = defaultOptionsRef.current;
|
||||||
|
|
||||||
|
if (!defaults || defaults.length === 0) {
|
||||||
|
return fetchedItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge: defaultOptions first, then fetched items (deduplicated)
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const merged: T[] = [];
|
||||||
|
|
||||||
|
for (const item of defaults) {
|
||||||
|
const key = String(item[valueKeyRef.current]);
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
merged.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of fetchedItems) {
|
||||||
|
const key = String(item[valueKeyRef.current]);
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
merged.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}, [currentCacheEntry?.options]);
|
||||||
|
|
||||||
|
const hasMore = currentCacheEntry?.hasMore ?? true;
|
||||||
|
const isTyping = search !== debouncedSearch;
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
isLoading: isLoading || isTyping,
|
||||||
|
hasMore,
|
||||||
|
search,
|
||||||
|
debouncedSearch,
|
||||||
|
setSearch,
|
||||||
|
fetchNextPage,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import type { ComboboxItem } from '@mantine/core';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Select Engine — Shared types for LocalSelect and AsyncSelect
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter context passed to the custom `filterOption` callback.
|
||||||
|
* Provides both the current search string and the currently selected value(s)
|
||||||
|
* so consumers can implement exclusion logic, compound search, or domain-specific filters.
|
||||||
|
*/
|
||||||
|
export interface SelectFilterContext<T> {
|
||||||
|
/** Current search input value */
|
||||||
|
search: string;
|
||||||
|
/** Currently selected value(s) — T | null for single, T[] for multi */
|
||||||
|
selected: T[] | T | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core configuration for the LocalSelect engine.
|
||||||
|
* This is the "headless" API — no RHF dependency.
|
||||||
|
*
|
||||||
|
* @template T - The shape of each item in the options array
|
||||||
|
*/
|
||||||
|
export interface LocalSelectBaseProps<T extends Record<string, any>> {
|
||||||
|
/** Array of complex objects to select from */
|
||||||
|
options: T[];
|
||||||
|
|
||||||
|
/** Property key to use as the unique string identifier for Mantine */
|
||||||
|
valueKey: keyof T & string;
|
||||||
|
|
||||||
|
/** Property key to use as the display label (simple mode) */
|
||||||
|
labelKey?: keyof T & string;
|
||||||
|
|
||||||
|
/** Custom label renderer — overrides `labelKey` for compound/custom labels */
|
||||||
|
renderLabel?: (item: T) => string;
|
||||||
|
|
||||||
|
/** Enable multi-select mode */
|
||||||
|
multiple?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom filter function for search and exclusion logic.
|
||||||
|
* Return `true` to keep the item in the dropdown, `false` to exclude it.
|
||||||
|
*/
|
||||||
|
filterOption?: (item: T, context: SelectFilterContext<T>) => boolean;
|
||||||
|
|
||||||
|
/** Callback fired when selection changes */
|
||||||
|
onSelect?: (value: T | T[] | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core configuration for the AsyncSelect engine.
|
||||||
|
* Extends LocalSelectBaseProps but replaces `options` with `loadOptions`.
|
||||||
|
* This is the "headless" API — no RHF dependency.
|
||||||
|
*
|
||||||
|
* @template T - The shape of each item in the options array
|
||||||
|
*/
|
||||||
|
export type AsyncSelectBaseProps<T extends Record<string, any>> = Omit<LocalSelectBaseProps<T>, 'options'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal result of the object-to-string mapping logic.
|
||||||
|
* Used by both the standalone and RHF-connected variants.
|
||||||
|
*/
|
||||||
|
export interface SelectMappingResult<T> {
|
||||||
|
/** Mantine-compatible ComboboxItem array for the Select/MultiSelect `data` prop */
|
||||||
|
options: ComboboxItem[];
|
||||||
|
|
||||||
|
/** O(1) reverse lookup map: string value → original object */
|
||||||
|
lookupMap: Map<string, T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AsyncSelect — Inversion of Control types for the async paginated engine
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The response shape returned by the `loadOptions` callback.
|
||||||
|
* Supports both paginated and non-paginated APIs.
|
||||||
|
*
|
||||||
|
* @template T - The shape of each option item
|
||||||
|
*/
|
||||||
|
export interface LoadOptionsResponse<T> {
|
||||||
|
/** The array of option objects for this page/batch */
|
||||||
|
options: T[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether more pages are available.
|
||||||
|
* - `true` → the engine will allow further scroll-triggered fetches.
|
||||||
|
* - `false` → no more data; subsequent scroll events are ignored.
|
||||||
|
* - `undefined` → treated as `false` (assumes non-paginated).
|
||||||
|
*/
|
||||||
|
hasMore?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The callback signature for loading options asynchronously.
|
||||||
|
* This follows the Inversion of Control principle: the component is
|
||||||
|
* completely ignorant of transport (REST, GraphQL, local filter, etc.).
|
||||||
|
*
|
||||||
|
* @param search - The current search input string
|
||||||
|
* @param page - The 1-indexed page number being requested
|
||||||
|
* @param prevOptions - All options accumulated from previous pages
|
||||||
|
* @returns A promise resolving to the options for this page + pagination signal
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // REST API with cursor pagination
|
||||||
|
* const loadOptions: LoadOptionsFn<User> = async (search, page) => {
|
||||||
|
* const res = await api.get('/users', { params: { q: search, page, limit: 20 } });
|
||||||
|
* return { options: res.data.items, hasMore: res.data.hasNextPage };
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* // Non-paginated (single-shot fetch)
|
||||||
|
* const loadOptions: LoadOptionsFn<Role> = async (search) => {
|
||||||
|
* const roles = await api.get('/roles', { params: { q: search } });
|
||||||
|
* return { options: roles.data, hasMore: false };
|
||||||
|
* };
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export type LoadOptionsFn<T> = (
|
||||||
|
search: string,
|
||||||
|
page: number,
|
||||||
|
prevOptions: T[],
|
||||||
|
) => Promise<LoadOptionsResponse<T>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal cache entry for the search-keyed options cache.
|
||||||
|
* Each unique search string maps to one entry tracking the accumulated
|
||||||
|
* options, pagination state, and current page for that search context.
|
||||||
|
*/
|
||||||
|
export interface OptionsCacheEntry<T> {
|
||||||
|
/** Accumulated options across all fetched pages for this search term */
|
||||||
|
options: T[];
|
||||||
|
|
||||||
|
/** Whether more pages are available for this search term */
|
||||||
|
hasMore: boolean;
|
||||||
|
|
||||||
|
/** The last successfully fetched page number (1-indexed) */
|
||||||
|
page: number;
|
||||||
|
|
||||||
|
/** Whether a fetch is currently in-flight for this search term */
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
useController,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
type UseControllerProps,
|
||||||
|
} from 'react-hook-form';
|
||||||
|
import type { SelectProps, MultiSelectProps } from '@mantine/core';
|
||||||
|
import { AsyncSelect } from '../custom/selects/AsyncSelect';
|
||||||
|
import type { AsyncSelectBaseProps, LoadOptionsFn } from '../custom/selects/types';
|
||||||
|
import { useTranslatedError } from '../useTranslatedError';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FieldAsyncSelect — RHF-connected Async Select
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Thin RHF wrapper around the standalone AsyncSelect engine.
|
||||||
|
// Adds useController binding + i18n error translation.
|
||||||
|
//
|
||||||
|
// Data Mapping Contract:
|
||||||
|
// Single: RHF stores T | null → maps to Mantine string | null
|
||||||
|
// Multi: RHF stores T[] → maps to Mantine string[]
|
||||||
|
//
|
||||||
|
// Inversion of Control: accepts `loadOptions` callback instead of
|
||||||
|
// hardcoded API endpoint. The component is transport-agnostic.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Mantine props we manage ourselves */
|
||||||
|
type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect';
|
||||||
|
type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect';
|
||||||
|
|
||||||
|
/** Async-specific props (IoC pattern) */
|
||||||
|
interface AsyncExtraProps<T> {
|
||||||
|
/** Async callback to load options — (search, page, prevOptions) => Promise */
|
||||||
|
loadOptions: LoadOptionsFn<T>;
|
||||||
|
|
||||||
|
/** Pre-loaded objects for edit forms (injected into dropdown regardless of fetch state) */
|
||||||
|
defaultOptions?: T[];
|
||||||
|
|
||||||
|
/** Search debounce delay in ms (default: 300) */
|
||||||
|
debounceMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single-select async RHF props */
|
||||||
|
export type FieldAsyncSelectSingleProps<
|
||||||
|
T extends Record<string, any>,
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
> = AsyncSelectBaseProps<T> &
|
||||||
|
AsyncExtraProps<T> &
|
||||||
|
UseControllerProps<TFieldValues, TName> &
|
||||||
|
Omit<SelectProps, ManagedSelectProps> & {
|
||||||
|
multiple?: false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Multi-select async RHF props */
|
||||||
|
export type FieldAsyncSelectMultiProps<
|
||||||
|
T extends Record<string, any>,
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
> = AsyncSelectBaseProps<T> &
|
||||||
|
AsyncExtraProps<T> &
|
||||||
|
UseControllerProps<TFieldValues, TName> &
|
||||||
|
Omit<MultiSelectProps, ManagedMultiSelectProps> & {
|
||||||
|
multiple: true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FieldAsyncSelectProps<
|
||||||
|
T extends Record<string, any>,
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
> =
|
||||||
|
| FieldAsyncSelectSingleProps<T, TFieldValues, TName>
|
||||||
|
| FieldAsyncSelectMultiProps<T, TFieldValues, TName>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component Implementation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function FieldAsyncSelectInner<
|
||||||
|
T extends Record<string, any>,
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
>(props: FieldAsyncSelectProps<T, TFieldValues, TName>) {
|
||||||
|
const {
|
||||||
|
// RHF controller props
|
||||||
|
name,
|
||||||
|
control,
|
||||||
|
rules,
|
||||||
|
shouldUnregister,
|
||||||
|
defaultValue,
|
||||||
|
disabled,
|
||||||
|
// Object/Async engine props
|
||||||
|
valueKey,
|
||||||
|
labelKey,
|
||||||
|
renderLabel,
|
||||||
|
multiple,
|
||||||
|
filterOption,
|
||||||
|
onSelect: onSelectCallback,
|
||||||
|
loadOptions,
|
||||||
|
defaultOptions,
|
||||||
|
debounceMs,
|
||||||
|
// Remaining Mantine props
|
||||||
|
...mantineProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const {
|
||||||
|
field,
|
||||||
|
fieldState: { error },
|
||||||
|
} = useController<TFieldValues, TName>({
|
||||||
|
name,
|
||||||
|
control,
|
||||||
|
rules,
|
||||||
|
shouldUnregister,
|
||||||
|
defaultValue,
|
||||||
|
disabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
const translatedError = useTranslatedError(error?.message);
|
||||||
|
|
||||||
|
const handleChange = (value: any) => {
|
||||||
|
field.onChange(value);
|
||||||
|
onSelectCallback?.(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const engineProps = {
|
||||||
|
valueKey,
|
||||||
|
labelKey,
|
||||||
|
renderLabel,
|
||||||
|
filterOption,
|
||||||
|
loadOptions,
|
||||||
|
defaultOptions,
|
||||||
|
debounceMs,
|
||||||
|
onBlur: field.onBlur,
|
||||||
|
error: translatedError,
|
||||||
|
disabled: field.disabled,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (multiple) {
|
||||||
|
return (
|
||||||
|
<AsyncSelect<T>
|
||||||
|
multiple
|
||||||
|
{...engineProps}
|
||||||
|
value={field.value ?? []}
|
||||||
|
onChange={handleChange}
|
||||||
|
{...(mantineProps as any)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AsyncSelect<T>
|
||||||
|
{...engineProps}
|
||||||
|
value={field.value ?? null}
|
||||||
|
onChange={handleChange}
|
||||||
|
{...(mantineProps as any)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FieldAsyncSelect = React.memo(FieldAsyncSelectInner) as typeof FieldAsyncSelectInner;
|
||||||
|
(FieldAsyncSelect as any).displayName = 'FieldAsyncSelect';
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
useController,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
type UseControllerProps,
|
||||||
|
} from 'react-hook-form';
|
||||||
|
import type { SelectProps, MultiSelectProps } from '@mantine/core';
|
||||||
|
import { LocalSelect } from '../custom/selects/LocalSelect';
|
||||||
|
import type { LocalSelectBaseProps } from '../custom/selects/types';
|
||||||
|
import { useTranslatedError } from '../useTranslatedError';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FieldLocalSelect — RHF-connected Local Select
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Follows the same architectural pattern as the existing `FieldSelect`:
|
||||||
|
// Mantine Component → withRHF HOC → FieldXxx
|
||||||
|
//
|
||||||
|
// But instead of using the generic withRHF factory (which assumes string values),
|
||||||
|
// we use a manual useController binding with an object interception layer.
|
||||||
|
// The actual rendering is delegated to the standalone LocalSelect engine
|
||||||
|
// in `custom/selects/LocalSelect.tsx`.
|
||||||
|
//
|
||||||
|
// Data Mapping Contract:
|
||||||
|
// Single: RHF stores T | null → maps to Mantine string | null
|
||||||
|
// Multi: RHF stores T[] → maps to Mantine string[]
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Mantine props we manage ourselves */
|
||||||
|
type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect';
|
||||||
|
type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect';
|
||||||
|
|
||||||
|
/** Single-select RHF props — stores T | null */
|
||||||
|
export type FieldLocalSelectSingleProps<
|
||||||
|
T extends Record<string, any>,
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
> = LocalSelectBaseProps<T> &
|
||||||
|
UseControllerProps<TFieldValues, TName> &
|
||||||
|
Omit<SelectProps, ManagedSelectProps> & {
|
||||||
|
multiple?: false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Multi-select RHF props — stores T[] */
|
||||||
|
export type FieldLocalSelectMultiProps<
|
||||||
|
T extends Record<string, any>,
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
> = LocalSelectBaseProps<T> &
|
||||||
|
UseControllerProps<TFieldValues, TName> &
|
||||||
|
Omit<MultiSelectProps, ManagedMultiSelectProps> & {
|
||||||
|
multiple: true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Discriminated union based on `multiple` */
|
||||||
|
export type FieldLocalSelectProps<
|
||||||
|
T extends Record<string, any>,
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
> =
|
||||||
|
| FieldLocalSelectSingleProps<T, TFieldValues, TName>
|
||||||
|
| FieldLocalSelectMultiProps<T, TFieldValues, TName>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component Implementation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function FieldLocalSelectInner<
|
||||||
|
T extends Record<string, any>,
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
>(props: FieldLocalSelectProps<T, TFieldValues, TName>) {
|
||||||
|
const {
|
||||||
|
// RHF controller props
|
||||||
|
name,
|
||||||
|
control,
|
||||||
|
rules,
|
||||||
|
shouldUnregister,
|
||||||
|
defaultValue,
|
||||||
|
disabled,
|
||||||
|
// LocalSelect engine props
|
||||||
|
options,
|
||||||
|
valueKey,
|
||||||
|
labelKey,
|
||||||
|
renderLabel,
|
||||||
|
multiple,
|
||||||
|
filterOption,
|
||||||
|
onSelect: onSelectCallback,
|
||||||
|
// Remaining Mantine props
|
||||||
|
...mantineProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const {
|
||||||
|
field,
|
||||||
|
fieldState: { error },
|
||||||
|
} = useController<TFieldValues, TName>({
|
||||||
|
name,
|
||||||
|
control,
|
||||||
|
rules,
|
||||||
|
shouldUnregister,
|
||||||
|
defaultValue,
|
||||||
|
disabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Translate the error message (handles JSON i18n payloads)
|
||||||
|
const translatedError = useTranslatedError(error?.message);
|
||||||
|
|
||||||
|
// Intercept onChange to pass full objects to RHF
|
||||||
|
const handleChange = (value: any) => {
|
||||||
|
field.onChange(value);
|
||||||
|
onSelectCallback?.(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build engine props based on single/multi mode
|
||||||
|
if (multiple) {
|
||||||
|
return (
|
||||||
|
<LocalSelect<T>
|
||||||
|
multiple
|
||||||
|
options={options}
|
||||||
|
valueKey={valueKey}
|
||||||
|
labelKey={labelKey}
|
||||||
|
renderLabel={renderLabel}
|
||||||
|
filterOption={filterOption}
|
||||||
|
value={field.value ?? []}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
error={translatedError}
|
||||||
|
disabled={field.disabled}
|
||||||
|
{...(mantineProps as any)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LocalSelect<T>
|
||||||
|
options={options}
|
||||||
|
valueKey={valueKey}
|
||||||
|
labelKey={labelKey}
|
||||||
|
renderLabel={renderLabel}
|
||||||
|
filterOption={filterOption}
|
||||||
|
value={field.value ?? null}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
error={translatedError}
|
||||||
|
disabled={field.disabled}
|
||||||
|
{...(mantineProps as any)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FieldLocalSelect = React.memo(FieldLocalSelectInner) as typeof FieldLocalSelectInner;
|
||||||
|
(FieldLocalSelect as any).displayName = 'FieldLocalSelect';
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useController, type FieldPath, type FieldValues, type UseControllerProps } from 'react-hook-form';
|
||||||
|
import { useEditor } from '@tiptap/react';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import Underline from '@tiptap/extension-underline';
|
||||||
|
import Link from '@tiptap/extension-link';
|
||||||
|
import TextAlign from '@tiptap/extension-text-align';
|
||||||
|
import { RichTextEditor } from '@mantine/tiptap';
|
||||||
|
import { Input } from '@mantine/core';
|
||||||
|
import { useTranslatedError } from '../useTranslatedError';
|
||||||
|
|
||||||
|
export type FieldRichTextEditorProps<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
> = UseControllerProps<TFieldValues, TName> & {
|
||||||
|
label?: React.ReactNode;
|
||||||
|
description?: React.ReactNode;
|
||||||
|
withAsterisk?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function FieldRichTextEditorComponent<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
>(props: FieldRichTextEditorProps<TFieldValues, TName>) {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
control,
|
||||||
|
rules,
|
||||||
|
shouldUnregister,
|
||||||
|
defaultValue,
|
||||||
|
disabled,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
withAsterisk,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const {
|
||||||
|
field,
|
||||||
|
fieldState: { error },
|
||||||
|
} = useController({
|
||||||
|
name,
|
||||||
|
control,
|
||||||
|
rules,
|
||||||
|
shouldUnregister,
|
||||||
|
defaultValue,
|
||||||
|
disabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
const translatedError = useTranslatedError(error?.message);
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
Underline,
|
||||||
|
Link,
|
||||||
|
TextAlign.configure({ types: ['heading', 'paragraph'], alignments: ['left', 'center', 'right', 'justify'] }),
|
||||||
|
],
|
||||||
|
content: field.value || '',
|
||||||
|
onUpdate({ editor }) {
|
||||||
|
field.onChange(editor.getHTML());
|
||||||
|
},
|
||||||
|
onBlur() {
|
||||||
|
field.onBlur();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor && field.value !== editor.getHTML()) {
|
||||||
|
editor.commands.setContent(field.value || '');
|
||||||
|
}
|
||||||
|
}, [field.value, editor]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input.Wrapper
|
||||||
|
label={label}
|
||||||
|
description={description}
|
||||||
|
withAsterisk={withAsterisk}
|
||||||
|
error={translatedError}
|
||||||
|
>
|
||||||
|
<RichTextEditor editor={editor}>
|
||||||
|
<RichTextEditor.Toolbar sticky stickyOffset={60}>
|
||||||
|
<RichTextEditor.ControlsGroup>
|
||||||
|
<RichTextEditor.Bold />
|
||||||
|
<RichTextEditor.Italic />
|
||||||
|
<RichTextEditor.Underline />
|
||||||
|
<RichTextEditor.Strikethrough />
|
||||||
|
<RichTextEditor.ClearFormatting />
|
||||||
|
</RichTextEditor.ControlsGroup>
|
||||||
|
|
||||||
|
<RichTextEditor.ControlsGroup>
|
||||||
|
<RichTextEditor.H1 />
|
||||||
|
<RichTextEditor.H2 />
|
||||||
|
<RichTextEditor.H3 />
|
||||||
|
</RichTextEditor.ControlsGroup>
|
||||||
|
|
||||||
|
<RichTextEditor.ControlsGroup>
|
||||||
|
<RichTextEditor.BulletList />
|
||||||
|
<RichTextEditor.OrderedList />
|
||||||
|
</RichTextEditor.ControlsGroup>
|
||||||
|
|
||||||
|
<RichTextEditor.ControlsGroup>
|
||||||
|
<RichTextEditor.Link />
|
||||||
|
<RichTextEditor.Unlink />
|
||||||
|
</RichTextEditor.ControlsGroup>
|
||||||
|
|
||||||
|
<RichTextEditor.ControlsGroup>
|
||||||
|
<RichTextEditor.AlignLeft />
|
||||||
|
<RichTextEditor.AlignCenter />
|
||||||
|
<RichTextEditor.AlignRight />
|
||||||
|
<RichTextEditor.AlignJustify />
|
||||||
|
</RichTextEditor.ControlsGroup>
|
||||||
|
</RichTextEditor.Toolbar>
|
||||||
|
|
||||||
|
<RichTextEditor.Content />
|
||||||
|
</RichTextEditor>
|
||||||
|
</Input.Wrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap with React.memo for identical performance characteristics as withRHF components
|
||||||
|
export const FieldRichTextEditor = React.memo(FieldRichTextEditorComponent) as typeof FieldRichTextEditorComponent;
|
||||||
@@ -28,6 +28,7 @@ export { FieldNumberInput } from './fields/number-input.field';
|
|||||||
export { FieldJsonInput } from './fields/json-input.field';
|
export { FieldJsonInput } from './fields/json-input.field';
|
||||||
export { FieldPinInput } from './fields/pin-input.field';
|
export { FieldPinInput } from './fields/pin-input.field';
|
||||||
export { FieldAutocomplete } from './fields/autocomplete.field';
|
export { FieldAutocomplete } from './fields/autocomplete.field';
|
||||||
|
export { FieldRichTextEditor } from './fields/rich-text.field';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Selection Fields
|
// Selection Fields
|
||||||
@@ -37,6 +38,25 @@ export { FieldMultiSelect } from './fields/multi-select.field';
|
|||||||
export { FieldNativeSelect } from './fields/native-select.field';
|
export { FieldNativeSelect } from './fields/native-select.field';
|
||||||
export { FieldTagsInput } from './fields/tags-input.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
|
// Toggle / Boolean Fields
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { type ComponentType, type Ref, useMemo } from 'react';
|
import React, { type ComponentType, type Ref } from 'react';
|
||||||
import {
|
import {
|
||||||
useController,
|
useController,
|
||||||
type FieldPath,
|
type FieldPath,
|
||||||
@@ -6,83 +6,8 @@ import {
|
|||||||
type UseControllerProps,
|
type UseControllerProps,
|
||||||
} from 'react-hook-form';
|
} from 'react-hook-form';
|
||||||
import { Input } from '@mantine/core';
|
import { Input } from '@mantine/core';
|
||||||
import { useTranslation } from '@repo/core-i18n';
|
import type { WithRHFOptions } from './types';
|
||||||
import type { ZodI18nPayload, WithRHFOptions } from './types';
|
import { useTranslatedError } from './useTranslatedError';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 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]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// withRHF — Higher-Order Component Factory
|
// withRHF — Higher-Order Component Factory
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
export * from '@mantine/core';
|
export * from '@mantine/core';
|
||||||
|
export {
|
||||||
|
List,
|
||||||
|
TypographyStylesProvider,
|
||||||
|
} from '@mantine/core';
|
||||||
|
|
||||||
export * from './Form';
|
export * from './Form';
|
||||||
export * from './system-pages/coming-soon';
|
export * from './system-pages/coming-soon';
|
||||||
|
|||||||
+54
-22
@@ -1,18 +1,34 @@
|
|||||||
|
/* =========================================
|
||||||
|
1. CORE IMPORTS & TAILWIND CONFIG
|
||||||
|
========================================= */
|
||||||
|
/* Import Mantine core and TipTap extensions */
|
||||||
@import '@mantine/core/styles.css';
|
@import '@mantine/core/styles.css';
|
||||||
|
@import '@mantine/tiptap/styles.css';
|
||||||
|
|
||||||
|
/* Initialize Tailwind CSS v4 engine */
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
/* Instruct Tailwind to scan the src directory for utility class usage */
|
||||||
@source "../src";
|
@source "../src";
|
||||||
|
|
||||||
@theme {
|
@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;
|
--base-font-size: 13px;
|
||||||
--font-sans: var(--mantine-font-family);
|
--font-sans: var(--mantine-font-family);
|
||||||
--font-mono: var(--mantine-font-family-monospace);
|
--font-mono: var(--mantine-font-family-monospace);
|
||||||
|
|
||||||
/* =========================================
|
/* =========================================
|
||||||
1. COLORS (Strictly mapped to Mantine 0-9)
|
3. COLOR SYSTEM (Mantine to Tailwind Sync)
|
||||||
|
Maps Tailwind's 50-900 scale directly to
|
||||||
|
Mantine's 0-9 scale for seamless theming.
|
||||||
|
Usage: `bg-brand-500`, `text-error-700`
|
||||||
========================================= */
|
========================================= */
|
||||||
|
|
||||||
|
/* Brand Colors */
|
||||||
--color-brand-50: var(--mantine-color-brand-0);
|
--color-brand-50: var(--mantine-color-brand-0);
|
||||||
--color-brand-100: var(--mantine-color-brand-1);
|
--color-brand-100: var(--mantine-color-brand-1);
|
||||||
--color-brand-200: var(--mantine-color-brand-2);
|
--color-brand-200: var(--mantine-color-brand-2);
|
||||||
@@ -24,6 +40,7 @@
|
|||||||
--color-brand-800: var(--mantine-color-brand-8);
|
--color-brand-800: var(--mantine-color-brand-8);
|
||||||
--color-brand-900: var(--mantine-color-brand-9);
|
--color-brand-900: var(--mantine-color-brand-9);
|
||||||
|
|
||||||
|
/* Error Colors (Red/Danger) */
|
||||||
--color-error-50: var(--mantine-color-error-0);
|
--color-error-50: var(--mantine-color-error-0);
|
||||||
--color-error-100: var(--mantine-color-error-1);
|
--color-error-100: var(--mantine-color-error-1);
|
||||||
--color-error-200: var(--mantine-color-error-2);
|
--color-error-200: var(--mantine-color-error-2);
|
||||||
@@ -35,6 +52,7 @@
|
|||||||
--color-error-800: var(--mantine-color-error-8);
|
--color-error-800: var(--mantine-color-error-8);
|
||||||
--color-error-900: var(--mantine-color-error-9);
|
--color-error-900: var(--mantine-color-error-9);
|
||||||
|
|
||||||
|
/* Warning Colors (Yellow/Orange) */
|
||||||
--color-warning-50: var(--mantine-color-warning-0);
|
--color-warning-50: var(--mantine-color-warning-0);
|
||||||
--color-warning-100: var(--mantine-color-warning-1);
|
--color-warning-100: var(--mantine-color-warning-1);
|
||||||
--color-warning-200: var(--mantine-color-warning-2);
|
--color-warning-200: var(--mantine-color-warning-2);
|
||||||
@@ -46,6 +64,7 @@
|
|||||||
--color-warning-800: var(--mantine-color-warning-8);
|
--color-warning-800: var(--mantine-color-warning-8);
|
||||||
--color-warning-900: var(--mantine-color-warning-9);
|
--color-warning-900: var(--mantine-color-warning-9);
|
||||||
|
|
||||||
|
/* Success Colors (Green) */
|
||||||
--color-success-50: var(--mantine-color-success-0);
|
--color-success-50: var(--mantine-color-success-0);
|
||||||
--color-success-100: var(--mantine-color-success-1);
|
--color-success-100: var(--mantine-color-success-1);
|
||||||
--color-success-200: var(--mantine-color-success-2);
|
--color-success-200: var(--mantine-color-success-2);
|
||||||
@@ -57,6 +76,7 @@
|
|||||||
--color-success-800: var(--mantine-color-success-8);
|
--color-success-800: var(--mantine-color-success-8);
|
||||||
--color-success-900: var(--mantine-color-success-9);
|
--color-success-900: var(--mantine-color-success-9);
|
||||||
|
|
||||||
|
/* Info Colors (Blue/Cyan) */
|
||||||
--color-info-50: var(--mantine-color-info-0);
|
--color-info-50: var(--mantine-color-info-0);
|
||||||
--color-info-100: var(--mantine-color-info-1);
|
--color-info-100: var(--mantine-color-info-1);
|
||||||
--color-info-200: var(--mantine-color-info-2);
|
--color-info-200: var(--mantine-color-info-2);
|
||||||
@@ -69,22 +89,26 @@
|
|||||||
--color-info-900: var(--mantine-color-info-9);
|
--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-xs: var(--mantine-spacing-xs);
|
||||||
--spacing-sm: var(--mantine-spacing-sm);
|
--spacing-sm: var(--mantine-spacing-sm);
|
||||||
--spacing-md: var(--mantine-spacing-md);
|
--spacing-md: var(--mantine-spacing-md);
|
||||||
--spacing-lg: var(--mantine-spacing-lg);
|
--spacing-lg: var(--mantine-spacing-lg);
|
||||||
--spacing-xl: var(--mantine-spacing-xl);
|
--spacing-xl: var(--mantine-spacing-xl);
|
||||||
|
|
||||||
|
/* Standard Tailwind responsive breakpoints and container sizes */
|
||||||
--breakpoint-sm: 40rem;
|
--breakpoint-sm: 40rem;
|
||||||
--breakpoint-md: 48rem;
|
--breakpoint-md: 48rem;
|
||||||
--breakpoint-lg: 64rem;
|
--breakpoint-lg: 64rem;
|
||||||
--breakpoint-xl: 80rem;
|
--breakpoint-xl: 80rem;
|
||||||
--breakpoint-2xl: 96rem;
|
--breakpoint-2xl: 96rem;
|
||||||
|
|
||||||
--container-3xs: 16rem;
|
--container-3xs: 16rem;
|
||||||
--container-2xs: 18rem;
|
--container-2xs: 18rem;
|
||||||
--container-xs: 20rem;
|
--container-xs: 20rem;
|
||||||
@@ -100,17 +124,22 @@
|
|||||||
--container-7xl: 80rem;
|
--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: var(--mantine-font-size-xs);
|
||||||
--text-xs--line-height: calc(1 / 0.75);
|
--text-xs--line-height: calc(1 / 0.75);
|
||||||
|
|
||||||
--text-sm: var(--mantine-font-size-sm);
|
--text-sm: var(--mantine-font-size-sm);
|
||||||
--text-sm--line-height: calc(1.25 / 0.875);
|
--text-sm--line-height: calc(1.25 / 0.875);
|
||||||
|
|
||||||
--text-base: var(--mantine-font-size-md);
|
--text-base: var(--mantine-font-size-md);
|
||||||
--text-base--line-height: calc(1.5 / 1);
|
--text-base--line-height: calc(1.5 / 1);
|
||||||
|
|
||||||
--text-lg: var(--mantine-font-size-lg);
|
--text-lg: var(--mantine-font-size-lg);
|
||||||
--text-lg--line-height: calc(1.75 / 1.125);
|
--text-lg--line-height: calc(1.75 / 1.125);
|
||||||
|
|
||||||
--text-xl: var(--mantine-font-size-xl);
|
--text-xl: var(--mantine-font-size-xl);
|
||||||
--text-xl--line-height: calc(1.75 / 1.25);
|
--text-xl--line-height: calc(1.75 / 1.25);
|
||||||
|
|
||||||
@@ -131,6 +160,7 @@
|
|||||||
--text-9xl: 8rem;
|
--text-9xl: 8rem;
|
||||||
--text-9xl--line-height: 1;
|
--text-9xl--line-height: 1;
|
||||||
|
|
||||||
|
/* Font Weights */
|
||||||
--font-weight-thin: 100;
|
--font-weight-thin: 100;
|
||||||
--font-weight-extralight: 200;
|
--font-weight-extralight: 200;
|
||||||
--font-weight-light: 300;
|
--font-weight-light: 300;
|
||||||
@@ -141,6 +171,7 @@
|
|||||||
--font-weight-extrabold: 800;
|
--font-weight-extrabold: 800;
|
||||||
--font-weight-black: 900;
|
--font-weight-black: 900;
|
||||||
|
|
||||||
|
/* Letter Spacing (Tracking) */
|
||||||
--tracking-tighter: -0.05em;
|
--tracking-tighter: -0.05em;
|
||||||
--tracking-tight: -0.025em;
|
--tracking-tight: -0.025em;
|
||||||
--tracking-normal: 0em;
|
--tracking-normal: 0em;
|
||||||
@@ -148,6 +179,7 @@
|
|||||||
--tracking-wider: 0.05em;
|
--tracking-wider: 0.05em;
|
||||||
--tracking-widest: 0.1em;
|
--tracking-widest: 0.1em;
|
||||||
|
|
||||||
|
/* Line Height (Leading) */
|
||||||
--leading-tight: 1.25;
|
--leading-tight: 1.25;
|
||||||
--leading-snug: 1.375;
|
--leading-snug: 1.375;
|
||||||
--leading-normal: 1.5;
|
--leading-normal: 1.5;
|
||||||
@@ -155,19 +187,23 @@
|
|||||||
--leading-loose: 2;
|
--leading-loose: 2;
|
||||||
|
|
||||||
/* =========================================
|
/* =========================================
|
||||||
4. RADIUS
|
6. BORDER RADIUS
|
||||||
|
Inherits exact corner rounding from Mantine.
|
||||||
========================================= */
|
========================================= */
|
||||||
--radius-xs: var(--mantine-radius-xs);
|
--radius-xs: var(--mantine-radius-xs);
|
||||||
--radius-sm: var(--mantine-radius-sm);
|
--radius-sm: var(--mantine-radius-sm);
|
||||||
--radius-md: var(--mantine-radius-md);
|
--radius-md: var(--mantine-radius-md);
|
||||||
--radius-lg: var(--mantine-radius-lg);
|
--radius-lg: var(--mantine-radius-lg);
|
||||||
--radius-xl: var(--mantine-radius-xl);
|
--radius-xl: var(--mantine-radius-xl);
|
||||||
|
|
||||||
--radius-2xl: 1rem;
|
--radius-2xl: 1rem;
|
||||||
--radius-3xl: 1.5rem;
|
--radius-3xl: 1.5rem;
|
||||||
--radius-4xl: 2rem;
|
--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-2xs: 0 1px rgb(0 0 0 / 0.05);
|
||||||
--shadow-xs: var(--mantine-shadow-xs);
|
--shadow-xs: var(--mantine-shadow-xs);
|
||||||
@@ -203,7 +239,7 @@
|
|||||||
--blur-3xl: 64px;
|
--blur-3xl: 64px;
|
||||||
|
|
||||||
/* =========================================
|
/* =========================================
|
||||||
6. MISCELLANEOUS (Aspect, Anim, Perspective)
|
8. MISCELLANEOUS & ANIMATIONS
|
||||||
========================================= */
|
========================================= */
|
||||||
--perspective-dramatic: 100px;
|
--perspective-dramatic: 100px;
|
||||||
--perspective-near: 300px;
|
--perspective-near: 300px;
|
||||||
@@ -217,31 +253,26 @@
|
|||||||
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||||
--ease-in-out: cubic-bezier(0.4, 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-spin: spin 1s linear infinite;
|
||||||
--animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) 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-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
--animate-bounce: bounce 1s infinite;
|
--animate-bounce: bounce 1s infinite;
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to {
|
to { transform: rotate(360deg); }
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@keyframes ping {
|
@keyframes ping {
|
||||||
75%,
|
75%, 100% {
|
||||||
100% {
|
|
||||||
transform: scale(2);
|
transform: scale(2);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
50% {
|
50% { opacity: 0.5; }
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@keyframes bounce {
|
@keyframes bounce {
|
||||||
0%,
|
0%, 100% {
|
||||||
100% {
|
|
||||||
transform: translateY(-25%);
|
transform: translateY(-25%);
|
||||||
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
|
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
|
||||||
}
|
}
|
||||||
@@ -253,9 +284,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================
|
/* =========================================
|
||||||
BASE RESETS
|
9. BASE RESETS
|
||||||
|
Applies global typography smoothing and
|
||||||
|
sets the root font size.
|
||||||
========================================= */
|
========================================= */
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
body {
|
body {
|
||||||
font-size: var(--base-font-size);
|
font-size: var(--base-font-size);
|
||||||
|
|||||||
Generated
+478
-4
@@ -245,7 +245,7 @@ importers:
|
|||||||
version: 5.4.17(@types/node@22.19.3)
|
version: 5.4.17(@types/node@22.19.3)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^4.0.17
|
specifier: ^4.0.17
|
||||||
version: 4.0.17(jsdom@26.1.0)
|
version: 4.0.17(@opentelemetry/api@1.9.1)
|
||||||
|
|
||||||
packages/configs/eslint:
|
packages/configs/eslint:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -442,7 +442,7 @@ importers:
|
|||||||
version: 5.5.4
|
version: 5.5.4
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^4.0.17
|
specifier: ^4.0.17
|
||||||
version: 4.0.17(jsdom@26.1.0)
|
version: 4.0.17(@opentelemetry/api@1.9.1)
|
||||||
|
|
||||||
packages/ui:
|
packages/ui:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -455,12 +455,33 @@ importers:
|
|||||||
'@mantine/hooks':
|
'@mantine/hooks':
|
||||||
specifier: ^8.3.15
|
specifier: ^8.3.15
|
||||||
version: 8.3.15(react@19.2.3)
|
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':
|
'@repo/core-i18n':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../core-i18n
|
version: link:../core-i18n
|
||||||
'@repo/utils':
|
'@repo/utils':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../utils
|
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:
|
dayjs:
|
||||||
specifier: ^1.11.19
|
specifier: ^1.11.19
|
||||||
version: 1.11.19
|
version: 1.11.19
|
||||||
@@ -555,7 +576,7 @@ importers:
|
|||||||
version: 5.5.4
|
version: 5.5.4
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^4.0.17
|
specifier: ^4.0.17
|
||||||
version: 4.0.17(jsdom@26.1.0)
|
version: 4.0.17(@opentelemetry/api@1.9.1)
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -1647,6 +1668,24 @@ packages:
|
|||||||
react: 19.2.3
|
react: 19.2.3
|
||||||
dev: false
|
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):
|
/@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.3):
|
||||||
resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
|
resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3066,6 +3105,309 @@ packages:
|
|||||||
'@testing-library/dom': 10.4.1
|
'@testing-library/dom': 10.4.1
|
||||||
dev: true
|
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:
|
/@tootallnate/once@2.0.0:
|
||||||
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
|
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
@@ -3370,7 +3712,6 @@ packages:
|
|||||||
'@types/react': ^19.2.0
|
'@types/react': ^19.2.0
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 19.2.7
|
'@types/react': 19.2.7
|
||||||
dev: true
|
|
||||||
|
|
||||||
/@types/react@19.2.7:
|
/@types/react@19.2.7:
|
||||||
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
|
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
|
||||||
@@ -3403,6 +3744,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||||
dev: false
|
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:
|
/@types/uuid@9.0.8:
|
||||||
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
|
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
|
||||||
dev: true
|
dev: true
|
||||||
@@ -6276,6 +6621,11 @@ packages:
|
|||||||
/fast-deep-equal@3.1.3:
|
/fast-deep-equal@3.1.3:
|
||||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
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:
|
/fast-glob@3.3.3:
|
||||||
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
||||||
engines: {node: '>=8.6.0'}
|
engines: {node: '>=8.6.0'}
|
||||||
@@ -7606,6 +7956,10 @@ packages:
|
|||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/linkifyjs@4.3.3:
|
||||||
|
resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/load-plugin@6.0.3:
|
/load-plugin@6.0.3:
|
||||||
resolution: {integrity: sha512-kc0X2FEUZr145odl68frm+lMJuQ23+rTXYmR6TImqPtbpmXC4vVXbWKDQ9IzndA0HfyQamWfKLhzsqGSTxE63w==}
|
resolution: {integrity: sha512-kc0X2FEUZr145odl68frm+lMJuQ23+rTXYmR6TImqPtbpmXC4vVXbWKDQ9IzndA0HfyQamWfKLhzsqGSTxE63w==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -8637,6 +8991,10 @@ packages:
|
|||||||
wcwidth: 1.0.1
|
wcwidth: 1.0.1
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/orderedmap@2.1.1:
|
||||||
|
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/own-keys@1.0.1:
|
/own-keys@1.0.1:
|
||||||
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -9068,6 +9426,106 @@ packages:
|
|||||||
react-is: 16.13.1
|
react-is: 16.13.1
|
||||||
dev: false
|
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:
|
/protobufjs@7.6.1:
|
||||||
resolution: {integrity: sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==}
|
resolution: {integrity: sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -9670,6 +10128,10 @@ packages:
|
|||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/rope-sequence@1.3.4:
|
||||||
|
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/rrweb-cssom@0.8.0:
|
/rrweb-cssom@0.8.0:
|
||||||
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
|
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
|
||||||
dev: true
|
dev: true
|
||||||
@@ -10901,6 +11363,14 @@ packages:
|
|||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
dev: false
|
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:
|
/utf8-byte-length@1.0.5:
|
||||||
resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==}
|
resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==}
|
||||||
dev: true
|
dev: true
|
||||||
@@ -11241,6 +11711,10 @@ packages:
|
|||||||
/vuvuzela@1.0.3:
|
/vuvuzela@1.0.3:
|
||||||
resolution: {integrity: sha512-Tm7jR1xTzBbPW+6y1tknKiEhz04Wf/1iZkcTJjSFcpNko43+dFW6+OOeQe9taJIug3NdfUAjFKgUSyQrIKaDvQ==}
|
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:
|
/w3c-xmlserializer@5.0.0:
|
||||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|||||||
Reference in New Issue
Block a user