compactSelect.tsx 12 KB

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