mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
237 lines
6.5 KiB
TypeScript
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;
|