Skip to content

Instantly share code, notes, and snippets.

@ilkou
Created March 26, 2024 13:54
Show Gist options
  • Save ilkou/7bf2dbd42a7faf70053b43034fc4b5a4 to your computer and use it in GitHub Desktop.
Save ilkou/7bf2dbd42a7faf70053b43034fc4b5a4 to your computer and use it in GitHub Desktop.
react-select with shadcn/ui
/* ----------- 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
};
};
@ilkou
Copy link
Author

ilkou commented Mar 27, 2024

thanks.. it would be nice if you could add typescript

the types are also inferred from react-select:

import type { Props } from 'react-select';
// import type { Props } from 'react-select/async'; // for AsyncSelect

and it's working in my project (I use flow)

Screenshot 2024-03-27 at 12 13 28

but if it didn't work for u in typescript u can refer to react-select with typescript

@ilkou
Copy link
Author

ilkou commented Apr 22, 2024

I recently migrated from flow to TypeScript and I'm using the following:

const CreatableSelect = React.forwardRef<
  React.ElementRef<typeof Creatable>,
  React.ComponentPropsWithoutRef<typeof Creatable>
>((props, ref) => {

@H7ioo
Copy link

H7ioo commented Jun 22, 2024

For anyone who is looking for a complete typed code.
Note: I added React Window
I would also like to thank the creator for publishing such a useful code 🙏

// ~~~ helper.ts
// SOURCE: https://gist.github.com/ilkou/7bf2dbd42a7faf70053b43034fc4b5a4
import { cn } from "./utils";
import {
  type ClassNamesConfig,
  type GroupBase,
  type StylesConfig,
} from "react-select";
/**
 * 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: ClassNamesConfig,
): ClassNamesConfig => {
  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: StylesConfig<
  unknown,
  boolean,
  GroupBase<unknown>
> = {
  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",
    },
  }),
};

// ~~~ ReactSelectCustomComponents.tsx
// SOURCE: https://gist.github.com/ilkou/7bf2dbd42a7faf70053b43034fc4b5a4
import * as React from "react";
import type {
  ClearIndicatorProps,
  DropdownIndicatorProps,
  MenuListProps,
  MenuProps,
  MultiValueRemoveProps,
  OptionProps,
} from "react-select";
import { components } from "react-select";
import {
  CaretSortIcon,
  CheckIcon,
  Cross2Icon as CloseIcon,
} from "@radix-ui/react-icons";
import { FixedSizeList as List } from "react-window";

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">
        {/* TODO: Figure out the type */}
        <div>{(props.data as { label: string }).label}</div>
        {props.isSelected && <CheckIcon />}
      </div>
    </components.Option>
  );
};

// Using Menu and MenuList fixes the scrolling behavior
export const Menu = (props: MenuProps) => {
  return <components.Menu {...props}>{props.children}</components.Menu>;
};

export const MenuList = (props: MenuListProps) => {
  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>
  );
};
// ~~~ Select.tsx
// SOURCE: https://gist.github.com/ilkou/7bf2dbd42a7faf70053b43034fc4b5a4
import * as React from "react";
import SelectComponent from "react-select";
import type { Props } from "react-select";
import { defaultClassNames, defaultStyles } from "~/lib/helper";
import {
  ClearIndicator,
  DropdownIndicator,
  MultiValueRemove,
  Option,
  Menu,
  MenuList,
} from "./ReactSelectCustomComponents";

const Select = React.forwardRef<
  React.ElementRef<typeof SelectComponent>,
  React.ComponentPropsWithoutRef<typeof SelectComponent>
>((props: Props, ref) => {
  const {
    value,
    onChange,
    options = [],
    styles = defaultStyles,
    classNames = defaultClassNames,
    components = {},
    ...rest
  } = props;

  const id = React.useId();

  return (
    <SelectComponent
      instanceId={id}
      ref={ref}
      value={value}
      onChange={onChange}
      options={options}
      unstyled
      components={{
        DropdownIndicator,
        ClearIndicator,
        MultiValueRemove,
        Option,
        Menu,
        MenuList,
        ...components,
      }}
      styles={styles}
      classNames={classNames}
      {...rest}
    />
  );
});
Select.displayName = "Select";
export default Select;

// ~~~ Creatable.tsx
// SOURCE: https://gist.github.com/ilkou/7bf2dbd42a7faf70053b43034fc4b5a4
import * as React from "react";
import CreatableSelect from "react-select/creatable";
import type { Props } from "react-select";
import { defaultClassNames, defaultStyles } from "~/lib/helper";
import {
  ClearIndicator,
  DropdownIndicator,
  Menu,
  MenuList,
  MultiValueRemove,
  Option,
} from "./ReactSelectCustomComponents";

