compactSelect.tsx 12 KB

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