index.tsx 13 KB

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