selectControl.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. import * as React from 'react';
  2. import ReactSelect, {
  3. components as selectComponents,
  4. GroupedOptionsType,
  5. mergeStyles,
  6. OptionsType,
  7. Props as ReactSelectProps,
  8. StylesConfig,
  9. } from 'react-select';
  10. import Async from 'react-select/async';
  11. import AsyncCreatable from 'react-select/async-creatable';
  12. import Creatable from 'react-select/creatable';
  13. import {withTheme} from '@emotion/react';
  14. import LoadingIndicator from 'app/components/loadingIndicator';
  15. import {IconChevron, IconClose} from 'app/icons';
  16. import space from 'app/styles/space';
  17. import {Choices, SelectValue} from 'app/types';
  18. import convertFromSelect2Choices from 'app/utils/convertFromSelect2Choices';
  19. import {Theme} from 'app/utils/theme';
  20. function isGroupedOptions<OptionType>(
  21. maybe:
  22. | ReturnType<typeof convertFromSelect2Choices>
  23. | GroupedOptionsType<OptionType>
  24. | OptionType[]
  25. | OptionsType<OptionType>
  26. ): maybe is GroupedOptionsType<OptionType> {
  27. if (!maybe || maybe.length === 0) {
  28. return false;
  29. }
  30. return (maybe as GroupedOptionsType<OptionType>)[0].options !== undefined;
  31. }
  32. const ClearIndicator = (
  33. props: React.ComponentProps<typeof selectComponents.ClearIndicator>
  34. ) => (
  35. <selectComponents.ClearIndicator {...props}>
  36. <IconClose size="10px" />
  37. </selectComponents.ClearIndicator>
  38. );
  39. const DropdownIndicator = (
  40. props: React.ComponentProps<typeof selectComponents.DropdownIndicator>
  41. ) => (
  42. <selectComponents.DropdownIndicator {...props}>
  43. <IconChevron direction="down" size="14px" />
  44. </selectComponents.DropdownIndicator>
  45. );
  46. const MultiValueRemove = (
  47. props: React.ComponentProps<typeof selectComponents.MultiValueRemove>
  48. ) => (
  49. <selectComponents.MultiValueRemove {...props}>
  50. <IconClose size="8px" />
  51. </selectComponents.MultiValueRemove>
  52. );
  53. const SelectLoadingIndicator = () => (
  54. <LoadingIndicator mini size={20} style={{height: 20, width: 20}} />
  55. );
  56. export type ControlProps<OptionType = GeneralSelectValue> = Omit<
  57. ReactSelectProps<OptionType>,
  58. 'onChange' | 'value'
  59. > & {
  60. /**
  61. * Set to true to prefix selected values with content
  62. */
  63. inFieldLabel?: string;
  64. /**
  65. * Backwards compatible shim to work with select2 style choice type.
  66. */
  67. choices?: Choices | ((props: ControlProps<OptionType>) => Choices);
  68. /**
  69. * Used by MultiSelectControl.
  70. */
  71. multiple?: boolean;
  72. /**
  73. * Handler for changes. Narrower than the types in react-select.
  74. */
  75. onChange?: (value?: OptionType | null) => void;
  76. /**
  77. * Unlike react-select which expects an OptionType as its value
  78. * we accept the option.value and resolve the option object.
  79. * Because this type is embedded in the OptionType generic we
  80. * can't have a good type here.
  81. */
  82. value?: any;
  83. };
  84. /**
  85. * Additional props provided by forwardRef and withTheme()
  86. */
  87. type WrappedControlProps<OptionType> = ControlProps<OptionType> & {
  88. theme: Theme;
  89. /**
  90. * Ref forwarded into ReactSelect component.
  91. * The any is inherited from react-select.
  92. */
  93. forwardedRef: React.Ref<ReactSelect>;
  94. };
  95. // TODO(ts) The exported component uses forwardRef.
  96. // This means we cannot fill the SelectValue generic
  97. // at the call site. We use `any` here to avoid type errors with select
  98. // controls that have custom option structures
  99. export type GeneralSelectValue = SelectValue<any>;
  100. function SelectControl<OptionType extends GeneralSelectValue = GeneralSelectValue>(
  101. props: WrappedControlProps<OptionType>
  102. ) {
  103. const {theme} = props;
  104. // TODO(epurkhiser): The loading indicator should probably also be our loading
  105. // indicator.
  106. // Unfortunately we cannot use emotions `css` helper here, since react-select
  107. // *requires* object styles, which the css helper cannot produce.
  108. const indicatorStyles = ({padding: _padding, ...provided}: React.CSSProperties) => ({
  109. ...provided,
  110. padding: '4px',
  111. alignItems: 'center',
  112. cursor: 'pointer',
  113. color: theme.subText,
  114. });
  115. const defaultStyles: StylesConfig = {
  116. control: (_, state: any) => ({
  117. height: '100%',
  118. fontSize: theme.fontSizeLarge,
  119. lineHeight: theme.text.lineHeightBody,
  120. display: 'flex',
  121. // @ts-ignore Ignore merge errors as only defining the property once
  122. // makes code harder to understand.
  123. ...{
  124. color: theme.formText,
  125. background: theme.background,
  126. border: `1px solid ${theme.border}`,
  127. boxShadow: `inset ${theme.dropShadowLight}`,
  128. },
  129. borderRadius: theme.borderRadius,
  130. transition: 'border 0.1s linear',
  131. alignItems: 'center',
  132. minHeight: '40px',
  133. '&:hover': {
  134. borderColor: theme.border,
  135. },
  136. ...(state.isFocused && {
  137. border: `1px solid ${theme.border}`,
  138. boxShadow: 'rgba(209, 202, 216, 0.5) 0 0 0 3px',
  139. }),
  140. ...(state.menuIsOpen && {
  141. borderBottomLeftRadius: '0',
  142. borderBottomRightRadius: '0',
  143. boxShadow: 'none',
  144. }),
  145. ...(state.isDisabled && {
  146. borderColor: theme.border,
  147. background: theme.backgroundSecondary,
  148. color: theme.disabled,
  149. cursor: 'not-allowed',
  150. }),
  151. ...(!state.isSearchable && {
  152. cursor: 'pointer',
  153. }),
  154. }),
  155. menu: (provided: React.CSSProperties) => ({
  156. ...provided,
  157. zIndex: theme.zIndex.dropdown,
  158. marginTop: '-1px',
  159. background: theme.background,
  160. border: `1px solid ${theme.border}`,
  161. borderRadius: `0 0 ${theme.borderRadius} ${theme.borderRadius}`,
  162. borderTop: `1px solid ${theme.border}`,
  163. boxShadow: theme.dropShadowLight,
  164. }),
  165. option: (provided: React.CSSProperties, state: any) => ({
  166. ...provided,
  167. lineHeight: '1.5',
  168. fontSize: theme.fontSizeMedium,
  169. cursor: 'pointer',
  170. color: state.isFocused
  171. ? theme.textColor
  172. : state.isSelected
  173. ? theme.background
  174. : theme.textColor,
  175. backgroundColor: state.isFocused
  176. ? theme.focus
  177. : state.isSelected
  178. ? theme.active
  179. : 'transparent',
  180. '&:active': {
  181. backgroundColor: theme.active,
  182. },
  183. }),
  184. valueContainer: (provided: React.CSSProperties) => ({
  185. ...provided,
  186. alignItems: 'center',
  187. }),
  188. input: (provided: React.CSSProperties) => ({
  189. ...provided,
  190. color: theme.formText,
  191. }),
  192. singleValue: (provided: React.CSSProperties) => ({
  193. ...provided,
  194. color: theme.formText,
  195. }),
  196. placeholder: (provided: React.CSSProperties) => ({
  197. ...provided,
  198. color: theme.formPlaceholder,
  199. }),
  200. multiValue: (provided: React.CSSProperties) => ({
  201. ...provided,
  202. color: '#007eff',
  203. backgroundColor: '#ebf5ff',
  204. borderRadius: '2px',
  205. border: '1px solid #c2e0ff',
  206. display: 'flex',
  207. }),
  208. multiValueLabel: (provided: React.CSSProperties) => ({
  209. ...provided,
  210. color: '#007eff',
  211. padding: '0',
  212. paddingLeft: '6px',
  213. lineHeight: '1.8',
  214. }),
  215. multiValueRemove: () => ({
  216. cursor: 'pointer',
  217. alignItems: 'center',
  218. borderLeft: '1px solid #c2e0ff',
  219. borderRadius: '0 2px 2px 0',
  220. display: 'flex',
  221. padding: '0 4px',
  222. marginLeft: '4px',
  223. '&:hover': {
  224. color: '#6284b9',
  225. background: '#cce5ff',
  226. },
  227. }),
  228. indicatorsContainer: () => ({
  229. display: 'grid',
  230. gridAutoFlow: 'column',
  231. gridGap: '2px',
  232. marginRight: '6px',
  233. }),
  234. clearIndicator: indicatorStyles,
  235. dropdownIndicator: indicatorStyles,
  236. loadingIndicator: indicatorStyles,
  237. groupHeading: (provided: React.CSSProperties) => ({
  238. ...provided,
  239. lineHeight: '1.5',
  240. fontWeight: 600,
  241. backgroundColor: theme.backgroundSecondary,
  242. color: theme.textColor,
  243. marginBottom: 0,
  244. padding: `${space(1)} ${space(1.5)}`,
  245. }),
  246. group: (provided: React.CSSProperties) => ({
  247. ...provided,
  248. padding: 0,
  249. }),
  250. };
  251. const getFieldLabelStyle = (label?: string) => ({
  252. ':before': {
  253. content: `"${label}"`,
  254. color: theme.gray300,
  255. fontWeight: 600,
  256. },
  257. });
  258. const {
  259. async,
  260. creatable,
  261. options,
  262. choices,
  263. clearable,
  264. components,
  265. styles,
  266. value,
  267. inFieldLabel,
  268. ...rest
  269. } = props;
  270. // Compatibility with old select2 API
  271. const choicesOrOptions =
  272. convertFromSelect2Choices(typeof choices === 'function' ? choices(props) : choices) ||
  273. options;
  274. // It's possible that `choicesOrOptions` does not exist (e.g. in the case of AsyncSelect)
  275. let mappedValue = value;
  276. if (choicesOrOptions) {
  277. /**
  278. * Value is expected to be object like the options list, we map it back from the options list.
  279. * Note that if the component doesn't have options or choices passed in
  280. * because the select component fetches the options finding the mappedValue will fail
  281. * and the component won't work
  282. */
  283. let flatOptions: any[] = [];
  284. if (isGroupedOptions<OptionType>(choicesOrOptions)) {
  285. flatOptions = choicesOrOptions.flatMap(option => option.options);
  286. } else {
  287. // @ts-ignore The types used in react-select generics (OptionType) don't
  288. // line up well with our option type (SelectValue). We need to do more work
  289. // to get these types to align.
  290. flatOptions = choicesOrOptions.flatMap(option => option);
  291. }
  292. mappedValue =
  293. props.multiple && Array.isArray(value)
  294. ? value.map(val => flatOptions.find(option => option.value === val))
  295. : flatOptions.find(opt => opt.value === value) || value;
  296. }
  297. // Override the default style with in-field labels if they are provided
  298. const inFieldLabelStyles = {
  299. singleValue: (base: React.CSSProperties) => ({
  300. ...base,
  301. ...getFieldLabelStyle(inFieldLabel),
  302. }),
  303. placeholder: (base: React.CSSProperties) => ({
  304. ...base,
  305. ...getFieldLabelStyle(inFieldLabel),
  306. }),
  307. };
  308. const labelOrDefaultStyles = inFieldLabel
  309. ? mergeStyles(defaultStyles, inFieldLabelStyles)
  310. : defaultStyles;
  311. // Allow the provided `styles` prop to override default styles using the same
  312. // function interface provided by react-styled. This ensures the `provided`
  313. // styles include our overridden default styles
  314. const mappedStyles = styles
  315. ? mergeStyles(labelOrDefaultStyles, styles)
  316. : labelOrDefaultStyles;
  317. const replacedComponents = {
  318. ClearIndicator,
  319. DropdownIndicator,
  320. MultiValueRemove,
  321. LoadingIndicator: SelectLoadingIndicator,
  322. IndicatorSeparator: null,
  323. };
  324. return (
  325. <SelectPicker<OptionType>
  326. styles={mappedStyles}
  327. components={{...replacedComponents, ...components}}
  328. async={async}
  329. creatable={creatable}
  330. isClearable={clearable}
  331. backspaceRemovesValue={clearable}
  332. value={mappedValue}
  333. isMulti={props.multiple || props.multi}
  334. isDisabled={props.isDisabled || props.disabled}
  335. options={options || (choicesOrOptions as OptionsType<OptionType>)}
  336. openMenuOnFocus={props.openMenuOnFocus === undefined ? true : props.openMenuOnFocus}
  337. {...rest}
  338. />
  339. );
  340. }
  341. const SelectControlWithTheme = withTheme(SelectControl);
  342. type PickerProps<OptionType> = ControlProps<OptionType> & {
  343. /**
  344. * Enable async option loading.
  345. */
  346. async?: boolean;
  347. /**
  348. * Enable 'create' mode which allows values to be created inline.
  349. */
  350. creatable?: boolean;
  351. /**
  352. * Enable 'clearable' which allows values to be removed.
  353. */
  354. clearable?: boolean;
  355. };
  356. function SelectPicker<OptionType>({
  357. async,
  358. creatable,
  359. forwardedRef,
  360. ...props
  361. }: PickerProps<OptionType>) {
  362. // Pick the right component to use
  363. // Using any here as react-select types also use any
  364. let Component: React.ComponentType<any> | undefined;
  365. if (async && creatable) {
  366. Component = AsyncCreatable;
  367. } else if (async && !creatable) {
  368. Component = Async;
  369. } else if (creatable) {
  370. Component = Creatable;
  371. } else {
  372. Component = ReactSelect;
  373. }
  374. return <Component ref={forwardedRef} {...props} />;
  375. }
  376. // The generics need to be filled here as forwardRef can't expose generics.
  377. const RefForwardedSelectControl = React.forwardRef<
  378. ReactSelect<GeneralSelectValue>,
  379. ControlProps<GeneralSelectValue>
  380. >(function RefForwardedSelectControl(props, ref) {
  381. return <SelectControlWithTheme forwardedRef={ref} {...props} />;
  382. });
  383. export default RefForwardedSelectControl;