refactor(FE): Support inputPrefix/inputSuffix on SelectInput

This commit is contained in:
rstubryan
2026-01-29 10:27:47 +07:00
parent 737d8e943c
commit 97bf785fe9
2 changed files with 265 additions and 125 deletions
+260 -103
View File
@@ -54,6 +54,9 @@ interface SelectInputBaseProps<T = OptionType> {
wrapper?: string; wrapper?: string;
label?: string; label?: string;
select?: string; select?: string;
inputPrefix?: string;
inputSuffix?: string;
inputPrefixSuffixWrapper?: string;
}; };
isError?: boolean; isError?: boolean;
errorMessage?: string; errorMessage?: string;
@@ -62,6 +65,8 @@ interface SelectInputBaseProps<T = OptionType> {
delay?: number; delay?: number;
onInputChange?: (search: string) => void; onInputChange?: (search: string) => void;
startAdornment?: ReactNode; startAdornment?: ReactNode;
inputPrefix?: ReactNode;
inputSuffix?: ReactNode;
menuPortalTarget?: HTMLElement | null; menuPortalTarget?: HTMLElement | null;
closeMenuOnSelect?: boolean; closeMenuOnSelect?: boolean;
hideSelectedOptions?: boolean; hideSelectedOptions?: boolean;
@@ -153,6 +158,8 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
createables = false, createables = false,
onInputChange, onInputChange,
startAdornment, startAdornment,
inputPrefix,
inputSuffix,
menuPortalTarget, menuPortalTarget,
closeMenuOnSelect, closeMenuOnSelect,
hideSelectedOptions, hideSelectedOptions,
@@ -227,111 +234,261 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
</span> </span>
)} )}
<SelectComponent<T, boolean, GroupBase<T>> {inputPrefix || inputSuffix ? (
instanceId='select' <div
value={value ?? (isMulti ? [] : null)} className={cn(
onChange={onChange ? handleChange : undefined} 'relative flex text-sm',
options={options} className?.inputPrefixSuffixWrapper
menuIsOpen={openMenu} )}
inputValue={internalInputValue} >
onInputChange={internalInputChangeHandler} {inputPrefix && (
onMenuClose={() => setInternalInputValue('')} <div
isMulti={isMulti} className={cn(
isDisabled={isDisabled || readOnly} 'inline-flex items-center px-3 border border-r-0 border-base-content/10 rounded-l-lg transition-all duration-200',
isLoading={isLoading} {
isClearable={isClearable} 'bg-gray-100 border-base-content/10': !isDisabled,
isRtl={isRtl} 'bg-gray-50 border-base-content/10': isDisabled,
isSearchable={isSearchable} 'border-error': isError,
placeholder={placeholder} },
closeMenuOnSelect={closeMenuOnSelect} className?.inputPrefix
hideSelectedOptions={hideSelectedOptions} )}
className={cn('w-full', className?.select)} >
classNames={{ {inputPrefix}
...(!startAdornment && { </div>
control: ({ isFocused, isDisabled }) => )}
cn('w-full rounded-lg! border bg-white transition-shadow', {
'cursor-pointer!': !readOnly && !isDisabled, <SelectComponent<T, boolean, GroupBase<T>>
'border-red-500! ring-2 ring-red-200': isError, instanceId='select'
'border-indigo-500 ring-2 ring-indigo-200': isFocused, value={value ?? (isMulti ? [] : null)}
'border-base-content/10!': !isError && !isFocused, onChange={onChange ? handleChange : undefined}
'bg-gray-100 text-gray-400 cursor-not-allowed': options={options}
isDisabled && !readOnly, menuIsOpen={openMenu}
'bg-transparent! cursor-not-allowed!': readOnly, 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={{
...(!startAdornment && {
control: ({ isFocused, isDisabled }) =>
cn('w-full rounded-lg! border bg-white transition-shadow', {
'cursor-pointer!': !readOnly && !isDisabled,
'border-red-500! ring-2 ring-red-200': isError,
'border-indigo-500 ring-2 ring-indigo-200': isFocused,
'border-base-content/10!': !isError && !isFocused,
'bg-gray-100 text-gray-400 cursor-not-allowed':
isDisabled && !readOnly,
'bg-transparent! cursor-not-allowed!': readOnly,
'rounded-l-none!': inputPrefix,
'rounded-r-none!': inputSuffix,
}),
valueContainer: () => cn('flex-1 px-3! pr-2! py-2.5! gap-1'),
}), }),
valueContainer: () => cn('flex-1 px-3! pr-2! py-2.5! gap-1'), placeholder: () =>
}), cn({
placeholder: () => 'text-gray-400 text-sm leading-tight': !isError,
cn({ 'text-red-300!': isError,
'text-gray-400 text-sm leading-tight': !isError, }),
'text-red-300!': isError, singleValue: () =>
cn({
'm-0! text-gray-900 text-sm leading-tight': !isError,
'text-error!': isError,
'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-gray-100 border-base-content/10': !isDisabled,
'bg-gray-50 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={{
...(!startAdornment && {
control: ({ isFocused, isDisabled }) =>
cn('w-full rounded-lg! border bg-white transition-shadow', {
'cursor-pointer!': !readOnly && !isDisabled,
'border-red-500! ring-2 ring-red-200': isError,
'border-indigo-500 ring-2 ring-indigo-200': isFocused,
'border-base-content/10!': !isError && !isFocused,
'bg-gray-100 text-gray-400 cursor-not-allowed':
isDisabled && !readOnly,
'bg-transparent! cursor-not-allowed!': readOnly,
}),
valueContainer: () => cn('flex-1 px-3! pr-2! py-2.5! gap-1'),
}), }),
singleValue: () => placeholder: () =>
cn({ cn({
'm-0! text-gray-900 text-sm leading-tight': !isError, 'text-gray-400 text-sm leading-tight': !isError,
'text-error!': isError, 'text-red-300!': isError,
'text-gray-900!': readOnly, }),
}), singleValue: () =>
input: () => cn('text-gray-900 m-0! p-0! text-sm leading-tight'), cn({
indicatorsContainer: () => cn('flex items-center gap-1 pr-3 py-2'), 'm-0! text-gray-900 text-sm leading-tight': !isError,
dropdownIndicator: ({ isFocused }) => 'text-error!': isError,
cn('p-0! rounded hover:bg-gray-100', { 'text-gray-900!': readOnly,
'text-gray-900': isFocused, }),
'text-gray-500': !isFocused, input: () => cn('text-gray-900 m-0! p-0! text-sm leading-tight'),
'text-error!': isError, indicatorsContainer: () => cn('flex items-center gap-1 pr-3 py-2'),
}), dropdownIndicator: ({ isFocused }) =>
clearIndicator: () => cn('p-0! rounded hover:bg-gray-100'), cn('p-0! rounded hover:bg-gray-100', {
menu: () => 'text-gray-900': isFocused,
cn( 'text-gray-500': !isFocused,
'border border-base-content/5 rounded-xl! bg-base-100 shadow-lg! my-1.5!' 'text-error!': isError,
), }),
menuList: () => cn('p-0! max-h-60 overflow-auto'), clearIndicator: () => cn('p-0! rounded hover:bg-gray-100'),
option: ({ isFocused, isSelected }) => menu: () =>
cn('px-3 py-2 rounded-md cursor-pointer!', { cn(
'bg-indigo-600 text-white': isFocused, 'border border-base-content/5 rounded-xl! bg-base-100 shadow-lg! my-1.5!'
'bg-blue-500!': isSelected, ),
'text-gray-700': !isFocused && !isSelected, menuList: () => cn('p-0! max-h-60 overflow-auto'),
}), option: ({ isFocused, isSelected }) =>
multiValue: ({ getValue, index }) => { cn('px-3 py-2 rounded-md cursor-pointer!', {
const selectedValues = getValue() as T[]; 'bg-indigo-600 text-white': isFocused,
return cn( 'bg-blue-500!': isSelected,
'bg-base-200! rounded-lg! py-[3px] px-2.5 m-0! flex items-center gap-1! w-fit gap-2!', 'text-gray-700': !isFocused && !isSelected,
selectedValues[index]?.className }),
); multiValue: ({ getValue, index }) => {
}, const selectedValues = getValue() as T[];
multiValueRemove: () => cn('p-0! w-3 h-3'), return cn(
multiValueLabel: ({ getValue, index }) => { 'bg-base-200! rounded-lg! py-[3px] px-2.5 m-0! flex items-center gap-1! w-fit gap-2!',
const selectedValues = getValue() as T[]; selectedValues[index]?.className
return cn( );
'p-0! text-base-content! text-xs!', },
selectedValues[index]?.labelClassName multiValueRemove: () => cn('p-0! w-3 h-3'),
); multiValueLabel: ({ getValue, index }) => {
}, const selectedValues = getValue() as T[];
}} return cn(
components={{ 'p-0! text-base-content! text-xs!',
...components, selectedValues[index]?.labelClassName
...(optionComponent ? { Option: optionComponent } : {}), );
MenuList: CustomMenuList, },
}} }}
{...(startAdornment && { components={{
shouldShowAdornment, ...components,
startAdornment, ...(optionComponent ? { Option: optionComponent } : {}),
})} MenuList: CustomMenuList,
menuPortalTarget={ }}
typeof document !== 'undefined' {...(startAdornment && {
? (menuPortalTarget ?? document.body) shouldShowAdornment,
: undefined startAdornment,
} })}
styles={{ menuPortalTarget={
menuPortal: (base) => ({ ...base, zIndex: 9999 }), typeof document !== 'undefined'
multiValue(base) { ? (menuPortalTarget ?? document.body)
return { : undefined
...base, }
borderRadius: '8px', styles={{
}; menuPortal: (base) => ({ ...base, zIndex: 9999 }),
}, multiValue(base) {
}} return {
onMenuScrollToBottom={onMenuScrollToBottom} ...base,
/> borderRadius: '8px',
};
},
}}
onMenuScrollToBottom={onMenuScrollToBottom}
/>
)}
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>} {isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
{!isError && bottomLabel && ( {!isError && bottomLabel && (
@@ -874,32 +874,15 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
(warehouseId: number) => { (warehouseId: number) => {
const stockInfo = warehouseStockMap.get(warehouseId); const stockInfo = warehouseStockMap.get(warehouseId);
if (!stockInfo) { if (!stockInfo) {
return ( return <span className='text-xs'>Kosong</span>;
<Badge
variant='ghost'
color='neutral'
size='sm'
className={{ badge: 'whitespace-nowrap font-semibold' }}
>
Kosong
</Badge>
);
} }
const { productCount } = stockInfo; const { productCount } = stockInfo;
let color: 'neutral' | 'success' | 'warning' = 'neutral';
if (productCount === 0) color = 'warning';
else if (productCount > 0) color = 'success';
return ( return (
<Badge <span className='text-xs whitespace-nowrap'>
variant='soft'
color={color}
size='sm'
className={{ badge: 'whitespace-nowrap font-semibold' }}
>
Tersedia {productCount} produk Tersedia {productCount} produk
</Badge> </span>
); );
}, },
[warehouseStockMap] [warehouseStockMap]
@@ -1360,7 +1343,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
errorMessage={formik.errors.source_warehouse_id as string} errorMessage={formik.errors.source_warehouse_id as string}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
isClearable isClearable
startAdornment={ inputPrefix={
formik.values.source_warehouse_id formik.values.source_warehouse_id
? getWarehouseStockAdornment( ? getWarehouseStockAdornment(
formik.values.source_warehouse_id formik.values.source_warehouse_id
@@ -1418,7 +1401,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
errorMessage={formik.errors.destination_warehouse_id as string} errorMessage={formik.errors.destination_warehouse_id as string}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
isClearable isClearable
startAdornment={ inputPrefix={
formik.values.destination_warehouse_id formik.values.destination_warehouse_id
? getWarehouseStockAdornment( ? getWarehouseStockAdornment(
formik.values.destination_warehouse_id formik.values.destination_warehouse_id