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,
|
||||
FieldRadioGroup, FieldSwitch, FieldChipGroup, FieldSegmentedControl,
|
||||
FieldSlider, FieldRangeSlider, FieldRating, FieldColorInput,
|
||||
FieldColorPicker, FieldFileInput
|
||||
FieldColorPicker, FieldFileInput, FieldLocalSelect, FieldAsyncSelect,
|
||||
FieldRichTextEditor
|
||||
} from '@repo/ui/form';
|
||||
import type { LoadOptionsFn } from '@repo/ui/form';
|
||||
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
|
||||
|
||||
const MOCK_POKEMON = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: `Pokemon ${i + 1}`,
|
||||
}));
|
||||
|
||||
const loadMockPokemonOptions: LoadOptionsFn<any> = async (search, page) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const filtered = MOCK_POKEMON.filter((p) => p.name.toLowerCase().includes(search.toLowerCase()));
|
||||
const pageSize = 20;
|
||||
const start = (page - 1) * pageSize;
|
||||
const paginated = filtered.slice(start, start + pageSize);
|
||||
return {
|
||||
options: paginated,
|
||||
hasMore: start + pageSize < filtered.length,
|
||||
};
|
||||
};
|
||||
|
||||
const loadRealPokemonOptions: LoadOptionsFn<any> = async (_search, page) => {
|
||||
const limit = 20;
|
||||
const offset = (page - 1) * limit;
|
||||
const res = await fetch(`https://pokeapi.co/api/v2/pokemon?limit=${limit}&offset=${offset}`);
|
||||
const data = await res.json();
|
||||
return {
|
||||
options: data.results.map((p: any, i: number) => ({ id: offset + i + 1, ...p })),
|
||||
hasMore: !!data.next,
|
||||
};
|
||||
};
|
||||
|
||||
const MOCK_VENDORS = [
|
||||
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
|
||||
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' },
|
||||
{ id: 'V3', code: 'VN-03', name: 'Vendor Three' },
|
||||
];
|
||||
|
||||
const loadMockVendorsOptions: LoadOptionsFn<any> = async (search, _page) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const filtered = MOCK_VENDORS.filter(v => v.name.toLowerCase().includes(search.toLowerCase()) || v.code.toLowerCase().includes(search.toLowerCase()));
|
||||
return { options: filtered, hasMore: false };
|
||||
};
|
||||
|
||||
export default function AllFieldsDemo() {
|
||||
const t = useFormDemoTranslation();
|
||||
|
||||
@@ -37,7 +79,23 @@ export default function AllFieldsDemo() {
|
||||
rating: 0,
|
||||
themeColor: '',
|
||||
colorPicker: '#1c7ed6',
|
||||
avatar: null
|
||||
avatar: null,
|
||||
localSelectEmpty: null,
|
||||
localSelectPrefilled: { id: 'V2', code: 'VN-02', name: 'Vendor Two' },
|
||||
asyncSelectEmpty: null,
|
||||
asyncSelectPrefilled: { id: 999, code: 'ASYNC-99', name: 'Pre-loaded Async Vendor' },
|
||||
localMultiPrefilled: [
|
||||
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
|
||||
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' }
|
||||
],
|
||||
asyncMultiPrefilled: [
|
||||
{ id: 888, code: 'ASYNC-88', name: 'Ghost Async Vendor 1' },
|
||||
{ id: 999, code: 'ASYNC-99', name: 'Ghost Async Vendor 2' }
|
||||
],
|
||||
richTextEmpty: "",
|
||||
richTextPrefilled: "<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">
|
||||
{/* --- Text & Numbers --- */}
|
||||
<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" />
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldTextInput name="customerName" control={control} label={t.fields.customerName} />
|
||||
@@ -73,7 +131,7 @@ export default function AllFieldsDemo() {
|
||||
|
||||
{/* --- Selections --- */}
|
||||
<div>
|
||||
<Title order={5} mb="sm" c="brand">Selections</Title>
|
||||
<Title order={5} mb="sm" c="brand">{t.sections.selections}</Title>
|
||||
<Divider mb="md" />
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldSelect
|
||||
@@ -93,22 +151,185 @@ export default function AllFieldsDemo() {
|
||||
<FieldAutocomplete
|
||||
name="country"
|
||||
control={control}
|
||||
label="Country"
|
||||
label={t.fields.country}
|
||||
data={['Indonesia', 'Singapore', 'Malaysia']}
|
||||
/>
|
||||
<FieldMultiSelect
|
||||
name="categories"
|
||||
control={control}
|
||||
label="Categories"
|
||||
label={t.fields.categories}
|
||||
data={['Electronics', 'Fashion', 'Food']}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldLocalSelect
|
||||
name="localSelect"
|
||||
control={control}
|
||||
label={t.fields.localSelect}
|
||||
placeholder={t.placeholders.selectComplexObject}
|
||||
options={[
|
||||
{ id: 1, name: 'Apple', type: 'Fruit' },
|
||||
{ id: 2, name: 'Carrot', type: 'Vegetable' },
|
||||
{ id: 3, name: 'Banana', type: 'Fruit' }
|
||||
]}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
clearable
|
||||
/>
|
||||
<FieldLocalSelect
|
||||
multiple
|
||||
name="multiLocalSelect"
|
||||
control={control}
|
||||
label={t.fields.multiLocalSelect}
|
||||
placeholder={t.placeholders.selectMultipleObjects}
|
||||
options={[
|
||||
{ id: 1, name: 'Red', hex: '#f00' },
|
||||
{ id: 2, name: 'Green', hex: '#0f0' },
|
||||
{ id: 3, name: 'Blue', hex: '#00f' }
|
||||
]}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `${item.name} (${item.hex})`}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldAsyncSelect
|
||||
name="asyncSelect"
|
||||
control={control}
|
||||
label={t.fields.asyncSelectMock}
|
||||
placeholder={t.placeholders.searchPokemon}
|
||||
loadOptions={loadMockPokemonOptions}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
clearable
|
||||
/>
|
||||
<FieldAsyncSelect
|
||||
multiple
|
||||
name="multiAsyncSelect"
|
||||
control={control}
|
||||
label={t.fields.multiAsyncSelect}
|
||||
placeholder={t.placeholders.selectMultiplePokemon}
|
||||
loadOptions={loadMockPokemonOptions}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldAsyncSelect
|
||||
name="realPokeSelect"
|
||||
control={control}
|
||||
label={t.fields.realPokeSingle}
|
||||
placeholder={t.placeholders.scrollDeduplication}
|
||||
loadOptions={loadRealPokemonOptions}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
clearable
|
||||
/>
|
||||
<FieldAsyncSelect
|
||||
multiple
|
||||
name="multiRealPokeSelect"
|
||||
control={control}
|
||||
label={t.fields.realPokeMulti}
|
||||
placeholder={t.placeholders.scrollDeduplication}
|
||||
loadOptions={loadRealPokemonOptions}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<FieldTagsInput name="tags" control={control} label={t.fields.tags} />
|
||||
|
||||
<Title order={5} mb="sm" mt="lg" c="brand">{t.sections.advancedObjectSelects}</Title>
|
||||
<Divider mb="md" />
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldLocalSelect
|
||||
name="localSelectEmpty"
|
||||
control={control}
|
||||
label={t.fields.localEmpty}
|
||||
options={MOCK_VENDORS}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
/>
|
||||
<FieldLocalSelect
|
||||
name="localSelectPrefilled"
|
||||
control={control}
|
||||
label={t.fields.localPrefilled}
|
||||
options={MOCK_VENDORS}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldAsyncSelect
|
||||
name="asyncSelectEmpty"
|
||||
control={control}
|
||||
label={t.fields.asyncEmpty}
|
||||
loadOptions={loadMockVendorsOptions}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
/>
|
||||
<FieldAsyncSelect
|
||||
name="asyncSelectPrefilled"
|
||||
control={control}
|
||||
label={t.fields.asyncPrefilled}
|
||||
loadOptions={loadMockVendorsOptions}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
defaultOptions={[{ id: 999, code: 'ASYNC-99', name: 'Pre-loaded Async Vendor' }]}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Title order={5} mb="sm" mt="lg" c="brand">{t.sections.multiSelectEditMode}</Title>
|
||||
<Divider mb="md" />
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldLocalSelect
|
||||
multiple
|
||||
name="localMultiPrefilled"
|
||||
control={control}
|
||||
label={t.fields.localMultiPrefilled}
|
||||
options={MOCK_VENDORS}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
/>
|
||||
<FieldAsyncSelect
|
||||
multiple
|
||||
name="asyncMultiPrefilled"
|
||||
control={control}
|
||||
label={t.fields.asyncMultiPrefilled}
|
||||
loadOptions={loadMockVendorsOptions}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Title order={5} mb="sm" mt="lg" c="brand">{t.sections.richTextEditor}</Title>
|
||||
<Divider mb="md" />
|
||||
<FieldRichTextEditor
|
||||
name="richTextEmpty"
|
||||
control={control}
|
||||
label={t.fields.richTextEmpty}
|
||||
description={t.descriptions.freshTipTap}
|
||||
/>
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<FieldRichTextEditor
|
||||
name="richTextPrefilled"
|
||||
control={control}
|
||||
label={t.fields.richTextPrefilled}
|
||||
description={t.descriptions.htmlStringLoaded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- Toggles & Choices --- */}
|
||||
<div>
|
||||
<Title order={5} mb="sm" c="brand">Toggles & Choices</Title>
|
||||
<Title order={5} mb="sm" c="brand">{t.sections.togglesAndChoices}</Title>
|
||||
<Divider mb="md" />
|
||||
<Group mb="md">
|
||||
<FieldCheckbox name="terms" control={control} label={t.fields.terms} />
|
||||
@@ -151,11 +372,11 @@ export default function AllFieldsDemo() {
|
||||
|
||||
{/* --- Ranges & Specialized --- */}
|
||||
<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" />
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<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 grow align="flex-start" mb="md">
|
||||
<FieldColorInput name="themeColor" control={control} label={t.fields.themeColor} />
|
||||
|
||||
+139
-12
@@ -1,12 +1,50 @@
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button, Paper, Title, Divider, Stack, Code, Alert } from '@repo/ui/components';
|
||||
import { FieldTextInput, FieldSelect, FieldSwitch } from '@repo/ui/form';
|
||||
import { Button, Paper, Title, Divider, Stack, Code, Alert, TypographyStylesProvider } from '@repo/ui/components';
|
||||
import { FieldTextInput, FieldSelect, FieldSwitch, FieldLocalSelect, FieldAsyncSelect, FieldRichTextEditor } from '@repo/ui/form';
|
||||
import { useConditionalField } from '@repo/ui/hooks';
|
||||
import { compose, required, emailValidator } from '@repo/ui/validators';
|
||||
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
|
||||
import { Info } from 'lucide-react';
|
||||
import { useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
|
||||
interface Region {
|
||||
id: string;
|
||||
code: string;
|
||||
taxRate: number;
|
||||
}
|
||||
|
||||
interface Warehouse {
|
||||
id: string;
|
||||
regionId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const REGIONS: Region[] = [
|
||||
{ id: 'R1', code: 'APAC', taxRate: 0.1 },
|
||||
{ id: 'R2', code: 'EMEA', taxRate: 0.2 }
|
||||
];
|
||||
|
||||
const mockFetchWarehouses = async (regionIds: string[], search: string, page: number) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const allWarehouses: Warehouse[] = [
|
||||
{ id: 'W1', regionId: 'R1', name: 'Singapore Hub' },
|
||||
{ id: 'W2', regionId: 'R1', name: 'Tokyo Depot' },
|
||||
{ id: 'W3', regionId: 'R2', name: 'London Central' },
|
||||
{ id: 'W4', regionId: 'R2', name: 'Berlin Storage' },
|
||||
];
|
||||
|
||||
const filtered = allWarehouses.filter(w => regionIds.includes(w.regionId) && w.name.toLowerCase().includes(search.toLowerCase()));
|
||||
const pageSize = 10;
|
||||
const start = (page - 1) * pageSize;
|
||||
const paginated = filtered.slice(start, start + pageSize);
|
||||
|
||||
return {
|
||||
options: paginated,
|
||||
hasMore: start + pageSize < filtered.length
|
||||
};
|
||||
};
|
||||
|
||||
export default function ReactiveWatchDemo() {
|
||||
const t = useFormDemoTranslation();
|
||||
@@ -14,10 +52,10 @@ export default function ReactiveWatchDemo() {
|
||||
// Define atomic validators for conditional fields
|
||||
const taxIdValidator = compose(z.string(), required(t.watch.corporateTaxId));
|
||||
const spouseNameValidator = compose(z.string(), required(t.watch.spouseName));
|
||||
const newsletterEmailValidator = compose(z.string(), required('Newsletter Email'), emailValidator());
|
||||
const roleValidator = compose(z.string(), required('Role'));
|
||||
const newsletterEmailValidator = compose(z.string(), required(t.fields.email), emailValidator());
|
||||
const roleValidator = compose(z.string(), required(t.fields.role));
|
||||
|
||||
const reactiveSchema = z
|
||||
const reactiveSchema = useMemo(() => z
|
||||
.object({
|
||||
userType: z.enum(['PERSONAL', 'CORPORATE']),
|
||||
corporateTaxId: z.string().optional(),
|
||||
@@ -27,6 +65,9 @@ export default function ReactiveWatchDemo() {
|
||||
newsletterEmail: z.string().optional(),
|
||||
department: z.string().optional(),
|
||||
role: z.string().optional(),
|
||||
regions: z.array(z.object({ id: z.string(), code: z.string(), taxRate: z.number() })).optional(),
|
||||
warehouses: z.array(z.object({ id: z.string(), regionId: z.string(), name: z.string() })).optional(),
|
||||
richTextLive: z.string().optional(),
|
||||
})
|
||||
.and(
|
||||
z.discriminatedUnion('userType', [
|
||||
@@ -51,7 +92,7 @@ export default function ReactiveWatchDemo() {
|
||||
z.object({ department: z.string().min(1), role: roleValidator }),
|
||||
z.object({ department: z.union([z.literal(''), z.undefined(), z.null()]).optional() }),
|
||||
]),
|
||||
);
|
||||
), [t, taxIdValidator, spouseNameValidator, newsletterEmailValidator, roleValidator]);
|
||||
|
||||
const { control, handleSubmit, setValue, unregister, clearErrors } = useForm<any>({
|
||||
resolver: zodResolver(reactiveSchema as any),
|
||||
@@ -64,6 +105,12 @@ export default function ReactiveWatchDemo() {
|
||||
newsletterEmail: '',
|
||||
department: '',
|
||||
role: '',
|
||||
regions: [{ id: 'R1', code: 'APAC', taxRate: 0.1 }],
|
||||
warehouses: [
|
||||
{ id: 'W-99', regionId: 'R1', name: 'APAC Central Hub' },
|
||||
{ id: 'W-98', regionId: 'R1', name: 'APAC Backup Hub' }
|
||||
],
|
||||
richTextLive: "<p>Start typing to see the live preview...</p>",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -73,6 +120,8 @@ export default function ReactiveWatchDemo() {
|
||||
const newsletter = useWatch({ control, name: 'newsletter' });
|
||||
const department = useWatch({ control, name: 'department' });
|
||||
const role = useWatch({ control, name: 'role' });
|
||||
const regions = useWatch({ control, name: 'regions' });
|
||||
const watchedRichTextLive = useWatch({ control, name: 'richTextLive' });
|
||||
|
||||
// Use the custom hook to cleanly unregister and reset fields when hidden
|
||||
useConditionalField({
|
||||
@@ -133,6 +182,27 @@ export default function ReactiveWatchDemo() {
|
||||
defaultValue: '',
|
||||
});
|
||||
|
||||
const isMounted = useRef(false);
|
||||
const prevRegionIds = useRef<string[]>(regions?.map((r: Region) => r.id) || []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted.current) {
|
||||
isMounted.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIds = regions?.map((r: Region) => r.id) || [];
|
||||
const prevIds = prevRegionIds.current;
|
||||
|
||||
const hasChanged = currentIds.length !== prevIds.length || currentIds.some((id: string) => !prevIds.includes(id));
|
||||
|
||||
if (hasChanged) {
|
||||
setValue('warehouses', []);
|
||||
clearErrors('warehouses');
|
||||
prevRegionIds.current = currentIds;
|
||||
}
|
||||
}, [regions, setValue, clearErrors]);
|
||||
|
||||
// Use watch only to display the JSON output at the bottom
|
||||
const allValues = useWatch({ control });
|
||||
|
||||
@@ -144,7 +214,7 @@ export default function ReactiveWatchDemo() {
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Title order={5} mb="sm" c="brand">
|
||||
Dynamic Fields & Validation
|
||||
{t.sections.reactiveWatchCascading}
|
||||
</Title>
|
||||
<Divider mb="sm" />
|
||||
|
||||
@@ -194,8 +264,8 @@ export default function ReactiveWatchDemo() {
|
||||
<FieldSelect
|
||||
name="department"
|
||||
control={control}
|
||||
label="Department"
|
||||
placeholder="Select a department"
|
||||
label={t.fields.department}
|
||||
placeholder={t.placeholders.selectComplexObject}
|
||||
data={[
|
||||
{ value: 'IT', label: 'Information Technology' },
|
||||
{ value: 'HR', label: 'Human Resources' },
|
||||
@@ -216,15 +286,72 @@ export default function ReactiveWatchDemo() {
|
||||
key={`role-select-${department}`}
|
||||
name="role"
|
||||
control={control}
|
||||
label="Role"
|
||||
placeholder="Select a role"
|
||||
label={t.fields.role}
|
||||
placeholder={t.placeholders.selectComplexObject}
|
||||
disabled={!department}
|
||||
data={currentRoleOptions}
|
||||
withAsterisk={!!department}
|
||||
/>
|
||||
|
||||
<Title order={5} mb="sm" c="brand" mt="lg">
|
||||
{t.sections.reactiveWatchCascading}
|
||||
</Title>
|
||||
<Divider mb="sm" />
|
||||
|
||||
<FieldLocalSelect<Region>
|
||||
multiple
|
||||
name="regions"
|
||||
control={control as any}
|
||||
label={t.fields.regions}
|
||||
options={REGIONS}
|
||||
valueKey="id"
|
||||
labelKey="code"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<FieldAsyncSelect<Warehouse>
|
||||
multiple
|
||||
key={`warehouse-select-${regions?.map((r: any) => r.id).join(',')}`}
|
||||
name="warehouses"
|
||||
control={control as any}
|
||||
label={t.fields.warehouses}
|
||||
disabled={!regions || regions.length === 0}
|
||||
loadOptions={useCallback(async (search, page) => {
|
||||
if (!regions || regions.length === 0) return { options: [], hasMore: false };
|
||||
return mockFetchWarehouses(regions.map((r: any) => r.id), search, page);
|
||||
}, [regions])}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.id}] ${item.name}`}
|
||||
clearable
|
||||
/>
|
||||
|
||||
{regions && regions.length > 0 && (
|
||||
<Alert mt="sm" color="teal">
|
||||
{t.descriptions.selectedRegionsTax} {regions.map((r: any) => `${r.code} (${(r.taxRate * 100).toFixed(0)}%)`).join(', ')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Title order={5} mb="sm" c="brand" mt="lg">
|
||||
{t.sections.reactiveRichTextPreview}
|
||||
</Title>
|
||||
<Divider mb="sm" />
|
||||
|
||||
<FieldRichTextEditor
|
||||
name="richTextLive"
|
||||
control={control as any}
|
||||
label={t.fields.liveEditor}
|
||||
description={t.descriptions.typeToSeePreview}
|
||||
/>
|
||||
|
||||
<Paper p="md" withBorder radius="md" mt="sm">
|
||||
<Title order={6} mb="xs">{t.sections.liveHtmlPreview}</Title>
|
||||
<TypographyStylesProvider>
|
||||
<div dangerouslySetInnerHTML={{ __html: watchedRichTextLive }} />
|
||||
</TypographyStylesProvider>
|
||||
</Paper>
|
||||
|
||||
<Button type="submit" mt="md">
|
||||
{t.common?.submit || 'Submit Reactive Form'}
|
||||
{t.common.submitReactive}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
+150
-10
@@ -1,10 +1,58 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button, Paper, Title, Group, Stack, Code, Divider } from '@repo/ui/components';
|
||||
import {
|
||||
FieldTextInput, FieldPasswordInput, FieldNumberInput
|
||||
FieldTextInput, FieldPasswordInput, FieldNumberInput,
|
||||
FieldLocalSelect, FieldAsyncSelect, FieldRichTextEditor
|
||||
} from '@repo/ui/form';
|
||||
import type { LoadOptionsFn } from '@repo/ui/form';
|
||||
|
||||
interface Department {
|
||||
code: string;
|
||||
name: string;
|
||||
costCenter: string;
|
||||
}
|
||||
|
||||
interface Assignee {
|
||||
id: number;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const MOCK_DEPARTMENTS: Department[] = [
|
||||
{ code: 'IT', name: 'Information Technology', costCenter: 'CC-100' },
|
||||
{ code: 'HR', name: 'Human Resources', costCenter: 'CC-200' },
|
||||
{ code: 'FIN', name: 'Finance', costCenter: 'CC-300' },
|
||||
];
|
||||
|
||||
const mockFetchUsers: LoadOptionsFn<Assignee> = async (search, page) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const allUsers = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
email: `user${i + 1}@company.com`
|
||||
}));
|
||||
const filtered = allUsers.filter(u => u.email.toLowerCase().includes(search.toLowerCase()));
|
||||
const pageSize = 5;
|
||||
const start = (page - 1) * pageSize;
|
||||
const paginated = filtered.slice(start, start + pageSize);
|
||||
|
||||
return {
|
||||
options: paginated,
|
||||
hasMore: start + pageSize < filtered.length
|
||||
};
|
||||
};
|
||||
|
||||
const MOCK_VENDORS = [
|
||||
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
|
||||
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' },
|
||||
];
|
||||
|
||||
const mockFetchVendors: LoadOptionsFn<any> = async (search, _page) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const filtered = MOCK_VENDORS.filter(v => v.name.toLowerCase().includes(search.toLowerCase()) || v.code.toLowerCase().includes(search.toLowerCase()));
|
||||
return { options: filtered, hasMore: false };
|
||||
};
|
||||
import {
|
||||
compose, required, rangeLength,
|
||||
positiveNumber, simplePassword,
|
||||
@@ -16,14 +64,20 @@ export default function ValidationBankDemo() {
|
||||
const t = useFormDemoTranslation();
|
||||
|
||||
// Compose the Zod schema using the atomic validators
|
||||
const validationSchema = z.object({
|
||||
const validationSchema = useMemo(() => z.object({
|
||||
username: compose(z.string(), required(t.fields.customerName), rangeLength(3, 15, t.fields.customerName)),
|
||||
simplePass: compose(z.string(), required(t.validation.simplePassword), simplePassword(6)),
|
||||
complexPass: compose(z.string(), required(t.validation.complexPassword), complexPassword(8)),
|
||||
age: compose(z.number(), required(t.fields.age), rangeValue(18, 65, t.fields.age)),
|
||||
score: compose(z.number(), required(t.validation.score), positiveNumber(t.validation.score)),
|
||||
phone: compose(z.string(), required(t.validation.phone), phoneValidator())
|
||||
});
|
||||
phone: compose(z.string(), required(t.validation.phone), phoneValidator()),
|
||||
department: z.object({ code: z.string(), name: z.string() }, { required_error: t.errors.departmentRequired }),
|
||||
assignees: z.array(z.object({ id: z.number(), email: z.string() })).min(2, t.errors.min2Assignees),
|
||||
prefilledVendor: z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }, { required_error: t.errors.vendorRequired }),
|
||||
emptyVendor: z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }, { required_error: t.errors.vendorRequired }),
|
||||
prefilledAsyncMulti: z.array(z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() })).min(1, t.errors.min1Vendor),
|
||||
richTextNotes: z.string().min(15, t.errors.notesMin15),
|
||||
}), [t]);
|
||||
|
||||
type ValidationFormValues = z.infer<typeof validationSchema>;
|
||||
|
||||
@@ -35,7 +89,16 @@ export default function ValidationBankDemo() {
|
||||
complexPass: '',
|
||||
age: undefined as any,
|
||||
score: undefined as any,
|
||||
phone: ''
|
||||
phone: '',
|
||||
department: null as any,
|
||||
assignees: [],
|
||||
prefilledVendor: { id: 'V1', code: 'VN-01', name: 'Vendor One' } as any,
|
||||
emptyVendor: null as any,
|
||||
prefilledAsyncMulti: [
|
||||
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
|
||||
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' }
|
||||
] as any,
|
||||
richTextNotes: '',
|
||||
}
|
||||
});
|
||||
|
||||
@@ -47,7 +110,7 @@ export default function ValidationBankDemo() {
|
||||
<Paper p="xl" withBorder radius="md">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<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" />
|
||||
|
||||
<FieldTextInput
|
||||
@@ -63,14 +126,14 @@ export default function ValidationBankDemo() {
|
||||
name="simplePass"
|
||||
control={control}
|
||||
label={t.validation.simplePassword}
|
||||
description="Min 6 chars"
|
||||
description={t.descriptions.min6Chars}
|
||||
withAsterisk
|
||||
/>
|
||||
<FieldPasswordInput
|
||||
name="complexPass"
|
||||
control={control}
|
||||
label={t.validation.complexPassword}
|
||||
description="Min 8, 1 uppercase, 1 number, 1 special"
|
||||
description={t.descriptions.min8Complex}
|
||||
withAsterisk
|
||||
/>
|
||||
</Group>
|
||||
@@ -87,7 +150,7 @@ export default function ValidationBankDemo() {
|
||||
name="score"
|
||||
control={control}
|
||||
label={t.validation.score}
|
||||
description="Must be > 0"
|
||||
description={t.descriptions.mustBePositive}
|
||||
withAsterisk
|
||||
/>
|
||||
</Group>
|
||||
@@ -96,10 +159,87 @@ export default function ValidationBankDemo() {
|
||||
name="phone"
|
||||
control={control}
|
||||
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
|
||||
/>
|
||||
|
||||
<Button type="submit" mt="md">{t.common.submit}</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
@@ -6,9 +6,26 @@
|
||||
},
|
||||
"common": {
|
||||
"submit": "Submit Data",
|
||||
"submitReactive": "Submit Reactive Form",
|
||||
"reset": "Reset Form",
|
||||
"submittedData": "Submitted Data"
|
||||
},
|
||||
"sections": {
|
||||
"validationBankTitle": "Validation Bank (Atomic Registry)",
|
||||
"objectLevelValidations": "Object Level Validations (Local & Async)",
|
||||
"validatedPrefilledObjects": "Validated Prefilled Objects",
|
||||
"richTextValidations": "Rich Text Editor Validations",
|
||||
"textAndNumbers": "Text & Numbers",
|
||||
"selections": "Selections",
|
||||
"advancedObjectSelects": "Advanced Object Selects (Custom Labels & Default Values)",
|
||||
"multiSelectEditMode": "Multi-Select Edit Mode (No defaultOptions fallback)",
|
||||
"richTextEditor": "Rich Text Editor (TipTap)",
|
||||
"togglesAndChoices": "Toggles & Choices",
|
||||
"rangesAndSpecialized": "Ranges & Specialized",
|
||||
"reactiveWatchCascading": "Reactive Watch (Cascading)",
|
||||
"reactiveRichTextPreview": "Reactive Rich Text Preview",
|
||||
"liveHtmlPreview": "Live HTML Preview Render"
|
||||
},
|
||||
"fields": {
|
||||
"customerName": "Customer Name",
|
||||
"email": "Email Address",
|
||||
@@ -26,7 +43,60 @@
|
||||
"orderType": "Order Type",
|
||||
"quantity": "Quantity",
|
||||
"fabricColor": "Fabric Color",
|
||||
"pin": "Security PIN"
|
||||
"pin": "Security PIN",
|
||||
"department": "Department",
|
||||
"assignees": "Assignees",
|
||||
"emptyVendor": "Empty Vendor",
|
||||
"prefilledVendor": "Prefilled Vendor",
|
||||
"prefilledAsyncMulti": "Prefilled Async Multi (No Fallback)",
|
||||
"importantNotes": "Important Notes",
|
||||
"country": "Country",
|
||||
"categories": "Categories",
|
||||
"localSelect": "Local Select",
|
||||
"multiLocalSelect": "Multi Local Select",
|
||||
"asyncSelectMock": "Async Select (Mock API)",
|
||||
"multiAsyncSelect": "Multi Async Select",
|
||||
"realPokeSingle": "Real PokeAPI (Single - Tests Deduplication)",
|
||||
"realPokeMulti": "Real PokeAPI (Multi - Tests Deduplication)",
|
||||
"localEmpty": "Local Empty",
|
||||
"localPrefilled": "Local Prefilled",
|
||||
"asyncEmpty": "Async Empty",
|
||||
"asyncPrefilled": "Async Prefilled (Edit Mode)",
|
||||
"localMultiPrefilled": "Local Multi Prefilled",
|
||||
"asyncMultiPrefilled": "Async Multi Prefilled (Ghost Items)",
|
||||
"richTextEmpty": "Rich Text (Empty)",
|
||||
"richTextPrefilled": "Rich Text (Prefilled / Edit Mode)",
|
||||
"priceRange": "Price Range",
|
||||
"role": "Role",
|
||||
"regions": "Regions",
|
||||
"warehouses": "Warehouses",
|
||||
"liveEditor": "Live Editor"
|
||||
},
|
||||
"placeholders": {
|
||||
"selectComplexObject": "Select a complex object",
|
||||
"selectMultipleObjects": "Select multiple objects",
|
||||
"searchPokemon": "Search pokemon...",
|
||||
"selectMultiplePokemon": "Select multiple pokemon...",
|
||||
"scrollDeduplication": "Scroll to test deduplication..."
|
||||
},
|
||||
"descriptions": {
|
||||
"min6Chars": "Min 6 chars",
|
||||
"min8Complex": "Min 8, 1 uppercase, 1 number, 1 special",
|
||||
"mustBePositive": "Must be > 0",
|
||||
"formatPhone": "Format: +62...",
|
||||
"zodMinLengthString": "This uses Zod minimum length string validation",
|
||||
"freshTipTap": "A fresh TipTap editor instance",
|
||||
"htmlStringLoaded": "HTML string successfully loaded from default values",
|
||||
"typeToSeePreview": "Type to see instantaneous reactive rendering below",
|
||||
"selectedRegionsTax": "Selected regions tax rates:"
|
||||
},
|
||||
"errors": {
|
||||
"departmentRequired": "Department is required",
|
||||
"vendorRequired": "Vendor is required",
|
||||
"min2Assignees": "Select at least 2 assignees",
|
||||
"min1Vendor": "Select at least 1 vendor",
|
||||
"notesMin15": "Notes must be at least 15 characters long (including HTML tags)",
|
||||
"selectRegionFirst": "Select a region first to load warehouses"
|
||||
},
|
||||
"validation": {
|
||||
"simplePassword": "Simple Password",
|
||||
|
||||
@@ -6,9 +6,26 @@
|
||||
},
|
||||
"common": {
|
||||
"submit": "Kirim Data",
|
||||
"submitReactive": "Kirim Form Reaktif",
|
||||
"reset": "Reset Form",
|
||||
"submittedData": "Data Terkirim"
|
||||
},
|
||||
"sections": {
|
||||
"validationBankTitle": "Bank Validasi (Registri Atomik)",
|
||||
"objectLevelValidations": "Validasi Tingkat Objek (Lokal & Async)",
|
||||
"validatedPrefilledObjects": "Objek Terisi yang Divalidasi",
|
||||
"richTextValidations": "Validasi Rich Text Editor",
|
||||
"textAndNumbers": "Teks & Angka",
|
||||
"selections": "Pilihan",
|
||||
"advancedObjectSelects": "Pemilihan Objek Tingkat Lanjut (Label Kustom & Nilai Default)",
|
||||
"multiSelectEditMode": "Mode Edit Multi-Select (Tanpa fallback defaultOptions)",
|
||||
"richTextEditor": "Rich Text Editor (TipTap)",
|
||||
"togglesAndChoices": "Tombol Sakelar & Pilihan",
|
||||
"rangesAndSpecialized": "Rentang & Khusus",
|
||||
"reactiveWatchCascading": "Reactive Watch (Berjenjang)",
|
||||
"reactiveRichTextPreview": "Pratinjau Rich Text Reaktif",
|
||||
"liveHtmlPreview": "Render Pratinjau HTML Langsung"
|
||||
},
|
||||
"fields": {
|
||||
"customerName": "Nama Pelanggan",
|
||||
"email": "Alamat Email",
|
||||
@@ -26,7 +43,60 @@
|
||||
"orderType": "Tipe Pesanan",
|
||||
"quantity": "Jumlah",
|
||||
"fabricColor": "Warna Kain",
|
||||
"pin": "PIN Keamanan"
|
||||
"pin": "PIN Keamanan",
|
||||
"department": "Departemen",
|
||||
"assignees": "Penerima Tugas",
|
||||
"emptyVendor": "Vendor Kosong",
|
||||
"prefilledVendor": "Vendor Terisi",
|
||||
"prefilledAsyncMulti": "Multi Async Terisi (Tanpa Fallback)",
|
||||
"importantNotes": "Catatan Penting",
|
||||
"country": "Negara",
|
||||
"categories": "Kategori",
|
||||
"localSelect": "Pilihan Lokal",
|
||||
"multiLocalSelect": "Pilihan Lokal Multi",
|
||||
"asyncSelectMock": "Pilihan Async (Mock API)",
|
||||
"multiAsyncSelect": "Pilihan Async Multi",
|
||||
"realPokeSingle": "API Pokemon Asli (Tunggal - Uji Deduplikasi)",
|
||||
"realPokeMulti": "API Pokemon Asli (Multi - Uji Deduplikasi)",
|
||||
"localEmpty": "Lokal Kosong",
|
||||
"localPrefilled": "Lokal Terisi",
|
||||
"asyncEmpty": "Async Kosong",
|
||||
"asyncPrefilled": "Async Terisi (Mode Edit)",
|
||||
"localMultiPrefilled": "Multi Lokal Terisi",
|
||||
"asyncMultiPrefilled": "Multi Async Terisi (Item Hantu)",
|
||||
"richTextEmpty": "Rich Text (Kosong)",
|
||||
"richTextPrefilled": "Rich Text (Terisi / Mode Edit)",
|
||||
"priceRange": "Rentang Harga",
|
||||
"role": "Peran",
|
||||
"regions": "Wilayah",
|
||||
"warehouses": "Gudang",
|
||||
"liveEditor": "Editor Langsung"
|
||||
},
|
||||
"placeholders": {
|
||||
"selectComplexObject": "Pilih objek yang kompleks",
|
||||
"selectMultipleObjects": "Pilih beberapa objek",
|
||||
"searchPokemon": "Cari pokemon...",
|
||||
"selectMultiplePokemon": "Pilih beberapa pokemon...",
|
||||
"scrollDeduplication": "Gulir untuk menguji deduplikasi..."
|
||||
},
|
||||
"descriptions": {
|
||||
"min6Chars": "Minimal 6 karakter",
|
||||
"min8Complex": "Min 8, 1 huruf besar, 1 angka, 1 karakter khusus",
|
||||
"mustBePositive": "Harus > 0",
|
||||
"formatPhone": "Format: +62...",
|
||||
"zodMinLengthString": "Ini menggunakan validasi panjang string minimum Zod",
|
||||
"freshTipTap": "Instance editor TipTap yang baru",
|
||||
"htmlStringLoaded": "String HTML berhasil dimuat dari nilai default",
|
||||
"typeToSeePreview": "Ketik untuk melihat render reaktif seketika di bawah",
|
||||
"selectedRegionsTax": "Tarif pajak wilayah yang dipilih:"
|
||||
},
|
||||
"errors": {
|
||||
"departmentRequired": "Departemen wajib diisi",
|
||||
"vendorRequired": "Vendor wajib diisi",
|
||||
"min2Assignees": "Pilih minimal 2 penerima tugas",
|
||||
"min1Vendor": "Pilih minimal 1 vendor",
|
||||
"notesMin15": "Catatan minimal harus terdiri dari 15 karakter (termasuk tag HTML)",
|
||||
"selectRegionFirst": "Pilih wilayah terlebih dahulu untuk memuat gudang"
|
||||
},
|
||||
"validation": {
|
||||
"simplePassword": "Sandi Sederhana",
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
Paper,
|
||||
} from '@repo/ui/components';
|
||||
import { ShieldCheck, Database, Lock, Layout, Activity, Printer, FileText } from 'lucide-react';
|
||||
import { Globe } from 'lucide-react';
|
||||
import { useTranslation } from '@repo/core-i18n';
|
||||
import PrinterList from './printer-list';
|
||||
import ExamplePage from './example/example.page';
|
||||
import EventsDemoPage from './events-demo';
|
||||
@@ -38,6 +40,7 @@ interface ShowcaseViewProps {
|
||||
|
||||
export default function ShowcaseView({ colorScheme, setColorScheme, density, setDensity }: ShowcaseViewProps) {
|
||||
const [activeTab, setActiveTab] = useState<string | null>('ui-components');
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
// Mock data for the table
|
||||
const tableData = [
|
||||
@@ -144,6 +147,18 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
|
||||
{getSubtitle()}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Select
|
||||
w={180}
|
||||
size="sm"
|
||||
variant="filled"
|
||||
leftSection={<Globe size={16} />}
|
||||
data={[
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'id', label: 'Bahasa Indonesia' },
|
||||
]}
|
||||
value={i18n.resolvedLanguage || i18n.language}
|
||||
onChange={(val) => val && i18n.changeLanguage(val)}
|
||||
/>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
|
||||
@@ -122,7 +122,8 @@ packages/ui/src/components/Form/
|
||||
├── tags-input.field.tsx # FieldTagsInput
|
||||
├── chip-group.field.tsx # FieldChipGroup
|
||||
├── 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:
|
||||
@@ -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
|
||||
|
||||
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 |
|
||||
| `FieldColorInput` | `ColorInput` | Color | Color picker with text input |
|
||||
| `FieldColorPicker` | `ColorPicker` | Color | Color picker only (uses `Input.Wrapper`) |
|
||||
| `FieldFileInput` | `FileInput` | File | File upload input |
|
||||
| `FieldLocalSelect` | `Select / MultiSelect` | Selection | Stores full `T` or `T[]` object in RHF instead of string ID. Accepts static `options` array with `valueKey`/`labelKey` mapping. |
|
||||
| `FieldAsyncSelect` | `Select / MultiSelect` | Selection | Async paginated object select with IoC `loadOptions` callback. Supports search-keyed caching, `defaultOptions` for edit forms, and automatic pagination detection. |
|
||||
| `FieldFileInput` | `<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",
|
||||
"@mantine/core": "^8.3.15",
|
||||
"@mantine/hooks": "^8.3.15",
|
||||
"@mantine/tiptap": "^9.3.2",
|
||||
"@repo/core-i18n": "workspace:*",
|
||||
"@repo/utils": "workspace:*",
|
||||
"@tiptap/extension-link": "^3.27.1",
|
||||
"@tiptap/extension-text-align": "^3.27.1",
|
||||
"@tiptap/extension-underline": "^3.27.1",
|
||||
"@tiptap/pm": "^3.27.1",
|
||||
"@tiptap/react": "^3.27.1",
|
||||
"@tiptap/starter-kit": "^3.27.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"react-hook-form": "^7.56.4",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
|
||||
@@ -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 { FieldPinInput } from './fields/pin-input.field';
|
||||
export { FieldAutocomplete } from './fields/autocomplete.field';
|
||||
export { FieldRichTextEditor } from './fields/rich-text.field';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selection Fields
|
||||
@@ -37,6 +38,25 @@ export { FieldMultiSelect } from './fields/multi-select.field';
|
||||
export { FieldNativeSelect } from './fields/native-select.field';
|
||||
export { FieldTagsInput } from './fields/tags-input.field';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local & Async Selection Fields
|
||||
// ---------------------------------------------------------------------------
|
||||
export { FieldLocalSelect } from './fields/local-select.field';
|
||||
export type { FieldLocalSelectProps } from './fields/local-select.field';
|
||||
export { FieldAsyncSelect } from './fields/async-select.field';
|
||||
export type { FieldAsyncSelectProps } from './fields/async-select.field';
|
||||
|
||||
// Standalone engines (no RHF dependency) for use outside form contexts
|
||||
export { LocalSelect, AsyncSelect } from './custom';
|
||||
export type {
|
||||
LocalSelectProps,
|
||||
AsyncSelectProps,
|
||||
LocalSelectBaseProps,
|
||||
SelectFilterContext,
|
||||
LoadOptionsResponse,
|
||||
LoadOptionsFn,
|
||||
} from './custom';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Toggle / Boolean Fields
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 {
|
||||
useController,
|
||||
type FieldPath,
|
||||
@@ -6,83 +6,8 @@ import {
|
||||
type UseControllerProps,
|
||||
} from 'react-hook-form';
|
||||
import { Input } from '@mantine/core';
|
||||
import { useTranslation } from '@repo/core-i18n';
|
||||
import type { ZodI18nPayload, WithRHFOptions } from './types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: Attempt to parse a Zod error message as a JSON i18n payload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function tryParseI18nPayload(message: string): ZodI18nPayload | null {
|
||||
// Quick guard: JSON payloads always start with '{'
|
||||
if (!message.startsWith('{')) return null;
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(message);
|
||||
|
||||
if (
|
||||
typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
'key' in parsed &&
|
||||
typeof (parsed as ZodI18nPayload).key === 'string'
|
||||
) {
|
||||
return parsed as ZodI18nPayload;
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON — this is expected for plain string error messages
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useTranslatedError — Hook that resolves a raw error message into a
|
||||
// user-facing translated string.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useTranslatedError(rawMessage: string | undefined): string | undefined {
|
||||
// Always call useTranslation — React hook rules require stable call order.
|
||||
// The 'validation' namespace is used for Zod error keys.
|
||||
// Falls back to 'common' automatically via i18next's ns resolution.
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
return useMemo(() => {
|
||||
if (!rawMessage) return undefined;
|
||||
|
||||
const payload = tryParseI18nPayload(rawMessage);
|
||||
|
||||
if (payload) {
|
||||
// Attempt to translate. If the key exists in i18n resources, we get
|
||||
// the translated string. Otherwise i18next returns the key itself,
|
||||
// and we fall back to the raw Zod message.
|
||||
const translated = t(payload.key, {
|
||||
...payload.values,
|
||||
ns: 'validation',
|
||||
defaultValue: payload.key, // fallback to the key itself
|
||||
});
|
||||
|
||||
// If i18next couldn't find the key (returned the key unchanged),
|
||||
// try without namespace, then fall back to the raw Zod message.
|
||||
if (translated === payload.key) {
|
||||
const commonAttempt = t(payload.key, {
|
||||
...payload.values,
|
||||
defaultValue: rawMessage,
|
||||
});
|
||||
return commonAttempt;
|
||||
}
|
||||
|
||||
return translated;
|
||||
}
|
||||
|
||||
// Not a JSON payload — check if the raw message itself is a translation key
|
||||
if (i18n.exists(rawMessage, { ns: 'validation' })) {
|
||||
return t(rawMessage, { ns: 'validation' });
|
||||
}
|
||||
|
||||
// Plain string error message — pass through as-is
|
||||
return rawMessage;
|
||||
}, [rawMessage, t, i18n]);
|
||||
}
|
||||
import type { WithRHFOptions } from './types';
|
||||
import { useTranslatedError } from './useTranslatedError';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// withRHF — Higher-Order Component Factory
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export * from '@mantine/core';
|
||||
export {
|
||||
List,
|
||||
TypographyStylesProvider,
|
||||
} from '@mantine/core';
|
||||
|
||||
export * from './Form';
|
||||
export * from './system-pages/coming-soon';
|
||||
|
||||
+60
-28
@@ -1,19 +1,35 @@
|
||||
/* =========================================
|
||||
1. CORE IMPORTS & TAILWIND CONFIG
|
||||
========================================= */
|
||||
/* Import Mantine core and TipTap extensions */
|
||||
@import '@mantine/core/styles.css';
|
||||
@import '@mantine/tiptap/styles.css';
|
||||
|
||||
/* Initialize Tailwind CSS v4 engine */
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* Instruct Tailwind to scan the src directory for utility class usage */
|
||||
@source "../src";
|
||||
|
||||
@theme {
|
||||
/* =========================================
|
||||
FONT FAMILY MAPPING (Mantine -> Tailwind)
|
||||
2. FONT FAMILY MAPPING
|
||||
Synchronizes Tailwind's typography utilities
|
||||
with Mantine's global font configurations.
|
||||
========================================= */
|
||||
--base-font-size: 13px;
|
||||
--font-sans: var(--mantine-font-family);
|
||||
--font-mono: var(--mantine-font-family-monospace);
|
||||
|
||||
/* =========================================
|
||||
1. COLORS (Strictly mapped to Mantine 0-9)
|
||||
3. COLOR SYSTEM (Mantine to Tailwind Sync)
|
||||
Maps Tailwind's 50-900 scale directly to
|
||||
Mantine's 0-9 scale for seamless theming.
|
||||
Usage: `bg-brand-500`, `text-error-700`
|
||||
========================================= */
|
||||
--color-brand-50: var(--mantine-color-brand-0);
|
||||
|
||||
/* Brand Colors */
|
||||
--color-brand-50: var(--mantine-color-brand-0);
|
||||
--color-brand-100: var(--mantine-color-brand-1);
|
||||
--color-brand-200: var(--mantine-color-brand-2);
|
||||
--color-brand-300: var(--mantine-color-brand-3);
|
||||
@@ -24,7 +40,8 @@
|
||||
--color-brand-800: var(--mantine-color-brand-8);
|
||||
--color-brand-900: var(--mantine-color-brand-9);
|
||||
|
||||
--color-error-50: var(--mantine-color-error-0);
|
||||
/* Error Colors (Red/Danger) */
|
||||
--color-error-50: var(--mantine-color-error-0);
|
||||
--color-error-100: var(--mantine-color-error-1);
|
||||
--color-error-200: var(--mantine-color-error-2);
|
||||
--color-error-300: var(--mantine-color-error-3);
|
||||
@@ -35,7 +52,8 @@
|
||||
--color-error-800: var(--mantine-color-error-8);
|
||||
--color-error-900: var(--mantine-color-error-9);
|
||||
|
||||
--color-warning-50: var(--mantine-color-warning-0);
|
||||
/* Warning Colors (Yellow/Orange) */
|
||||
--color-warning-50: var(--mantine-color-warning-0);
|
||||
--color-warning-100: var(--mantine-color-warning-1);
|
||||
--color-warning-200: var(--mantine-color-warning-2);
|
||||
--color-warning-300: var(--mantine-color-warning-3);
|
||||
@@ -46,7 +64,8 @@
|
||||
--color-warning-800: var(--mantine-color-warning-8);
|
||||
--color-warning-900: var(--mantine-color-warning-9);
|
||||
|
||||
--color-success-50: var(--mantine-color-success-0);
|
||||
/* Success Colors (Green) */
|
||||
--color-success-50: var(--mantine-color-success-0);
|
||||
--color-success-100: var(--mantine-color-success-1);
|
||||
--color-success-200: var(--mantine-color-success-2);
|
||||
--color-success-300: var(--mantine-color-success-3);
|
||||
@@ -57,7 +76,8 @@
|
||||
--color-success-800: var(--mantine-color-success-8);
|
||||
--color-success-900: var(--mantine-color-success-9);
|
||||
|
||||
--color-info-50: var(--mantine-color-info-0);
|
||||
/* Info Colors (Blue/Cyan) */
|
||||
--color-info-50: var(--mantine-color-info-0);
|
||||
--color-info-100: var(--mantine-color-info-1);
|
||||
--color-info-200: var(--mantine-color-info-2);
|
||||
--color-info-300: var(--mantine-color-info-3);
|
||||
@@ -69,22 +89,26 @@
|
||||
--color-info-900: var(--mantine-color-info-9);
|
||||
|
||||
/* =========================================
|
||||
2. SPACING & CONTAINERS
|
||||
4. SPACING, BREAKPOINTS & CONTAINERS
|
||||
Aligns Tailwind's padding/margin scale
|
||||
with Mantine's layout engine.
|
||||
========================================= */
|
||||
--spacing: 0.25rem;
|
||||
--spacing: 0.25rem; /* Base Tailwind unit (1 = 0.25rem) */
|
||||
|
||||
/* Core mapped to Mantine */
|
||||
/* Map core layout spacing to Mantine */
|
||||
--spacing-xs: var(--mantine-spacing-xs);
|
||||
--spacing-sm: var(--mantine-spacing-sm);
|
||||
--spacing-md: var(--mantine-spacing-md);
|
||||
--spacing-lg: var(--mantine-spacing-lg);
|
||||
--spacing-xl: var(--mantine-spacing-xl);
|
||||
|
||||
/* Standard Tailwind responsive breakpoints and container sizes */
|
||||
--breakpoint-sm: 40rem;
|
||||
--breakpoint-md: 48rem;
|
||||
--breakpoint-lg: 64rem;
|
||||
--breakpoint-xl: 80rem;
|
||||
--breakpoint-2xl: 96rem;
|
||||
|
||||
--container-3xs: 16rem;
|
||||
--container-2xs: 18rem;
|
||||
--container-xs: 20rem;
|
||||
@@ -100,17 +124,22 @@
|
||||
--container-7xl: 80rem;
|
||||
|
||||
/* =========================================
|
||||
3. TYPOGRAPHY
|
||||
5. TYPOGRAPHY SCALES
|
||||
Base sizes (xs to xl) inherit from Mantine.
|
||||
Extended sizes (2xl to 9xl) use static rems.
|
||||
========================================= */
|
||||
/* Core text sizes mapped to Mantine, extended kept static */
|
||||
--text-xs: var(--mantine-font-size-xs);
|
||||
--text-xs--line-height: calc(1 / 0.75);
|
||||
|
||||
--text-sm: var(--mantine-font-size-sm);
|
||||
--text-sm--line-height: calc(1.25 / 0.875);
|
||||
|
||||
--text-base: var(--mantine-font-size-md);
|
||||
--text-base--line-height: calc(1.5 / 1);
|
||||
|
||||
--text-lg: var(--mantine-font-size-lg);
|
||||
--text-lg--line-height: calc(1.75 / 1.125);
|
||||
|
||||
--text-xl: var(--mantine-font-size-xl);
|
||||
--text-xl--line-height: calc(1.75 / 1.25);
|
||||
|
||||
@@ -131,6 +160,7 @@
|
||||
--text-9xl: 8rem;
|
||||
--text-9xl--line-height: 1;
|
||||
|
||||
/* Font Weights */
|
||||
--font-weight-thin: 100;
|
||||
--font-weight-extralight: 200;
|
||||
--font-weight-light: 300;
|
||||
@@ -141,6 +171,7 @@
|
||||
--font-weight-extrabold: 800;
|
||||
--font-weight-black: 900;
|
||||
|
||||
/* Letter Spacing (Tracking) */
|
||||
--tracking-tighter: -0.05em;
|
||||
--tracking-tight: -0.025em;
|
||||
--tracking-normal: 0em;
|
||||
@@ -148,6 +179,7 @@
|
||||
--tracking-wider: 0.05em;
|
||||
--tracking-widest: 0.1em;
|
||||
|
||||
/* Line Height (Leading) */
|
||||
--leading-tight: 1.25;
|
||||
--leading-snug: 1.375;
|
||||
--leading-normal: 1.5;
|
||||
@@ -155,19 +187,23 @@
|
||||
--leading-loose: 2;
|
||||
|
||||
/* =========================================
|
||||
4. RADIUS
|
||||
6. BORDER RADIUS
|
||||
Inherits exact corner rounding from Mantine.
|
||||
========================================= */
|
||||
--radius-xs: var(--mantine-radius-xs);
|
||||
--radius-sm: var(--mantine-radius-sm);
|
||||
--radius-md: var(--mantine-radius-md);
|
||||
--radius-lg: var(--mantine-radius-lg);
|
||||
--radius-xl: var(--mantine-radius-xl);
|
||||
|
||||
--radius-2xl: 1rem;
|
||||
--radius-3xl: 1.5rem;
|
||||
--radius-4xl: 2rem;
|
||||
|
||||
/* =========================================
|
||||
5. SHADOWS & BLURS
|
||||
7. SHADOWS & BLURS
|
||||
Ensures popovers, modals, and dropdowns
|
||||
share identical elevation depths.
|
||||
========================================= */
|
||||
--shadow-2xs: 0 1px rgb(0 0 0 / 0.05);
|
||||
--shadow-xs: var(--mantine-shadow-xs);
|
||||
@@ -203,7 +239,7 @@
|
||||
--blur-3xl: 64px;
|
||||
|
||||
/* =========================================
|
||||
6. MISCELLANEOUS (Aspect, Anim, Perspective)
|
||||
8. MISCELLANEOUS & ANIMATIONS
|
||||
========================================= */
|
||||
--perspective-dramatic: 100px;
|
||||
--perspective-near: 300px;
|
||||
@@ -217,31 +253,26 @@
|
||||
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* Standard Tailwind Animations */
|
||||
--animate-spin: spin 1s linear infinite;
|
||||
--animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||
--animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
--animate-bounce: bounce 1s infinite;
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes ping {
|
||||
75%,
|
||||
100% {
|
||||
75%, 100% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
100% {
|
||||
0%, 100% {
|
||||
transform: translateY(-25%);
|
||||
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
|
||||
}
|
||||
@@ -253,13 +284,14 @@
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
BASE RESETS
|
||||
9. BASE RESETS
|
||||
Applies global typography smoothing and
|
||||
sets the root font size.
|
||||
========================================= */
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
font-size: var(--base-font-size);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+478
-4
@@ -245,7 +245,7 @@ importers:
|
||||
version: 5.4.17(@types/node@22.19.3)
|
||||
vitest:
|
||||
specifier: ^4.0.17
|
||||
version: 4.0.17(jsdom@26.1.0)
|
||||
version: 4.0.17(@opentelemetry/api@1.9.1)
|
||||
|
||||
packages/configs/eslint:
|
||||
dependencies:
|
||||
@@ -442,7 +442,7 @@ importers:
|
||||
version: 5.5.4
|
||||
vitest:
|
||||
specifier: ^4.0.17
|
||||
version: 4.0.17(jsdom@26.1.0)
|
||||
version: 4.0.17(@opentelemetry/api@1.9.1)
|
||||
|
||||
packages/ui:
|
||||
dependencies:
|
||||
@@ -455,12 +455,33 @@ importers:
|
||||
'@mantine/hooks':
|
||||
specifier: ^8.3.15
|
||||
version: 8.3.15(react@19.2.3)
|
||||
'@mantine/tiptap':
|
||||
specifier: ^9.3.2
|
||||
version: 9.3.2(@mantine/core@8.3.15)(@mantine/hooks@8.3.15)(@tiptap/extension-link@3.27.1)(@tiptap/react@3.27.1)(react-dom@19.2.3)(react@19.2.3)
|
||||
'@repo/core-i18n':
|
||||
specifier: workspace:*
|
||||
version: link:../core-i18n
|
||||
'@repo/utils':
|
||||
specifier: workspace:*
|
||||
version: link:../utils
|
||||
'@tiptap/extension-link':
|
||||
specifier: ^3.27.1
|
||||
version: 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
'@tiptap/extension-text-align':
|
||||
specifier: ^3.27.1
|
||||
version: 3.27.1(@tiptap/core@3.27.1)
|
||||
'@tiptap/extension-underline':
|
||||
specifier: ^3.27.1
|
||||
version: 3.27.1(@tiptap/core@3.27.1)
|
||||
'@tiptap/pm':
|
||||
specifier: ^3.27.1
|
||||
version: 3.27.1
|
||||
'@tiptap/react':
|
||||
specifier: ^3.27.1
|
||||
version: 3.27.1(@floating-ui/dom@1.7.5)(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
|
||||
'@tiptap/starter-kit':
|
||||
specifier: ^3.27.1
|
||||
version: 3.27.1
|
||||
dayjs:
|
||||
specifier: ^1.11.19
|
||||
version: 1.11.19
|
||||
@@ -555,7 +576,7 @@ importers:
|
||||
version: 5.5.4
|
||||
vitest:
|
||||
specifier: ^4.0.17
|
||||
version: 4.0.17(jsdom@26.1.0)
|
||||
version: 4.0.17(@opentelemetry/api@1.9.1)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -1647,6 +1668,24 @@ packages:
|
||||
react: 19.2.3
|
||||
dev: false
|
||||
|
||||
/@mantine/tiptap@9.3.2(@mantine/core@8.3.15)(@mantine/hooks@8.3.15)(@tiptap/extension-link@3.27.1)(@tiptap/react@3.27.1)(react-dom@19.2.3)(react@19.2.3):
|
||||
resolution: {integrity: sha512-X344wqt3eusMLPANWuNSnKoFjTDlCEOUpYq6hPWU6uBvxEHJGHuJFXjtm/jg/D6aNEvWr+P+m1+ElE7eLT/G0A==}
|
||||
peerDependencies:
|
||||
'@mantine/core': 9.3.2
|
||||
'@mantine/hooks': 9.3.2
|
||||
'@tiptap/extension-link': '>=3.3.0'
|
||||
'@tiptap/react': '>=3.3.0'
|
||||
react: ^19.2.0
|
||||
react-dom: ^19.2.0
|
||||
dependencies:
|
||||
'@mantine/core': 8.3.15(@mantine/hooks@8.3.15)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
|
||||
'@mantine/hooks': 8.3.15(react@19.2.3)
|
||||
'@tiptap/extension-link': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
'@tiptap/react': 3.27.1(@floating-ui/dom@1.7.5)(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
dev: false
|
||||
|
||||
/@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.3):
|
||||
resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
|
||||
peerDependencies:
|
||||
@@ -3066,6 +3105,309 @@ packages:
|
||||
'@testing-library/dom': 10.4.1
|
||||
dev: true
|
||||
|
||||
/@tiptap/core@3.27.1(@tiptap/pm@3.27.1):
|
||||
resolution: {integrity: sha512-rV6Qn4wmC6BxfF+4mu6bqGWj9vA4oXXhsrpXaJL2uhjxeHAGofjwcHof2X84VYzeyXgdlsGmqKie4TAppVXZUQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/pm': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/pm': 3.27.1
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-blockquote@3.27.1(@tiptap/core@3.27.1):
|
||||
resolution: {integrity: sha512-VMF7xJx6qEGiX6DTKNiL31NLqypOcd/4sNjFSe8rb41PwejBJh/nOqVIbBvWkiT6NMGFLxMhj7zJ8/zPo1hXeg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-bold@3.27.1(@tiptap/core@3.27.1):
|
||||
resolution: {integrity: sha512-TlC5bsS+pqETTrlz4CZz9RO/cKBYtELGIxwtKeivUn3eNfnOxQbbu4WDsiwIfzRFyd0OMnKl6BPM2KnYEehoEQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-bubble-menu@3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1):
|
||||
resolution: {integrity: sha512-j/j8Qp9Z5nViade2m7zjrO/CYH/Ca80Qj7aqo0eUaei6FZQ5izlF9o4XQU5EFMAutV6mwynsPUp8FVo5sCuYfw==}
|
||||
requiresBuild: true
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
'@tiptap/pm': 3.27.1
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.5
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
'@tiptap/pm': 3.27.1
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@tiptap/extension-bullet-list@3.27.1(@tiptap/extension-list@3.27.1):
|
||||
resolution: {integrity: sha512-faCUHnRP47o9Zh9VZZX6EX/569udw9Vopm2PgEKPWuKLE2qaS5WBuUVU0iItdJmKUqaWiOZkpoW4jvnDmj0dfg==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-code-block@3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1):
|
||||
resolution: {integrity: sha512-pHlzmZx2OlHfyQ0yRlT5UL4mGokz947DthZuYefN1OleVqOkHpWBG+2JQwqoNq6bmzMne92zbH32rhcJUEYSjA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
'@tiptap/pm': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
'@tiptap/pm': 3.27.1
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-code@3.27.1(@tiptap/core@3.27.1):
|
||||
resolution: {integrity: sha512-epOUpFfEmBzjvnqvjv2qHX7NAuLo5dlOGV690lWu+sAYMjibuJBeVvAiKPyFCfRCCTUxdbDB3jbaOA1yEcEJ7w==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-document@3.27.1(@tiptap/core@3.27.1):
|
||||
resolution: {integrity: sha512-8FbBTkfnRP4iVaoj+2h3iWa+H0eGDD3yTyVCwrmue/sQTkqUNUoSuAZa3GDG4Sd41xdPwTJxl9nUWGgM1qDCnw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-dropcursor@3.27.1(@tiptap/extensions@3.27.1):
|
||||
resolution: {integrity: sha512-blFf9x9RG0Qr7P3FoAH/033ffa+mMLZn34trVs8Vi0Ppk6FmJAg5HpYFOtmYoeREdNDJ5rHJKV7SoACbOHgskQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/extensions': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-floating-menu@3.27.1(@floating-ui/dom@1.7.5)(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1):
|
||||
resolution: {integrity: sha512-BmJF1VqB7dSJkgAalrpVFj88WLhxKjcWPuWHOqf2ITrUU2832BhKLXKmxjWUy1gqV8PfNNVWtGfIERy7I0y0+Q==}
|
||||
requiresBuild: true
|
||||
peerDependencies:
|
||||
'@floating-ui/dom': ^1.0.0
|
||||
'@tiptap/core': 3.27.1
|
||||
'@tiptap/pm': 3.27.1
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.5
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
'@tiptap/pm': 3.27.1
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@tiptap/extension-gapcursor@3.27.1(@tiptap/extensions@3.27.1):
|
||||
resolution: {integrity: sha512-QoezN0wdvXIwLQ4ee2ccWDaX3RG0lzgQpIMpMz55oPDhpUVax1+19ApsS53LkcktpS4EbnPL4xO4DaJk0Vp7PQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/extensions': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-hard-break@3.27.1(@tiptap/core@3.27.1):
|
||||
resolution: {integrity: sha512-iv/m9hzl6jfSj9Q8UEjAxONvCoUDaP7M9SRCPx3PaLNxA230TTD6RE0Ye4zFJ8ze7ZVoJJMAqg9Qpq1iYg2JOQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-heading@3.27.1(@tiptap/core@3.27.1):
|
||||
resolution: {integrity: sha512-SrC4l1kEIyv9ZXFaI/8LQqU2MyMmjczw7XXsWUQOTN4YXv0JyVgMNR3cI/wz0d2xsTfBdZ1N85Tdng+Ga1t0Sg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-horizontal-rule@3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1):
|
||||
resolution: {integrity: sha512-QlKE7qn5qMnIGVGhXQlvYedvLtNJ9z0dmit5w8vPb8tKzW4Spk6M7N2kruprrDA8GBwHfeR5wmF+njfUm34qxg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
'@tiptap/pm': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
'@tiptap/pm': 3.27.1
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-italic@3.27.1(@tiptap/core@3.27.1):
|
||||
resolution: {integrity: sha512-jGGeyn9uRUnNjSTHpbqhiGsp6KaYTSbV09jDXPJI9cDwfV9hpugLvpaCZd0BMBbhU1B1W6kOfX0BE15qX/HQfA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-link@3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1):
|
||||
resolution: {integrity: sha512-/2jBfsxBZUDGJmpZifqRQPz7f1E5qpS1BckTZ39TADzUJX+feKy7RJ3DtQ02+8y6SSMzvP9loGVjrk6zEMTk4g==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
'@tiptap/pm': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
'@tiptap/pm': 3.27.1
|
||||
linkifyjs: 4.3.3
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-list-item@3.27.1(@tiptap/extension-list@3.27.1):
|
||||
resolution: {integrity: sha512-zwRl01ETfCkWUvtvK5fw9bXtAajMPkvlkE3Cq6JvH3LF7XXJwDtNj5Tj7exacMpCaSZmlNc43vFb2rAYnrnwMA==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-list-keymap@3.27.1(@tiptap/extension-list@3.27.1):
|
||||
resolution: {integrity: sha512-OIMZNlzPSO8WRd4ic73Fxckzl4N1tesjjLL2XApaNA/uMpO0LoF6WSRPAWv+Z24Wp92ARRJAnRP7iZoI5+Jxig==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-list@3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1):
|
||||
resolution: {integrity: sha512-c2Upru7lj0/ZV/Ibww6cNz6sUS8m6Dp/9uygFhYcZOd3X8M0xBIEk42c6m6SQehkPziVA8QOgNJz7sMqsbz1OQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
'@tiptap/pm': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
'@tiptap/pm': 3.27.1
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-ordered-list@3.27.1(@tiptap/extension-list@3.27.1):
|
||||
resolution: {integrity: sha512-GYrKqD//9nHJ2r80uXqbDMzRnFpGzbaEQRTSGaO/SH7DvXWFMow8evkOdjQ7PCQO07jNjJo75+A85Jwu3Ov3AA==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-paragraph@3.27.1(@tiptap/core@3.27.1):
|
||||
resolution: {integrity: sha512-7K7eo1gruOgAsnbK+GCV23AUVUI0cL1bTig8HaPneoFMVbig7vddk8jNLKBWO8TXVbG7TuHdnDN4F98vdtwh5Q==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-strike@3.27.1(@tiptap/core@3.27.1):
|
||||
resolution: {integrity: sha512-Y3DW1jlSlCNCyMGHP3+3qBNNPS83wuFz4RTYGjZtvRRTCRh7apZme9XRWMq1rN5mJ2Cr7fKocA2/5Bs13KgN6Q==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-text-align@3.27.1(@tiptap/core@3.27.1):
|
||||
resolution: {integrity: sha512-EXawuJBO55wd8WcTbHTMoPhv0CGQxza4yCCPB5Hqz4ZPQwahIr3ej+8yp/kimIl0xokabwZ0/Fu8STQ4AkZv5g==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-text@3.27.1(@tiptap/core@3.27.1):
|
||||
resolution: {integrity: sha512-6ZwaZwSrDh+KFFv6V1J79oO37yPs7y1bFxvk1/9Ih2rn3Xr5AWz+eMS+n8RpH3djBVVAQpdIAeYQgcn+VCSsTg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-underline@3.27.1(@tiptap/core@3.27.1):
|
||||
resolution: {integrity: sha512-N889J4nXN/TPfVt8uF9N1A0SY82E90zwc1y26lqOcw6KWNLmQrlhMh/9OD4ikLDbekmFpOBq/UicpHf/6S8hbQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extensions@3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1):
|
||||
resolution: {integrity: sha512-1Tdx9faw8k0/83V6X+xCDVhV8yElGt95JxeW3YMkKQJI56QdlPz0xOdJPlMiSGJKinPyVier+x9LJD/YZUZIaw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
'@tiptap/pm': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
'@tiptap/pm': 3.27.1
|
||||
dev: false
|
||||
|
||||
/@tiptap/pm@3.27.1:
|
||||
resolution: {integrity: sha512-Ffjx+vimmBU7zH/KrpXzJid3+pziCe/VL2aexSTP63cyQwKQ65LkFkCKaIsSpFdQQuakVZBGWjCA5RoBV852pw==}
|
||||
dependencies:
|
||||
prosemirror-changeset: 2.4.1
|
||||
prosemirror-commands: 1.7.1
|
||||
prosemirror-dropcursor: 1.8.2
|
||||
prosemirror-gapcursor: 1.4.1
|
||||
prosemirror-history: 1.5.0
|
||||
prosemirror-inputrules: 1.5.1
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.9
|
||||
prosemirror-schema-list: 1.5.1
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-tables: 1.8.5
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.41.9
|
||||
dev: false
|
||||
|
||||
/@tiptap/react@3.27.1(@floating-ui/dom@1.7.5)(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3):
|
||||
resolution: {integrity: sha512-/Wn2fc9zMtX08MXYScDFsm4wJ8lzfhfPEdbtls7WCDlbtrop48PWlkHDBBJrywARfAQTB2mFs9KiFy9yrQm5Lg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
'@tiptap/pm': 3.27.1
|
||||
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
'@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
'@tiptap/pm': 3.27.1
|
||||
'@types/react': 19.2.7
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||
'@types/use-sync-external-store': 0.0.6
|
||||
fast-equals: 5.4.0
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
use-sync-external-store: 1.6.0(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@tiptap/extension-bubble-menu': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
'@tiptap/extension-floating-menu': 3.27.1(@floating-ui/dom@1.7.5)(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
transitivePeerDependencies:
|
||||
- '@floating-ui/dom'
|
||||
dev: false
|
||||
|
||||
/@tiptap/starter-kit@3.27.1:
|
||||
resolution: {integrity: sha512-vfxRsqW8rCc0k4pzo0ilU3wobVi2wqVj88VZI2SlgZlNnUAkrDGDIAph7CTa9k9fshV+O1ivpEgPC5yC046jow==}
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
'@tiptap/extension-blockquote': 3.27.1(@tiptap/core@3.27.1)
|
||||
'@tiptap/extension-bold': 3.27.1(@tiptap/core@3.27.1)
|
||||
'@tiptap/extension-bullet-list': 3.27.1(@tiptap/extension-list@3.27.1)
|
||||
'@tiptap/extension-code': 3.27.1(@tiptap/core@3.27.1)
|
||||
'@tiptap/extension-code-block': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
'@tiptap/extension-document': 3.27.1(@tiptap/core@3.27.1)
|
||||
'@tiptap/extension-dropcursor': 3.27.1(@tiptap/extensions@3.27.1)
|
||||
'@tiptap/extension-gapcursor': 3.27.1(@tiptap/extensions@3.27.1)
|
||||
'@tiptap/extension-hard-break': 3.27.1(@tiptap/core@3.27.1)
|
||||
'@tiptap/extension-heading': 3.27.1(@tiptap/core@3.27.1)
|
||||
'@tiptap/extension-horizontal-rule': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
'@tiptap/extension-italic': 3.27.1(@tiptap/core@3.27.1)
|
||||
'@tiptap/extension-link': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
'@tiptap/extension-list': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
'@tiptap/extension-list-item': 3.27.1(@tiptap/extension-list@3.27.1)
|
||||
'@tiptap/extension-list-keymap': 3.27.1(@tiptap/extension-list@3.27.1)
|
||||
'@tiptap/extension-ordered-list': 3.27.1(@tiptap/extension-list@3.27.1)
|
||||
'@tiptap/extension-paragraph': 3.27.1(@tiptap/core@3.27.1)
|
||||
'@tiptap/extension-strike': 3.27.1(@tiptap/core@3.27.1)
|
||||
'@tiptap/extension-text': 3.27.1(@tiptap/core@3.27.1)
|
||||
'@tiptap/extension-underline': 3.27.1(@tiptap/core@3.27.1)
|
||||
'@tiptap/extensions': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
'@tiptap/pm': 3.27.1
|
||||
dev: false
|
||||
|
||||
/@tootallnate/once@2.0.0:
|
||||
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
|
||||
engines: {node: '>= 10'}
|
||||
@@ -3370,7 +3712,6 @@ packages:
|
||||
'@types/react': ^19.2.0
|
||||
dependencies:
|
||||
'@types/react': 19.2.7
|
||||
dev: true
|
||||
|
||||
/@types/react@19.2.7:
|
||||
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
|
||||
@@ -3403,6 +3744,10 @@ packages:
|
||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||
dev: false
|
||||
|
||||
/@types/use-sync-external-store@0.0.6:
|
||||
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||
dev: false
|
||||
|
||||
/@types/uuid@9.0.8:
|
||||
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
|
||||
dev: true
|
||||
@@ -6276,6 +6621,11 @@ packages:
|
||||
/fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
/fast-equals@5.4.0:
|
||||
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
dev: false
|
||||
|
||||
/fast-glob@3.3.3:
|
||||
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
@@ -7606,6 +7956,10 @@ packages:
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
dev: false
|
||||
|
||||
/linkifyjs@4.3.3:
|
||||
resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==}
|
||||
dev: false
|
||||
|
||||
/load-plugin@6.0.3:
|
||||
resolution: {integrity: sha512-kc0X2FEUZr145odl68frm+lMJuQ23+rTXYmR6TImqPtbpmXC4vVXbWKDQ9IzndA0HfyQamWfKLhzsqGSTxE63w==}
|
||||
dependencies:
|
||||
@@ -8637,6 +8991,10 @@ packages:
|
||||
wcwidth: 1.0.1
|
||||
dev: true
|
||||
|
||||
/orderedmap@2.1.1:
|
||||
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
|
||||
dev: false
|
||||
|
||||
/own-keys@1.0.1:
|
||||
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -9068,6 +9426,106 @@ packages:
|
||||
react-is: 16.13.1
|
||||
dev: false
|
||||
|
||||
/prosemirror-changeset@2.4.1:
|
||||
resolution: {integrity: sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==}
|
||||
dependencies:
|
||||
prosemirror-transform: 1.12.0
|
||||
dev: false
|
||||
|
||||
/prosemirror-commands@1.7.1:
|
||||
resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==}
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.9
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.0
|
||||
dev: false
|
||||
|
||||
/prosemirror-dropcursor@1.8.2:
|
||||
resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==}
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.41.9
|
||||
dev: false
|
||||
|
||||
/prosemirror-gapcursor@1.4.1:
|
||||
resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==}
|
||||
dependencies:
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.9
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-view: 1.41.9
|
||||
dev: false
|
||||
|
||||
/prosemirror-history@1.5.0:
|
||||
resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==}
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.41.9
|
||||
rope-sequence: 1.3.4
|
||||
dev: false
|
||||
|
||||
/prosemirror-inputrules@1.5.1:
|
||||
resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==}
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.0
|
||||
dev: false
|
||||
|
||||
/prosemirror-keymap@1.2.3:
|
||||
resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
w3c-keyname: 2.2.8
|
||||
dev: false
|
||||
|
||||
/prosemirror-model@1.25.9:
|
||||
resolution: {integrity: sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA==}
|
||||
dependencies:
|
||||
orderedmap: 2.1.1
|
||||
dev: false
|
||||
|
||||
/prosemirror-schema-list@1.5.1:
|
||||
resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.9
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.0
|
||||
dev: false
|
||||
|
||||
/prosemirror-state@1.4.4:
|
||||
resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==}
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.9
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.41.9
|
||||
dev: false
|
||||
|
||||
/prosemirror-tables@1.8.5:
|
||||
resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==}
|
||||
dependencies:
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.9
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.41.9
|
||||
dev: false
|
||||
|
||||
/prosemirror-transform@1.12.0:
|
||||
resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==}
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.9
|
||||
dev: false
|
||||
|
||||
/prosemirror-view@1.41.9:
|
||||
resolution: {integrity: sha512-clTunTX+eaLbr87L1V1QPheRlEQJyTlL3gXe9x3jQIk3rL0RVWxviDGz8tFaydwIVm+hKhYCyr+R/zBtWr9s6A==}
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.9
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.0
|
||||
dev: false
|
||||
|
||||
/protobufjs@7.6.1:
|
||||
resolution: {integrity: sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -9670,6 +10128,10 @@ packages:
|
||||
fsevents: 2.3.3
|
||||
dev: true
|
||||
|
||||
/rope-sequence@1.3.4:
|
||||
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
|
||||
dev: false
|
||||
|
||||
/rrweb-cssom@0.8.0:
|
||||
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
|
||||
dev: true
|
||||
@@ -10901,6 +11363,14 @@ packages:
|
||||
tslib: 2.8.1
|
||||
dev: false
|
||||
|
||||
/use-sync-external-store@1.6.0(react@19.2.3):
|
||||
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
dev: false
|
||||
|
||||
/utf8-byte-length@1.0.5:
|
||||
resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==}
|
||||
dev: true
|
||||
@@ -11241,6 +11711,10 @@ packages:
|
||||
/vuvuzela@1.0.3:
|
||||
resolution: {integrity: sha512-Tm7jR1xTzBbPW+6y1tknKiEhz04Wf/1iZkcTJjSFcpNko43+dFW6+OOeQe9taJIug3NdfUAjFKgUSyQrIKaDvQ==}
|
||||
|
||||
/w3c-keyname@2.2.8:
|
||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||
dev: false
|
||||
|
||||
/w3c-xmlserializer@5.0.0:
|
||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
Reference in New Issue
Block a user