combobox.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807
  1. import {
  2. type ForwardedRef,
  3. forwardRef,
  4. Fragment,
  5. type MouseEventHandler,
  6. type ReactNode,
  7. useCallback,
  8. useEffect,
  9. useLayoutEffect,
  10. useMemo,
  11. useRef,
  12. useState,
  13. } from 'react';
  14. import styled from '@emotion/styled';
  15. import {type AriaComboBoxProps, useComboBox} from '@react-aria/combobox';
  16. import type {AriaListBoxOptions} from '@react-aria/listbox';
  17. import {ariaHideOutside} from '@react-aria/overlays';
  18. import {type ComboBoxState, useComboBoxState} from '@react-stately/combobox';
  19. import type {CollectionChildren, Key, KeyboardEvent} from '@react-types/shared';
  20. import {Button} from 'sentry/components/button';
  21. import {ListBox} from 'sentry/components/compactSelect/listBox';
  22. import type {
  23. SelectKey,
  24. SelectOptionOrSectionWithKey,
  25. SelectOptionWithKey,
  26. SelectSectionWithKey,
  27. } from 'sentry/components/compactSelect/types';
  28. import {
  29. getDisabledOptions,
  30. getEscapedKey,
  31. getHiddenOptions,
  32. } from 'sentry/components/compactSelect/utils';
  33. import {GrowingInput} from 'sentry/components/growingInput';
  34. import LoadingIndicator from 'sentry/components/loadingIndicator';
  35. import {Overlay} from 'sentry/components/overlay';
  36. import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context';
  37. import type {Token, TokenResult} from 'sentry/components/searchSyntax/parser';
  38. import {IconMegaphone} from 'sentry/icons';
  39. import {t} from 'sentry/locale';
  40. import {space} from 'sentry/styles/space';
  41. import {defined} from 'sentry/utils';
  42. import mergeRefs from 'sentry/utils/mergeRefs';
  43. import {useFeedbackForm} from 'sentry/utils/useFeedbackForm';
  44. import useOverlay from 'sentry/utils/useOverlay';
  45. import usePrevious from 'sentry/utils/usePrevious';
  46. type SearchQueryBuilderComboboxProps<T extends SelectOptionOrSectionWithKey<string>> = {
  47. children: CollectionChildren<T>;
  48. inputLabel: string;
  49. inputValue: string;
  50. items: T[];
  51. /**
  52. * Called when the input is blurred.
  53. * Passes the current input value.
  54. */
  55. onCustomValueBlurred: (value: string) => void;
  56. /**
  57. * Called when the user commits a value with the enter key.
  58. * Passes the current input value.
  59. */
  60. onCustomValueCommitted: (value: string) => void;
  61. /**
  62. * Called when the user selects an option from the dropdown.
  63. * Passes the value of the selected item.
  64. */
  65. onOptionSelected: (value: string) => void;
  66. token: TokenResult<Token>;
  67. autoFocus?: boolean;
  68. /**
  69. * Display an entirely custom menu.
  70. */
  71. customMenu?: CustomComboboxMenu;
  72. /**
  73. * Whether to display the tabbed menu.
  74. */
  75. displayTabbedMenu?: boolean;
  76. filterValue?: string;
  77. isLoading?: boolean;
  78. maxOptions?: number;
  79. onClick?: (e: React.MouseEvent) => void;
  80. /**
  81. * Called when the user explicitly closes the combobox with the escape key.
  82. */
  83. onExit?: () => void;
  84. onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void;
  85. onInputChange?: React.ChangeEventHandler<HTMLInputElement>;
  86. onKeyDown?: (e: KeyboardEvent) => void;
  87. onKeyUp?: (e: KeyboardEvent) => void;
  88. onOpenChange?: (newOpenState: boolean) => void;
  89. onPaste?: (e: React.ClipboardEvent<HTMLInputElement>) => void;
  90. openOnFocus?: boolean;
  91. placeholder?: string;
  92. /**
  93. * Function to determine whether the menu should close when interacting with
  94. * other elements.
  95. */
  96. shouldCloseOnInteractOutside?: (interactedElement: Element) => boolean;
  97. /**
  98. * Whether the menu should filter results based on the filterValue.
  99. * Disable if the filtering should be handled by the caller.
  100. */
  101. shouldFilterResults?: boolean;
  102. tabIndex?: number;
  103. };
  104. type CustomComboboxMenu = (props: {
  105. isOpen: boolean;
  106. listBoxRef: React.RefObject<HTMLUListElement>;
  107. popoverRef: React.RefObject<HTMLDivElement>;
  108. }) => React.ReactNode;
  109. function itemIsSection(
  110. item: SelectOptionOrSectionWithKey<string>
  111. ): item is SelectSectionWithKey<string> {
  112. return 'options' in item;
  113. }
  114. function findItemInSections(items: SelectOptionOrSectionWithKey<string>[], key: Key) {
  115. for (const item of items) {
  116. if (itemIsSection(item)) {
  117. const option = item.options.find(child => child.key === key);
  118. if (option) {
  119. return option;
  120. }
  121. } else {
  122. if (item.key === key) {
  123. return item;
  124. }
  125. }
  126. }
  127. return null;
  128. }
  129. function mergeSets<T>(...sets: Set<T>[]) {
  130. const combinedSet = new Set<T>();
  131. for (const set of sets) {
  132. for (const value of set) {
  133. combinedSet.add(value);
  134. }
  135. }
  136. return combinedSet;
  137. }
  138. function menuIsOpen({
  139. state,
  140. hiddenOptions,
  141. totalOptions,
  142. displayTabbedMenu,
  143. isLoading,
  144. hasCustomMenu,
  145. }: {
  146. hiddenOptions: Set<SelectKey>;
  147. state: ComboBoxState<any>;
  148. totalOptions: number;
  149. displayTabbedMenu?: boolean;
  150. hasCustomMenu?: boolean;
  151. isLoading?: boolean;
  152. }) {
  153. if (displayTabbedMenu || isLoading || hasCustomMenu) {
  154. return state.isOpen;
  155. }
  156. // When the tabbed menu is not being displayed and we aren't loading anything,
  157. // only show when there is something to select from.
  158. return state.isOpen && totalOptions > hiddenOptions.size;
  159. }
  160. function useHiddenItems<T extends SelectOptionOrSectionWithKey<string>>({
  161. items,
  162. filterValue,
  163. maxOptions,
  164. displayTabbedMenu,
  165. selectedSection,
  166. shouldFilterResults,
  167. }: {
  168. filterValue: string;
  169. items: T[];
  170. selectedSection: Key | null;
  171. displayTabbedMenu?: boolean;
  172. maxOptions?: number;
  173. shouldFilterResults?: boolean;
  174. }) {
  175. const hiddenOptions: Set<SelectKey> = useMemo(() => {
  176. if (displayTabbedMenu) {
  177. if (selectedSection === null) {
  178. const sets = items.map(section =>
  179. itemIsSection(section)
  180. ? getHiddenOptions(section.options, filterValue, maxOptions)
  181. : new Set<string>()
  182. );
  183. return mergeSets(...sets);
  184. }
  185. const hiddenSections = items.filter(item => item.key !== selectedSection);
  186. const shownSection = items.filter(item => item.key === selectedSection);
  187. const hiddenFromOtherSections = getHiddenOptions(hiddenSections, '', 0);
  188. const hiddenFromShownSection = getHiddenOptions(shownSection, '', maxOptions);
  189. return mergeSets(hiddenFromOtherSections, hiddenFromShownSection);
  190. }
  191. return getHiddenOptions(items, shouldFilterResults ? filterValue : '', maxOptions);
  192. }, [
  193. displayTabbedMenu,
  194. items,
  195. shouldFilterResults,
  196. filterValue,
  197. maxOptions,
  198. selectedSection,
  199. ]);
  200. const disabledKeys: string[] = useMemo(
  201. () => [...getDisabledOptions(items), ...hiddenOptions].map(getEscapedKey),
  202. [hiddenOptions, items]
  203. );
  204. return {
  205. hiddenOptions,
  206. disabledKeys,
  207. };
  208. }
  209. // The menu size can change from things like loading states, long options,
  210. // or custom menus like a date picker. This hook ensures that the overlay
  211. // is updated in response to these changes.
  212. function useUpdateOverlayPositionOnMenuContentChange({
  213. inputValue,
  214. isLoading,
  215. isOpen,
  216. updateOverlayPosition,
  217. hasCustomMenu,
  218. }: {
  219. inputValue: string;
  220. isOpen: boolean;
  221. updateOverlayPosition: (() => void) | null;
  222. hasCustomMenu?: boolean;
  223. isLoading?: boolean;
  224. }) {
  225. const previousValues = usePrevious({isLoading, isOpen, inputValue, hasCustomMenu});
  226. useLayoutEffect(() => {
  227. if (
  228. (isOpen && previousValues?.inputValue !== inputValue) ||
  229. previousValues?.isLoading !== isLoading ||
  230. hasCustomMenu !== previousValues?.hasCustomMenu
  231. ) {
  232. updateOverlayPosition?.();
  233. }
  234. }, [
  235. inputValue,
  236. isLoading,
  237. isOpen,
  238. previousValues,
  239. updateOverlayPosition,
  240. hasCustomMenu,
  241. ]);
  242. }
  243. function ListBoxSectionButton({
  244. onClick,
  245. selected,
  246. children,
  247. }: {
  248. children: ReactNode;
  249. onClick: () => void;
  250. selected: boolean;
  251. }) {
  252. return (
  253. <SectionButton
  254. size="zero"
  255. borderless
  256. aria-selected={selected}
  257. onClick={onClick}
  258. tabIndex={-1}
  259. >
  260. {children}
  261. </SectionButton>
  262. );
  263. }
  264. function FeedbackFooter() {
  265. const {searchSource} = useSearchQueryBuilder();
  266. const openForm = useFeedbackForm();
  267. if (!openForm) {
  268. return null;
  269. }
  270. return (
  271. <SectionedOverlayFooter>
  272. <Button
  273. size="xs"
  274. icon={<IconMegaphone />}
  275. onClick={() =>
  276. openForm({
  277. messagePlaceholder: t('How can we make search better for you?'),
  278. tags: {
  279. feedback_source: 'search_query_builder',
  280. search_source: searchSource,
  281. },
  282. })
  283. }
  284. >
  285. {t('Give Feedback')}
  286. </Button>
  287. </SectionedOverlayFooter>
  288. );
  289. }
  290. function SectionedListBox<T extends SelectOptionOrSectionWithKey<string>>({
  291. popoverRef,
  292. listBoxRef,
  293. listBoxProps,
  294. state,
  295. hiddenOptions,
  296. isOpen,
  297. selectedSection,
  298. setSelectedSection,
  299. }: {
  300. hiddenOptions: Set<SelectKey>;
  301. isOpen: boolean;
  302. listBoxProps: AriaListBoxOptions<T>;
  303. listBoxRef: React.RefObject<HTMLUListElement>;
  304. popoverRef: React.RefObject<HTMLDivElement>;
  305. selectedSection: Key | null;
  306. setSelectedSection: (section: Key | null) => void;
  307. state: ComboBoxState<T>;
  308. }) {
  309. const sections = useMemo(
  310. () => [...state.collection].filter(node => node.type === 'section'),
  311. [state.collection]
  312. );
  313. const totalItems = state.collection.size;
  314. const totalItemsInSection = selectedSection
  315. ? [...(state.collection.getChildren?.(selectedSection) ?? [])].length
  316. : totalItems;
  317. const expectedHiddenOptions = totalItems - totalItemsInSection;
  318. const sectionHasHiddenOptions = hiddenOptions.size > expectedHiddenOptions;
  319. return (
  320. <SectionedOverlay ref={popoverRef}>
  321. {isOpen ? (
  322. <Fragment>
  323. <SectionedListBoxTabPane>
  324. <ListBoxSectionButton
  325. selected={selectedSection === null}
  326. onClick={() => {
  327. setSelectedSection(null);
  328. state.selectionManager.setFocusedKey(null);
  329. }}
  330. >
  331. {t('All')}
  332. </ListBoxSectionButton>
  333. {sections.map(node => (
  334. <ListBoxSectionButton
  335. key={node.key}
  336. selected={selectedSection === node.key}
  337. onClick={() => {
  338. setSelectedSection(node.key);
  339. state.selectionManager.setFocusedKey(null);
  340. }}
  341. >
  342. {node.props.title}
  343. </ListBoxSectionButton>
  344. ))}
  345. </SectionedListBoxTabPane>
  346. <SectionedListBoxPane>
  347. <ListBox
  348. {...listBoxProps}
  349. ref={listBoxRef}
  350. listState={state}
  351. hasSearch={!sectionHasHiddenOptions}
  352. hiddenOptions={hiddenOptions}
  353. keyDownHandler={() => true}
  354. overlayIsOpen={isOpen}
  355. showSectionHeaders={!selectedSection}
  356. size="sm"
  357. />
  358. </SectionedListBoxPane>
  359. <FeedbackFooter />
  360. </Fragment>
  361. ) : null}
  362. </SectionedOverlay>
  363. );
  364. }
  365. function OverlayContent({
  366. customMenu,
  367. displayTabbedMenu,
  368. filterValue,
  369. hiddenOptions,
  370. isLoading,
  371. isOpen,
  372. listBoxProps,
  373. listBoxRef,
  374. popoverRef,
  375. selectedSection,
  376. setSelectedSection,
  377. state,
  378. totalOptions,
  379. }: {
  380. filterValue: string;
  381. hiddenOptions: Set<SelectKey>;
  382. isOpen: boolean;
  383. listBoxProps: AriaListBoxOptions<any>;
  384. listBoxRef: React.RefObject<HTMLUListElement>;
  385. popoverRef: React.RefObject<HTMLDivElement>;
  386. selectedSection: Key | null;
  387. setSelectedSection: (section: Key | null) => void;
  388. state: ComboBoxState<any>;
  389. totalOptions: number;
  390. customMenu?: CustomComboboxMenu;
  391. displayTabbedMenu?: boolean;
  392. isLoading?: boolean;
  393. }) {
  394. if (customMenu) {
  395. return customMenu({popoverRef, listBoxRef, isOpen});
  396. }
  397. if (displayTabbedMenu) {
  398. return (
  399. <SectionedListBox
  400. popoverRef={popoverRef}
  401. listBoxProps={listBoxProps}
  402. listBoxRef={listBoxRef}
  403. state={state}
  404. isOpen={isOpen}
  405. hiddenOptions={hiddenOptions}
  406. selectedSection={selectedSection}
  407. setSelectedSection={setSelectedSection}
  408. />
  409. );
  410. }
  411. return (
  412. <ListBoxOverlay ref={popoverRef}>
  413. {isLoading && hiddenOptions.size >= totalOptions ? (
  414. <LoadingWrapper>
  415. <LoadingIndicator mini />
  416. </LoadingWrapper>
  417. ) : (
  418. <ListBox
  419. {...listBoxProps}
  420. ref={listBoxRef}
  421. listState={state}
  422. hasSearch={!!filterValue}
  423. hiddenOptions={hiddenOptions}
  424. keyDownHandler={() => true}
  425. overlayIsOpen={isOpen}
  426. showSectionHeaders={!filterValue}
  427. size="sm"
  428. />
  429. )}
  430. </ListBoxOverlay>
  431. );
  432. }
  433. function SearchQueryBuilderComboboxInner<T extends SelectOptionOrSectionWithKey<string>>(
  434. {
  435. children,
  436. items,
  437. inputValue,
  438. filterValue = inputValue,
  439. placeholder,
  440. onCustomValueBlurred,
  441. onCustomValueCommitted,
  442. onOptionSelected,
  443. inputLabel,
  444. onExit,
  445. onKeyDown,
  446. onKeyUp,
  447. onInputChange,
  448. onOpenChange,
  449. autoFocus,
  450. openOnFocus,
  451. onFocus,
  452. tabIndex = -1,
  453. maxOptions,
  454. shouldFilterResults = true,
  455. shouldCloseOnInteractOutside,
  456. onPaste,
  457. displayTabbedMenu,
  458. isLoading,
  459. onClick,
  460. customMenu,
  461. }: SearchQueryBuilderComboboxProps<T>,
  462. ref: ForwardedRef<HTMLInputElement>
  463. ) {
  464. const {disabled} = useSearchQueryBuilder();
  465. const listBoxRef = useRef<HTMLUListElement>(null);
  466. const inputRef = useRef<HTMLInputElement>(null);
  467. const popoverRef = useRef<HTMLDivElement>(null);
  468. const [selectedSection, setSelectedSection] = useState<Key | null>(null);
  469. const {hiddenOptions, disabledKeys} = useHiddenItems({
  470. items,
  471. filterValue,
  472. maxOptions,
  473. displayTabbedMenu,
  474. selectedSection,
  475. shouldFilterResults,
  476. });
  477. const onSelectionChange = useCallback(
  478. (key: Key | null) => {
  479. const selectedOption = key ? findItemInSections(items, key) : null;
  480. if (selectedOption && 'textValue' in selectedOption && selectedOption.textValue) {
  481. onOptionSelected(selectedOption.textValue);
  482. } else if (key) {
  483. onOptionSelected(key.toString());
  484. }
  485. },
  486. [items, onOptionSelected]
  487. );
  488. const comboBoxProps: Partial<AriaComboBoxProps<T>> = {
  489. items,
  490. autoFocus,
  491. inputValue: filterValue,
  492. onSelectionChange,
  493. allowsCustomValue: true,
  494. disabledKeys,
  495. isDisabled: disabled,
  496. };
  497. const state = useComboBoxState<T>({
  498. children,
  499. allowsEmptyCollection: true,
  500. // We handle closing on blur ourselves to prevent the combobox from closing
  501. // when the user clicks inside the tabbed menu
  502. shouldCloseOnBlur: false,
  503. ...comboBoxProps,
  504. });
  505. const {inputProps, listBoxProps} = useComboBox<T>(
  506. {
  507. ...comboBoxProps,
  508. 'aria-label': inputLabel,
  509. listBoxRef,
  510. inputRef,
  511. popoverRef,
  512. onFocus: e => {
  513. if (openOnFocus) {
  514. state.open();
  515. }
  516. onFocus?.(e);
  517. },
  518. onBlur: e => {
  519. if (e.relatedTarget && !shouldCloseOnInteractOutside?.(e.relatedTarget)) {
  520. return;
  521. }
  522. onCustomValueBlurred(inputValue);
  523. state.close();
  524. },
  525. onKeyDown: e => {
  526. onKeyDown?.(e);
  527. switch (e.key) {
  528. case 'Escape':
  529. state.close();
  530. onExit?.();
  531. return;
  532. case 'Enter':
  533. if (state.selectionManager.focusedKey) {
  534. return;
  535. }
  536. state.close();
  537. onCustomValueCommitted(inputValue);
  538. return;
  539. default:
  540. return;
  541. }
  542. },
  543. onKeyUp,
  544. },
  545. state
  546. );
  547. const previousInputValue = usePrevious(inputValue);
  548. useEffect(() => {
  549. if (inputValue !== previousInputValue) {
  550. state.selectionManager.setFocusedKey(null);
  551. }
  552. }, [inputValue, previousInputValue, state.selectionManager]);
  553. const totalOptions = items.reduce(
  554. (acc, item) => acc + (itemIsSection(item) ? item.options.length : 1),
  555. 0
  556. );
  557. const hasCustomMenu = defined(customMenu);
  558. const isOpen = menuIsOpen({
  559. state,
  560. hiddenOptions,
  561. totalOptions,
  562. displayTabbedMenu,
  563. isLoading,
  564. hasCustomMenu,
  565. });
  566. useEffect(() => {
  567. onOpenChange?.(isOpen);
  568. }, [onOpenChange, isOpen]);
  569. const {
  570. overlayProps,
  571. triggerProps,
  572. update: updateOverlayPosition,
  573. } = useOverlay({
  574. type: 'listbox',
  575. isOpen,
  576. position: 'bottom-start',
  577. offset: [-12, 8],
  578. isKeyboardDismissDisabled: true,
  579. shouldCloseOnBlur: true,
  580. shouldCloseOnInteractOutside: el => {
  581. if (popoverRef.current?.contains(el)) {
  582. return false;
  583. }
  584. return shouldCloseOnInteractOutside?.(el) ?? true;
  585. },
  586. onInteractOutside: () => {
  587. if (state.inputValue) {
  588. onCustomValueBlurred(inputValue);
  589. } else {
  590. onExit?.();
  591. }
  592. state.close();
  593. },
  594. preventOverflowOptions: {boundary: document.body, altAxis: true},
  595. });
  596. const handleInputClick: MouseEventHandler<HTMLInputElement> = useCallback(
  597. e => {
  598. e.stopPropagation();
  599. inputProps.onClick?.(e);
  600. state.toggle();
  601. onClick?.(e);
  602. },
  603. [inputProps, state, onClick]
  604. );
  605. useUpdateOverlayPositionOnMenuContentChange({
  606. inputValue,
  607. isLoading,
  608. isOpen,
  609. updateOverlayPosition,
  610. hasCustomMenu,
  611. });
  612. // useCombobox will hide outside elements with aria-hidden="true" when it is open [1].
  613. // Because we switch elements when the custom or tabbed menu is displayed, we need to
  614. // manually call this function an extra time to ensure the correct elements are hidden.
  615. //
  616. // [1]: https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/combobox/src/useComboBox.ts#L337C3-L341C44
  617. useEffect(() => {
  618. if (isOpen && inputRef.current && popoverRef.current) {
  619. return ariaHideOutside([inputRef.current, popoverRef.current]);
  620. }
  621. return () => {};
  622. }, [inputRef, popoverRef, isOpen, customMenu, displayTabbedMenu]);
  623. return (
  624. <Wrapper>
  625. <UnstyledInput
  626. {...inputProps}
  627. size="md"
  628. ref={mergeRefs([ref, inputRef, triggerProps.ref])}
  629. type="text"
  630. placeholder={placeholder}
  631. onClick={handleInputClick}
  632. value={inputValue}
  633. onChange={onInputChange}
  634. tabIndex={tabIndex}
  635. onPaste={onPaste}
  636. disabled={disabled}
  637. />
  638. <StyledPositionWrapper {...overlayProps} visible={isOpen}>
  639. <OverlayContent
  640. customMenu={customMenu}
  641. displayTabbedMenu={displayTabbedMenu}
  642. filterValue={filterValue}
  643. hiddenOptions={hiddenOptions}
  644. isLoading={isLoading}
  645. isOpen={isOpen}
  646. listBoxProps={listBoxProps}
  647. listBoxRef={listBoxRef}
  648. popoverRef={popoverRef}
  649. selectedSection={selectedSection}
  650. setSelectedSection={setSelectedSection}
  651. state={state}
  652. totalOptions={totalOptions}
  653. />
  654. </StyledPositionWrapper>
  655. </Wrapper>
  656. );
  657. }
  658. /**
  659. * A combobox component which is used in freeText tokens and filter values.
  660. */
  661. export const SearchQueryBuilderCombobox = forwardRef(SearchQueryBuilderComboboxInner) as <
  662. T extends SelectOptionWithKey<string>,
  663. >(
  664. props: SearchQueryBuilderComboboxProps<T> & {ref?: ForwardedRef<HTMLInputElement>}
  665. ) => ReturnType<typeof SearchQueryBuilderComboboxInner>;
  666. const Wrapper = styled('div')`
  667. position: relative;
  668. display: flex;
  669. align-items: stretch;
  670. height: 100%;
  671. width: 100%;
  672. `;
  673. const UnstyledInput = styled(GrowingInput)`
  674. background: transparent;
  675. border: none;
  676. box-shadow: none;
  677. flex-grow: 1;
  678. padding: 0;
  679. height: auto;
  680. min-height: auto;
  681. resize: none;
  682. min-width: 1px;
  683. border-radius: 0;
  684. &:focus {
  685. outline: none;
  686. border: none;
  687. box-shadow: none;
  688. }
  689. `;
  690. const StyledPositionWrapper = styled('div')<{visible?: boolean}>`
  691. display: ${p => (p.visible ? 'block' : 'none')};
  692. z-index: ${p => p.theme.zIndex.tooltip};
  693. `;
  694. const ListBoxOverlay = styled(Overlay)`
  695. max-height: 400px;
  696. min-width: 200px;
  697. width: 600px;
  698. max-width: min-content;
  699. overflow-y: auto;
  700. `;
  701. const SectionedOverlay = styled(Overlay)`
  702. overflow: hidden;
  703. display: grid;
  704. grid-template-columns: 120px 240px;
  705. grid-template-rows: 1fr auto;
  706. grid-template-areas:
  707. 'left right'
  708. 'footer footer';
  709. height: 400px;
  710. width: 360px;
  711. `;
  712. const SectionedOverlayFooter = styled('div')`
  713. display: flex;
  714. align-items: center;
  715. justify-content: flex-end;
  716. grid-area: footer;
  717. padding: ${space(1)};
  718. border-top: 1px solid ${p => p.theme.innerBorder};
  719. `;
  720. const SectionedListBoxPane = styled('div')`
  721. overflow-y: auto;
  722. `;
  723. const SectionedListBoxTabPane = styled(SectionedListBoxPane)`
  724. padding: ${space(1)};
  725. display: flex;
  726. flex-direction: column;
  727. gap: ${space(0.25)};
  728. border-right: 1px solid ${p => p.theme.innerBorder};
  729. `;
  730. const SectionButton = styled(Button)`
  731. display: block;
  732. height: 32px;
  733. width: 100%;
  734. text-align: left;
  735. font-weight: ${p => p.theme.fontWeightNormal};
  736. padding: 0 ${space(1)};
  737. span {
  738. justify-content: flex-start;
  739. }
  740. &[aria-selected='true'] {
  741. background-color: ${p => p.theme.purple100};
  742. color: ${p => p.theme.purple300};
  743. font-weight: ${p => p.theme.fontWeightBold};
  744. }
  745. `;
  746. const LoadingWrapper = styled('div')`
  747. display: flex;
  748. justify-content: center;
  749. align-items: center;
  750. height: 140px;
  751. `;