feat: add reusable AsyncSelect and ObjectSelect components with infinite scroll hook support

This commit is contained in:
Firman Ramdhani
2026-06-16 01:23:36 +07:00
parent 19066dde89
commit 195928d56a
14 changed files with 1706 additions and 80 deletions
@@ -0,0 +1,156 @@
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 { ObjectSelectBaseProps } 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.
// ---------------------------------------------------------------------------
/** 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 */
interface AsyncExtraProps<T> {
apiEndpoint: string;
transformResponse?: (res: any) => { data: T[]; hasMore?: boolean } | T[];
pageSize?: number;
debounceMs?: number;
fetchFn?: (url: string) => Promise<any>;
}
/** Single-select async RHF props */
export type FieldAsyncSelectSingleProps<
T extends Record<string, any>,
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Omit<ObjectSelectBaseProps<T>, 'data'> &
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>,
> = Omit<ObjectSelectBaseProps<T>, 'data'> &
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: onObjectSelect,
apiEndpoint,
transformResponse,
pageSize,
debounceMs,
fetchFn,
// 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);
onObjectSelect?.(value);
};
const engineProps = {
valueKey,
labelKey,
renderLabel,
filterOption,
apiEndpoint,
transformResponse,
pageSize,
debounceMs,
fetchFn,
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';