const Creatable = React.forwardRef<
  React.ElementRef<typeof CreatableSelect>,
  React.ComponentPropsWithoutRef<typeof CreatableSelect>
>((props: Props, ref) => {
  const {
    value,
    onChange,
    options = [],
    styles = defaultStyles,
    classNames = defaultClassNames,
    components = {},
    ...rest
  } = props;

  const id = React.useId();

  return (
    <CreatableSelect
      instanceId={id}
      ref={ref}
      value={value}
      onChange={onChange}
      options={options}
      unstyled
      components={{
        DropdownIndicator,
        ClearIndicator,
        MultiValueRemove,
        Option,
        Menu,
        MenuList,
        ...components,
      }}
      styles={styles}
      classNames={classNames}
      {...rest}
    />
  );
});
Creatable.displayName = "Creatable";
export default Creatable;

// ~~~ useFormatters.tsx
// SOURCE: https://gist.github.com/ilkou/7bf2dbd42a7faf70053b43034fc4b5a4

import { type CreatableAdditionalProps } from "node_modules/react-select/dist/declarations/src/useCreatable";
import { type GroupBase } from "react-select";

/**
 * 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: CreatableAdditionalProps<
    unknown,
    GroupBase<unknown>
  >["formatCreateLabel"] = (label) => (
    <span className={"text-sm"}>
      Add
      <span className={"font-semibold"}>{` "${label}"`}</span>
    </span>
  );

  // useful for GroupedOptions
  const formatGroupLabel: (group: GroupBase<unknown>) => React.ReactNode = (
    data,
  ) => (
    <div className={"flex items-center justify-between"}>
      <span>{data.label}</span>
      <span
        className={
          "rounded-md bg-secondary px-1 text-xs font-normal text-secondary-foreground shadow-sm"
        }
      >
        {data.options.length}
      </span>
    </div>
  );
  return {
    formatCreateLabel,
    formatGroupLabel,
  };
};

@feliche93
Copy link

For anyone who is looking for a complete typed code. Note: I added React Window I would also like to thank the creator for publishing such a useful code 🙏

// ~~~ helper.ts
// SOURCE: https://gist.github.com/ilkou/7bf2dbd42a7faf70053b43034fc4b5a4
import { cn } from "./utils";
import {
  type ClassNamesConfig,
  type GroupBase,
  type StylesConfig,
} from "react-select";
/**
 * 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: ClassNamesConfig,
): ClassNamesConfig => {
  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: StylesConfig<
  unknown,
  boolean,
  GroupBase<unknown>
> = {
  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",
    },
  }),
};

// ~~~ ReactSelectCustomComponents.tsx
// SOURCE: https://gist.github.com/ilkou/7bf2dbd42a7faf70053b43034fc4b5a4
import * as React from "react";
import type {
  ClearIndicatorProps,
  DropdownIndicatorProps,
  MenuListProps,
  MenuProps,
  MultiValueRemoveProps,
  OptionProps,
} from "react-select";
import { components } from "react-select";
import {
  CaretSortIcon,
  CheckIcon,
  Cross2Icon as CloseIcon,
} from "@radix-ui/react-icons";
import { FixedSizeList as List } from "react-window";

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">
        {/* TODO: Figure out the type */}
        <div>{(props.data as { label: string }).label}</div>
        {props.isSelected && <CheckIcon />}
      </div>
    </components.Option>
  );
};

// Using Menu and MenuList fixes the scrolling behavior
export const Menu = (props: MenuProps) => {
  return <components.Menu {...props}>{props.children}</components.Menu>;
};

export const MenuList = (props: MenuListProps) => {
  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>
  );
};
// ~~~ Select.tsx
// SOURCE: https://gist.github.com/ilkou/7bf2dbd42a7faf70053b43034fc4b5a4
import * as React from "react";
import SelectComponent from "react-select";
import type { Props } from "react-select";
import { defaultClassNames, defaultStyles } from "~/lib/helper";
import {
  ClearIndicator,
  DropdownIndicator,
  MultiValueRemove,
  Option,
  Menu,
  MenuList,
} from "./ReactSelectCustomComponents";

const Select = React.forwardRef<
  React.ElementRef<typeof SelectComponent>,
  React.ComponentPropsWithoutRef<typeof SelectComponent>
>((props: Props, ref) => {
  const {
    value,
    onChange,
    options = [],
    styles = defaultStyles,
    classNames = defaultClassNames,
    components = {},
    ...rest
  } = props;

  const id = React.useId();

  return (
    <SelectComponent
      instanceId={id}
      ref={ref}
      value={value}
      onChange={onChange}
      options={options}
      unstyled
      components={{
        DropdownIndicator,
        ClearIndicator,
        MultiValueRemove,
        Option,
        Menu,
        MenuList,
        ...components,
      }}
      styles={styles}
      classNames={classNames}
      {...rest}
    />
  );
});
Select.displayName = "Select";
export default Select;

