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;
label?: string;
select?: string;
inputPrefix?: string;
inputSuffix?: string;
inputPrefixSuffixWrapper?: string;
};
isError?: boolean;
errorMessage?: string;
@@ -62,6 +65,8 @@ interface SelectInputBaseProps<T = OptionType> {
delay?: number;
onInputChange?: (search: string) => void;
startAdornment?: ReactNode;
inputPrefix?: ReactNode;
inputSuffix?: ReactNode;
menuPortalTarget?: HTMLElement | null;
closeMenuOnSelect?: boolean;
hideSelectedOptions?: boolean;
@@ -153,6 +158,8 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
createables = false,
onInputChange,
startAdornment,
inputPrefix,
inputSuffix,
menuPortalTarget,
closeMenuOnSelect,
hideSelectedOptions,
@@ -227,111 +234,261 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
</span>
)}
<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,
{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-gray-100 border-base-content/10': !isDisabled,
'bg-gray-50 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={{
...(!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({
'text-gray-400 text-sm leading-tight': !isError,
'text-red-300!': isError,
placeholder: () =>
cn({
'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: () =>
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}
/>
placeholder: () =>
cn({
'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}
/>
)}
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
{!isError && bottomLabel && (
@@ -874,32 +874,15 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
(warehouseId: number) => {
const stockInfo = warehouseStockMap.get(warehouseId);
if (!stockInfo) {
return (
<Badge
variant='ghost'
color='neutral'
size='sm'
className={{ badge: 'whitespace-nowrap font-semibold' }}
>
Kosong
</Badge>
);
return <span className='text-xs'>Kosong</span>;
}
const { productCount } = stockInfo;
let color: 'neutral' | 'success' | 'warning' = 'neutral';
if (productCount === 0) color = 'warning';
else if (productCount > 0) color = 'success';
return (
<Badge
variant='soft'
color={color}
size='sm'
className={{ badge: 'whitespace-nowrap font-semibold' }}
>
<span className='text-xs whitespace-nowrap'>
Tersedia {productCount} produk
</Badge>
</span>
);
},
[warehouseStockMap]
@@ -1360,7 +1343,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
errorMessage={formik.errors.source_warehouse_id as string}
isDisabled={type === 'detail'}
isClearable
startAdornment={
inputPrefix={
formik.values.source_warehouse_id
? getWarehouseStockAdornment(
formik.values.source_warehouse_id
@@ -1418,7 +1401,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
errorMessage={formik.errors.destination_warehouse_id as string}
isDisabled={type === 'detail'}
isClearable
startAdornment={
inputPrefix={
formik.values.destination_warehouse_id
? getWarehouseStockAdornment(
formik.values.destination_warehouse_id