diff --git a/apps/web/src/apps/showcase/example/features/form-demo/components/all-fields-demo.tsx b/apps/web/src/apps/showcase/example/features/form-demo/components/all-fields-demo.tsx index 77a43cb..8d31ee5 100644 --- a/apps/web/src/apps/showcase/example/features/form-demo/components/all-fields-demo.tsx +++ b/apps/web/src/apps/showcase/example/features/form-demo/components/all-fields-demo.tsx @@ -6,8 +6,9 @@ import { FieldMultiSelect, FieldNativeSelect, FieldTagsInput, FieldCheckbox, FieldRadioGroup, FieldSwitch, FieldChipGroup, FieldSegmentedControl, FieldSlider, FieldRangeSlider, FieldRating, FieldColorInput, - FieldColorPicker, FieldFileInput, FieldObjectSelect, FieldAsyncSelect + FieldColorPicker, FieldFileInput, FieldLocalSelect, FieldAsyncSelect } from '@repo/ui/form'; +import type { LoadOptionsFn } from '@repo/ui/form'; import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation'; const MOCK_POKEMON = Array.from({ length: 100 }, (_, i) => ({ @@ -15,22 +16,39 @@ const MOCK_POKEMON = Array.from({ length: 100 }, (_, i) => ({ name: `Pokemon ${i + 1}`, })); -const fetchMockPokemon = async (url: string) => { - // Parse the query params sent by useInfiniteScroll - const urlObj = new URL(url, 'http://localhost'); - const page = parseInt(urlObj.searchParams.get('page') || '1', 10); - const pageSize = parseInt(urlObj.searchParams.get('pageSize') || '20', 10); - const search = urlObj.searchParams.get('search')?.toLowerCase() || ''; - - // Fake network delay +const loadMockPokemonOptions: LoadOptionsFn = async (search, page) => { await new Promise((resolve) => setTimeout(resolve, 500)); - - // Filter and paginate - const filtered = MOCK_POKEMON.filter((p) => p.name.toLowerCase().includes(search)); + 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, + }; +}; - return { results: paginated }; +const loadRealPokemonOptions: LoadOptionsFn = async (_search, page) => { + const limit = 20; + const offset = (page - 1) * limit; + const res = await fetch(`https://pokeapi.co/api/v2/pokemon?limit=${limit}&offset=${offset}`); + const data = await res.json(); + return { + options: data.results.map((p: any, i: number) => ({ id: offset + i + 1, ...p })), + hasMore: !!data.next, + }; +}; + +const MOCK_VENDORS = [ + { id: 'V1', code: 'VN-01', name: 'Vendor One' }, + { id: 'V2', code: 'VN-02', name: 'Vendor Two' }, + { id: 'V3', code: 'VN-03', name: 'Vendor Three' }, +]; + +const loadMockVendorsOptions: LoadOptionsFn = async (search, _page) => { + await new Promise((resolve) => setTimeout(resolve, 500)); + const filtered = MOCK_VENDORS.filter(v => v.name.toLowerCase().includes(search.toLowerCase()) || v.code.toLowerCase().includes(search.toLowerCase())); + return { options: filtered, hasMore: false }; }; export default function AllFieldsDemo() { @@ -61,10 +79,18 @@ export default function AllFieldsDemo() { themeColor: '', colorPicker: '#1c7ed6', avatar: null, - objectSelect: null, - asyncSelect: null, - multiObjectSelect: [], - multiAsyncSelect: [], + 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' } + ], realPokeSelect: null, multiRealPokeSelect: [] } @@ -133,12 +159,12 @@ export default function AllFieldsDemo() { /> - - res.results} + loadOptions={loadMockPokemonOptions} valueKey="id" labelKey="name" clearable @@ -182,9 +206,7 @@ export default function AllFieldsDemo() { control={control} label="Multi Async Select" placeholder="Select multiple pokemon..." - apiEndpoint="/api/mock/pokemon" - fetchFn={fetchMockPokemon} - transformResponse={(res) => res.results} + loadOptions={loadMockPokemonOptions} valueKey="id" labelKey="name" clearable @@ -196,11 +218,7 @@ export default function AllFieldsDemo() { control={control} label="Real PokeAPI (Single - Tests Deduplication)" placeholder="Scroll to test deduplication..." - apiEndpoint="https://pokeapi.co/api/v2/pokemon" - transformResponse={(res) => ({ - data: res.results.map((p: any, i: number) => ({ id: i + 1, ...p })), - hasMore: !!res.next - })} + loadOptions={loadRealPokemonOptions} valueKey="id" labelKey="name" clearable @@ -211,17 +229,82 @@ export default function AllFieldsDemo() { control={control} label="Real PokeAPI (Multi - Tests Deduplication)" placeholder="Scroll to test deduplication..." - apiEndpoint="https://pokeapi.co/api/v2/pokemon" - transformResponse={(res) => ({ - data: res.results.map((p: any, i: number) => ({ id: i + 1, ...p })), - hasMore: !!res.next - })} + loadOptions={loadRealPokemonOptions} valueKey="id" labelKey="name" clearable /> + + Advanced Object Selects (Custom Labels & Default Values) + + + `[${item.code}] ${item.name}`} + clearable + /> + `[${item.code}] ${item.name}`} + clearable + /> + + + `[${item.code}] ${item.name}`} + clearable + /> + `[${item.code}] ${item.name}`} + defaultOptions={[{ id: 999, code: 'ASYNC-99', name: 'Pre-loaded Async Vendor' }]} + clearable + /> + + + Multi-Select Edit Mode (No defaultOptions fallback) + + + `[${item.code}] ${item.name}`} + clearable + /> + `[${item.code}] ${item.name}`} + clearable + /> + {/* --- Toggles & Choices --- */} diff --git a/apps/web/src/apps/showcase/example/features/form-demo/components/reactive-watch-demo.tsx b/apps/web/src/apps/showcase/example/features/form-demo/components/reactive-watch-demo.tsx index 447d2c4..feeec0d 100644 --- a/apps/web/src/apps/showcase/example/features/form-demo/components/reactive-watch-demo.tsx +++ b/apps/web/src/apps/showcase/example/features/form-demo/components/reactive-watch-demo.tsx @@ -2,11 +2,49 @@ 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 { FieldTextInput, FieldSelect, FieldSwitch, FieldLocalSelect, FieldAsyncSelect } 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 } 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(); @@ -27,6 +65,8 @@ 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(), }) .and( z.discriminatedUnion('userType', [ @@ -64,6 +104,11 @@ 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' } + ], }, }); @@ -73,6 +118,7 @@ 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' }); // Use the custom hook to cleanly unregister and reset fields when hidden useConditionalField({ @@ -133,6 +179,27 @@ export default function ReactiveWatchDemo() { defaultValue: '', }); + const isMounted = useRef(false); + const prevRegionIds = useRef(regions?.map((r: Region) => r.id) || []); + + useEffect(() => { + if (!isMounted.current) { + isMounted.current = true; + return; + } + + const currentIds = regions?.map((r: Region) => r.id) || []; + const prevIds = prevRegionIds.current; + + const hasChanged = currentIds.length !== prevIds.length || currentIds.some((id: string) => !prevIds.includes(id)); + + if (hasChanged) { + setValue('warehouses', []); + clearErrors('warehouses'); + prevRegionIds.current = currentIds; + } + }, [regions, setValue, clearErrors]); + // Use watch only to display the JSON output at the bottom const allValues = useWatch({ control }); @@ -223,6 +290,44 @@ export default function ReactiveWatchDemo() { withAsterisk={!!department} /> + + Cascading Object Selects + + + + + multiple + name="regions" + control={control as any} + label="Regions" + options={REGIONS} + valueKey="id" + labelKey="code" + clearable + /> + + + multiple + key={`warehouse-select-${regions?.map((r: any) => r.id).join(',')}`} + name="warehouses" + control={control as any} + label="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 && ( + + Selected regions tax rates: {regions.map((r: any) => `${r.code} (${(r.taxRate * 100).toFixed(0)}%)`).join(', ')} + + )} + diff --git a/apps/web/src/apps/showcase/example/features/form-demo/components/validation-bank-demo.tsx b/apps/web/src/apps/showcase/example/features/form-demo/components/validation-bank-demo.tsx index 06f24d6..8a13102 100644 --- a/apps/web/src/apps/showcase/example/features/form-demo/components/validation-bank-demo.tsx +++ b/apps/web/src/apps/showcase/example/features/form-demo/components/validation-bank-demo.tsx @@ -3,8 +3,55 @@ 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 } from '@repo/ui/form'; +import type { LoadOptionsFn } from '@repo/ui/form'; + +interface Department { + code: string; + name: string; + costCenter: string; +} + +interface Assignee { + id: number; + email: string; +} + +const MOCK_DEPARTMENTS: Department[] = [ + { code: 'IT', name: 'Information Technology', costCenter: 'CC-100' }, + { code: 'HR', name: 'Human Resources', costCenter: 'CC-200' }, + { code: 'FIN', name: 'Finance', costCenter: 'CC-300' }, +]; + +const mockFetchUsers: LoadOptionsFn = async (search, page) => { + await new Promise((resolve) => setTimeout(resolve, 500)); + const allUsers = Array.from({ length: 20 }, (_, i) => ({ + id: i + 1, + email: `user${i + 1}@company.com` + })); + const filtered = allUsers.filter(u => u.email.toLowerCase().includes(search.toLowerCase())); + const pageSize = 5; + const start = (page - 1) * pageSize; + const paginated = filtered.slice(start, start + pageSize); + + return { + options: paginated, + hasMore: start + pageSize < filtered.length + }; +}; + +const MOCK_VENDORS = [ + { id: 'V1', code: 'VN-01', name: 'Vendor One' }, + { id: 'V2', code: 'VN-02', name: 'Vendor Two' }, +]; + +const mockFetchVendors: LoadOptionsFn = async (search, _page) => { + await new Promise((resolve) => setTimeout(resolve, 500)); + const filtered = MOCK_VENDORS.filter(v => v.name.toLowerCase().includes(search.toLowerCase()) || v.code.toLowerCase().includes(search.toLowerCase())); + return { options: filtered, hasMore: false }; +}; import { compose, required, rangeLength, positiveNumber, simplePassword, @@ -22,7 +69,12 @@ export default function ValidationBankDemo() { 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: 'Department is required' }), + assignees: z.array(z.object({ id: z.number(), email: z.string() })).min(2, "Select at least 2 assignees"), + prefilledVendor: z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }, { required_error: 'Vendor is required' }), + emptyVendor: z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }, { required_error: 'Vendor is required' }), + prefilledAsyncMulti: z.array(z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() })).min(1, "Select at least 1 vendor"), }); type ValidationFormValues = z.infer; @@ -35,7 +87,15 @@ 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, } }); @@ -100,6 +160,72 @@ export default function ValidationBankDemo() { withAsterisk /> + Object Level Validations (Local & Async) + + + + name="department" + control={control as any} + label="Department" + options={MOCK_DEPARTMENTS} + valueKey="code" + renderLabel={(item) => `[${item.code}] ${item.name}`} + clearable + withAsterisk + /> + + + multiple + name="assignees" + control={control as any} + label="Assignees" + loadOptions={mockFetchUsers} + valueKey="id" + labelKey="email" + searchable + clearable + withAsterisk + /> + + Validated Prefilled Objects + + + + `[${item.code}] ${item.name}`} + clearable + withAsterisk + /> + `[${item.code}] ${item.name}`} + defaultOptions={[{ id: 'V1', code: 'VN-01', name: 'Vendor One' }]} + clearable + withAsterisk + /> + + + `[${item.code}] ${item.name}`} + clearable + withAsterisk + /> + diff --git a/packages/ui/docs/FORM-COMPONENTS.md b/packages/ui/docs/FORM-COMPONENTS.md index e65c0ec..a9dd0ff 100644 --- a/packages/ui/docs/FORM-COMPONENTS.md +++ b/packages/ui/docs/FORM-COMPONENTS.md @@ -479,6 +479,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`) 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) | `)} - data={options} + 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 ObjectSelect = React.memo(ObjectSelectInner) as typeof ObjectSelectInner; -(ObjectSelect as any).displayName = 'ObjectSelect'; +export const LocalSelect = React.memo(LocalSelectInner) as typeof LocalSelectInner; +(LocalSelect as any).displayName = 'LocalSelect'; diff --git a/packages/ui/src/components/Form/custom/selects/hooks/useAsyncPaginate.ts b/packages/ui/src/components/Form/custom/selects/hooks/useAsyncPaginate.ts new file mode 100644 index 0000000..6ff341f --- /dev/null +++ b/packages/ui/src/components/Form/custom/selects/hooks/useAsyncPaginate.ts @@ -0,0 +1,335 @@ +import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; +import { useDebouncedValue } from '@mantine/hooks'; +import type { LoadOptionsFn, OptionsCacheEntry } from '../types'; + +// --------------------------------------------------------------------------- +// useAsyncPaginate — Production-grade paginated data fetching hook +// --------------------------------------------------------------------------- +// +// Architecture derived from `react-select-async-paginate` with adaptations +// for Mantine's Select/MultiSelect. Key mechanisms: +// +// 1. Search-keyed cache (Map>) +// → Switching between previously-typed searches reuses cached data +// without re-fetching from the server. +// +// 2. Request ID counter (requestIdRef) +// → Stale responses from slow or out-of-order fetches are silently +// discarded by comparing the ID captured at request start with the +// current counter value. +// +// 3. isMounted guard (mountedRef) +// → Responses arriving after the component unmounts are discarded, +// preventing React state updates on unmounted components. +// +// 4. Duplicate fetch prevention (fetchingRef boolean) +// → Guards against concurrent fetches for the same search+page combo. +// +// 5. Single consolidated effect +// → One useEffect keyed on `debouncedSearch` handles both the initial +// load (mount with search='') and subsequent search-change resets. +// This eliminates the double-initial-fetch bug from the old hook. +// --------------------------------------------------------------------------- + +export interface UseAsyncPaginateOptions> { + /** Async callback to load options. Receives (search, page, prevOptions). */ + loadOptions: LoadOptionsFn; + + /** Property key used to deduplicate incoming items */ + valueKey: keyof T & string; + + /** Search debounce delay in ms (default: 300) */ + debounceMs?: number; + + /** + * Pre-loaded objects to inject into the options list. + * Used for edit-form scenarios where the RHF default value's object + * may not appear in the first page of API results. + */ + defaultOptions?: T[]; +} + +export interface UseAsyncPaginateReturn { + /** Merged data: defaultOptions + accumulated fetched pages (deduplicated) */ + data: T[]; + + /** True during any active fetch */ + isLoading: boolean; + + /** Whether the current search term has more pages available */ + hasMore: boolean; + + /** Current search term (raw, not debounced) */ + search: string; + + /** The debounced search term currently driving fetches */ + debouncedSearch: string; + + /** Update the search term — triggers debounce + cache lookup/fetch */ + setSearch: (s: string) => void; + + /** Trigger next page load for the current search term */ + fetchNextPage: () => void; + + /** Clear all cached pages and re-fetch from page 1 */ + reset: () => void; +} + +export function useAsyncPaginate>( + options: UseAsyncPaginateOptions, +): UseAsyncPaginateReturn { + const { + loadOptions, + valueKey, + debounceMs = 300, + defaultOptions, + } = options; + + // ----------------------------------------------------------------------- + // State + // ----------------------------------------------------------------------- + + const [search, setSearch] = useState(''); + const [debouncedSearch] = useDebouncedValue(search, debounceMs); + + // Search-keyed cache: each search string maps to its own pagination state + const [cache, setCache] = useState>>(() => new Map()); + + // Loading flag — drives the UI spinner + const [isLoading, setIsLoading] = useState(false); + + // ----------------------------------------------------------------------- + // Refs for guards + // ----------------------------------------------------------------------- + + /** Monotonically increasing counter to detect stale responses */ + const requestIdRef = useRef(0); + + /** Guards against concurrent fetches */ + const fetchingRef = useRef(false); + + /** Tracks if the component is still mounted */ + const mountedRef = useRef(true); + + /** Stable ref for loadOptions to avoid effect re-fires on closure changes */ + const loadOptionsRef = useRef(loadOptions); + loadOptionsRef.current = loadOptions; + + /** Stable ref for valueKey */ + const valueKeyRef = useRef(valueKey); + valueKeyRef.current = valueKey; + + /** Stable ref for defaultOptions to avoid dependency churn */ + const defaultOptionsRef = useRef(defaultOptions); + defaultOptionsRef.current = defaultOptions; + + // ----------------------------------------------------------------------- + // Cleanup on unmount + // ----------------------------------------------------------------------- + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + // ----------------------------------------------------------------------- + // Core fetch logic + // ----------------------------------------------------------------------- + + const fetchPage = useCallback( + async (searchTerm: string, page: number) => { + if (fetchingRef.current) return; + fetchingRef.current = true; + + // Capture request ID — if it changes before the response arrives, + // the response is stale and should be discarded. + const capturedId = ++requestIdRef.current; + + setIsLoading(true); + + try { + // Get accumulated options from the cache for prevOptions + const cachedEntry = cache.get(searchTerm); + const prevOptions = cachedEntry?.options ?? []; + + const response = await loadOptionsRef.current(searchTerm, page, prevOptions); + + // Guard: discard if unmounted or stale + if (!mountedRef.current || requestIdRef.current !== capturedId) { + return; + } + + const newItems = response.options ?? []; + const hasMore = response.hasMore ?? false; + + // Deduplicate: merge prev + new, keyed by valueKey + const vk = valueKeyRef.current; + const seen = new Set(); + const merged: T[] = []; + + // Accumulate from previous pages first + for (const item of prevOptions) { + const key = String(item[vk]); + if (!seen.has(key)) { + seen.add(key); + merged.push(item); + } + } + + // Append new items (only unique ones) + let newUniqueCount = 0; + for (const item of newItems) { + const key = String(item[vk]); + if (!seen.has(key)) { + seen.add(key); + merged.push(item); + newUniqueCount++; + } + } + + // If the API returned items but ALL were duplicates, treat as exhausted. + // This prevents infinite scroll loops on naive APIs that ignore pagination. + const effectiveHasMore = newItems.length > 0 && newUniqueCount === 0 + ? false + : hasMore; + + setCache((prev) => { + const next = new Map(prev); + next.set(searchTerm, { + options: merged, + hasMore: effectiveHasMore, + page, + isLoading: false, + }); + return next; + }); + } catch (error) { + if (!mountedRef.current || requestIdRef.current !== capturedId) { + return; + } + + console.error('[useAsyncPaginate] loadOptions failed:', error); + + // Mark the cache entry as exhausted to prevent retry loops + setCache((prev) => { + const next = new Map(prev); + const existing = prev.get(searchTerm); + next.set(searchTerm, { + options: existing?.options ?? [], + hasMore: false, + page: existing?.page ?? 0, + isLoading: false, + }); + return next; + }); + } finally { + if (mountedRef.current) { + setIsLoading(false); + } + fetchingRef.current = false; + } + }, + // We intentionally exclude `cache` from deps to avoid re-creating this callback + // on every cache update. Instead, we read cache inside via the state setter's prev. + // The `cache.get(searchTerm)` read above is for prevOptions passed to loadOptions — + // this is acceptable because the callback is only called when we're NOT already fetching. + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + // ----------------------------------------------------------------------- + // Effect: Fetch on debounced search change (including initial mount) + // ----------------------------------------------------------------------- + // + // This single effect replaces the old two-effect pattern that caused + // double initial fetches. On mount, debouncedSearch starts as '' and + // triggers a single page-1 fetch. On search change, it looks up the + // cache and either reuses cached data or fetches page 1 for the new term. + // ----------------------------------------------------------------------- + + useEffect(() => { + const cached = cache.get(debouncedSearch); + + // If we already have cached data for this search term, no fetch needed + if (cached && cached.options.length > 0) { + return; + } + + // No cache entry — fetch page 1 + fetchPage(debouncedSearch, 1); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearch]); + + // ----------------------------------------------------------------------- + // fetchNextPage — called by Mantine's scroll handler + // ----------------------------------------------------------------------- + + const fetchNextPage = useCallback(() => { + const cached = cache.get(debouncedSearch); + if (!cached || !cached.hasMore || isLoading || fetchingRef.current) return; + fetchPage(debouncedSearch, cached.page + 1); + }, [cache, debouncedSearch, isLoading, fetchPage]); + + // ----------------------------------------------------------------------- + // reset — clear cache and re-fetch from scratch + // ----------------------------------------------------------------------- + + const reset = useCallback(() => { + setCache(new Map()); + requestIdRef.current++; + fetchPage(debouncedSearch, 1); + }, [debouncedSearch, fetchPage]); + + // ----------------------------------------------------------------------- + // Merge defaultOptions with fetched data (deduplicated) + // ----------------------------------------------------------------------- + + const currentCacheEntry = cache.get(debouncedSearch); + + const data = useMemo(() => { + const fetchedItems = currentCacheEntry?.options ?? []; + const defaults = defaultOptionsRef.current; + + if (!defaults || defaults.length === 0) { + return fetchedItems; + } + + // Merge: defaultOptions first, then fetched items (deduplicated) + const seen = new Set(); + const merged: T[] = []; + + for (const item of defaults) { + const key = String(item[valueKeyRef.current]); + if (!seen.has(key)) { + seen.add(key); + merged.push(item); + } + } + + for (const item of fetchedItems) { + const key = String(item[valueKeyRef.current]); + if (!seen.has(key)) { + seen.add(key); + merged.push(item); + } + } + + return merged; + }, [currentCacheEntry?.options]); + + const hasMore = currentCacheEntry?.hasMore ?? true; + const isTyping = search !== debouncedSearch; + + return { + data, + isLoading: isLoading || isTyping, + hasMore, + search, + debouncedSearch, + setSearch, + fetchNextPage, + reset, + }; +} diff --git a/packages/ui/src/components/Form/custom/selects/hooks/useAsyncSelectInfiniteScroll.ts b/packages/ui/src/components/Form/custom/selects/hooks/useAsyncSelectInfiniteScroll.ts deleted file mode 100644 index 6eb6959..0000000 --- a/packages/ui/src/components/Form/custom/selects/hooks/useAsyncSelectInfiniteScroll.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; -import { useDebouncedValue } from '@mantine/hooks'; - -// --------------------------------------------------------------------------- -// useAsyncSelectInfiniteScroll — Paginated data fetching hook -// --------------------------------------------------------------------------- -// -// Inspired by `react-select-async-paginate`. Manages: -// - Page-based accumulation (append without flickering) -// - Debounced search (resets pages on search change) -// - hasMore detection (page returns fewer items than pageSize) -// - Duplicate fetch guards -// -// Uses native `fetch` for zero-dependency portability. -// --------------------------------------------------------------------------- - -export interface UseAsyncSelectInfiniteScrollOptions> { - /** API endpoint URL. Receives query params: ?page=N&pageSize=M&search=S */ - apiEndpoint: string; - - /** Property key used to deduplicate incoming API items */ - valueKey: keyof T & string; - - /** Items per page (default: 20) */ - pageSize?: number; - - /** Transform raw API response into T[] or { data: T[], hasMore?: boolean } */ - transformResponse?: (res: any) => { data: T[]; hasMore?: boolean } | T[]; - - /** Search debounce delay in ms (default: 300) */ - debounceMs?: number; - - /** - * Custom fetch function. If provided, replaces the default `fetch` call. - * Useful for mocking in tests or using a shared HTTP client. - */ - fetchFn?: (url: string) => Promise; -} - -export interface UseAsyncSelectInfiniteScrollReturn { - /** Flattened accumulated data across all fetched pages */ - data: T[]; - - /** True during any active fetch */ - isLoading: boolean; - - /** False when the last page returned fewer items than pageSize */ - hasMore: boolean; - - /** Current search term (raw, not debounced) */ - search: string; - - /** Update the search term — triggers debounce + reset */ - setSearch: (s: string) => void; - - /** Trigger next page load */ - fetchNextPage: () => void; - - /** Clear all pages and reset to initial state */ - reset: () => void; - - /** The debounced search term */ - debouncedSearch: string; -} - -export function useAsyncSelectInfiniteScroll>( - options: UseAsyncSelectInfiniteScrollOptions, -): UseAsyncSelectInfiniteScrollReturn { - const { - apiEndpoint, - valueKey, - pageSize = 20, - transformResponse = (res) => res as T[], - debounceMs = 300, - fetchFn, - } = options; - - const [pages, setPages] = useState([]); - const [currentPage, setCurrentPage] = useState(1); - const [hasMore, setHasMore] = useState(true); - const [isLoading, setIsLoading] = useState(false); - const [search, setSearch] = useState(''); - const [debouncedSearch] = useDebouncedValue(search, debounceMs); - - // Guard against duplicate fetches - const fetchingRef = useRef(false); - - // Flatten all pages into a single stable array - const data = useMemo(() => pages.flat(), [pages]); - - // Fetch a specific page - const fetchPage = useCallback( - async (page: number, searchTerm: string) => { - if (fetchingRef.current) return; - fetchingRef.current = true; - setIsLoading(true); - - try { - const separator = apiEndpoint.includes('?') ? '&' : '?'; - const url = `${apiEndpoint}${separator}page=${page}&pageSize=${pageSize}&search=${encodeURIComponent(searchTerm)}`; - - let rawData: any; - if (fetchFn) { - rawData = await fetchFn(url); - } else { - const response = await fetch(url); - rawData = await response.json(); - } - - const transformed = transformResponse(rawData); - const isObjectForm = !Array.isArray(transformed) && 'data' in transformed; - const items = isObjectForm ? transformed.data : (transformed as T[]); - const explicitHasMore = isObjectForm ? transformed.hasMore : undefined; - - setPages((prev) => { - // Detect duplicates to break infinite loop on naive APIs - const existingKeys = new Set(prev.flat().map((i) => String(i[valueKey]))); - const newUniqueItems = items.filter((i) => !existingKeys.has(String(i[valueKey]))); - - // If the API returned items, but ALL of them were duplicates of what we already have, - // the API is likely stuck (e.g. ignoring pagination). Break the loop. - if (items.length > 0 && newUniqueItems.length === 0) { - setHasMore(false); - return prev; - } - - // Page 1 replaces everything (search reset), otherwise append unique items - if (page === 1) return [newUniqueItems]; - return [...prev, newUniqueItems]; - }); - - // Determine if we have more pages - if (explicitHasMore !== undefined) { - setHasMore(explicitHasMore); - } else { - setHasMore(items.length >= pageSize); - } - - setCurrentPage(page); - } catch (error) { - console.error('[useAsyncSelectInfiniteScroll] Fetch failed:', error); - setHasMore(false); - } finally { - setIsLoading(false); - fetchingRef.current = false; - } - }, - [apiEndpoint, pageSize, transformResponse, fetchFn, valueKey], - ); - - // Fetch next page (called by scroll handler) - const fetchNextPage = useCallback(() => { - if (!hasMore || isLoading || fetchingRef.current) return; - fetchPage(currentPage + 1, debouncedSearch); - }, [hasMore, isLoading, currentPage, debouncedSearch, fetchPage]); - - // Reset and re-fetch from page 1 - const reset = useCallback(() => { - setPages([]); - setCurrentPage(1); - setHasMore(true); - fetchPage(1, debouncedSearch); - }, [debouncedSearch, fetchPage]); - - // Initial fetch on mount - useEffect(() => { - fetchPage(1, ''); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Re-fetch on debounced search change - useEffect(() => { - setPages([]); - setCurrentPage(1); - setHasMore(true); - fetchPage(1, debouncedSearch); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedSearch]); - - return { - data, - isLoading, - hasMore, - search, - debouncedSearch, - setSearch, - fetchNextPage, - reset, - }; -} diff --git a/packages/ui/src/components/Form/custom/selects/types.ts b/packages/ui/src/components/Form/custom/selects/types.ts index b00594b..92ba417 100644 --- a/packages/ui/src/components/Form/custom/selects/types.ts +++ b/packages/ui/src/components/Form/custom/selects/types.ts @@ -1,7 +1,7 @@ import type { ComboboxItem } from '@mantine/core'; // --------------------------------------------------------------------------- -// ObjectSelect — Shared types for the reusable Object Select engine +// Select Engine — Shared types for LocalSelect and AsyncSelect // --------------------------------------------------------------------------- /** @@ -9,7 +9,7 @@ import type { ComboboxItem } from '@mantine/core'; * 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 ObjectSelectFilterContext { +export interface SelectFilterContext { /** Current search input value */ search: string; /** Currently selected value(s) — T | null for single, T[] for multi */ @@ -17,14 +17,14 @@ export interface ObjectSelectFilterContext { } /** - * Core configuration for the Object Select engine. + * Core configuration for the LocalSelect engine. * This is the "headless" API — no RHF dependency. * - * @template T - The shape of each item in the data array + * @template T - The shape of each item in the options array */ -export interface ObjectSelectBaseProps> { +export interface LocalSelectBaseProps> { /** Array of complex objects to select from */ - data: T[]; + options: T[]; /** Property key to use as the unique string identifier for Mantine */ valueKey: keyof T & string; @@ -42,20 +42,102 @@ export interface ObjectSelectBaseProps> { * 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: ObjectSelectFilterContext) => boolean; + filterOption?: (item: T, context: SelectFilterContext) => boolean; /** Callback fired when selection changes */ onSelect?: (value: T | T[] | null) => void; } +/** + * Core configuration for the AsyncSelect engine. + * Extends LocalSelectBaseProps but replaces `options` with `loadOptions`. + * This is the "headless" API — no RHF dependency. + * + * @template T - The shape of each item in the options array + */ +export type AsyncSelectBaseProps> = Omit, 'options'>; + /** * Internal result of the object-to-string mapping logic. * Used by both the standalone and RHF-connected variants. */ -export interface ObjectSelectMappingResult { +export interface SelectMappingResult { /** Mantine-compatible ComboboxItem array for the Select/MultiSelect `data` prop */ options: ComboboxItem[]; /** O(1) reverse lookup map: string value → original object */ lookupMap: Map; } + +// --------------------------------------------------------------------------- +// AsyncSelect — Inversion of Control types for the async paginated engine +// --------------------------------------------------------------------------- + +/** + * The response shape returned by the `loadOptions` callback. + * Supports both paginated and non-paginated APIs. + * + * @template T - The shape of each option item + */ +export interface LoadOptionsResponse { + /** The array of option objects for this page/batch */ + options: T[]; + + /** + * Whether more pages are available. + * - `true` → the engine will allow further scroll-triggered fetches. + * - `false` → no more data; subsequent scroll events are ignored. + * - `undefined` → treated as `false` (assumes non-paginated). + */ + hasMore?: boolean; +} + +/** + * The callback signature for loading options asynchronously. + * This follows the Inversion of Control principle: the component is + * completely ignorant of transport (REST, GraphQL, local filter, etc.). + * + * @param search - The current search input string + * @param page - The 1-indexed page number being requested + * @param prevOptions - All options accumulated from previous pages + * @returns A promise resolving to the options for this page + pagination signal + * + * @example + * ```ts + * // REST API with cursor pagination + * const loadOptions: LoadOptionsFn = async (search, page) => { + * const res = await api.get('/users', { params: { q: search, page, limit: 20 } }); + * return { options: res.data.items, hasMore: res.data.hasNextPage }; + * }; + * + * // Non-paginated (single-shot fetch) + * const loadOptions: LoadOptionsFn = async (search) => { + * const roles = await api.get('/roles', { params: { q: search } }); + * return { options: roles.data, hasMore: false }; + * }; + * ``` + */ +export type LoadOptionsFn = ( + search: string, + page: number, + prevOptions: T[], +) => Promise>; + +/** + * Internal cache entry for the search-keyed options cache. + * Each unique search string maps to one entry tracking the accumulated + * options, pagination state, and current page for that search context. + */ +export interface OptionsCacheEntry { + /** Accumulated options across all fetched pages for this search term */ + options: T[]; + + /** Whether more pages are available for this search term */ + hasMore: boolean; + + /** The last successfully fetched page number (1-indexed) */ + page: number; + + /** Whether a fetch is currently in-flight for this search term */ + isLoading: boolean; +} diff --git a/packages/ui/src/components/Form/fields/async-select.field.tsx b/packages/ui/src/components/Form/fields/async-select.field.tsx index 4e81e96..04ab605 100644 --- a/packages/ui/src/components/Form/fields/async-select.field.tsx +++ b/packages/ui/src/components/Form/fields/async-select.field.tsx @@ -7,7 +7,7 @@ import { } from 'react-hook-form'; import type { SelectProps, MultiSelectProps } from '@mantine/core'; import { AsyncSelect } from '../custom/selects/AsyncSelect'; -import type { ObjectSelectBaseProps } from '../custom/selects/types'; +import type { AsyncSelectBaseProps, LoadOptionsFn } from '../custom/selects/types'; import { useTranslatedError } from '../useTranslatedError'; // --------------------------------------------------------------------------- @@ -16,19 +16,29 @@ import { useTranslatedError } from '../useTranslatedError'; // // 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 */ +/** Async-specific props (IoC pattern) */ interface AsyncExtraProps { - apiEndpoint: string; - transformResponse?: (res: any) => { data: T[]; hasMore?: boolean } | T[]; - pageSize?: number; + /** Async callback to load options — (search, page, prevOptions) => Promise */ + loadOptions: LoadOptionsFn; + + /** Pre-loaded objects for edit forms (injected into dropdown regardless of fetch state) */ + defaultOptions?: T[]; + + /** Search debounce delay in ms (default: 300) */ debounceMs?: number; - fetchFn?: (url: string) => Promise; } /** Single-select async RHF props */ @@ -36,7 +46,7 @@ export type FieldAsyncSelectSingleProps< T extends Record, TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath, -> = Omit, 'data'> & +> = AsyncSelectBaseProps & AsyncExtraProps & UseControllerProps & Omit & { @@ -48,7 +58,7 @@ export type FieldAsyncSelectMultiProps< T extends Record, TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath, -> = Omit, 'data'> & +> = AsyncSelectBaseProps & AsyncExtraProps & UseControllerProps & Omit & { @@ -86,12 +96,10 @@ function FieldAsyncSelectInner< renderLabel, multiple, filterOption, - onSelect: onObjectSelect, - apiEndpoint, - transformResponse, - pageSize, + onSelect: onSelectCallback, + loadOptions, + defaultOptions, debounceMs, - fetchFn, // Remaining Mantine props ...mantineProps } = props; @@ -112,7 +120,7 @@ function FieldAsyncSelectInner< const handleChange = (value: any) => { field.onChange(value); - onObjectSelect?.(value); + onSelectCallback?.(value); }; const engineProps = { @@ -120,11 +128,9 @@ function FieldAsyncSelectInner< labelKey, renderLabel, filterOption, - apiEndpoint, - transformResponse, - pageSize, + loadOptions, + defaultOptions, debounceMs, - fetchFn, onBlur: field.onBlur, error: translatedError, disabled: field.disabled, diff --git a/packages/ui/src/components/Form/fields/object-select.field.tsx b/packages/ui/src/components/Form/fields/local-select.field.tsx similarity index 75% rename from packages/ui/src/components/Form/fields/object-select.field.tsx rename to packages/ui/src/components/Form/fields/local-select.field.tsx index 8db8106..30862fe 100644 --- a/packages/ui/src/components/Form/fields/object-select.field.tsx +++ b/packages/ui/src/components/Form/fields/local-select.field.tsx @@ -6,12 +6,12 @@ import { type UseControllerProps, } from 'react-hook-form'; import type { SelectProps, MultiSelectProps } from '@mantine/core'; -import { ObjectSelect } from '../custom/selects/ObjectSelect'; -import type { ObjectSelectBaseProps } from '../custom/selects/types'; +import { LocalSelect } from '../custom/selects/LocalSelect'; +import type { LocalSelectBaseProps } from '../custom/selects/types'; import { useTranslatedError } from '../useTranslatedError'; // --------------------------------------------------------------------------- -// FieldObjectSelect — RHF-connected Object Select +// FieldLocalSelect — RHF-connected Local Select // --------------------------------------------------------------------------- // // Follows the same architectural pattern as the existing `FieldSelect`: @@ -19,8 +19,12 @@ import { useTranslatedError } from '../useTranslatedError'; // // 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 ObjectSelect engine -// in `custom/ObjectSelect.tsx`. +// 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 */ @@ -28,45 +32,45 @@ type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBl type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect'; /** Single-select RHF props — stores T | null */ -export type FieldObjectSelectSingleProps< +export type FieldLocalSelectSingleProps< T extends Record, TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath, -> = ObjectSelectBaseProps & +> = LocalSelectBaseProps & UseControllerProps & Omit & { multiple?: false; }; /** Multi-select RHF props — stores T[] */ -export type FieldObjectSelectMultiProps< +export type FieldLocalSelectMultiProps< T extends Record, TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath, -> = ObjectSelectBaseProps & +> = LocalSelectBaseProps & UseControllerProps & Omit & { multiple: true; }; /** Discriminated union based on `multiple` */ -export type FieldObjectSelectProps< +export type FieldLocalSelectProps< T extends Record, TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath, > = - | FieldObjectSelectSingleProps - | FieldObjectSelectMultiProps; + | FieldLocalSelectSingleProps + | FieldLocalSelectMultiProps; // --------------------------------------------------------------------------- // Component Implementation // --------------------------------------------------------------------------- -function FieldObjectSelectInner< +function FieldLocalSelectInner< T extends Record, TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath, ->(props: FieldObjectSelectProps) { +>(props: FieldLocalSelectProps) { const { // RHF controller props name, @@ -75,14 +79,14 @@ function FieldObjectSelectInner< shouldUnregister, defaultValue, disabled, - // ObjectSelect engine props - data, + // LocalSelect engine props + options, valueKey, labelKey, renderLabel, multiple, filterOption, - onSelect: onObjectSelect, + onSelect: onSelectCallback, // Remaining Mantine props ...mantineProps } = props; @@ -105,15 +109,15 @@ function FieldObjectSelectInner< // Intercept onChange to pass full objects to RHF const handleChange = (value: any) => { field.onChange(value); - onObjectSelect?.(value); + onSelectCallback?.(value); }; // Build engine props based on single/multi mode if (multiple) { return ( - + multiple - data={data} + options={options} valueKey={valueKey} labelKey={labelKey} renderLabel={renderLabel} @@ -129,8 +133,8 @@ function FieldObjectSelectInner< } return ( - - data={data} + + options={options} valueKey={valueKey} labelKey={labelKey} renderLabel={renderLabel} @@ -145,5 +149,5 @@ function FieldObjectSelectInner< ); } -export const FieldObjectSelect = React.memo(FieldObjectSelectInner) as typeof FieldObjectSelectInner; -(FieldObjectSelect as any).displayName = 'FieldObjectSelect'; +export const FieldLocalSelect = React.memo(FieldLocalSelectInner) as typeof FieldLocalSelectInner; +(FieldLocalSelect as any).displayName = 'FieldLocalSelect'; diff --git a/packages/ui/src/components/Form/index.ts b/packages/ui/src/components/Form/index.ts index 57f3bcc..6cef66b 100644 --- a/packages/ui/src/components/Form/index.ts +++ b/packages/ui/src/components/Form/index.ts @@ -38,16 +38,23 @@ export { FieldNativeSelect } from './fields/native-select.field'; export { FieldTagsInput } from './fields/tags-input.field'; // --------------------------------------------------------------------------- -// Object & Async Selection Fields +// Local & Async Selection Fields // --------------------------------------------------------------------------- -export { FieldObjectSelect } from './fields/object-select.field'; -export type { FieldObjectSelectProps } from './fields/object-select.field'; +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 { ObjectSelect, AsyncSelect } from './custom'; -export type { ObjectSelectProps, AsyncSelectProps, ObjectSelectBaseProps, ObjectSelectFilterContext } from './custom'; +export { LocalSelect, AsyncSelect } from './custom'; +export type { + LocalSelectProps, + AsyncSelectProps, + LocalSelectBaseProps, + SelectFilterContext, + LoadOptionsResponse, + LoadOptionsFn, +} from './custom'; // --------------------------------------------------------------------------- // Toggle / Boolean Fields