selectControl.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  1. import {forwardRef, useCallback, useMemo} 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 {useTheme} from '@emotion/react';
  14. import styled from '@emotion/styled';
  15. import LoadingIndicator from 'sentry/components/loadingIndicator';
  16. import {IconChevron, IconClose} from 'sentry/icons';
  17. import space from 'sentry/styles/space';
  18. import {Choices, SelectValue} from 'sentry/types';
  19. import convertFromSelect2Choices from 'sentry/utils/convertFromSelect2Choices';
  20. import {FormSize} from 'sentry/utils/theme';
  21. import Option from './selectOption';
  22. function isGroupedOptions<OptionType>(
  23. maybe:
  24. | ReturnType<typeof convertFromSelect2Choices>
  25. | GroupedOptionsType<OptionType>
  26. | OptionType[]
  27. | OptionsType<OptionType>
  28. ): maybe is GroupedOptionsType<OptionType> {
  29. if (!maybe || maybe.length === 0) {
  30. return false;
  31. }
  32. return (maybe as GroupedOptionsType<OptionType>)[0].options !== undefined;
  33. }
  34. const ClearIndicator = (
  35. props: React.ComponentProps<typeof selectComponents.ClearIndicator>
  36. ) => (
  37. <selectComponents.ClearIndicator {...props}>
  38. <IconClose size="10px" />
  39. </selectComponents.ClearIndicator>
  40. );
  41. const DropdownIndicator = (
  42. props: React.ComponentProps<typeof selectComponents.DropdownIndicator>
  43. ) => (
  44. <selectComponents.DropdownIndicator {...props}>
  45. <IconChevron direction="down" size="14px" />
  46. </selectComponents.DropdownIndicator>
  47. );
  48. const MultiValueRemove = (
  49. props: React.ComponentProps<typeof selectComponents.MultiValueRemove>
  50. ) => (
  51. <selectComponents.MultiValueRemove {...props}>
  52. <IconClose size="8px" />
  53. </selectComponents.MultiValueRemove>
  54. );
  55. const SelectLoadingIndicator = () => (
  56. <LoadingIndicator mini size={20} style={{height: 20, width: 20}} />
  57. );
  58. const SingleValue = (
  59. props: React.ComponentProps<typeof selectComponents.SingleValue>
  60. ) => {
  61. const {leadingItems, label} = props.data;
  62. return (
  63. <selectComponents.SingleValue {...props}>
  64. <SingleValueWrap>
  65. {leadingItems}
  66. <SingleValueLabel>{label}</SingleValueLabel>
  67. </SingleValueWrap>
  68. </selectComponents.SingleValue>
  69. );
  70. };
  71. const SingleValueWrap = styled('div')`
  72. display: grid;
  73. grid-auto-flow: column;
  74. gap: ${space(1)};
  75. align-items: center;
  76. `;
  77. const SingleValueLabel = styled('div')`
  78. ${p => p.theme.overflowEllipsis};
  79. `;
  80. export type ControlProps<OptionType = GeneralSelectValue> = Omit<
  81. ReactSelectProps<OptionType>,
  82. 'onChange' | 'value'
  83. > & {
  84. /**
  85. * Backwards compatible shim to work with select2 style choice type.
  86. */
  87. choices?: Choices | ((props: ControlProps<OptionType>) => Choices);
  88. /**
  89. * Set to true to prefix selected values with content
  90. */
  91. inFieldLabel?: string;
  92. /**
  93. * Whether this is used inside compactSelect. See
  94. * components/compactSelect.tsx
  95. */
  96. isCompact?: boolean;
  97. /**
  98. * Maximum width of the menu component. Menu item labels that overflow the
  99. * menu's boundaries will automatically be truncated.
  100. */
  101. maxMenuWidth?: number | string;
  102. /**
  103. * Used by MultiSelectControl.
  104. */
  105. multiple?: boolean;
  106. /**
  107. * Handler for changes. Narrower than the types in react-select.
  108. */
  109. onChange?: (value?: OptionType | null) => void;
  110. /**
  111. * Show line dividers between options
  112. */
  113. showDividers?: boolean;
  114. size?: FormSize;
  115. /**
  116. * Unlike react-select which expects an OptionType as its value
  117. * we accept the option.value and resolve the option object.
  118. * Because this type is embedded in the OptionType generic we
  119. * can't have a good type here.
  120. */
  121. value?: any;
  122. };
  123. /**
  124. * Additional props provided by forwardRef
  125. */
  126. type WrappedControlProps<OptionType> = ControlProps<OptionType> & {
  127. /**
  128. * Ref forwarded into ReactSelect component.
  129. * The any is inherited from react-select.
  130. */
  131. forwardedRef: React.Ref<ReactSelect>;
  132. };
  133. // TODO(ts) The exported component uses forwardRef.
  134. // This means we cannot fill the SelectValue generic
  135. // at the call site. We use `any` here to avoid type errors with select
  136. // controls that have custom option structures
  137. export type GeneralSelectValue = SelectValue<any>;
  138. function SelectControl<OptionType extends GeneralSelectValue = GeneralSelectValue>(
  139. props: WrappedControlProps<OptionType>
  140. ) {
  141. const theme = useTheme();
  142. const {size, isCompact, isSearchable, maxMenuWidth, menuHeight} = props;
  143. // TODO(epurkhiser): The loading indicator should probably also be our loading
  144. // indicator.
  145. // Unfortunately we cannot use emotions `css` helper here, since react-select
  146. // *requires* object styles, which the css helper cannot produce.
  147. const indicatorStyles = useCallback(
  148. ({padding: _padding, ...provided}: React.CSSProperties) => ({
  149. ...provided,
  150. padding: '4px',
  151. alignItems: 'center',
  152. cursor: 'pointer',
  153. color: theme.subText,
  154. }),
  155. [theme]
  156. );
  157. const defaultStyles = useMemo<StylesConfig>(
  158. () => ({
  159. control: (_, state: any) => ({
  160. display: 'flex',
  161. // @ts-ignore Ignore merge errors as only defining the property once
  162. // makes code harder to understand.
  163. ...{
  164. color: theme.formText,
  165. background: theme.background,
  166. border: `1px solid ${theme.border}`,
  167. boxShadow: theme.dropShadowLight,
  168. },
  169. borderRadius: theme.borderRadius,
  170. transition: 'border 0.1s, box-shadow 0.1s',
  171. alignItems: 'center',
  172. ...(state.isFocused && {
  173. borderColor: theme.focusBorder,
  174. boxShadow: `${theme.focusBorder} 0 0 0 1px`,
  175. }),
  176. ...(state.isDisabled && {
  177. borderColor: theme.border,
  178. background: theme.backgroundSecondary,
  179. color: theme.disabled,
  180. cursor: 'not-allowed',
  181. }),
  182. ...(!state.isSearchable && {
  183. cursor: 'pointer',
  184. }),
  185. ...(isCompact
  186. ? {
  187. padding: `${space(0.5)} ${space(0.5)}`,
  188. borderRadius: 0,
  189. border: 'none',
  190. boxShadow: 'none',
  191. cursor: 'initial',
  192. minHeight: 'none',
  193. ...(isSearchable
  194. ? {marginTop: 1}
  195. : {
  196. height: 0,
  197. padding: 0,
  198. overflow: 'hidden',
  199. }),
  200. }
  201. : theme.form[size ?? 'md']),
  202. }),
  203. menu: (provided: React.CSSProperties) => ({
  204. ...provided,
  205. zIndex: theme.zIndex.dropdown,
  206. background: theme.backgroundElevated,
  207. border: `1px solid ${theme.border}`,
  208. borderRadius: theme.borderRadius,
  209. boxShadow: theme.dropShadowHeavy,
  210. width: 'auto',
  211. minWidth: '100%',
  212. maxWidth: maxMenuWidth ?? 'auto',
  213. ...(isCompact && {
  214. position: 'relative',
  215. margin: 0,
  216. borderRadius: 0,
  217. border: 'none',
  218. boxShadow: 'none',
  219. zIndex: 'initial',
  220. ...(isSearchable && {paddingTop: 0}),
  221. }),
  222. }),
  223. menuList: (provided: React.CSSProperties) => ({
  224. ...provided,
  225. ...(isCompact && {
  226. ...(menuHeight && {
  227. maxHeight: menuHeight,
  228. }),
  229. ...(isSearchable && {
  230. paddingTop: 0,
  231. }),
  232. }),
  233. }),
  234. menuPortal: () => ({
  235. maxWidth: maxMenuWidth ?? '24rem',
  236. zIndex: theme.zIndex.dropdown,
  237. width: '90%',
  238. position: 'fixed',
  239. left: '50%',
  240. top: '50%',
  241. transform: 'translate(-50%, -50%)',
  242. background: theme.backgroundElevated,
  243. border: `1px solid ${theme.border}`,
  244. borderRadius: theme.borderRadius,
  245. boxShadow: theme.dropShadowHeavy,
  246. overflow: 'hidden',
  247. }),
  248. option: (provided: React.CSSProperties) => ({
  249. ...provided,
  250. cursor: 'pointer',
  251. color: theme.textColor,
  252. background: 'transparent',
  253. padding: 0,
  254. ':active': {
  255. background: 'transparent',
  256. },
  257. }),
  258. valueContainer: (provided: React.CSSProperties) => ({
  259. ...provided,
  260. alignItems: 'center',
  261. ...(isCompact
  262. ? {
  263. padding: `${space(0.5)} ${space(1)}`,
  264. border: `1px solid ${theme.innerBorder}`,
  265. borderRadius: theme.borderRadius,
  266. cursor: 'text',
  267. background: theme.backgroundSecondary,
  268. }
  269. : {
  270. paddingLeft: theme.formPadding[size ?? 'md'].paddingLeft,
  271. paddingRight: space(0.5),
  272. }),
  273. }),
  274. input: (provided: React.CSSProperties) => ({
  275. ...provided,
  276. color: theme.formText,
  277. margin: 0,
  278. ...(isCompact && {
  279. padding: 0,
  280. }),
  281. }),
  282. singleValue: (provided: React.CSSProperties) => ({
  283. ...provided,
  284. color: theme.formText,
  285. display: 'flex',
  286. alignItems: 'center',
  287. marginLeft: 0,
  288. marginRight: 0,
  289. width: `calc(100% - ${theme.formPadding[size ?? 'md'].paddingLeft}px - ${space(
  290. 0.5
  291. )})`,
  292. }),
  293. placeholder: (provided: React.CSSProperties) => ({
  294. ...provided,
  295. color: theme.formPlaceholder,
  296. ...(isCompact && {
  297. padding: 0,
  298. margin: 0,
  299. }),
  300. }),
  301. multiValue: (provided: React.CSSProperties) => ({
  302. ...provided,
  303. color: '#007eff',
  304. backgroundColor: '#ebf5ff',
  305. borderRadius: '2px',
  306. border: '1px solid #c2e0ff',
  307. display: 'flex',
  308. }),
  309. multiValueLabel: (provided: React.CSSProperties) => ({
  310. ...provided,
  311. color: '#007eff',
  312. padding: '0',
  313. paddingLeft: '6px',
  314. lineHeight: '1.8',
  315. }),
  316. multiValueRemove: () => ({
  317. cursor: 'pointer',
  318. alignItems: 'center',
  319. borderLeft: '1px solid #c2e0ff',
  320. borderRadius: '0 2px 2px 0',
  321. display: 'flex',
  322. padding: '0 4px',
  323. marginLeft: '4px',
  324. '&:hover': {
  325. color: '#6284b9',
  326. background: '#cce5ff',
  327. },
  328. }),
  329. indicatorsContainer: () => ({
  330. display: 'grid',
  331. gridAutoFlow: 'column',
  332. gridGap: '2px',
  333. marginRight: '6px',
  334. ...(isCompact && {display: 'none'}),
  335. }),
  336. clearIndicator: indicatorStyles,
  337. dropdownIndicator: indicatorStyles,
  338. loadingIndicator: indicatorStyles,
  339. groupHeading: (provided: React.CSSProperties) => ({
  340. ...provided,
  341. lineHeight: '1.5',
  342. fontWeight: 600,
  343. color: theme.subText,
  344. marginBottom: 0,
  345. padding: `${space(0.5)} ${space(1.5)}`,
  346. }),
  347. group: (provided: React.CSSProperties) => ({
  348. ...provided,
  349. paddingTop: 0,
  350. ':last-of-type': {
  351. paddingBottom: 0,
  352. },
  353. }),
  354. }),
  355. [theme, size, maxMenuWidth, menuHeight, indicatorStyles, isSearchable, isCompact]
  356. );
  357. const getFieldLabelStyle = (label?: string) => ({
  358. ':before': {
  359. content: `"${label}"`,
  360. color: theme.gray300,
  361. fontWeight: 600,
  362. marginRight: space(1),
  363. },
  364. });
  365. const {
  366. async,
  367. creatable,
  368. options,
  369. choices,
  370. clearable,
  371. components,
  372. styles,
  373. value,
  374. inFieldLabel,
  375. ...rest
  376. } = props;
  377. // Compatibility with old select2 API
  378. const choicesOrOptions =
  379. convertFromSelect2Choices(typeof choices === 'function' ? choices(props) : choices) ||
  380. options;
  381. // It's possible that `choicesOrOptions` does not exist (e.g. in the case of AsyncSelect)
  382. let mappedValue = value;
  383. if (choicesOrOptions) {
  384. /**
  385. * Value is expected to be object like the options list, we map it back from the options list.
  386. * Note that if the component doesn't have options or choices passed in
  387. * because the select component fetches the options finding the mappedValue will fail
  388. * and the component won't work
  389. */
  390. let flatOptions: any[] = [];
  391. if (isGroupedOptions<OptionType>(choicesOrOptions)) {
  392. flatOptions = choicesOrOptions.flatMap(option => option.options);
  393. } else {
  394. // @ts-ignore The types used in react-select generics (OptionType) don't
  395. // line up well with our option type (SelectValue). We need to do more work
  396. // to get these types to align.
  397. flatOptions = choicesOrOptions.flatMap(option => option);
  398. }
  399. mappedValue =
  400. props.multiple && Array.isArray(value)
  401. ? value.map(val => flatOptions.find(option => option.value === val))
  402. : flatOptions.find(opt => opt.value === value) || value;
  403. }
  404. // Override the default style with in-field labels if they are provided
  405. const inFieldLabelStyles = {
  406. singleValue: (base: React.CSSProperties) => ({
  407. ...base,
  408. ...getFieldLabelStyle(inFieldLabel),
  409. }),
  410. placeholder: (base: React.CSSProperties) => ({
  411. ...base,
  412. ...getFieldLabelStyle(inFieldLabel),
  413. }),
  414. };
  415. const labelOrDefaultStyles = inFieldLabel
  416. ? mergeStyles(defaultStyles, inFieldLabelStyles)
  417. : defaultStyles;
  418. // Allow the provided `styles` prop to override default styles using the same
  419. // function interface provided by react-styled. This ensures the `provided`
  420. // styles include our overridden default styles
  421. const mappedStyles = styles
  422. ? mergeStyles(labelOrDefaultStyles, styles)
  423. : labelOrDefaultStyles;
  424. const replacedComponents = {
  425. SingleValue,
  426. ClearIndicator,
  427. DropdownIndicator,
  428. MultiValueRemove,
  429. LoadingIndicator: SelectLoadingIndicator,
  430. IndicatorSeparator: null,
  431. Option,
  432. };
  433. return (
  434. <SelectPicker<OptionType>
  435. styles={mappedStyles}
  436. components={{...replacedComponents, ...components}}
  437. async={async}
  438. creatable={creatable}
  439. isClearable={clearable}
  440. backspaceRemovesValue={clearable}
  441. value={mappedValue}
  442. isMulti={props.multiple || props.multi}
  443. isDisabled={props.isDisabled || props.disabled}
  444. isOptionDisabled={opt => !!opt.disabled}
  445. showDividers={props.showDividers}
  446. options={options || (choicesOrOptions as OptionsType<OptionType>)}
  447. openMenuOnFocus={props.openMenuOnFocus}
  448. blurInputOnSelect={!props.multiple && !props.multi}
  449. closeMenuOnSelect={!(props.multiple || props.multi)}
  450. hideSelectedOptions={false}
  451. tabSelectsValue={false}
  452. {...rest}
  453. />
  454. );
  455. }
  456. type PickerProps<OptionType> = ControlProps<OptionType> & {
  457. /**
  458. * Enable async option loading.
  459. */
  460. async?: boolean;
  461. /**
  462. * Enable 'clearable' which allows values to be removed.
  463. */
  464. clearable?: boolean;
  465. /**
  466. * Enable 'create' mode which allows values to be created inline.
  467. */
  468. creatable?: boolean;
  469. };
  470. function SelectPicker<OptionType>({
  471. async,
  472. creatable,
  473. forwardedRef,
  474. ...props
  475. }: PickerProps<OptionType>) {
  476. // Pick the right component to use
  477. // Using any here as react-select types also use any
  478. let Component: React.ComponentType<any> | undefined;
  479. if (async && creatable) {
  480. Component = AsyncCreatable;
  481. } else if (async && !creatable) {
  482. Component = Async;
  483. } else if (creatable) {
  484. Component = Creatable;
  485. } else {
  486. Component = ReactSelect;
  487. }
  488. return <Component ref={forwardedRef} {...props} />;
  489. }
  490. // The generics need to be filled here as forwardRef can't expose generics.
  491. const RefForwardedSelectControl = forwardRef<
  492. ReactSelect<GeneralSelectValue>,
  493. ControlProps<GeneralSelectValue>
  494. >(function RefForwardedSelectControl(props, ref) {
  495. return <SelectControl forwardedRef={ref as any} {...props} />;
  496. });
  497. export default RefForwardedSelectControl;