compactSelect.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
  2. import {components as selectComponents, OptionTypeBase} from 'react-select';
  3. import isPropValid from '@emotion/is-prop-valid';
  4. import {useTheme} from '@emotion/react';
  5. import styled from '@emotion/styled';
  6. import {useButton} from '@react-aria/button';
  7. import {FocusScope} from '@react-aria/focus';
  8. import {useMenuTrigger} from '@react-aria/menu';
  9. import {useResizeObserver} from '@react-aria/utils';
  10. import Badge from 'sentry/components/badge';
  11. import Button from 'sentry/components/button';
  12. import DropdownButton, {DropdownButtonProps} from 'sentry/components/dropdownButton';
  13. import SelectControl, {
  14. ControlProps,
  15. GeneralSelectValue,
  16. } from 'sentry/components/forms/controls/selectControl';
  17. import LoadingIndicator from 'sentry/components/loadingIndicator';
  18. import {Overlay, PositionWrapper} from 'sentry/components/overlay';
  19. import space from 'sentry/styles/space';
  20. import {FormSize} from 'sentry/utils/theme';
  21. import useOverlay, {UseOverlayProps} from 'sentry/utils/useOverlay';
  22. interface Props<OptionType extends OptionTypeBase, MultipleType extends boolean>
  23. extends Omit<ControlProps<OptionType>, 'choices' | 'multiple' | 'onChange'>,
  24. UseOverlayProps {
  25. options: Array<OptionType & {options?: OptionType[]}>;
  26. /**
  27. * Pass class name to the outer wrap
  28. */
  29. className?: string;
  30. /**
  31. * Whether new options are being loaded. When true, CompactSelect will
  32. * display a loading indicator in the header.
  33. */
  34. isLoading?: boolean;
  35. multiple?: MultipleType;
  36. onChange?: MultipleType extends true
  37. ? (values: OptionType[]) => void
  38. : (value: OptionType) => void;
  39. onChangeValueMap?: (value: OptionType[]) => ControlProps<OptionType>['value'];
  40. /**
  41. * Tag name for the outer wrap, defaults to `div`
  42. */
  43. renderWrapAs?: React.ElementType;
  44. /**
  45. * Affects the size of the trigger button and menu items.
  46. */
  47. size?: FormSize;
  48. /**
  49. * Optionally replace the trigger button with a different component. Note
  50. * that the replacement must have the `props` and `ref` (supplied in
  51. * TriggerProps) forwarded its outer wrap, otherwise the accessibility
  52. * features won't work correctly.
  53. */
  54. trigger?: (props: Omit<DropdownButtonProps, 'children'>) => React.ReactNode;
  55. /**
  56. * By default, the menu trigger will be rendered as a button, with
  57. * triggerLabel as the button label.
  58. */
  59. triggerLabel?: React.ReactNode;
  60. /**
  61. * If using the default button trigger (i.e. the custom `trigger` prop has
  62. * not been provided), then `triggerProps` will be passed on to the button
  63. * component.
  64. */
  65. triggerProps?: DropdownButtonProps;
  66. }
  67. /**
  68. * Recursively finds the selected option(s) from an options array. Useful for
  69. * non-flat arrays that contain sections (groups of options).
  70. */
  71. function getSelectedOptions<
  72. OptionType extends GeneralSelectValue,
  73. MultipleType extends boolean
  74. >(
  75. opts: Props<OptionType, MultipleType>['options'],
  76. value: Props<OptionType, MultipleType>['value']
  77. ): Props<OptionType, MultipleType>['options'] {
  78. return opts.reduce((acc: Props<OptionType, MultipleType>['options'], cur) => {
  79. if (cur.options) {
  80. return acc.concat(getSelectedOptions(cur.options, value));
  81. }
  82. if (cur.value === value) {
  83. return acc.concat(cur);
  84. }
  85. return acc;
  86. }, []);
  87. }
  88. // Exported so we can further customize this component with react-select's
  89. // components prop elsewhere
  90. export const CompactSelectControl = ({
  91. innerProps,
  92. ...props
  93. }: React.ComponentProps<typeof selectComponents.Control>) => {
  94. const {hasValue, selectProps} = props;
  95. const {isSearchable, menuTitle, isClearable, isLoading} = selectProps;
  96. return (
  97. <Fragment>
  98. {(menuTitle || isClearable || isLoading) && (
  99. <MenuHeader>
  100. <MenuTitle>
  101. <span>{menuTitle}</span>
  102. </MenuTitle>
  103. {isLoading && <StyledLoadingIndicator size={12} mini />}
  104. {hasValue && isClearable && !isLoading && (
  105. <ClearButton
  106. type="button"
  107. size="zero"
  108. borderless
  109. onClick={() => props.clearValue()}
  110. // set tabIndex -1 to autofocus search on open
  111. tabIndex={isSearchable ? -1 : undefined}
  112. >
  113. Clear
  114. </ClearButton>
  115. )}
  116. </MenuHeader>
  117. )}
  118. <selectComponents.Control
  119. {...props}
  120. innerProps={{...innerProps, ...(!isSearchable && {'aria-hidden': true})}}
  121. />
  122. </Fragment>
  123. );
  124. };
  125. /**
  126. * A select component with a more compact trigger button. Accepts the same
  127. * props as SelectControl, plus some more for the trigger button & overlay.
  128. */
  129. function CompactSelect<
  130. OptionType extends GeneralSelectValue = GeneralSelectValue,
  131. MultipleType extends boolean = false
  132. >({
  133. // Select props
  134. options,
  135. onChange,
  136. defaultValue,
  137. value: valueProp,
  138. isDisabled: disabledProp,
  139. isSearchable = false,
  140. multiple,
  141. placeholder = 'Search…',
  142. onChangeValueMap,
  143. // Trigger button & wrapper props
  144. trigger,
  145. triggerLabel,
  146. triggerProps,
  147. isOpen: isOpenProp,
  148. size = 'md',
  149. className,
  150. renderWrapAs,
  151. closeOnSelect = true,
  152. menuTitle,
  153. onClose,
  154. // Overlay props
  155. offset = 8,
  156. position = 'bottom-start',
  157. shouldCloseOnBlur = true,
  158. isDismissable = true,
  159. maxMenuHeight = 400,
  160. ...props
  161. }: Props<OptionType, MultipleType>) {
  162. // Manage the dropdown menu's open state
  163. const isDisabled = disabledProp || options?.length === 0;
  164. const {
  165. isOpen,
  166. state,
  167. triggerProps: overlayTriggerProps,
  168. triggerRef,
  169. overlayProps,
  170. } = useOverlay({
  171. isOpen: isOpenProp,
  172. onClose,
  173. offset,
  174. position,
  175. isDismissable,
  176. shouldCloseOnBlur,
  177. shouldCloseOnInteractOutside: target =>
  178. target && triggerRef.current !== target && !triggerRef.current?.contains(target),
  179. });
  180. const {menuTriggerProps} = useMenuTrigger(
  181. {type: 'listbox', isDisabled},
  182. {...state, focusStrategy: 'first'},
  183. triggerRef
  184. );
  185. const {buttonProps} = useButton({isDisabled, ...menuTriggerProps}, triggerRef);
  186. // Keep an internal copy of the current select value and update the control
  187. // button's label when the value changes
  188. const [internalValue, setInternalValue] = useState(valueProp ?? defaultValue);
  189. // Keep track of the default trigger label when the value changes
  190. const defaultTriggerLabel = useMemo(() => {
  191. const newValue = valueProp ?? internalValue;
  192. const valueSet = Array.isArray(newValue) ? newValue : [newValue];
  193. const selectedOptions = valueSet
  194. .map(val => getSelectedOptions<OptionType, MultipleType>(options, val))
  195. .flat();
  196. return (
  197. <Fragment>
  198. <ButtonLabel>{selectedOptions[0]?.label ?? ''}</ButtonLabel>
  199. {selectedOptions.length > 1 && (
  200. <StyledBadge text={`+${selectedOptions.length - 1}`} />
  201. )}
  202. </Fragment>
  203. );
  204. }, [options, valueProp, internalValue]);
  205. const onValueChange = useCallback(
  206. option => {
  207. const valueMap = onChangeValueMap ?? (opts => opts.map(opt => opt.value));
  208. const newValue = Array.isArray(option) ? valueMap(option) : option?.value;
  209. setInternalValue(newValue);
  210. onChange?.(option);
  211. if (closeOnSelect && !multiple) {
  212. state.close();
  213. }
  214. },
  215. [state, closeOnSelect, multiple, onChange, onChangeValueMap]
  216. );
  217. // Calculate the current trigger element's width. This will be used as
  218. // the min width for the menu.
  219. const [triggerWidth, setTriggerWidth] = useState<number>();
  220. // Update triggerWidth when its size changes using useResizeObserver
  221. const updateTriggerWidth = useCallback(async () => {
  222. // Wait until the trigger element finishes rendering, otherwise
  223. // ResizeObserver might throw an infinite loop error.
  224. await new Promise(resolve => window.setTimeout(resolve));
  225. const newTriggerWidth = triggerRef.current?.offsetWidth;
  226. newTriggerWidth && setTriggerWidth(newTriggerWidth);
  227. }, [triggerRef]);
  228. useResizeObserver({ref: triggerRef, onResize: updateTriggerWidth});
  229. // If ResizeObserver is not available, manually update the width
  230. // when any of [trigger, triggerLabel, triggerProps] changes.
  231. useEffect(() => {
  232. if (typeof window.ResizeObserver !== 'undefined') {
  233. return;
  234. }
  235. updateTriggerWidth();
  236. }, [updateTriggerWidth]);
  237. function renderTrigger() {
  238. if (trigger) {
  239. return trigger({
  240. size,
  241. isOpen,
  242. ...triggerProps,
  243. ...overlayTriggerProps,
  244. ...buttonProps,
  245. });
  246. }
  247. return (
  248. <DropdownButton
  249. size={size}
  250. isOpen={isOpen}
  251. {...triggerProps}
  252. {...overlayTriggerProps}
  253. {...buttonProps}
  254. >
  255. {triggerLabel ?? defaultTriggerLabel}
  256. </DropdownButton>
  257. );
  258. }
  259. const theme = useTheme();
  260. const menuHeight = useMemo(
  261. () =>
  262. overlayProps.style?.maxHeight
  263. ? Math.min(+overlayProps.style?.maxHeight, maxMenuHeight)
  264. : maxMenuHeight,
  265. [overlayProps, maxMenuHeight]
  266. );
  267. function renderMenu() {
  268. if (!isOpen) {
  269. return null;
  270. }
  271. return (
  272. <FocusScope restoreFocus autoFocus>
  273. <PositionWrapper zIndex={theme.zIndex.dropdown} {...overlayProps}>
  274. <StyledOverlay minWidth={triggerWidth}>
  275. <SelectControl
  276. components={{Control: CompactSelectControl, ClearIndicator: null}}
  277. {...props}
  278. options={options}
  279. value={valueProp ?? internalValue}
  280. multiple={multiple}
  281. onChange={onValueChange}
  282. size={size}
  283. menuTitle={menuTitle}
  284. placeholder={placeholder}
  285. isSearchable={isSearchable}
  286. menuHeight={menuHeight}
  287. menuPlacement="bottom"
  288. menuIsOpen
  289. isCompact
  290. controlShouldRenderValue={false}
  291. hideSelectedOptions={false}
  292. menuShouldScrollIntoView={false}
  293. blurInputOnSelect={false}
  294. closeMenuOnSelect={false}
  295. closeMenuOnScroll={false}
  296. openMenuOnFocus
  297. />
  298. </StyledOverlay>
  299. </PositionWrapper>
  300. </FocusScope>
  301. );
  302. }
  303. return (
  304. <MenuControlWrap className={className} as={renderWrapAs} role="presentation">
  305. {renderTrigger()}
  306. {renderMenu()}
  307. </MenuControlWrap>
  308. );
  309. }
  310. export default CompactSelect;
  311. const MenuControlWrap = styled('div')``;
  312. const ButtonLabel = styled('span')`
  313. ${p => p.theme.overflowEllipsis}
  314. text-align: left;
  315. `;
  316. const StyledBadge = styled(Badge)`
  317. flex-shrink: 0;
  318. top: auto;
  319. `;
  320. const StyledOverlay = styled(Overlay, {
  321. shouldForwardProp: prop => typeof prop === 'string' && isPropValid(prop),
  322. })<{minWidth?: number}>`
  323. max-width: calc(100vw - ${space(2)} * 2);
  324. overflow: hidden;
  325. ${p => p.minWidth && `min-width: ${p.minWidth}px;`}
  326. `;
  327. const MenuHeader = styled('div')`
  328. position: relative;
  329. display: flex;
  330. align-items: center;
  331. justify-content: space-between;
  332. padding: ${space(0.25)} ${space(1)} ${space(0.25)} ${space(1.5)};
  333. box-shadow: 0 1px 0 ${p => p.theme.translucentInnerBorder};
  334. z-index: 1;
  335. `;
  336. const MenuTitle = styled('span')`
  337. font-weight: 600;
  338. font-size: ${p => p.theme.fontSizeSmall};
  339. color: ${p => p.theme.headingColor};
  340. white-space: nowrap;
  341. margin: ${space(0.5)} ${space(2)} ${space(0.5)} 0;
  342. `;
  343. const StyledLoadingIndicator = styled(LoadingIndicator)`
  344. && {
  345. margin: ${space(0.5)} ${space(0.5)} ${space(0.5)} ${space(1)};
  346. height: ${space(1.5)};
  347. width: ${space(1.5)};
  348. }
  349. `;
  350. const ClearButton = styled(Button)`
  351. font-size: ${p => p.theme.fontSizeSmall};
  352. color: ${p => p.theme.subText};
  353. `;