From 195928d56a35a0b55958dd96ba1efe60a8aadb70 Mon Sep 17 00:00:00 2001
From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com>
Date: Tue, 16 Jun 2026 01:23:36 +0700
Subject: [PATCH] feat: add reusable AsyncSelect and ObjectSelect components
with infinite scroll hook support
---
.../form-demo/components/all-fields-demo.tsx | 122 ++++++++-
packages/ui/docs/FORM-COMPONENTS.md | 2 +
.../__tests__/async-select.field.test.tsx | 249 +++++++++++++++++
.../__tests__/object-select.field.test.tsx | 255 ++++++++++++++++++
.../ui/src/components/Form/custom/index.ts | 14 +
.../Form/custom/selects/AsyncSelect.tsx | 236 ++++++++++++++++
.../Form/custom/selects/ObjectSelect.tsx | 181 +++++++++++++
.../hooks/useAsyncSelectInfiniteScroll.ts | 190 +++++++++++++
.../components/Form/custom/selects/types.ts | 61 +++++
.../Form/fields/async-select.field.tsx | 156 +++++++++++
.../Form/fields/object-select.field.tsx | 149 ++++++++++
packages/ui/src/components/Form/index.ts | 12 +
.../src/components/Form/useTranslatedError.ts | 78 ++++++
packages/ui/src/components/Form/withRHF.tsx | 81 +-----
14 files changed, 1706 insertions(+), 80 deletions(-)
create mode 100644 packages/ui/src/components/Form/__tests__/async-select.field.test.tsx
create mode 100644 packages/ui/src/components/Form/__tests__/object-select.field.test.tsx
create mode 100644 packages/ui/src/components/Form/custom/index.ts
create mode 100644 packages/ui/src/components/Form/custom/selects/AsyncSelect.tsx
create mode 100644 packages/ui/src/components/Form/custom/selects/ObjectSelect.tsx
create mode 100644 packages/ui/src/components/Form/custom/selects/hooks/useAsyncSelectInfiniteScroll.ts
create mode 100644 packages/ui/src/components/Form/custom/selects/types.ts
create mode 100644 packages/ui/src/components/Form/fields/async-select.field.tsx
create mode 100644 packages/ui/src/components/Form/fields/object-select.field.tsx
create mode 100644 packages/ui/src/components/Form/useTranslatedError.ts
diff --git a/apps/web/src/apps/showcase/example/features/form-demo/components/all-fields-demo.tsx b/apps/web/src/apps/showcase/example/features/form-demo/components/all-fields-demo.tsx
index b7cd86d..77a43cb 100644
--- a/apps/web/src/apps/showcase/example/features/form-demo/components/all-fields-demo.tsx
+++ b/apps/web/src/apps/showcase/example/features/form-demo/components/all-fields-demo.tsx
@@ -6,10 +6,33 @@ import {
FieldMultiSelect, FieldNativeSelect, FieldTagsInput, FieldCheckbox,
FieldRadioGroup, FieldSwitch, FieldChipGroup, FieldSegmentedControl,
FieldSlider, FieldRangeSlider, FieldRating, FieldColorInput,
- FieldColorPicker, FieldFileInput
+ FieldColorPicker, FieldFileInput, FieldObjectSelect, FieldAsyncSelect
} 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 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
+ await new Promise((resolve) => setTimeout(resolve, 500));
+
+ // Filter and paginate
+ const filtered = MOCK_POKEMON.filter((p) => p.name.toLowerCase().includes(search));
+ const start = (page - 1) * pageSize;
+ const paginated = filtered.slice(start, start + pageSize);
+
+ return { results: paginated };
+};
+
export default function AllFieldsDemo() {
const t = useFormDemoTranslation();
@@ -37,7 +60,13 @@ export default function AllFieldsDemo() {
rating: 0,
themeColor: '',
colorPicker: '#1c7ed6',
- avatar: null
+ avatar: null,
+ objectSelect: null,
+ asyncSelect: null,
+ multiObjectSelect: [],
+ multiAsyncSelect: [],
+ realPokeSelect: null,
+ multiRealPokeSelect: []
}
});
@@ -103,6 +132,95 @@ export default function AllFieldsDemo() {
data={['Electronics', 'Fashion', 'Food']}
/>
+
+
+ `${item.name} (${item.hex})`}
+ clearable
+ />
+
+
+ res.results}
+ valueKey="id"
+ labelKey="name"
+ clearable
+ />
+ res.results}
+ valueKey="id"
+ labelKey="name"
+ clearable
+ />
+
+
+ ({
+ data: res.results.map((p: any, i: number) => ({ id: i + 1, ...p })),
+ hasMore: !!res.next
+ })}
+ valueKey="id"
+ labelKey="name"
+ clearable
+ />
+ ({
+ data: res.results.map((p: any, i: number) => ({ id: i + 1, ...p })),
+ hasMore: !!res.next
+ })}
+ valueKey="id"
+ labelKey="name"
+ clearable
+ />
+
diff --git a/packages/ui/docs/FORM-COMPONENTS.md b/packages/ui/docs/FORM-COMPONENTS.md
index dcbc0f1..e65c0ec 100644
--- a/packages/ui/docs/FORM-COMPONENTS.md
+++ b/packages/ui/docs/FORM-COMPONENTS.md
@@ -683,6 +683,8 @@ export const FieldDatePicker = withRHF(
| `FieldRating` | `Rating` | Range | Star rating |
| `FieldColorInput` | `ColorInput` | Color | Color picker with text input |
| `FieldColorPicker` | `ColorPicker` | Color | Color picker only (uses `Input.Wrapper`) |
+| `FieldObjectSelect` | `Select / MultiSelect` | Selection | Stores full `T` or `T[]` object in RHF instead of string ID |
+| `FieldAsyncSelect` | `Select / MultiSelect` | Selection | Paginated infinite scroll select mapping API responses to RHF objects |
| `FieldFileInput` | `FileInput` | File | File upload input |
---
diff --git a/packages/ui/src/components/Form/__tests__/async-select.field.test.tsx b/packages/ui/src/components/Form/__tests__/async-select.field.test.tsx
new file mode 100644
index 0000000..675ef38
--- /dev/null
+++ b/packages/ui/src/components/Form/__tests__/async-select.field.test.tsx
@@ -0,0 +1,249 @@
+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';
+
+// ---------------------------------------------------------------------------
+// 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_API_RESPONSE = {
+ items: [
+ { id: 1, code: 'V1', name: 'Vendor 1' },
+ { id: 2, code: 'V2', name: 'Vendor 2' },
+ { id: 3, code: 'V3', name: 'Vendor 3' },
+ ],
+ total: 3,
+};
+
+// Mock fetchFn
+const mockFetchFn = vi.fn().mockResolvedValue(MOCK_API_RESPONSE);
+
+// Test wrapper removed to avoid useForm conflicts
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('FieldAsyncSelect', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('fetches initial page on mount and renders items', async () => {
+ const user = userEvent.setup();
+
+ function TestForm() {
+ const { control } = useForm({ defaultValues: { vendor: null } });
+ return (
+
+
+
+ );
+ }
+
+ render();
+
+ // Wait for initial fetch
+ await waitFor(() => {
+ expect(mockFetchFn).toHaveBeenCalledWith('/api/vendors?page=1&pageSize=20&search=');
+ });
+
+ 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;
+
+ function TestForm() {
+ const { control, handleSubmit } = useForm({ defaultValues: { vendor: null } });
+ return (
+
+
+
+ );
+ }
+
+ render();
+
+ await waitFor(() => {
+ expect(mockFetchFn).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 () => {
+ // We mock fetch to return only V1 and V2
+ const partialFetchFn = vi.fn().mockResolvedValue({
+ items: [
+ { id: 1, code: 'V1', name: 'Vendor 1' },
+ { id: 2, code: 'V2', name: 'Vendor 2' },
+ ]
+ });
+
+ // The form starts with V99 pre-selected (e.g. from server hydration)
+ const PRESELECTED_VENDOR = { id: 99, code: 'V99', name: 'Vendor 99' };
+
+ function TestForm() {
+ const { control } = useForm({ defaultValues: { vendor: PRESELECTED_VENDOR } });
+ return (
+
+
+
+ );
+ }
+
+ render();
+
+ await waitFor(() => {
+ expect(partialFetchFn).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();
+
+ function TestForm() {
+ const { control } = useForm({ defaultValues: { vendor: null } });
+ return (
+
+
+
+ );
+ }
+
+ render();
+
+ // Wait for initial fetch
+ await waitFor(() => {
+ expect(mockFetchFn).toHaveBeenCalledTimes(1);
+ });
+
+ const input = screen.getByPlaceholderText('Search vendor');
+ await user.type(input, 'test');
+
+ // Wait for debounced fetch
+ await waitFor(() => {
+ expect(mockFetchFn).toHaveBeenCalledTimes(2);
+ expect(mockFetchFn).toHaveBeenLastCalledWith('/api/vendors?page=1&pageSize=20&search=test');
+ });
+ });
+
+ it('gracefully deduplicates overlapping data across API responses', async () => {
+ // API returns Vendor 1 twice
+ const badFetchFn = vi.fn().mockResolvedValue({
+ items: [
+ { id: 1, code: 'V1', name: 'Vendor 1' },
+ { id: 1, code: 'V1', name: 'Vendor 1 (Duplicate)' },
+ { id: 2, code: 'V2', name: 'Vendor 2' },
+ ]
+ });
+
+ function TestForm() {
+ const { control } = useForm({ defaultValues: { vendor: null } });
+ return (
+
+
+
+ );
+ }
+
+ render();
+
+ await waitFor(() => {
+ expect(badFetchFn).toHaveBeenCalledTimes(1);
+ });
+
+ const user = userEvent.setup();
+ await user.click(screen.getByPlaceholderText('Select bad vendor'));
+
+ // Should only render "Vendor 1" once, ignoring the duplicate with id=1
+ const vendor1Options = screen.getAllByText('Vendor 1');
+ expect(vendor1Options.length).toBe(1);
+
+ // The duplicate name should NOT be rendered
+ expect(screen.queryByText('Vendor 1 (Duplicate)')).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/ui/src/components/Form/__tests__/object-select.field.test.tsx b/packages/ui/src/components/Form/__tests__/object-select.field.test.tsx
new file mode 100644
index 0000000..3d00073
--- /dev/null
+++ b/packages/ui/src/components/Form/__tests__/object-select.field.test.tsx
@@ -0,0 +1,255 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+import { useForm, FormProvider } from 'react-hook-form';
+import { MantineProvider } from '@mantine/core';
+import { FieldObjectSelect } from '../fields/object-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('FieldObjectSelect', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders with labelKey and displays correct labels', () => {
+ function TestForm() {
+ const { control } = useForm({ defaultValues: { vendor: null } });
+ return (
+
+
+
+ );
+ }
+
+ render();
+ expect(screen.getByText('Vendor')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Select vendor')).toBeInTheDocument();
+ });
+
+ it('stores the full original object in RHF on selection', async () => {
+ const user = userEvent.setup();
+ let capturedData: any = null;
+
+ function TestForm() {
+ const { control, handleSubmit } = useForm({ defaultValues: { vendor: null } });
+ return (
+
+
+
+ );
+ }
+
+ render();
+
+ // Open dropdown
+ await user.click(screen.getByPlaceholderText('Select vendor'));
+
+ // Click Vendor 2
+ await user.click(screen.getByText('Vendor 2'));
+
+ // Submit
+ await user.click(screen.getByText('Submit'));
+
+ // Should contain the full object, not just '2'
+ expect(capturedData).toEqual({ vendor: VENDORS[1] });
+ });
+
+ it('renders with renderLabel for compound labels', async () => {
+ const user = userEvent.setup();
+
+ function TestForm() {
+ const { control } = useForm({ defaultValues: { vendor: null } });
+ return (
+
+ `${item.code} - ${item.name}`}
+ placeholder="Select vendor"
+ />
+
+ );
+ }
+
+ render();
+ await user.click(screen.getByPlaceholderText('Select vendor'));
+
+ // Compound label should be visible
+ expect(screen.getByText('V1 - Vendor 1')).toBeInTheDocument();
+ });
+
+
+
+ it('filterOption excludes items from dropdown', async () => {
+ const user = userEvent.setup();
+
+ function TestForm() {
+ const { control } = useForm({ defaultValues: { vendor: null } });
+ return (
+
+ item.active === true}
+ />
+
+ );
+ }
+
+ render();
+ await user.click(screen.getByPlaceholderText('Select vendor'));
+
+ // Active vendors should be present
+ expect(screen.getByText('Vendor 1')).toBeInTheDocument();
+ expect(screen.getByText('Vendor 3')).toBeInTheDocument();
+ // Inactive vendor should not be present
+ expect(screen.queryByText('Vendor 2')).not.toBeInTheDocument();
+ });
+
+ it('multi-select stores T[] in RHF', async () => {
+ const user = userEvent.setup();
+ let capturedData: any = null;
+
+ function TestForm() {
+ const { control, handleSubmit } = useForm({ defaultValues: { vendors: [] } });
+ return (
+
+
+
+ );
+ }
+
+ render();
+ await user.click(screen.getByPlaceholderText('Select vendors'));
+ await user.click(screen.getByText('Vendor 1'));
+ await user.click(screen.getByText('Vendor 3'));
+
+ await user.click(screen.getByText('Submit'));
+ expect(capturedData).toEqual({ vendors: [VENDORS[0], VENDORS[2]] });
+ });
+
+ it('multi-select clearable resets to empty array', async () => {
+ const user = userEvent.setup();
+ let capturedData: any = null;
+
+ function TestForm() {
+ const { control, handleSubmit } = useForm({ defaultValues: { vendors: [VENDORS[0]] } });
+ return (
+
+
+
+ );
+ }
+
+ const { container } = render();
+
+ const clearButton = container.querySelector('.mantine-CloseButton-root') || container.querySelector('button[aria-label="Clear value"]');
+ expect(clearButton).not.toBeNull();
+ await user.click(clearButton!);
+
+ await user.click(screen.getByText('Submit'));
+ expect(capturedData).toEqual({ vendors: [] });
+ });
+
+ it('onSelect callback fires with correct object', async () => {
+ const user = userEvent.setup();
+ const handleSelect = vi.fn();
+
+ function TestForm() {
+ const { control } = useForm({ defaultValues: { vendor: null } });
+ return (
+
+
+
+ );
+ }
+
+ render();
+ await user.click(screen.getByPlaceholderText('Select vendor'));
+ await user.click(screen.getByText('Vendor 2'));
+
+ expect(handleSelect).toHaveBeenCalledWith(VENDORS[1]);
+ });
+});
diff --git a/packages/ui/src/components/Form/custom/index.ts b/packages/ui/src/components/Form/custom/index.ts
new file mode 100644
index 0000000..eb8663f
--- /dev/null
+++ b/packages/ui/src/components/Form/custom/index.ts
@@ -0,0 +1,14 @@
+// ---------------------------------------------------------------------------
+// Custom Form Components — Barrel Export
+// ---------------------------------------------------------------------------
+// Reusable select engines that work WITHOUT React Hook Form.
+// For RHF-connected versions, use `@repo/ui/form` (FieldObjectSelect, FieldAsyncSelect).
+// ---------------------------------------------------------------------------
+
+export { ObjectSelect } from './selects/ObjectSelect';
+export type { ObjectSelectProps, ObjectSelectSingleProps, ObjectSelectMultiProps } from './selects/ObjectSelect';
+
+export { AsyncSelect } from './selects/AsyncSelect';
+export type { AsyncSelectProps, AsyncSelectSingleProps, AsyncSelectMultiProps } from './selects/AsyncSelect';
+
+export type { ObjectSelectBaseProps, ObjectSelectFilterContext, ObjectSelectMappingResult } from './selects/types';
diff --git a/packages/ui/src/components/Form/custom/selects/AsyncSelect.tsx b/packages/ui/src/components/Form/custom/selects/AsyncSelect.tsx
new file mode 100644
index 0000000..64de783
--- /dev/null
+++ b/packages/ui/src/components/Form/custom/selects/AsyncSelect.tsx
@@ -0,0 +1,236 @@
+import React, { useMemo, useCallback } from 'react';
+import { Select, MultiSelect, Loader, type SelectProps, type MultiSelectProps, type ComboboxItem } from '@mantine/core';
+import type { ObjectSelectBaseProps, ObjectSelectFilterContext } from './types';
+import { useAsyncSelectInfiniteScroll } from './hooks/useAsyncSelectInfiniteScroll';
+
+// ---------------------------------------------------------------------------
+// AsyncSelect — Reusable async/paginated Select engine (no RHF dependency)
+// ---------------------------------------------------------------------------
+
+/** 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 */
+interface AsyncExtraProps {
+ apiEndpoint: string;
+ transformResponse?: (res: any) => { data: T[]; hasMore?: boolean } | T[];
+ pageSize?: number;
+ debounceMs?: number;
+ fetchFn?: (url: string) => Promise;
+}
+
+/** Props for single-select async mode */
+export type AsyncSelectSingleProps> = Omit, 'data'> &
+ AsyncExtraProps &
+ Omit & {
+ multiple?: false;
+ value?: T | null;
+ onChange?: (value: T | null) => void;
+ };
+
+/** Props for multi-select async mode */
+export type AsyncSelectMultiProps> = Omit, 'data'> &
+ AsyncExtraProps &
+ Omit & {
+ multiple: true;
+ value?: T[];
+ onChange?: (value: T[]) => void;
+ };
+
+export type AsyncSelectProps> = AsyncSelectSingleProps | AsyncSelectMultiProps;
+
+// ---------------------------------------------------------------------------
+// Helper: Resolve label for a data item
+// ---------------------------------------------------------------------------
+
+function resolveLabel>(
+ item: T,
+ labelKey?: keyof T & string,
+ renderLabel?: (item: T) => string,
+): string {
+ if (renderLabel) return renderLabel(item);
+ if (labelKey) return String(item[labelKey] ?? '');
+ return String(item[Object.keys(item)[0] as keyof T] ?? '');
+}
+
+// ---------------------------------------------------------------------------
+// Component Implementation
+// ---------------------------------------------------------------------------
+
+function AsyncSelectInner>(props: AsyncSelectProps) {
+ const {
+ valueKey,
+ labelKey,
+ renderLabel,
+ multiple,
+ filterOption,
+ onSelect: onObjectSelect,
+ value,
+ onChange,
+ apiEndpoint,
+ transformResponse,
+ pageSize,
+ debounceMs,
+ fetchFn,
+ searchable,
+ ...mantineProps
+ } = props;
+
+ // Use the infinite scroll hook for data fetching
+ const {
+ data: fetchedData,
+ isLoading,
+ fetchNextPage,
+ search,
+ debouncedSearch,
+ setSearch,
+ } = useAsyncSelectInfiniteScroll({
+ apiEndpoint,
+ valueKey,
+ pageSize,
+ transformResponse,
+ debounceMs,
+ fetchFn,
+ });
+
+ // Inject pre-selected values that aren't in the fetched data yet,
+ // and safeguard against bad APIs that return duplicate items across pages.
+ const dataWithInjected = useMemo(() => {
+ const uniqueItems: T[] = [];
+ const seen = new Set();
+
+ // 1. Deduplicate fetched data from the API (hook already deduplicates internally, but this is an extra UI safeguard)
+ for (const item of fetchedData) {
+ const key = String(item[valueKey]);
+ if (!seen.has(key)) {
+ seen.add(key);
+ uniqueItems.push(item);
+ }
+ }
+
+ // 2. Inject active RHF values if they aren't in the fetched list
+ 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); // Put 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
+ const lookupMap = useMemo(() => {
+ const map = new Map();
+ for (const item of dataWithInjected) {
+ map.set(String(item[valueKey]), item);
+ }
+ return map;
+ }, [dataWithInjected, valueKey]);
+
+ // Build Mantine options from the resolved data
+ const baseOptions = useMemo(() => {
+ let filtered = dataWithInjected;
+
+ if (filterOption) {
+ const context: ObjectSelectFilterContext = {
+ search, // Pass the active search string to the custom filter
+ 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 isTyping = search !== debouncedSearch;
+ const isFetching = isLoading || isTyping;
+ const rightSection = isFetching ? : mantineProps.rightSection;
+
+ // Handle search → delegate to the hook's setSearch (debounced)
+ const handleSearchChange = useCallback(
+ (val: string) => {
+ setSearch(val);
+ },
+ [setSearch],
+ );
+
+ // 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 should always display what the backend returns.
+ const mantineFilter = useCallback(({ options: opts }: { options: ComboboxItem[] }) => opts, []);
+
+ // ----- Multi-select mode -----
+ if (multiple) {
+ const currentValues = Array.isArray(value) ? value.map((v) => String(v[valueKey])) : [];
+
+ const handleMultiChange = (vals: string[]) => {
+ const objects = vals.map((v) => lookupMap.get(v)!).filter(Boolean);
+ (onChange as ((v: T[]) => void) | undefined)?.(objects);
+ onObjectSelect?.(objects);
+ };
+
+ return (
+ )}
+ data={baseOptions}
+ value={currentValues}
+ onChange={handleMultiChange}
+ searchable={searchable ?? true}
+ onSearchChange={handleSearchChange}
+ scrollAreaProps={scrollAreaProps}
+ filter={mantineFilter}
+ rightSection={rightSection}
+ />
+ );
+ }
+
+ // ----- Single-select mode -----
+ const currentValue = value ? String((value as T)[valueKey]) : null;
+
+ const handleSingleChange = (val: string | null) => {
+ const obj = val ? (lookupMap.get(val) ?? null) : null;
+ (onChange as ((v: T | null) => void) | undefined)?.(obj);
+ onObjectSelect?.(obj);
+ };
+
+ return (
+