index.tsx 14 KB

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