selectControl.tsx 15 KB

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