index.tsx 11 KB

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