Created
March 26, 2024 13:54
-
-
Save ilkou/7bf2dbd42a7faf70053b43034fc4b5a4 to your computer and use it in GitHub Desktop.
react-select with shadcn/ui
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* ----------- 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 | |
}; | |
}; |
@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
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
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>
This will cause errors because the other components don't have the same Option type
☝️ 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
And so on to resolve all the errors.
I hope this was clear and helpful.