compactSelect.tsx 12 KB

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