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

605 lines
19 KiB
TypeScript

'use client';
import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react';
import Select, {
OptionProps,
GroupBase,
InputActionMeta,
MultiValue,
SingleValue,
components as ReactSelectComponents,
ControlProps,
MenuListProps,
} 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 useSWRInfinite from 'swr/infinite';
import { httpClientFetcher } from '@/services/http/client';
import {
BaseApiResponse,
ErrorApiResponse,
SuccessApiResponse,
} from '@/types/api/api-general';
import { isResponseError, isResponseSuccess } from '@/lib/api-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>;
components?: Partial<typeof ReactSelectComponents>;
isDisabled?: boolean;
readOnly?: boolean;
isLoading?: boolean;
isClearable?: boolean;
isRtl?: boolean;
isSearchable?: boolean;
isMulti?: boolean;
placeholder?: string;
required?: boolean;
className?: {
wrapper?: string;
label?: string;
select?: string;
inputPrefix?: string;
inputSuffix?: string;
inputPrefixSuffixWrapper?: string;
};
isError?: boolean;
errorMessage?: string;
isAnimated?: boolean;
openMenu?: boolean;
delay?: number;
onInputChange?: (search: string) => void;
startAdornment?: ReactNode;
inputPrefix?: ReactNode;
inputSuffix?: ReactNode;
menuPortalTarget?: HTMLElement | null;
closeMenuOnSelect?: boolean;
hideSelectedOptions?: boolean;
onMenuScrollToBottom?: ((event: WheelEvent | TouchEvent) => void) | undefined;
}
export interface SelectInputProps<T = OptionType>
extends SelectInputBaseProps<T> {
createables?: boolean;
value?: T | T[] | null;
onChange?: (val: T | T[] | null) => void;
}
const animatedComponents = makeAnimated();
const CustomControl = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>,
>(
props: ControlProps<Option, IsMulti, Group>
) => {
const { children, innerProps } = 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 pl-3 gap-1 flex items-center' {...innerProps}>
{shouldShowAdornment && startAdornment}
{children}
</div>
</ReactSelectComponents.Control>
);
};
const CustomMenuList = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>,
>(
props: MenuListProps<Option, IsMulti, Group>
) => {
const { children, selectProps, options } = props;
const { isLoading } = selectProps;
return (
<ReactSelectComponents.MenuList {...props}>
{children}
{options.length > 0 && isLoading && (
<div className='px-3 py-2 rounded-md text-center text-gray-400'>
<span className='loading loading-spinner loading-md' />
</div>
)}
</ReactSelectComponents.MenuList>
);
};
const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
const {
label,
bottomLabel,
value,
onChange,
options,
optionComponent,
components: customComponents,
isDisabled,
isLoading,
isClearable,
isRtl,
isSearchable = true,
isMulti,
placeholder,
required,
className,
isError,
errorMessage,
isAnimated = true,
openMenu,
delay = 300,
createables = false,
onInputChange,
startAdornment,
inputPrefix,
inputSuffix,
menuPortalTarget,
closeMenuOnSelect,
hideSelectedOptions,
onMenuScrollToBottom,
readOnly,
} = props;
const [internalInputValue, setInternalInputValue] = useState('');
const [debouncedInputValue] = useDebounce(internalInputValue, delay);
const shouldShowAdornment = startAdornment && !internalInputValue;
const components = useMemo(() => {
const base = isAnimated ? animatedComponents : {};
const mergedComponents = { ...base, IndicatorSeparator: () => null };
if (startAdornment) {
mergedComponents.Control = CustomControl;
}
if (customComponents) {
Object.assign(mergedComponents, customComponents);
}
return mergedComponents;
}, [isAnimated, startAdornment, customComponents]);
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 text-start', className?.wrapper)}>
{label && (
<span
className={cn(
'w-full py-2 text-xs font-semibold leading-5',
{ 'text-error': isError },
className?.label
)}
>
{label}
{required && (
<>
{' '}
<span className='tooltip tooltip-error' data-tip='required'>
<span className='text-error'>*</span>
</span>
</>
)}
</span>
)}
{inputPrefix || inputSuffix ? (
<div
className={cn(
'relative flex text-sm',
className?.inputPrefixSuffixWrapper
)}
>
{inputPrefix && (
<div
className={cn(
'inline-flex items-center px-3 border border-r-0 border-base-content/10 rounded-l-lg transition-all duration-200',
{
'bg-base-100 border-base-content/10': !isDisabled,
'bg-base-200 border-base-content/10': isDisabled,
'border-error': isError,
},
className?.inputPrefix
)}
>
{inputPrefix}
</div>
)}
<SelectComponent<T, boolean, GroupBase<T>>
instanceId='select'
value={value ?? (isMulti ? [] : null)}
onChange={onChange ? handleChange : undefined}
options={options}
menuIsOpen={openMenu}
inputValue={internalInputValue}
onInputChange={internalInputChangeHandler}
onMenuClose={() => setInternalInputValue('')}
isMulti={isMulti}
isDisabled={isDisabled || readOnly}
isLoading={isLoading}
isClearable={isClearable}
isRtl={isRtl}
isSearchable={isSearchable}
placeholder={placeholder}
closeMenuOnSelect={closeMenuOnSelect}
hideSelectedOptions={hideSelectedOptions}
className={cn('w-full flex-1', className?.select)}
classNames={{
control: ({ isFocused, isDisabled }) =>
cn('w-full border transition-shadow', 'rounded-lg!', {
'bg-base-100!': !isDisabled && !readOnly,
'bg-base-200! text-gray-400 cursor-not-allowed':
isDisabled && !readOnly,
'bg-transparent! cursor-not-allowed!': readOnly,
'cursor-pointer!': !readOnly && !isDisabled,
'border-error!': isError,
'ring-2 ring-error/20': isError,
'border-indigo-500 ring-2 ring-indigo-200':
isFocused && !startAdornment && !isError,
'border-base-content/10!': !isError && !isFocused,
'rounded-l-none!': inputPrefix && !startAdornment,
'rounded-r-none!': inputSuffix && !startAdornment,
}),
valueContainer: () => cn('flex-1 px-3! pr-2! py-2.5! gap-1'),
placeholder: () =>
cn('text-gray-400 text-sm leading-tight', {
'text-error!': isError,
}),
singleValue: () =>
cn('m-0! text-gray-900 text-sm leading-tight', {
'text-error!': isError && !readOnly,
'text-gray-900!': readOnly,
}),
input: () => cn('text-gray-900 m-0! p-0! text-sm leading-tight'),
indicatorsContainer: () =>
cn('flex items-center gap-1 pr-3 py-2'),
dropdownIndicator: ({ isFocused }) =>
cn('p-0! rounded hover:bg-gray-100', {
'text-gray-900': isFocused,
'text-gray-500': !isFocused,
'text-error!': isError,
}),
clearIndicator: () => cn('p-0! rounded hover:bg-gray-100'),
menu: () =>
cn(
'border border-base-content/5 rounded-xl! bg-base-100 shadow-lg! my-1.5!'
),
menuList: () => cn('p-0! max-h-60 overflow-auto'),
option: ({ isFocused, isSelected }) =>
cn('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-base-200! rounded-lg! py-[3px] px-2.5 m-0! flex items-center gap-1! w-fit gap-2!',
selectedValues[index]?.className
);
},
multiValueRemove: () => cn('p-0! w-3 h-3'),
multiValueLabel: ({ getValue, index }) => {
const selectedValues = getValue() as T[];
return cn(
'p-0! text-base-content! text-xs!',
selectedValues[index]?.labelClassName
);
},
}}
components={{
...components,
...(optionComponent ? { Option: optionComponent } : {}),
MenuList: CustomMenuList,
}}
{...(startAdornment && {
shouldShowAdornment,
startAdornment,
})}
menuPortalTarget={
typeof document !== 'undefined'
? (menuPortalTarget ?? document.body)
: undefined
}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
multiValue(base) {
return {
...base,
borderRadius: '8px',
};
},
}}
onMenuScrollToBottom={onMenuScrollToBottom}
/>
{inputSuffix && (
<div
className={cn(
'inline-flex items-center px-3 border border-l-0 border-base-content/10 rounded-r-lg transition-all duration-200',
{
'bg-base-100 border-base-content/10': !isDisabled,
'bg-base-200 border-base-content/10': isDisabled,
'border-error': isError,
},
className?.inputSuffix
)}
>
{inputSuffix}
</div>
)}
</div>
) : (
<SelectComponent<T, boolean, GroupBase<T>>
instanceId='select'
value={value ?? (isMulti ? [] : null)}
onChange={onChange ? handleChange : undefined}
options={options}
menuIsOpen={openMenu}
inputValue={internalInputValue}
onInputChange={internalInputChangeHandler}
onMenuClose={() => setInternalInputValue('')}
isMulti={isMulti}
isDisabled={isDisabled || readOnly}
isLoading={isLoading}
isClearable={isClearable}
isRtl={isRtl}
isSearchable={isSearchable}
placeholder={placeholder}
closeMenuOnSelect={closeMenuOnSelect}
hideSelectedOptions={hideSelectedOptions}
className={cn('w-full', className?.select)}
classNames={{
control: ({ isFocused, isDisabled }) =>
cn('w-full border transition-shadow rounded-lg!', {
'bg-base-100!': !isDisabled && !readOnly,
'bg-base-200! text-gray-400 cursor-not-allowed':
isDisabled && !readOnly,
'bg-transparent! cursor-not-allowed!': readOnly,
'cursor-pointer!': !readOnly && !isDisabled,
'border-error!': isError,
'ring-2 ring-error/20': isError,
'border-indigo-500 ring-2 ring-indigo-200':
isFocused && !startAdornment && !isError,
'border-base-content/10!': !isError && !isFocused,
}),
valueContainer: () => cn('flex-1 px-3! pr-2! py-2.5! gap-1'),
placeholder: () =>
cn('text-gray-400 text-sm leading-tight', {
'text-error!': isError,
}),
singleValue: () =>
cn('m-0! text-gray-900 text-sm leading-tight', {
'text-error!': isError && !readOnly,
'text-gray-900!': readOnly,
}),
input: () => cn('text-gray-900 m-0! p-0! text-sm leading-tight'),
indicatorsContainer: () => cn('flex items-center gap-1 pr-3 py-2'),
dropdownIndicator: ({ isFocused }) =>
cn('p-0! rounded hover:bg-gray-100', {
'text-gray-900': isFocused,
'text-gray-500': !isFocused,
'text-error!': isError,
}),
clearIndicator: () => cn('p-0! rounded hover:bg-gray-100'),
menu: () =>
cn(
'border border-base-content/5 rounded-xl! bg-base-100 shadow-lg! my-1.5!'
),
menuList: () => cn('p-0! max-h-60 overflow-auto'),
option: ({ isFocused, isSelected }) =>
cn('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-base-200! rounded-lg! py-[3px] px-2.5 m-0! flex items-center gap-1! w-fit gap-2!',
selectedValues[index]?.className
);
},
multiValueRemove: () => cn('p-0! w-3 h-3'),
multiValueLabel: ({ getValue, index }) => {
const selectedValues = getValue() as T[];
return cn(
'p-0! text-base-content! text-xs!',
selectedValues[index]?.labelClassName
);
},
}}
components={{
...components,
...(optionComponent ? { Option: optionComponent } : {}),
MenuList: CustomMenuList,
}}
{...(startAdornment && {
shouldShowAdornment,
startAdornment,
})}
menuPortalTarget={
typeof document !== 'undefined'
? (menuPortalTarget ?? document.body)
: undefined
}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
multiValue(base) {
return {
...base,
borderRadius: '8px',
};
},
}}
onMenuScrollToBottom={onMenuScrollToBottom}
/>
)}
{isError && (
<p className='w-full mt-1.5 text-xs text-error'>{errorMessage}</p>
)}
{!isError && bottomLabel && (
<p className='w-full mt-1.5 text-xs opacity-60'>{bottomLabel}</p>
)}
</div>
);
};
const useSelect = <T,>(
basePath: string | null,
valueKey: keyof T | string,
labelKey: keyof T | string,
searchKey: string = 'search',
params?: { [key: string]: string }
) => {
const [inputValue, setInputValue] = useState('');
const pageKey = 'page';
const limitKey = 'limit';
const limit = params?.['limit'] ?? 10;
const getKey = (
pageIndex: number,
previousPageData?: BaseApiResponse<T[]>
) => {
// stop when backend says no more pages
if (previousPageData && isResponseSuccess(previousPageData)) {
const meta = previousPageData.meta;
if (meta && meta.page >= meta.total_pages) return null;
}
const qs = new URLSearchParams({
...(params ?? {}),
[searchKey]: inputValue ?? '',
[pageKey]: String(pageIndex + 1),
[limitKey]: String(limit),
}).toString();
return basePath ? `${basePath}?${qs}` : null;
};
const {
data: pages,
isLoading,
isValidating,
size,
setSize,
} = useSWRInfinite<BaseApiResponse<T[]>>(getKey, (url) =>
httpClientFetcher<BaseApiResponse<T[]>>(url)
);
const options = useMemo(() => {
if (!pages) return [];
return pages.flatMap((page) =>
isResponseSuccess(page)
? page.data.map((item) => ({
value: getByPath<T, number>(item, valueKey as string),
label: getByPath<T, string>(item, labelKey as string),
}))
: []
);
}, [pages, valueKey, labelKey]);
const lastPage = pages?.[pages.length - 1];
const hasMore =
!!lastPage &&
isResponseSuccess(lastPage) &&
!!lastPage.meta &&
lastPage.meta.page < lastPage.meta.total_pages;
const loadMore = () => {
if (!hasMore) return;
setSize(size + 1);
};
let formattedSuccessRawData: SuccessApiResponse<T[]> | undefined = undefined;
let formattedErrorRawData: ErrorApiResponse | undefined = undefined;
const latestPagesIndex = pages?.length ? pages.length - 1 : 0;
if (isResponseSuccess(pages?.[latestPagesIndex])) {
formattedSuccessRawData = {
...pages?.[latestPagesIndex],
data:
pages?.flatMap((page) => (isResponseSuccess(page) ? page.data : [])) ??
[],
};
}
if (isResponseError(pages?.[latestPagesIndex])) {
formattedErrorRawData = pages?.[latestPagesIndex];
}
return {
inputValue,
setInputValue,
options,
rawData: isResponseSuccess(pages?.[latestPagesIndex])
? formattedSuccessRawData
: formattedErrorRawData,
isLoadingOptions: isLoading || isValidating,
isLoadingMore: isValidating && size > 1,
hasMore,
loadMore,
};
};
export { useSelect };
export default SelectInput;