// ~~~ Creatable.tsx
// SOURCE: https://gist.github.com/ilkou/7bf2dbd42a7faf70053b43034fc4b5a4
import * as React from "react";
import CreatableSelect from "react-select/creatable";
import type { Props } from "react-select";
import { defaultClassNames, defaultStyles } from "~/lib/helper";
import {
  ClearIndicator,
  DropdownIndicator,
  Menu,
  MenuList,
  MultiValueRemove,
  Option,
} from "./ReactSelectCustomComponents";

const Creatable = React.forwardRef<
  React.ElementRef<typeof CreatableSelect>,
  React.ComponentPropsWithoutRef<typeof CreatableSelect>
>((props: Props, ref) => {
  const {
    value,
    onChange,
    options = [],
    styles = defaultStyles,
    classNames = defaultClassNames,
    components = {},
    ...rest
  } = props;

  const id = React.useId();

  return (
    <CreatableSelect
      instanceId={id}
      ref={ref}
      value={value}
      onChange={onChange}
      options={options}
      unstyled
      components={{
        DropdownIndicator,
        ClearIndicator,
        MultiValueRemove,
        Option,
        Menu,
        MenuList,
        ...components,
      }}
      styles={styles}
      classNames={classNames}
      {...rest}
    />
  );
});
Creatable.displayName = "Creatable";
export default Creatable;

// ~~~ useFormatters.tsx
// SOURCE: https://gist.github.com/ilkou/7bf2dbd42a7faf70053b43034fc4b5a4

import { type CreatableAdditionalProps } from "node_modules/react-select/dist/declarations/src/useCreatable";
import { type GroupBase } from "react-select";

/**
 * 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: CreatableAdditionalProps<
    unknown,
    GroupBase<unknown>
  >["formatCreateLabel"] = (label) => (
    <span className={"text-sm"}>
      Add
      <span className={"font-semibold"}>{` "${label}"`}</span>
    </span>
  );

  // useful for GroupedOptions
  const formatGroupLabel: (group: GroupBase<unknown>) => React.ReactNode = (
    data,
  ) => (
    <div className={"flex items-center justify-between"}>
      <span>{data.label}</span>
      <span
        className={
          "rounded-md bg-secondary px-1 text-xs font-normal text-secondary-foreground shadow-sm"
        }
      >
        {data.options.length}
      </span>
    </div>
  );
  return {
    formatCreateLabel,
    formatGroupLabel,
  };
};

@H7ioo this is super helpful 🙏 Do you also have the async-select component typed by any chance? I get an error for the Props attribute. Thanks for sharing this with the community

@H7ioo
Copy link

H7ioo commented Aug 4, 2024

@feliche93.
It is actually the same. You have to update the import only.

// SOURCE: https://gist.github.com/ilkou/7bf2dbd42a7faf70053b43034fc4b5a4
import * as React from "react";
import AsyncSelectComponent from "react-select/async";
import type { Props } from "react-select";
import { defaultClassNames, defaultStyles } from "~/lib/helper";
import {
  ClearIndicator,
  DropdownIndicator,
  MultiValueRemove,
  Option,
  Menu,
  MenuList,
} from "./ReactSelectCustomComponents";

const AsyncSelect = React.forwardRef<
  React.ElementRef<typeof AsyncSelectComponent>,
  React.ComponentPropsWithoutRef<typeof AsyncSelectComponent>
>((props: Props, ref) => {
  const {
    value,
    onChange,
    options = [],
    styles = defaultStyles,
    classNames = defaultClassNames,
    components = {},
    ...rest
  } = props;

  const id = React.useId();

  return (
    <AsyncSelectComponent
      className="HELLO"
      instanceId={id}
      ref={ref}
      value={value}
      onChange={onChange}
      options={options}
      unstyled
      components={{
        DropdownIndicator,
        ClearIndicator,
        MultiValueRemove,
        Option,
        Menu,
        MenuList,
        ...components,
      }}
      styles={styles}
      classNames={classNames}
      {...rest}
    />
  );
});
AsyncSelect.displayName = "Async Select";
export default AsyncSelect;

@fitimbytyqi
Copy link

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 ?

@H7ioo
Copy link

H7ioo commented Aug 28, 2024

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.

@fitimbytyqi
Copy link

@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.

@fitimbytyqi
Copy link

fitimbytyqi commented Sep 17, 2024

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;

@dancoon
Copy link

dancoon commented Oct 14, 2024

@H7ioo How can I use achieve multi select with the Select component?

@ilkou
Copy link
Author

ilkou commented Oct 14, 2024

@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

this gist proposition only adds the styling part, react-select components work as usual

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment