index.tsx 12 KB

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