Files
lti-web-client/src/components/input/SelectInput.tsx
T

237 lines
6.5 KiB
TypeScript

'use client';
import {
ComponentType,
ReactNode,
useEffect,
useMemo,
useState,
} from 'react';
import Select, {
OptionProps,
GroupBase,
InputActionMeta,
MultiValue,
SingleValue,
} from 'react-select';
import CreatableSelect from 'react-select/creatable';
import makeAnimated from 'react-select/animated';
import { useDebounce } from 'use-debounce';
import { cn } from '@/lib/helper';
export interface OptionType {
value: string | number;
label: string;
className?: string;
labelClassName?: string;
}
export type OptionComponent<T = OptionType> = ComponentType<
OptionProps<T, boolean, GroupBase<T>>
>;
interface SelectInputBaseProps<T = OptionType> {
label?: ReactNode;
bottomLabel?: ReactNode;
options: T[];
optionComponent?: OptionComponent<T>;
isDisabled?: boolean;
isLoading?: boolean;
isClearable?: boolean;
isRtl?: boolean;
isSearchable?: boolean;
isMulti?: boolean;
placeholder?: string;
required?: boolean;
className?: {
wrapper?: string;
label?: string;
select?: string;
};
isError?: boolean;
errorMessage?: string;
isAnimated?: boolean;
openMenu?: boolean;
delay?: number;
onInputChange?: (search: string) => void;
}
interface SelectInputProps<T = OptionType> extends SelectInputBaseProps<T> {
createables?: boolean;
value?: T | T[] | null;
onChange?: (val: T | T[] | null) => void;
}
const animatedComponents = makeAnimated();
const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
const {
label,
bottomLabel,
value,
onChange,
options,
optionComponent,
isDisabled,
isLoading,
isClearable,
isRtl,
isSearchable = true,
isMulti,
placeholder,
required,
className,
isError,
errorMessage,
isAnimated = true,
openMenu,
delay = 300,
createables = false,
onInputChange,
} = props;
const [internalInputValue, setInternalInputValue] = useState('');
const [debouncedInputValue] = useDebounce(internalInputValue, delay);
const components = useMemo(() => {
const base = isAnimated ? animatedComponents : {};
return { ...base, IndicatorSeparator: () => null };
}, [isAnimated]);
const internalInputChangeHandler = (
val: string,
meta: InputActionMeta
) => {
if (meta.action === 'input-change') setInternalInputValue(val);
if (meta.action === 'menu-close') setInternalInputValue('');
};
useEffect(() => {
onInputChange?.(debouncedInputValue);
}, [onInputChange, debouncedInputValue]);
const SelectComponent = createables ? CreatableSelect : Select;
/** 🎯 handleChange tanpa any */
const handleChange = (
val: MultiValue<T> | SingleValue<T>
): void => {
if (!val) {
onChange?.(null);
return;
}
if (isMulti) {
onChange?.(val as T[]);
} else {
onChange?.(val as T);
}
};
return (
<div
className={cn(
'w-full flex flex-col gap-2 text-start',
className?.wrapper
)}
>
{label && (
<span
className={cn(
'w-full text-sm font-normal leading-5',
{ 'text-error': isError },
className?.label
)}
>
{label}
{required && (
<span className="tooltip tooltip-error" data-tip="required">
<span className="text-error"> *</span>
</span>
)}
</span>
)}
<SelectComponent<T, boolean, GroupBase<T>>
instanceId="select"
value={value ?? (isMulti ? [] : null)}
onChange={handleChange}
options={options}
menuIsOpen={openMenu}
inputValue={internalInputValue}
onInputChange={internalInputChangeHandler}
isMulti={isMulti}
isDisabled={isDisabled}
isLoading={isLoading}
isClearable={isClearable}
isRtl={isRtl}
isSearchable={isSearchable}
placeholder={placeholder}
className={cn('w-full', className?.select)}
classNames={{
control: ({ isFocused, isDisabled }) =>
cn(
'w-full min-h-12! rounded-lg! 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: () =>
cn({ 'text-gray-900': !isError, 'text-error!': isError }),
input: () => cn('text-gray-900'),
indicatorsContainer: () => cn('flex items-center gap-1 pr-2'),
dropdownIndicator: ({ isFocused }) =>
cn('p-1 rounded-md hover:bg-gray-100', {
'text-gray-900': isFocused,
'text-gray-500': !isFocused,
'text-error!': isError,
}),
menu: () =>
cn('border border-gray-200 rounded-lg bg-white shadow-lg!'),
menuList: () => cn('p-2! max-h-60 overflow-auto'),
option: ({ isFocused, isSelected }) =>
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,
}),
multiValue: ({ getValue, index }) => {
const selectedValues = getValue() as T[];
return cn(
'bg-indigo-50 rounded-md py-0.5 pl-2 pr-1 flex items-center gap-1!',
selectedValues[index]?.className
);
},
multiValueLabel: ({ getValue, index }) => {
const selectedValues = getValue() as T[];
return cn('text-indigo-700', selectedValues[index]?.labelClassName);
},
}}
components={{
...components,
...(optionComponent ? { Option: optionComponent } : {}),
}}
menuPortalTarget={
typeof document !== 'undefined' ? document.body : undefined
}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
}}
/>
{isError && <p className="w-full text-sm text-error">{errorMessage}</p>}
{!isError && bottomLabel && (
<p className="w-full text-sm opacity-60">{bottomLabel}</p>
)}
</div>
);
};
export default SelectInput;