combobox.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
  1. import {
  2. type ForwardedRef,
  3. forwardRef,
  4. type MouseEventHandler,
  5. type ReactNode,
  6. useCallback,
  7. useEffect,
  8. useLayoutEffect,
  9. useMemo,
  10. useRef,
  11. } from 'react';
  12. import {usePopper} from 'react-popper';
  13. import styled from '@emotion/styled';
  14. import {type AriaComboBoxProps, useComboBox} from '@react-aria/combobox';
  15. import type {AriaListBoxOptions} from '@react-aria/listbox';
  16. import {ariaHideOutside} from '@react-aria/overlays';
  17. import {type ComboBoxState, useComboBoxState} from '@react-stately/combobox';
  18. import type {CollectionChildren, Key, KeyboardEvent} from '@react-types/shared';
  19. import {ListBox} from 'sentry/components/compactSelect/listBox';
  20. import type {
  21. SelectKey,
  22. SelectOptionOrSectionWithKey,
  23. SelectOptionWithKey,
  24. } from 'sentry/components/compactSelect/types';
  25. import {
  26. getDisabledOptions,
  27. getHiddenOptions,
  28. } from 'sentry/components/compactSelect/utils';
  29. import {GrowingInput} from 'sentry/components/growingInput';
  30. import LoadingIndicator from 'sentry/components/loadingIndicator';
  31. import {Overlay} from 'sentry/components/overlay';
  32. import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context';
  33. import {itemIsSection} from 'sentry/components/searchQueryBuilder/tokens/utils';
  34. import type {Token, TokenResult} from 'sentry/components/searchSyntax/parser';
  35. import {space} from 'sentry/styles/space';
  36. import {defined} from 'sentry/utils';
  37. import mergeRefs from 'sentry/utils/mergeRefs';
  38. import useOverlay from 'sentry/utils/useOverlay';
  39. import usePrevious from 'sentry/utils/usePrevious';
  40. type SearchQueryBuilderComboboxProps<T extends SelectOptionOrSectionWithKey<string>> = {
  41. children: CollectionChildren<T>;
  42. inputLabel: string;
  43. inputValue: string;
  44. items: T[];
  45. /**
  46. * Called when the input is blurred.
  47. * Passes the current input value.
  48. */
  49. onCustomValueBlurred: (value: string) => void;
  50. /**
  51. * Called when the user commits a value with the enter key.
  52. * Passes the current input value.
  53. */
  54. onCustomValueCommitted: (value: string) => void;
  55. /**
  56. * Called when the user selects an option from the dropdown.
  57. * Passes the selected option.
  58. */
  59. onOptionSelected: (option: T) => void;
  60. token: TokenResult<Token>;
  61. autoFocus?: boolean;
  62. /**
  63. * Display an entirely custom menu.
  64. */
  65. customMenu?: CustomComboboxMenu<T>;
  66. /**
  67. * If the combobox has additional information to display, passing JSX
  68. * to this prop will display it in an overlay at the top left position.
  69. */
  70. description?: ReactNode;
  71. filterValue?: string;
  72. isLoading?: boolean;
  73. /**
  74. * When passing `isOpen`, the open state is controlled by the parent.
  75. */
  76. isOpen?: boolean;
  77. maxOptions?: number;
  78. onClick?: (e: React.MouseEvent) => void;
  79. /**
  80. * Called when the user explicitly closes the combobox with the escape key.
  81. */
  82. onExit?: () => void;
  83. onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void;
  84. onInputChange?: React.ChangeEventHandler<HTMLInputElement>;
  85. onKeyDown?: (e: KeyboardEvent, extra: {state: ComboBoxState<T>}) => void;
  86. onKeyDownCapture?: (
  87. e: React.KeyboardEvent<HTMLInputElement>,
  88. extra: {state: ComboBoxState<T>}
  89. ) => void;
  90. onKeyUp?: (e: KeyboardEvent) => void;
  91. onOpenChange?: (newOpenState: boolean) => void;
  92. onPaste?: (e: React.ClipboardEvent<HTMLInputElement>) => void;
  93. openOnFocus?: boolean;
  94. placeholder?: string;
  95. /**
  96. * Function to determine whether the menu should close when interacting with
  97. * other elements.
  98. */
  99. shouldCloseOnInteractOutside?: (interactedElement: Element) => boolean;
  100. /**
  101. * Whether the menu should filter results based on the filterValue.
  102. * Disable if the filtering should be handled by the caller.
  103. */
  104. shouldFilterResults?: boolean;
  105. tabIndex?: number;
  106. };
  107. type OverlayProps = ReturnType<typeof useOverlay>['overlayProps'];
  108. export type CustomComboboxMenuProps<T> = {
  109. filterValue: string;
  110. hiddenOptions: Set<SelectKey>;
  111. isOpen: boolean;
  112. listBoxProps: AriaListBoxOptions<T>;
  113. listBoxRef: React.RefObject<HTMLUListElement>;
  114. overlayProps: OverlayProps;
  115. popoverRef: React.RefObject<HTMLDivElement>;
  116. state: ComboBoxState<T>;
  117. };
  118. export type CustomComboboxMenu<T> = (
  119. props: CustomComboboxMenuProps<T>
  120. ) => React.ReactNode;
  121. const DESCRIPTION_POPPER_OPTIONS = {
  122. placement: 'top-start' as const,
  123. strategy: 'fixed' as const,
  124. modifiers: [
  125. {
  126. name: 'offset',
  127. options: {
  128. offset: [-12, 8],
  129. },
  130. },
  131. ],
  132. };
  133. function findItemInSections<T extends SelectOptionOrSectionWithKey<string>>(
  134. items: T[],
  135. key: Key
  136. ): T | null {
  137. for (const item of items) {
  138. if (itemIsSection(item)) {
  139. const option = item.options.find(child => child.key === key);
  140. if (option) {
  141. return option as T;
  142. }
  143. } else {
  144. if (item.key === key) {
  145. return item;
  146. }
  147. }
  148. }
  149. return null;
  150. }
  151. function menuIsOpen({
  152. state,
  153. hiddenOptions,
  154. totalOptions,
  155. isLoading,
  156. hasCustomMenu,
  157. isOpen,
  158. }: {
  159. hiddenOptions: Set<SelectKey>;
  160. state: ComboBoxState<any>;
  161. totalOptions: number;
  162. hasCustomMenu?: boolean;
  163. isLoading?: boolean;
  164. isOpen?: boolean;
  165. }) {
  166. const openState = isOpen ?? state.isOpen;
  167. if (isLoading || hasCustomMenu) {
  168. return openState;
  169. }
  170. // When a custom menu is not being displayed and we aren't loading anything,
  171. // only show when there is something to select from.
  172. return openState && totalOptions > hiddenOptions.size;
  173. }
  174. function useHiddenItems<T extends SelectOptionOrSectionWithKey<string>>({
  175. items,
  176. filterValue,
  177. maxOptions,
  178. shouldFilterResults,
  179. }: {
  180. filterValue: string;
  181. items: T[];
  182. maxOptions?: number;
  183. shouldFilterResults?: boolean;
  184. }) {
  185. const hiddenOptions: Set<SelectKey> = useMemo(() => {
  186. return getHiddenOptions(items, shouldFilterResults ? filterValue : '', maxOptions);
  187. }, [items, shouldFilterResults, filterValue, maxOptions]);
  188. const disabledKeys = useMemo(
  189. () => [...getDisabledOptions(items), ...hiddenOptions],
  190. [hiddenOptions, items]
  191. );
  192. return {
  193. hiddenOptions,
  194. disabledKeys,
  195. };
  196. }
  197. // The menu size can change from things like loading states, long options,
  198. // or custom menus like a date picker. This hook ensures that the overlay
  199. // is updated in response to these changes.
  200. function useUpdateOverlayPositionOnMenuContentChange({
  201. inputValue,
  202. isLoading,
  203. isOpen,
  204. updateOverlayPosition,
  205. hasCustomMenu,
  206. }: {
  207. inputValue: string;
  208. isOpen: boolean;
  209. updateOverlayPosition: (() => void) | null;
  210. hasCustomMenu?: boolean;
  211. isLoading?: boolean;
  212. }) {
  213. const previousValues = usePrevious({isLoading, isOpen, inputValue, hasCustomMenu});
  214. useLayoutEffect(() => {
  215. if (
  216. (isOpen && previousValues?.inputValue !== inputValue) ||
  217. previousValues?.isLoading !== isLoading ||
  218. hasCustomMenu !== previousValues?.hasCustomMenu
  219. ) {
  220. updateOverlayPosition?.();
  221. }
  222. }, [
  223. inputValue,
  224. isLoading,
  225. isOpen,
  226. previousValues,
  227. updateOverlayPosition,
  228. hasCustomMenu,
  229. ]);
  230. }
  231. function OverlayContent<T extends SelectOptionOrSectionWithKey<string>>({
  232. customMenu,
  233. filterValue,
  234. hiddenOptions,
  235. isLoading,
  236. isOpen,
  237. listBoxProps,
  238. listBoxRef,
  239. popoverRef,
  240. state,
  241. totalOptions,
  242. overlayProps,
  243. }: {
  244. filterValue: string;
  245. hiddenOptions: Set<SelectKey>;
  246. isOpen: boolean;
  247. listBoxProps: AriaListBoxOptions<any>;
  248. listBoxRef: React.RefObject<HTMLUListElement>;
  249. overlayProps: OverlayProps;
  250. popoverRef: React.RefObject<HTMLDivElement>;
  251. state: ComboBoxState<any>;
  252. totalOptions: number;
  253. customMenu?: CustomComboboxMenu<T>;
  254. isLoading?: boolean;
  255. }) {
  256. if (customMenu) {
  257. return customMenu({
  258. popoverRef,
  259. listBoxRef,
  260. isOpen,
  261. hiddenOptions,
  262. listBoxProps,
  263. state,
  264. overlayProps,
  265. filterValue,
  266. });
  267. }
  268. return (
  269. <StyledPositionWrapper {...overlayProps} visible={isOpen}>
  270. <ListBoxOverlay ref={popoverRef}>
  271. {isLoading && hiddenOptions.size >= totalOptions ? (
  272. <LoadingWrapper>
  273. <LoadingIndicator mini />
  274. </LoadingWrapper>
  275. ) : (
  276. <ListBox
  277. {...listBoxProps}
  278. ref={listBoxRef}
  279. listState={state}
  280. hasSearch={!!filterValue}
  281. hiddenOptions={hiddenOptions}
  282. keyDownHandler={() => true}
  283. overlayIsOpen={isOpen}
  284. showSectionHeaders={!filterValue}
  285. size="sm"
  286. />
  287. )}
  288. </ListBoxOverlay>
  289. </StyledPositionWrapper>
  290. );
  291. }
  292. function SearchQueryBuilderComboboxInner<T extends SelectOptionOrSectionWithKey<string>>(
  293. {
  294. children,
  295. description,
  296. items,
  297. inputValue,
  298. filterValue = inputValue,
  299. placeholder,
  300. onCustomValueBlurred,
  301. onCustomValueCommitted,
  302. onOptionSelected,
  303. inputLabel,
  304. onExit,
  305. onKeyDown,
  306. onKeyDownCapture,
  307. onKeyUp,
  308. onInputChange,
  309. onOpenChange,
  310. autoFocus,
  311. openOnFocus,
  312. onFocus,
  313. tabIndex = -1,
  314. maxOptions,
  315. shouldFilterResults = true,
  316. shouldCloseOnInteractOutside,
  317. onPaste,
  318. isLoading,
  319. onClick,
  320. customMenu,
  321. isOpen: incomingIsOpen,
  322. }: SearchQueryBuilderComboboxProps<T>,
  323. ref: ForwardedRef<HTMLInputElement>
  324. ) {
  325. const {disabled} = useSearchQueryBuilder();
  326. const listBoxRef = useRef<HTMLUListElement>(null);
  327. const inputRef = useRef<HTMLInputElement>(null);
  328. const popoverRef = useRef<HTMLDivElement>(null);
  329. const descriptionRef = useRef<HTMLDivElement>(null);
  330. const {hiddenOptions, disabledKeys} = useHiddenItems({
  331. items,
  332. filterValue,
  333. maxOptions,
  334. shouldFilterResults,
  335. });
  336. const onSelectionChange = useCallback(
  337. (key: Key) => {
  338. const selectedOption = findItemInSections(items, key);
  339. if (selectedOption) {
  340. onOptionSelected(selectedOption);
  341. }
  342. },
  343. [items, onOptionSelected]
  344. );
  345. const comboBoxProps: Partial<AriaComboBoxProps<T>> = {
  346. items,
  347. autoFocus,
  348. inputValue: filterValue,
  349. onSelectionChange,
  350. allowsCustomValue: true,
  351. disabledKeys,
  352. isDisabled: disabled,
  353. };
  354. const state = useComboBoxState<T>({
  355. children,
  356. allowsEmptyCollection: true,
  357. // We handle closing on blur ourselves to prevent the combobox from closing
  358. // when the user clicks inside the custom menu
  359. shouldCloseOnBlur: false,
  360. ...comboBoxProps,
  361. });
  362. const {inputProps, listBoxProps} = useComboBox<T>(
  363. {
  364. ...comboBoxProps,
  365. 'aria-label': inputLabel,
  366. listBoxRef,
  367. inputRef,
  368. popoverRef,
  369. onFocus: e => {
  370. if (openOnFocus) {
  371. state.open();
  372. }
  373. onFocus?.(e);
  374. },
  375. onBlur: e => {
  376. if (e.relatedTarget && !shouldCloseOnInteractOutside?.(e.relatedTarget)) {
  377. return;
  378. }
  379. onCustomValueBlurred(inputValue);
  380. state.close();
  381. },
  382. onKeyDown: e => {
  383. onKeyDown?.(e, {state});
  384. switch (e.key) {
  385. case 'Escape':
  386. state.close();
  387. onExit?.();
  388. return;
  389. case 'Enter':
  390. if (state.selectionManager.focusedKey) {
  391. return;
  392. }
  393. state.close();
  394. onCustomValueCommitted(inputValue);
  395. return;
  396. default:
  397. return;
  398. }
  399. },
  400. onKeyUp,
  401. },
  402. state
  403. );
  404. const previousInputValue = usePrevious(inputValue);
  405. useEffect(() => {
  406. if (inputValue !== previousInputValue) {
  407. state.selectionManager.setFocusedKey(null);
  408. }
  409. }, [inputValue, previousInputValue, state.selectionManager]);
  410. const totalOptions = items.reduce(
  411. (acc, item) => acc + (itemIsSection(item) ? item.options.length : 1),
  412. 0
  413. );
  414. const hasCustomMenu = defined(customMenu);
  415. const isOpen = menuIsOpen({
  416. state,
  417. hiddenOptions,
  418. totalOptions,
  419. isLoading,
  420. hasCustomMenu,
  421. isOpen: incomingIsOpen,
  422. });
  423. useEffect(() => {
  424. onOpenChange?.(isOpen);
  425. }, [onOpenChange, isOpen]);
  426. const {
  427. overlayProps,
  428. triggerProps,
  429. update: updateOverlayPosition,
  430. } = useOverlay({
  431. type: 'listbox',
  432. isOpen,
  433. position: 'bottom-start',
  434. offset: [-12, 8],
  435. isKeyboardDismissDisabled: true,
  436. shouldCloseOnBlur: true,
  437. shouldCloseOnInteractOutside: el => {
  438. if (popoverRef.current?.contains(el)) {
  439. return false;
  440. }
  441. return shouldCloseOnInteractOutside?.(el) ?? true;
  442. },
  443. onInteractOutside: () => {
  444. if (state.inputValue) {
  445. onCustomValueBlurred(inputValue);
  446. } else {
  447. onExit?.();
  448. }
  449. state.close();
  450. },
  451. shouldApplyMinWidth: false,
  452. preventOverflowOptions: {boundary: document.body},
  453. flipOptions: {
  454. // We don't want the menu to ever flip to the other side of the input
  455. fallbackPlacements: [],
  456. },
  457. });
  458. const descriptionPopper = usePopper(
  459. inputRef.current,
  460. descriptionRef.current,
  461. DESCRIPTION_POPPER_OPTIONS
  462. );
  463. const handleInputClick: MouseEventHandler<HTMLInputElement> = useCallback(
  464. e => {
  465. e.stopPropagation();
  466. inputProps.onClick?.(e);
  467. state.toggle();
  468. onClick?.(e);
  469. },
  470. [inputProps, state, onClick]
  471. );
  472. useUpdateOverlayPositionOnMenuContentChange({
  473. inputValue,
  474. isLoading,
  475. isOpen,
  476. updateOverlayPosition,
  477. hasCustomMenu,
  478. });
  479. // useCombobox will hide outside elements with aria-hidden="true" when it is open [1].
  480. // Because we switch elements when a custom menu is displayed, we need to manually
  481. // call this function an extra time to ensure the correct elements are hidden.
  482. //
  483. // [1]: https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/combobox/src/useComboBox.ts#L337C3-L341C44
  484. useEffect(() => {
  485. if (isOpen) {
  486. return ariaHideOutside(
  487. [inputRef.current, popoverRef.current, descriptionRef.current].filter(defined)
  488. );
  489. }
  490. return () => {};
  491. }, [inputRef, popoverRef, isOpen, customMenu]);
  492. return (
  493. <Wrapper>
  494. <UnstyledInput
  495. {...inputProps}
  496. size="md"
  497. ref={mergeRefs([ref, inputRef, triggerProps.ref])}
  498. type="text"
  499. placeholder={placeholder}
  500. onClick={handleInputClick}
  501. value={inputValue}
  502. onChange={onInputChange}
  503. tabIndex={tabIndex}
  504. onPaste={onPaste}
  505. disabled={disabled}
  506. onKeyDownCapture={e => onKeyDownCapture?.(e, {state})}
  507. />
  508. {description ? (
  509. <StyledPositionWrapper
  510. {...descriptionPopper.attributes.popper}
  511. ref={descriptionRef}
  512. style={descriptionPopper.styles.popper}
  513. visible
  514. role="tooltip"
  515. >
  516. <DescriptionOverlay>{description}</DescriptionOverlay>
  517. </StyledPositionWrapper>
  518. ) : null}
  519. <OverlayContent
  520. customMenu={customMenu}
  521. filterValue={filterValue}
  522. hiddenOptions={hiddenOptions}
  523. isLoading={isLoading}
  524. isOpen={isOpen}
  525. listBoxProps={listBoxProps}
  526. listBoxRef={listBoxRef}
  527. popoverRef={popoverRef}
  528. state={state}
  529. totalOptions={totalOptions}
  530. overlayProps={overlayProps}
  531. />
  532. </Wrapper>
  533. );
  534. }
  535. /**
  536. * A combobox component which is used in freeText tokens and filter values.
  537. */
  538. export const SearchQueryBuilderCombobox = forwardRef(SearchQueryBuilderComboboxInner) as <
  539. T extends SelectOptionWithKey<string>,
  540. >(
  541. props: SearchQueryBuilderComboboxProps<T> & {ref?: ForwardedRef<HTMLInputElement>}
  542. ) => ReturnType<typeof SearchQueryBuilderComboboxInner>;
  543. const Wrapper = styled('div')`
  544. position: relative;
  545. display: flex;
  546. align-items: stretch;
  547. height: 100%;
  548. width: 100%;
  549. `;
  550. const UnstyledInput = styled(GrowingInput)`
  551. background: transparent;
  552. border: none;
  553. box-shadow: none;
  554. flex-grow: 1;
  555. padding: 0;
  556. height: auto;
  557. min-height: auto;
  558. resize: none;
  559. min-width: 1px;
  560. border-radius: 0;
  561. &:focus {
  562. outline: none;
  563. border: none;
  564. box-shadow: none;
  565. }
  566. `;
  567. const StyledPositionWrapper = styled('div')<{visible?: boolean}>`
  568. display: ${p => (p.visible ? 'block' : 'none')};
  569. z-index: ${p => p.theme.zIndex.tooltip};
  570. `;
  571. const ListBoxOverlay = styled(Overlay)`
  572. max-height: 400px;
  573. min-width: 200px;
  574. width: 600px;
  575. max-width: min-content;
  576. overflow-y: auto;
  577. `;
  578. const DescriptionOverlay = styled(Overlay)`
  579. min-width: 200px;
  580. max-width: 400px;
  581. padding: ${space(1)} ${space(1.5)};
  582. line-height: 1.2;
  583. `;
  584. const LoadingWrapper = styled('div')`
  585. display: flex;
  586. justify-content: center;
  587. align-items: center;
  588. height: 140px;
  589. `;