index.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
  2. import isPropValid from '@emotion/is-prop-valid';
  3. import {useTheme} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import {useComboBox} from '@react-aria/combobox';
  6. import {Item, Section} from '@react-stately/collections';
  7. import {type ComboBoxStateOptions, useComboBoxState} from '@react-stately/combobox';
  8. import omit from 'lodash/omit';
  9. import {ListBox} from 'sentry/components/compactSelect/listBox';
  10. import {
  11. getDisabledOptions,
  12. getEscapedKey,
  13. getHiddenOptions,
  14. getItemsWithKeys,
  15. } from 'sentry/components/compactSelect/utils';
  16. import {GrowingInput} from 'sentry/components/growingInput';
  17. import Input from 'sentry/components/input';
  18. import InteractionStateLayer from 'sentry/components/interactionStateLayer';
  19. import LoadingIndicator from 'sentry/components/loadingIndicator';
  20. import {Overlay, PositionWrapper} from 'sentry/components/overlay';
  21. import {t} from 'sentry/locale';
  22. import {space} from 'sentry/styles/space';
  23. import mergeRefs from 'sentry/utils/mergeRefs';
  24. import type {FormSize} from 'sentry/utils/theme';
  25. import useOverlay from 'sentry/utils/useOverlay';
  26. import type {
  27. ComboBoxOption,
  28. ComboBoxOptionOrSection,
  29. ComboBoxOptionOrSectionWithKey,
  30. } from './types';
  31. interface ComboBoxProps<Value extends string>
  32. extends Omit<
  33. ComboBoxStateOptions<ComboBoxOptionOrSection<Value>>,
  34. 'allowsCustomValue'
  35. > {
  36. 'aria-label': string;
  37. className?: string;
  38. disabled?: boolean;
  39. growingInput?: boolean;
  40. hasSearch?: boolean;
  41. hiddenOptions?: Set<string>;
  42. isLoading?: boolean;
  43. loadingMessage?: string;
  44. menuSize?: FormSize;
  45. menuWidth?: string;
  46. size?: FormSize;
  47. sizeLimit?: number;
  48. sizeLimitMessage?: string;
  49. }
  50. function ComboBox<Value extends string>({
  51. size = 'md',
  52. menuSize,
  53. className,
  54. placeholder,
  55. disabled,
  56. isLoading,
  57. loadingMessage,
  58. sizeLimitMessage,
  59. menuTrigger = 'focus',
  60. growingInput = false,
  61. onOpenChange,
  62. menuWidth,
  63. hiddenOptions,
  64. hasSearch,
  65. ...props
  66. }: ComboBoxProps<Value>) {
  67. const theme = useTheme();
  68. const listBoxRef = useRef<HTMLUListElement>(null);
  69. const inputRef = useRef<HTMLInputElement>(null);
  70. const popoverRef = useRef<HTMLDivElement>(null);
  71. const state = useComboBoxState({
  72. // Mapping our disabled prop to react-aria's isDisabled
  73. isDisabled: disabled,
  74. onOpenChange: (isOpen, ...otherArgs) => {
  75. onOpenChange?.(isOpen, ...otherArgs);
  76. if (isOpen) {
  77. // Ensure the selected element is being focused
  78. state.selectionManager.setFocusedKey(state.selectedKey);
  79. }
  80. },
  81. ...props,
  82. });
  83. const {inputProps, listBoxProps} = useComboBox(
  84. {
  85. listBoxRef,
  86. inputRef,
  87. popoverRef,
  88. shouldFocusWrap: true,
  89. isDisabled: disabled,
  90. ...props,
  91. },
  92. state
  93. );
  94. // Make popover width constant while it is open
  95. useEffect(() => {
  96. if (!menuWidth && popoverRef.current && state.isOpen) {
  97. const popoverElement = popoverRef.current;
  98. popoverElement.style.width = `${popoverElement.offsetWidth + 4}px`;
  99. return () => {
  100. popoverElement.style.width = 'max-content';
  101. };
  102. }
  103. return () => {};
  104. }, [menuWidth, state.isOpen]);
  105. useEffect(() => {
  106. const popoverElement = popoverRef.current;
  107. // Reset scroll state on opening the popover
  108. if (popoverElement) {
  109. popoverElement.scrollTop = 0;
  110. }
  111. }, [state.isOpen]);
  112. const {overlayProps, triggerProps} = useOverlay({
  113. type: 'listbox',
  114. isOpen: state.isOpen,
  115. position: 'bottom-start',
  116. offset: [0, 8],
  117. isDismissable: true,
  118. onInteractOutside: () => {
  119. state.close();
  120. inputRef.current?.blur();
  121. },
  122. shouldCloseOnBlur: true,
  123. });
  124. // The menu opens after selecting an item but the input stays focused
  125. // This ensures the user can open the menu again by clicking on the input
  126. const handleInputClick = useCallback(() => {
  127. if (!state.isOpen && menuTrigger === 'focus') {
  128. state.open();
  129. }
  130. }, [state, menuTrigger]);
  131. const handleInputMouseUp = useCallback((event: React.MouseEvent<HTMLInputElement>) => {
  132. // Prevents the input from being selected when clicking on the trigger
  133. event.preventDefault();
  134. }, []);
  135. const handleInputFocus = useCallback(
  136. (event: React.FocusEvent<HTMLInputElement>) => {
  137. const onFocusProp = inputProps.onFocus;
  138. onFocusProp?.(event);
  139. if (menuTrigger === 'focus') {
  140. state.open();
  141. }
  142. // Need to setTimeout otherwise Chrome might reset the selection on padding click
  143. setTimeout(() => {
  144. event.target.select();
  145. }, 0);
  146. },
  147. [inputProps.onFocus, menuTrigger, state]
  148. );
  149. const InputComponent = growingInput ? StyledGrowingInput : StyledInput;
  150. return (
  151. <ControlWrapper className={className}>
  152. {!state.isFocused && <InteractionStateLayer />}
  153. <InputComponent
  154. {...inputProps}
  155. onClick={handleInputClick}
  156. placeholder={placeholder}
  157. onMouseUp={handleInputMouseUp}
  158. onFocus={handleInputFocus}
  159. ref={mergeRefs([inputRef, triggerProps.ref])}
  160. size={size}
  161. />
  162. <StyledPositionWrapper
  163. {...overlayProps}
  164. zIndex={theme.zIndex?.tooltip}
  165. visible={state.isOpen}
  166. >
  167. <StyledOverlay ref={popoverRef} width={menuWidth}>
  168. {isLoading && (
  169. <MenuHeader size={menuSize ?? size}>
  170. <MenuTitle>{loadingMessage ?? t('Loading...')}</MenuTitle>
  171. <MenuHeaderTrailingItems>
  172. {isLoading && <StyledLoadingIndicator size={12} mini />}
  173. </MenuHeaderTrailingItems>
  174. </MenuHeader>
  175. )}
  176. {/* Listbox adds a separator if it is not the first item
  177. To avoid this, we wrap it into a div */}
  178. <div>
  179. <ListBox
  180. {...listBoxProps}
  181. overlayIsOpen={state.isOpen}
  182. hiddenOptions={hiddenOptions}
  183. hasSearch={hasSearch}
  184. ref={listBoxRef}
  185. listState={state}
  186. keyDownHandler={() => true}
  187. size={menuSize ?? size}
  188. sizeLimitMessage={sizeLimitMessage}
  189. />
  190. <EmptyMessage>No items found</EmptyMessage>
  191. </div>
  192. </StyledOverlay>
  193. </StyledPositionWrapper>
  194. </ControlWrapper>
  195. );
  196. }
  197. /**
  198. * Component that allows users to select an option from a dropdown list
  199. * by typing in a input field
  200. *
  201. * **WARNING: This component is still experimental and may change in the future.**
  202. */
  203. function ControlledComboBox<Value extends string>({
  204. options,
  205. sizeLimit,
  206. value,
  207. onOpenChange,
  208. ...props
  209. }: Omit<
  210. ComboBoxProps<Value>,
  211. 'items' | 'defaultItems' | 'children' | 'hasSearch' | 'hiddenOptions'
  212. > & {
  213. options: ComboBoxOptionOrSection<Value>[];
  214. defaultValue?: Value;
  215. onChange?: (value: ComboBoxOption<Value>) => void;
  216. value?: Value;
  217. }) {
  218. const [isFiltering, setIsFiltering] = useState(true);
  219. const [inputValue, setInputValue] = useState(() => {
  220. return (
  221. options
  222. .flatMap(item => ('options' in item ? item.options : [item]))
  223. .find(option => option.value === value)?.label ?? ''
  224. );
  225. });
  226. // Sync input value with value prop
  227. const previousValue = useRef(value);
  228. if (previousValue.current !== value) {
  229. const selectedLabel = options
  230. .flatMap(item => ('options' in item ? item.options : [item]))
  231. .find(option => option.value === value)?.label;
  232. if (selectedLabel) {
  233. setInputValue(selectedLabel);
  234. }
  235. previousValue.current = value;
  236. }
  237. const items = useMemo(() => {
  238. return getItemsWithKeys(options) as ComboBoxOptionOrSectionWithKey<Value>[];
  239. }, [options]);
  240. const hiddenOptions = useMemo(
  241. () => getHiddenOptions(items, isFiltering ? inputValue : '', sizeLimit),
  242. [items, isFiltering, inputValue, sizeLimit]
  243. );
  244. const disabledKeys = useMemo(
  245. () => [...getDisabledOptions(items), ...hiddenOptions].map(getEscapedKey),
  246. [hiddenOptions, items]
  247. );
  248. const valueRef = useRef(value);
  249. valueRef.current = value;
  250. const handleChange = useCallback(
  251. (key: string | number) => {
  252. // Prevent calling onChange on closing the menu without selecting a different value
  253. if (getEscapedKey(valueRef.current) === key) {
  254. return;
  255. }
  256. if (props.onSelectionChange) {
  257. props.onSelectionChange(key);
  258. }
  259. const flatItems = items.flatMap(item =>
  260. 'options' in item ? item.options : [item]
  261. );
  262. const selectedOption = flatItems.find(item => item.key === key);
  263. if (selectedOption) {
  264. if (props.onChange) {
  265. props.onChange(omit(selectedOption, 'key'));
  266. }
  267. setInputValue(selectedOption.label);
  268. }
  269. },
  270. [items, props]
  271. );
  272. const handleInputChange = useCallback((newInputValue: string) => {
  273. setIsFiltering(true);
  274. setInputValue(newInputValue);
  275. }, []);
  276. const handleOpenChange = useCallback(
  277. (isOpen: boolean) => {
  278. // Disable filtering right after the dropdown is opened
  279. if (isOpen) {
  280. setIsFiltering(false);
  281. }
  282. onOpenChange?.(isOpen);
  283. },
  284. [onOpenChange]
  285. );
  286. return (
  287. <ComboBox
  288. disabledKeys={disabledKeys}
  289. inputValue={inputValue}
  290. onInputChange={handleInputChange}
  291. selectedKey={value && getEscapedKey(value)}
  292. defaultSelectedKey={props.defaultValue && getEscapedKey(props.defaultValue)}
  293. onSelectionChange={handleChange}
  294. items={items}
  295. onOpenChange={handleOpenChange}
  296. hasSearch={isFiltering ? !!inputValue : false}
  297. hiddenOptions={hiddenOptions}
  298. {...props}
  299. >
  300. {items.map(item => {
  301. if ('options' in item) {
  302. return (
  303. <Section key={item.key} title={item.label}>
  304. {item.options.map(option => (
  305. <Item {...option} key={option.key} textValue={option.label}>
  306. {item.label}
  307. </Item>
  308. ))}
  309. </Section>
  310. );
  311. }
  312. return (
  313. <Item {...item} key={item.key} textValue={item.label}>
  314. {item.label}
  315. </Item>
  316. );
  317. })}
  318. </ComboBox>
  319. );
  320. }
  321. const ControlWrapper = styled('div')`
  322. position: relative;
  323. width: max-content;
  324. min-width: 150px;
  325. max-width: 100%;
  326. cursor: pointer;
  327. `;
  328. const StyledInput = styled(Input)`
  329. max-width: inherit;
  330. min-width: inherit;
  331. &:not(:focus) {
  332. pointer-events: none;
  333. }
  334. `;
  335. const StyledGrowingInput = styled(GrowingInput)`
  336. max-width: inherit;
  337. min-width: inherit;
  338. &:not(:focus) {
  339. cursor: pointer;
  340. }
  341. `;
  342. const StyledPositionWrapper = styled(PositionWrapper, {
  343. shouldForwardProp: prop => isPropValid(prop),
  344. })<{visible?: boolean}>`
  345. min-width: 100%;
  346. display: ${p => (p.visible ? 'block' : 'none')};
  347. `;
  348. const StyledOverlay = styled(Overlay)<{width?: string}>`
  349. /* Should be a flex container so that when maxHeight is set (to avoid page overflow),
  350. ListBoxWrap/GridListWrap will also shrink to fit */
  351. display: flex;
  352. flex-direction: column;
  353. overflow: hidden;
  354. position: absolute;
  355. max-height: 32rem;
  356. min-width: 100%;
  357. overflow-y: auto;
  358. width: ${p => p.width ?? 'auto'};
  359. `;
  360. export const EmptyMessage = styled('p')`
  361. text-align: center;
  362. color: ${p => p.theme.subText};
  363. padding: ${space(1)} ${space(1.5)} ${space(1)};
  364. margin: 0;
  365. /* Message should only be displayed when _all_ preceding lists are empty */
  366. display: block;
  367. ul:not(:empty) ~ & {
  368. display: none;
  369. }
  370. `;
  371. const headerVerticalPadding: Record<FormSize, string> = {
  372. xs: space(0.25),
  373. sm: space(0.5),
  374. md: space(0.75),
  375. };
  376. const MenuHeader = styled('div')<{size: FormSize}>`
  377. position: relative;
  378. display: flex;
  379. align-items: center;
  380. justify-content: space-between;
  381. padding: ${p => headerVerticalPadding[p.size]} ${space(1.5)};
  382. box-shadow: 0 1px 0 ${p => p.theme.translucentInnerBorder};
  383. line-height: ${p => p.theme.text.lineHeightBody};
  384. z-index: 2;
  385. font-size: ${p =>
  386. p.size !== 'xs' ? p.theme.fontSizeSmall : p.theme.fontSizeExtraSmall};
  387. color: ${p => p.theme.headingColor};
  388. `;
  389. const MenuHeaderTrailingItems = styled('div')`
  390. display: grid;
  391. grid-auto-flow: column;
  392. gap: ${space(0.5)};
  393. `;
  394. const MenuTitle = styled('span')`
  395. font-size: inherit; /* Inherit font size from MenuHeader */
  396. font-weight: 600;
  397. white-space: nowrap;
  398. margin-right: ${space(2)};
  399. `;
  400. const StyledLoadingIndicator = styled(LoadingIndicator)`
  401. && {
  402. margin: 0 ${space(0.5)} 0 ${space(1)};
  403. height: 12px;
  404. width: 12px;
  405. }
  406. `;
  407. export {ControlledComboBox as ComboBox};