refactor(FE-92-87): mengganti select input dengan reuseable component

This commit is contained in:
randy-ar
2025-10-30 21:23:37 +07:00
parent d0d201bf3a
commit 99194eaf80
5 changed files with 320 additions and 205 deletions
+64 -19
View File
@@ -1,22 +1,23 @@
'use client';
import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react';
import useSWR from 'swr';
import Select, {
OptionProps,
GroupBase,
InputActionMeta,
MultiValue,
SingleValue,
components as ReactSelectComponents,
ControlProps,
} from 'react-select';
import CreatableSelect from 'react-select/creatable';
import makeAnimated from 'react-select/animated';
import { useDebounce } from 'use-debounce';
import { cn, getByPath } from '@/lib/helper';
import useSWR from 'swr';
import { httpClientFetcher } from '@/services/http/client';
import { isResponseSuccess } from '@/lib/api-helper';
import { BaseApiResponse } from '@/types/api/api-general';
import { isResponseSuccess } from '@/lib/api-helper';
export interface OptionType {
value: string | number;
@@ -53,6 +54,7 @@ interface SelectInputBaseProps<T = OptionType> {
openMenu?: boolean;
delay?: number;
onInputChange?: (search: string) => void;
startAdornment?: ReactNode;
}
interface SelectInputProps<T = OptionType> extends SelectInputBaseProps<T> {
@@ -63,6 +65,33 @@ interface SelectInputProps<T = OptionType> extends SelectInputBaseProps<T> {
const animatedComponents = makeAnimated();
const CustomControl = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>(
props: ControlProps<Option, IsMulti, Group>
) => {
const { children } = props;
const customProps = props.selectProps as unknown as {
shouldShowAdornment?: boolean;
startAdornment?: ReactNode;
};
const shouldShowAdornment = customProps.shouldShowAdornment ?? false;
const startAdornment = customProps.startAdornment;
return (
<ReactSelectComponents.Control {...props}>
<div className='flex-1 px-4! py-1.5 gap-1 flex items-center'>
{shouldShowAdornment && startAdornment}
{children}
</div>
</ReactSelectComponents.Control>
);
};
const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
const {
label,
@@ -87,15 +116,24 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
delay = 300,
createables = false,
onInputChange,
startAdornment,
} = props;
const [internalInputValue, setInternalInputValue] = useState('');
const [debouncedInputValue] = useDebounce(internalInputValue, delay);
const shouldShowAdornment = startAdornment && !internalInputValue;
const components = useMemo(() => {
const base = isAnimated ? animatedComponents : {};
return { ...base, IndicatorSeparator: () => null };
}, [isAnimated]);
const customComponents = { ...base, IndicatorSeparator: () => null };
if (startAdornment) {
customComponents.Control = CustomControl;
}
return customComponents;
}, [isAnimated, startAdornment]);
const internalInputChangeHandler = (val: string, meta: InputActionMeta) => {
if (meta.action === 'input-change') setInternalInputValue(val);
@@ -148,12 +186,13 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
<SelectComponent<T, boolean, GroupBase<T>>
instanceId='select'
value={value ?? (isMulti ? [] : null)}
onChange={handleChange}
value={value ?? (isMulti ? [] : undefined)}
onChange={onChange ? handleChange : undefined}
options={options}
menuIsOpen={openMenu}
inputValue={internalInputValue}
onInputChange={internalInputChangeHandler}
onMenuClose={() => setInternalInputValue('')}
isMulti={isMulti}
isDisabled={isDisabled}
isLoading={isLoading}
@@ -163,17 +202,19 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
placeholder={placeholder}
className={cn('w-full', className?.select)}
classNames={{
control: ({ isFocused, isDisabled }) =>
cn(
'w-full min-h-12! rounded border bg-white transition-shadow cursor-pointer!',
{
'border-red-500! ring-2 ring-red-200': isError,
'border-indigo-500 ring-2 ring-indigo-200': isFocused,
'border-gray-300': !isError && !isFocused,
'bg-gray-100 text-gray-400 cursor-not-allowed': isDisabled,
}
),
valueContainer: () => cn('flex-1 px-4! py-2! gap-1'),
...(!startAdornment && {
control: ({ isFocused, isDisabled }) =>
cn(
'w-full min-h-12! rounded border bg-white transition-shadow cursor-pointer!',
{
'border-red-500! ring-2 ring-red-200': isError,
'border-indigo-500 ring-2 ring-indigo-200': isFocused,
'border-gray-300': !isError && !isFocused,
'bg-gray-100 text-gray-400 cursor-not-allowed': isDisabled,
}
),
valueContainer: () => cn('flex-1 px-4! py-2! gap-1'),
}),
placeholder: () =>
cn({ 'text-gray-400': !isError, 'text-red-300!': isError }),
singleValue: () =>
@@ -190,7 +231,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
cn('border border-gray-200 rounded! bg-base-100 shadow-lg!'),
menuList: () => cn('p-2! max-h-60 overflow-auto'),
option: ({ isFocused, isSelected }) =>
cn('mt-1 px-3 py-2 rounded cursor-pointer!', {
cn('mt-1 px-3 py-2 rounded-md cursor-pointer!', {
'bg-indigo-600 text-white': isFocused,
'bg-blue-500!': isSelected,
'text-gray-700': !isFocused && !isSelected,
@@ -211,6 +252,10 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
...components,
...(optionComponent ? { Option: optionComponent } : {}),
}}
{...(startAdornment && {
shouldShowAdornment,
startAdornment,
})}
menuPortalTarget={
typeof document !== 'undefined' ? document.body : undefined
}