-
-
Save ilkou/7bf2dbd42a7faf70053b43034fc4b5a4 to your computer and use it in GitHub Desktop.
/* ----------- simple-select.js ----------- */ | |
import * as React from 'react'; | |
import Select from 'react-select'; | |
import type { Props } from 'react-select'; | |
import { defaultClassNames, defaultStyles } from './helper'; | |
import { | |
ClearIndicator, | |
DropdownIndicator, | |
MultiValueRemove, | |
Option | |
} from './components'; | |
const SimpleSelect = React.forwardRef((props: Props, ref) => { | |
const { | |
value, | |
onChange, | |
options = [], | |
styles = defaultStyles, | |
classNames = defaultClassNames, | |
components = {}, | |
...rest | |
} = props; | |
return ( | |
<Select | |
ref={ref} | |
value={value} | |
onChange={onChange} | |
options={options} | |
unstyled | |
components={{ | |
DropdownIndicator, | |
ClearIndicator, | |
MultiValueRemove, | |
Option, | |
...components | |
}} | |
styles={styles} | |
classNames={classNames} | |
{...rest} | |
/> | |
); | |
}); | |
export default SimpleSelect; | |
/* ----------- helper.js ----------- */ | |
import { cn } from 'lib/utils'; | |
/** | |
* styles that aligns with shadcn/ui | |
*/ | |
const controlStyles = { | |
base: 'flex !min-h-9 w-full rounded-md border border-input bg-transparent pl-3 py-1 pr-1 gap-1 text-sm shadow-sm transition-colors hover:cursor-pointer', | |
focus: 'outline-none ring-1 ring-ring', | |
disabled: 'cursor-not-allowed opacity-50' | |
}; | |
const placeholderStyles = 'text-sm text-muted-foreground'; | |
const valueContainerStyles = 'gap-1'; | |
const multiValueStyles = | |
'inline-flex items-center gap-2 rounded-md border border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80 px-1.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2'; | |
const indicatorsContainerStyles = 'gap-1'; | |
const clearIndicatorStyles = 'p-1 rounded-md'; | |
const indicatorSeparatorStyles = 'bg-border'; | |
const dropdownIndicatorStyles = 'p-1 rounded-md'; | |
const menuStyles = | |
'p-1 mt-1 border bg-popover shadow-md rounded-md text-popover-foreground'; | |
const groupHeadingStyles = | |
'py-2 px-1 text-secondary-foreground text-sm font-semibold'; | |
const optionStyles = { | |
base: 'hover:cursor-pointer hover:bg-accent hover:text-accent-foreground px-2 py-1.5 rounded-sm !text-sm !cursor-default !select-none !outline-none font-sans', | |
focus: 'active:bg-accent/90 bg-accent text-accent-foreground', | |
disabled: 'pointer-events-none opacity-50', | |
selected: '' | |
}; | |
const noOptionsMessageStyles = | |
'text-accent-foreground p-2 bg-accent border border-dashed border-border rounded-sm'; | |
const loadingIndicatorStyles = | |
'flex items-center justify-center h-4 w-4 opacity-50'; | |
const loadingMessageStyles = 'text-accent-foreground p-2 bg-accent'; | |
/** | |
* This factory method is used to build custom classNames configuration | |
*/ | |
export const createClassNames = (classNames) => { | |
return { | |
clearIndicator: (state) => | |
cn(clearIndicatorStyles, classNames?.clearIndicator?.(state)), | |
container: (state) => cn(classNames?.container?.(state)), | |
control: (state) => | |
cn( | |
controlStyles.base, | |
state.isDisabled && controlStyles.disabled, | |
state.isFocused && controlStyles.focus, | |
classNames?.control?.(state) | |
), | |
dropdownIndicator: (state) => | |
cn(dropdownIndicatorStyles, classNames?.dropdownIndicator?.(state)), | |
group: (state) => cn(classNames?.group?.(state)), | |
groupHeading: (state) => | |
cn(groupHeadingStyles, classNames?.groupHeading?.(state)), | |
indicatorsContainer: (state) => | |
cn(indicatorsContainerStyles, classNames?.indicatorsContainer?.(state)), | |
indicatorSeparator: (state) => | |
cn(indicatorSeparatorStyles, classNames?.indicatorSeparator?.(state)), | |
input: (state) => cn(classNames?.input?.(state)), | |
loadingIndicator: (state) => | |
cn(loadingIndicatorStyles, classNames?.loadingIndicator?.(state)), | |
loadingMessage: (state) => | |
cn(loadingMessageStyles, classNames?.loadingMessage?.(state)), | |
menu: (state) => cn(menuStyles, classNames?.menu?.(state)), | |
menuList: (state) => cn(classNames?.menuList?.(state)), | |
menuPortal: (state) => cn(classNames?.menuPortal?.(state)), | |
multiValue: (state) => | |
cn(multiValueStyles, classNames?.multiValue?.(state)), | |
multiValueLabel: (state) => cn(classNames?.multiValueLabel?.(state)), | |
multiValueRemove: (state) => cn(classNames?.multiValueRemove?.(state)), | |
noOptionsMessage: (state) => | |
cn(noOptionsMessageStyles, classNames?.noOptionsMessage?.(state)), | |
option: (state) => | |
cn( | |
optionStyles.base, | |
state.isFocused && optionStyles.focus, | |
state.isDisabled && optionStyles.disabled, | |
state.isSelected && optionStyles.selected, | |
classNames?.option?.(state) | |
), | |
placeholder: (state) => | |
cn(placeholderStyles, classNames?.placeholder?.(state)), | |
singleValue: (state) => cn(classNames?.singleValue?.(state)), | |
valueContainer: (state) => | |
cn(valueContainerStyles, classNames?.valueContainer?.(state)) | |
}; | |
}; | |
export const defaultClassNames = createClassNames({}); | |
export const defaultStyles = { | |
input: (base) => ({ | |
...base, | |
'input:focus': { | |
boxShadow: 'none' | |
} | |
}), | |
multiValueLabel: (base) => ({ | |
...base, | |
whiteSpace: 'normal', | |
overflow: 'visible' | |
}), | |
control: (base) => ({ | |
...base, | |
transition: 'none' | |
// minHeight: '2.25rem', // we used !min-h-9 instead | |
}), | |
menuList: (base) => ({ | |
...base, | |
'::-webkit-scrollbar': { | |
background: 'transparent' | |
}, | |
'::-webkit-scrollbar-track': { | |
background: 'transparent' | |
}, | |
'::-webkit-scrollbar-thumb': { | |
background: 'hsl(var(--border))' | |
}, | |
'::-webkit-scrollbar-thumb:hover': { | |
background: 'transparent' | |
} | |
}) | |
}; | |
/* ----------- components.jsx ----------- */ | |
import * as React from 'react'; | |
import type { | |
ClearIndicatorProps, | |
DropdownIndicatorProps, | |
MultiValueRemoveProps, | |
OptionProps | |
} from 'react-select'; | |
import { components } from 'react-select'; | |
import { | |
CaretSortIcon, | |
CheckIcon, | |
Cross2Icon as CloseIcon | |
} from '@radix-ui/react-icons'; | |
export const DropdownIndicator = (props: DropdownIndicatorProps) => { | |
return ( | |
<components.DropdownIndicator {...props}> | |
<CaretSortIcon className={'h-4 w-4 opacity-50'} /> | |
</components.DropdownIndicator> | |
); | |
}; | |
export const ClearIndicator = (props: ClearIndicatorProps) => { | |
return ( | |
<components.ClearIndicator {...props}> | |
<CloseIcon className={'h-3.5 w-3.5 opacity-50'} /> | |
</components.ClearIndicator> | |
); | |
}; | |
export const MultiValueRemove = (props: MultiValueRemoveProps) => { | |
return ( | |
<components.MultiValueRemove {...props}> | |
<CloseIcon className={'h-3 w-3 opacity-50'} /> | |
</components.MultiValueRemove> | |
); | |
}; | |
export const Option = (props: OptionProps) => { | |
return ( | |
<components.Option {...props}> | |
<div className="flex items-center justify-between"> | |
<div>{props.data.label}</div> | |
{props.isSelected && <CheckIcon />} | |
</div> | |
</components.Option> | |
); | |
}; | |
/* ----------- async-select.jsx ----------- */ | |
import * as React from 'react'; | |
import Async from 'react-select/async'; | |
import type { Props } from 'react-select/async'; | |
import { defaultClassNames, defaultStyles } from './helper'; | |
import { | |
ClearIndicator, | |
DropdownIndicator, | |
MultiValueRemove, | |
Option | |
} from './components'; | |
const AsyncSelect = React.forwardRef((props: Props, ref) => { | |
const { | |
value, | |
onChange, | |
styles = defaultStyles, | |
classNames = defaultClassNames, | |
components = {}, | |
...rest | |
} = props; | |
return ( | |
<Async | |
ref={ref} | |
value={value} | |
onChange={onChange} | |
unstyled | |
components={{ | |
DropdownIndicator, | |
ClearIndicator, | |
MultiValueRemove, | |
Option, | |
...components | |
}} | |
styles={styles} | |
classNames={classNames} | |
{...rest} | |
/> | |
); | |
}); | |
export default AsyncSelect; | |
/* ----------- hooks.jsx ----------- */ | |
/** | |
* This hook could be added to your select component if needed: | |
* const formatters = useFormatters() | |
* <Select | |
* // other props | |
* {...formatters} | |
* /> | |
*/ | |
export const useFormatters = () => { | |
// useful for CreatableSelect | |
const formatCreateLabel = (label) => ( | |
<span className={'text-sm'}> | |
Add | |
<span className={'font-semibold'}>{` "${label}"`}</span> | |
</span> | |
); | |
// useful for GroupedOptions | |
const formatGroupLabel = (data) => ( | |
<div className={'flex justify-between items-center'}> | |
<span>{data.label}</span> | |
<span | |
className={ | |
'rounded-md text-xs font-normal text-secondary-foreground bg-secondary shadow-sm px-1' | |
} | |
> | |
{data.options.length} | |
</span> | |
</div> | |
); | |
return { | |
formatCreateLabel, | |
formatGroupLabel | |
}; | |
}; |
You're welcome.
If you import Select from "react-select" you would have the same type.
The issue is from react-select itself.
I found a way to resolve the issue but you have to update all the components.
Select component receives a generic Option which is what you are looking for.
/** The value of the select; reflected by the selected option */
value: PropsValue<Option>;
To make this work you should update the Select component to receive a generic "OptionType" which set for default to {label: string, value: string}
.
Then you need to update the SelectComponent generic to have that OptionType like this SelectComponent<OptionType>
const Select = React.forwardRef<
React.ElementRef<typeof SelectComponent<OptionType>>,
React.ComponentPropsWithoutRef<typeof SelectComponent<OptionType>>
>((props: Props<OptionType>, ref) => {
const {
value, // This is OptionType
onChange,
options = [],
styles = defaultStyles,
classNames = defaultClassNames,
components = {},
...rest
} = props;
const id = React.useId();
return (
<SelectComponent
instanceId={id}
ref={ref}
value={value} // Now this is OptionType
onChange={onChange}
options={options}
unstyled
components={{
DropdownIndicator,
ClearIndicator,
MultiValueRemove,
Option,
Menu,
MenuList,
...components,
}}
styles={styles}
classNames={classNames}
{...rest}
/>
);
});
This will cause errors because the other components don't have the same Option type
<SelectComponent
...
components={{
DropdownIndicator, // Those components need to be updated to have the OptionType generic
...
}}
...
/>
☝️ Don't forget you have to wrap the forwardRef component so you can receive generic.
Those solutions could help: https://stackoverflow.com/questions/58469229/react-with-typescript-generics-while-using-react-forwardref
export const DropdownIndicator = <OptionType extends object = {label:string, value: string}>(props: DropdownIndicatorProps<OptionType>) => {
return (
<components.DropdownIndicator {...props}>
<CaretSortIcon className={"h-4 w-4 opacity-50"} />
</components.DropdownIndicator>
);
};
And so on to resolve all the errors.
I hope this was clear and helpful.
@H7ioo Hey, thanks a lot for the feedback, I had found a solution similar but yours looks more clean, I'll try it out soon.
For anyone trying to make this component super generic with typescript, here's what I came up with the help of @H7ioo, thanks a lot
import React, { ReactElement, Ref } from 'react';
import SelectComponent, {
components,
ClassNamesConfig,
DropdownIndicatorProps,
GroupBase,
StylesConfig,
MultiValueRemoveProps,
ClearIndicatorProps,
OptionProps,
MenuProps,
MenuListProps,
Props,
SelectInstance,
createFilter,
} from 'react-select';
import { FixedSizeList as List } from 'react-window';
import { cn } from '@/lib/utils';
import { Check, ChevronDown, X } from 'lucide-react';
/** select option type */
export type OptionType = { label: string; value: string | number };
/**
* styles that aligns with shadcn/ui
*/
const selectStyles = {
controlStyles: {
base: 'flex !min-h-9 w-full rounded-md border border-input bg-transparent pl-3 py-1 pr-1 gap-1 text-sm shadow-sm transition-colors hover:cursor-pointer',
focus: 'outline-none ring-1 ring-ring',
disabled: 'cursor-not-allowed opacity-50',
},
placeholderStyles: 'text-muted-foreground text-sm ml-1 font-medium',
valueContainerStyles: 'gap-1',
multiValueStyles:
'inline-flex items-center gap-2 rounded-md border border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80 px-1.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
indicatorsContainerStyles: 'gap-1',
clearIndicatorStyles: 'p-1 rounded-md',
indicatorSeparatorStyles: 'bg-muted',
dropdownIndicatorStyles: 'p-1 rounded-md',
menu: 'mt-1.5 p-1.5 border border-input bg-background text-sm rounded-lg',
menuList: 'morel-scrollbar',
groupHeadingStyles:
'py-2 px-1 text-secondary-foreground text-sm font-semibold',
optionStyles: {
base: 'hover:cursor-pointer hover:bg-accent hover:text-accent-foreground px-2 py-1.5 rounded-sm !text-sm !cursor-default !select-none !outline-none font-sans',
focus: 'active:bg-accent/90 bg-accent text-accent-foreground',
disabled: 'pointer-events-none opacity-50',
selected: '',
},
noOptionsMessageStyles:
'text-muted-foreground py-4 text-center text-sm border border-border rounded-sm',
label: 'text-muted-foreground text-sm',
loadingIndicatorStyles: 'flex items-center justify-center h-4 w-4 opacity-50',
loadingMessageStyles: 'text-accent-foreground p-2 bg-accent',
};
/**
* This factory method is used to build custom classNames configuration
*/
export const createClassNames = (
classNames: ClassNamesConfig<OptionType, boolean, GroupBase<OptionType>>
): ClassNamesConfig<OptionType, boolean, GroupBase<OptionType>> => {
return {
clearIndicator: (state) =>
cn(
selectStyles.clearIndicatorStyles,
classNames?.clearIndicator?.(state)
),
container: (state) => cn(classNames?.container?.(state)),
control: (state) =>
cn(
selectStyles.controlStyles.base,
state.isDisabled && selectStyles.controlStyles.disabled,
state.isFocused && selectStyles.controlStyles.focus,
classNames?.control?.(state)
),
dropdownIndicator: (state) =>
cn(
selectStyles.dropdownIndicatorStyles,
classNames?.dropdownIndicator?.(state)
),
group: (state) => cn(classNames?.group?.(state)),
groupHeading: (state) =>
cn(selectStyles.groupHeadingStyles, classNames?.groupHeading?.(state)),
indicatorsContainer: (state) =>
cn(
selectStyles.indicatorsContainerStyles,
classNames?.indicatorsContainer?.(state)
),
indicatorSeparator: (state) =>
cn(
selectStyles.indicatorSeparatorStyles,
classNames?.indicatorSeparator?.(state)
),
input: (state) => cn(classNames?.input?.(state)),
loadingIndicator: (state) =>
cn(
selectStyles.loadingIndicatorStyles,
classNames?.loadingIndicator?.(state)
),
loadingMessage: (state) =>
cn(
selectStyles.loadingMessageStyles,
classNames?.loadingMessage?.(state)
),
menu: (state) => cn(selectStyles.menu, classNames?.menu?.(state)),
menuList: (state) => cn(classNames?.menuList?.(state)),
menuPortal: (state) => cn(classNames?.menuPortal?.(state)),
multiValue: (state) =>
cn(selectStyles.multiValueStyles, classNames?.multiValue?.(state)),
multiValueLabel: (state) => cn(classNames?.multiValueLabel?.(state)),
multiValueRemove: (state) => cn(classNames?.multiValueRemove?.(state)),
noOptionsMessage: (state) =>
cn(
selectStyles.noOptionsMessageStyles,
classNames?.noOptionsMessage?.(state)
),
option: (state) =>
cn(
selectStyles.optionStyles.base,
state.isFocused && selectStyles.optionStyles.focus,
state.isDisabled && selectStyles.optionStyles.disabled,
state.isSelected && selectStyles.optionStyles.selected,
classNames?.option?.(state)
),
placeholder: (state) =>
cn(selectStyles.placeholderStyles, classNames?.placeholder?.(state)),
singleValue: (state) => cn(classNames?.singleValue?.(state)),
valueContainer: (state) =>
cn(
selectStyles.valueContainerStyles,
classNames?.valueContainer?.(state)
),
};
};
export const defaultClassNames = createClassNames({});
export const defaultStyles: StylesConfig<
OptionType,
boolean,
GroupBase<OptionType>
> = {
input: (base) => ({
...base,
'input:focus': {
boxShadow: 'none',
},
}),
multiValueLabel: (base) => ({
...base,
whiteSpace: 'normal',
overflow: 'visible',
}),
control: (base) => ({
...base,
transition: 'none',
// minHeight: '2.25rem', // we used !min-h-9 instead
}),
menuList: (base) => ({
...base,
'::-webkit-scrollbar': {
background: 'transparent',
},
'::-webkit-scrollbar-track': {
background: 'transparent',
},
'::-webkit-scrollbar-thumb': {
background: 'hsl(var(--border))',
},
'::-webkit-scrollbar-thumb:hover': {
background: 'transparent',
},
}),
};
/**
* React select custom components
*/
export const DropdownIndicator = (
props: DropdownIndicatorProps<OptionType>
) => {
return (
<components.DropdownIndicator {...props}>
<ChevronDown className='h-4 w-4 opacity-50' />
</components.DropdownIndicator>
);
};
export const ClearIndicator = (props: ClearIndicatorProps<OptionType>) => {
return (
<components.ClearIndicator {...props}>
<X className='h-4 w-4 opacity-50' />
</components.ClearIndicator>
);
};
export const MultiValueRemove = (props: MultiValueRemoveProps<OptionType>) => {
return (
<components.MultiValueRemove {...props}>
<X className='h-3.5 w-3.5 opacity-50' />
</components.MultiValueRemove>
);
};
export const Option = (props: OptionProps<OptionType>) => {
return (
<components.Option {...props}>
<div className='flex items-center justify-between'>
<div>{props.label}</div>
{props.isSelected && <Check className='h-4 w-4 opacity-50' />}
</div>
</components.Option>
);
};
// Using Menu and MenuList fixes the scrolling behavior
export const Menu = (props: MenuProps<OptionType>) => {
return <components.Menu {...props}>{props.children}</components.Menu>;
};
export const MenuList = (props: MenuListProps<OptionType>) => {
const { children, maxHeight } = props;
const childrenArray = React.Children.toArray(children);
const calculateHeight = () => {
// When using children it resizes correctly
const totalHeight = childrenArray.length * 35; // Adjust item height if different
return totalHeight < maxHeight ? totalHeight : maxHeight;
};
const height = calculateHeight();
// Ensure childrenArray has length. Even when childrenArray is empty there is one element left
if (!childrenArray || childrenArray.length - 1 === 0) {
return <components.MenuList {...props} />;
}
return (
<List
height={height}
itemCount={childrenArray.length}
itemSize={35} // Adjust item height if different
width='100%'
>
{({ index, style }) => <div style={style}>{childrenArray[index]}</div>}
</List>
);
};
const BaseSelect = <IsMulti extends boolean = false>(
props: Props<OptionType, IsMulti> & { isMulti?: IsMulti },
ref: React.Ref<SelectInstance<OptionType, IsMulti, GroupBase<OptionType>>>
) => {
const {
styles = defaultStyles,
classNames = defaultClassNames,
components = {},
...rest
} = props;
const instanceId = React.useId();
return (
<SelectComponent<OptionType, IsMulti, GroupBase<OptionType>>
ref={ref}
instanceId={instanceId}
unstyled
filterOption={createFilter({
matchFrom: 'any',
stringify: (option) => option.label,
})}
components={{
DropdownIndicator,
ClearIndicator,
MultiValueRemove,
Option,
Menu,
MenuList,
...components,
}}
styles={styles}
classNames={classNames}
{...rest}
/>
);
};
export default React.forwardRef(BaseSelect) as <
IsMulti extends boolean = false,
>(
p: Props<OptionType, IsMulti> & {
ref?: Ref<
React.LegacyRef<
SelectInstance<OptionType, IsMulti, GroupBase<OptionType>>
>
>;
isMulti?: IsMulti;
}
) => ReactElement;
@H7ioo How can I use achieve multi select with the Select component?
@H7ioo How can I use achieve multi select with the Select component?
check the official website for all the use cases, the multi select
example is in the first page
![Screenshot 2024-10-14 at 15 21 16](https://private-user-images.githubusercontent.com/48165230/376279970-2a3eb4b6-5b1f-4993-841c-d8421196a6c7.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3Mzg4Mzk1MzIsIm5iZiI6MTczODgzOTIzMiwicGF0aCI6Ii80ODE2NTIzMC8zNzYyNzk5NzAtMmEzZWI0YjYtNWIxZi00OTkzLTg0MWMtZDg0MjExOTZhNmM3LnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTAyMDYlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwMjA2VDEwNTM1MlomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWZlZjk4MjNlMTFmMTY1NTU0MjYwZjg3ZWExYjlmODIwZDAyYmQwNTZhMzY4OGI2MjJlYjQ5NzliMDgxNTQyMWQmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.YrZV3y8CQ_A8qXOggzB7ZrNEL3Iw_JnB8JI3yZIenIk)
this gist
proposition only adds the styling part, react-select components work as usual
Small react19 update to the default export based on to @fitimbytyqi's work (replacing deprecated React.LegacyRef
with RefAttributes<T>['ref']
)
export default React.forwardRef(BaseSelect) as <
IsMulti extends boolean = false,
>(
p: Props<OptionType, IsMulti> & {
ref?: React.RefAttributes<
SelectInstance<OptionType, IsMulti, GroupBase<OptionType>>
>['ref']
isMulti?: IsMulti
},
) => ReactElement
Thanks a lot for this code snippet. however I think that there is an issue with typescript here.
`const options: { value: string; label: string }[] = [
{ value: "chocolate", label: "Chocolate" },
{ value: "strawberry", label: "Strawberry" },
{ value: "vanilla", label: "Vanilla" },
];
<Select
options={options}
onChange={(value) => console.log(value)}
/>`
In this case value's type is unknown and not the one from options. Any idea how we can solve this ?