control.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550
  1. import {createContext, Fragment, useCallback, useMemo, 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 {FocusScope} from '@react-aria/focus';
  6. import {useKeyboard} from '@react-aria/interactions';
  7. import {mergeProps} from '@react-aria/utils';
  8. import {ListState} from '@react-stately/list';
  9. import {OverlayTriggerState} from '@react-stately/overlays';
  10. import Badge from 'sentry/components/badge';
  11. import {Button} from 'sentry/components/button';
  12. import DropdownButton, {DropdownButtonProps} from 'sentry/components/dropdownButton';
  13. import LoadingIndicator from 'sentry/components/loadingIndicator';
  14. import {Overlay, PositionWrapper} from 'sentry/components/overlay';
  15. import {t} from 'sentry/locale';
  16. import {space} from 'sentry/styles/space';
  17. import {defined} from 'sentry/utils';
  18. import {FormSize} from 'sentry/utils/theme';
  19. import useOverlay, {UseOverlayProps} from 'sentry/utils/useOverlay';
  20. import {SelectOption} from './types';
  21. export interface SelectContextValue {
  22. /**
  23. * Filter function to determine whether an option should be rendered in the select
  24. * list. A true return value means the option should be rendered. This function is
  25. * automatically updated based on the current search string.
  26. */
  27. filterOption: (opt: SelectOption<React.Key>) => boolean;
  28. overlayIsOpen: boolean;
  29. /**
  30. * Function to be called once when a list is initialized, to register its state in
  31. * SelectContext. In composite selectors, where there can be multiple lists, the
  32. * `index` parameter is the list's index number (the order in which it appears). In
  33. * non-composite selectors, where there's only one list, that list's index is 0.
  34. */
  35. registerListState: (index: number, listState: ListState<any>) => void;
  36. /**
  37. * Function to be called when a list's selection state changes. We need a complete
  38. * list of all selected options to label the trigger button. The `index` parameter
  39. * indentifies the list, in the same way as in `registerListState`.
  40. */
  41. saveSelectedOptions: (
  42. index: number,
  43. newSelectedOptions: SelectOption<React.Key> | SelectOption<React.Key>[]
  44. ) => void;
  45. /**
  46. * The control's overlay state. Useful for opening/closing the menu from inside the
  47. * selector.
  48. */
  49. overlayState?: OverlayTriggerState;
  50. }
  51. export const SelectContext = createContext<SelectContextValue>({
  52. registerListState: () => {},
  53. saveSelectedOptions: () => {},
  54. filterOption: () => true,
  55. overlayIsOpen: false,
  56. });
  57. export interface ControlProps extends UseOverlayProps {
  58. children?: React.ReactNode;
  59. className?: string;
  60. /**
  61. * If true, there will be a "Clear" button in the menu header.
  62. */
  63. clearable?: boolean;
  64. disabled?: boolean;
  65. /**
  66. * Message to be displayed when all options have been filtered out (via search).
  67. */
  68. emptyMessage?: string;
  69. /**
  70. * Whether to render a grid list rather than a list box.
  71. *
  72. * Unlike list boxes, grid lists are two-dimensional. Users can press Arrow Up/Down to
  73. * move between rows (options), and Arrow Left/Right to move between "columns". This
  74. * is useful when the select options have smaller, interactive elements
  75. * (buttons/links) inside. Grid lists allow users to focus on those child elements
  76. * using the Arrow Left/Right keys and interact with them, which isn't possible with
  77. * list boxes.
  78. */
  79. grid?: boolean;
  80. /**
  81. * If true, there will be a loading indicator in the menu header.
  82. */
  83. loading?: boolean;
  84. maxMenuHeight?: number | string;
  85. maxMenuWidth?: number | string;
  86. /**
  87. * Footer to be rendered at the bottom of the menu.
  88. */
  89. menuFooter?:
  90. | React.ReactNode
  91. | ((actions: {closeOverlay: () => void}) => React.ReactNode);
  92. /**
  93. * Title to display in the menu's header. Keep the title as short as possible.
  94. */
  95. menuTitle?: React.ReactNode;
  96. menuWidth?: number | string;
  97. /**
  98. * Called when the clear button is clicked (applicable only when `clearable` is
  99. * true).
  100. */
  101. onClear?: () => void;
  102. /**
  103. * Called when the search input's value changes (applicable only when `searchable`
  104. * is true).
  105. */
  106. onSearch?: (value: string) => void;
  107. /**
  108. * The search input's placeholder text (applicable only when `searchable` is true).
  109. */
  110. searchPlaceholder?: string;
  111. /**
  112. * If true, there will be a search box on top of the menu, useful for quickly finding
  113. * menu items.
  114. */
  115. searchable?: boolean;
  116. size?: FormSize;
  117. /**
  118. * Optional replacement for the default trigger button. Note that the replacement must
  119. * forward `props` and `ref` its outer wrap, otherwise many accessibility features
  120. * won't work correctly.
  121. */
  122. trigger?: (args: {
  123. props: Omit<DropdownButtonProps, 'children'>;
  124. ref: React.RefObject<HTMLButtonElement>;
  125. }) => React.ReactNode;
  126. /**
  127. * Label text inside the default trigger button. This is optional — by default the
  128. * selected option's label will be used.
  129. */
  130. triggerLabel?: React.ReactNode;
  131. /**
  132. * Props to be passed to the default trigger button.
  133. */
  134. triggerProps?: DropdownButtonProps;
  135. }
  136. /**
  137. * Controls Select's open state and exposes SelectContext to all chidlren.
  138. */
  139. export function Control({
  140. // Control props
  141. trigger,
  142. triggerLabel: triggerLabelProp,
  143. triggerProps,
  144. isOpen,
  145. onClose,
  146. disabled,
  147. position = 'bottom-start',
  148. offset,
  149. menuTitle,
  150. maxMenuHeight = '32rem',
  151. maxMenuWidth,
  152. menuWidth,
  153. menuFooter,
  154. // Select props
  155. size = 'md',
  156. searchable = false,
  157. searchPlaceholder = 'Search…',
  158. onSearch,
  159. clearable = false,
  160. onClear,
  161. loading = false,
  162. grid = false,
  163. children,
  164. ...wrapperProps
  165. }: ControlProps) {
  166. // Set up list states (in composite selects, each region has its own state, that way
  167. // selection values are contained within each region).
  168. const [listStates, setListStates] = useState<ListState<any>[]>([]);
  169. const registerListState = useCallback<SelectContextValue['registerListState']>(
  170. (index, listState) => {
  171. setListStates(current => [
  172. ...current.slice(0, index),
  173. listState,
  174. ...current.slice(index + 1),
  175. ]);
  176. },
  177. []
  178. );
  179. /**
  180. * Search/filter value, used to filter out the list of displayed elements
  181. */
  182. const [search, setSearch] = useState('');
  183. const updateSearch = useCallback(
  184. (newValue: string) => {
  185. setSearch(newValue);
  186. onSearch?.(newValue);
  187. },
  188. [onSearch]
  189. );
  190. const filterOption = useCallback<SelectContextValue['filterOption']>(
  191. opt =>
  192. String(opt.label ?? '')
  193. .toLowerCase()
  194. .includes(search.toLowerCase()),
  195. [search]
  196. );
  197. const {keyboardProps: searchKeyboardProps} = useKeyboard({
  198. onKeyDown: e => {
  199. // When the search input is focused, and the user presses Arrow Down,
  200. // we should move the focus to the menu items list.
  201. if (e.key === 'ArrowDown') {
  202. e.preventDefault(); // Prevent scroll action
  203. overlayRef.current
  204. ?.querySelector<HTMLLIElement>(`li[role="${grid ? 'row' : 'option'}"]`)
  205. ?.focus();
  206. }
  207. // Continue propagation, otherwise the overlay won't close on Esc key press
  208. e.continuePropagation();
  209. },
  210. });
  211. /**
  212. * Clears selection values across all list states
  213. */
  214. const clearSelection = useCallback(() => {
  215. listStates.forEach(listState => listState.selectionManager.clearSelection());
  216. onClear?.();
  217. }, [onClear, listStates]);
  218. // Manage overlay position
  219. const {
  220. isOpen: overlayIsOpen,
  221. state: overlayState,
  222. triggerRef,
  223. triggerProps: overlayTriggerProps,
  224. overlayRef,
  225. overlayProps,
  226. } = useOverlay({
  227. type: grid ? 'menu' : 'listbox',
  228. position,
  229. offset,
  230. isOpen,
  231. onOpenChange: async open => {
  232. // On open
  233. if (open) {
  234. // Wait for overlay to appear/disappear
  235. await new Promise(resolve => resolve(null));
  236. const firstSelectedOption = overlayRef.current?.querySelector<HTMLLIElement>(
  237. `li[role="${grid ? 'row' : 'option'}"][aria-selected="true"]`
  238. );
  239. // Focus on first selected item
  240. if (firstSelectedOption) {
  241. firstSelectedOption.focus();
  242. return;
  243. }
  244. // If no item is selected, focus on first item instead
  245. overlayRef.current
  246. ?.querySelector<HTMLLIElement>(`li[role="${grid ? 'row' : 'option'}"]`)
  247. ?.focus();
  248. return;
  249. }
  250. // On close
  251. onClose?.();
  252. setSearch(''); // Clear search string
  253. // Wait for overlay to appear/disappear
  254. await new Promise(resolve => resolve(null));
  255. triggerRef.current?.focus();
  256. },
  257. });
  258. /**
  259. * A list of selected options across all select regions, to be used to generate the
  260. * trigger label.
  261. */
  262. const [selectedOptions, setSelectedOptions] = useState<
  263. Array<SelectOption<React.Key> | SelectOption<React.Key>[]>
  264. >([]);
  265. const saveSelectedOptions = useCallback<SelectContextValue['saveSelectedOptions']>(
  266. (index, newSelectedOptions) => {
  267. setSelectedOptions(current => [
  268. ...current.slice(0, index),
  269. newSelectedOptions,
  270. ...current.slice(index + 1),
  271. ]);
  272. },
  273. []
  274. );
  275. /**
  276. * Trigger label, generated from current selection values. If more than one option is
  277. * selected, then a count badge will appear.
  278. */
  279. const triggerLabel: React.ReactNode = useMemo(() => {
  280. if (defined(triggerLabelProp)) {
  281. return triggerLabelProp;
  282. }
  283. const options = selectedOptions.flat().filter(Boolean);
  284. if (options.length === 0) {
  285. return <TriggerLabel>{t('None')}</TriggerLabel>;
  286. }
  287. return (
  288. <Fragment>
  289. <TriggerLabel>{options[0]?.label}</TriggerLabel>
  290. {options.length > 1 && <StyledBadge text={`+${options.length - 1}`} />}
  291. </Fragment>
  292. );
  293. }, [triggerLabelProp, selectedOptions]);
  294. const {keyboardProps: triggerKeyboardProps} = useKeyboard({
  295. onKeyDown: e => {
  296. // Open the select menu when user presses Arrow Up/Down.
  297. if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
  298. e.preventDefault(); // Prevent scroll
  299. overlayState.open();
  300. }
  301. },
  302. });
  303. const contextValue = useMemo(
  304. () => ({
  305. registerListState,
  306. saveSelectedOptions,
  307. overlayState,
  308. overlayIsOpen,
  309. filterOption,
  310. }),
  311. [registerListState, saveSelectedOptions, overlayState, overlayIsOpen, filterOption]
  312. );
  313. const theme = useTheme();
  314. return (
  315. <SelectContext.Provider value={contextValue}>
  316. <ControlWrap {...wrapperProps}>
  317. {trigger ? (
  318. trigger(
  319. mergeProps(triggerProps, triggerKeyboardProps, overlayTriggerProps, {
  320. size,
  321. disabled,
  322. isOpen: overlayIsOpen,
  323. })
  324. )
  325. ) : (
  326. <DropdownButton
  327. size={size}
  328. {...mergeProps(triggerProps, triggerKeyboardProps, overlayTriggerProps)}
  329. isOpen={overlayIsOpen}
  330. disabled={disabled}
  331. >
  332. {triggerLabel}
  333. </DropdownButton>
  334. )}
  335. <StyledPositionWrapper
  336. zIndex={theme.zIndex.tooltip}
  337. visible={overlayIsOpen}
  338. {...overlayProps}
  339. >
  340. <StyledOverlay
  341. width={menuWidth}
  342. maxWidth={maxMenuWidth}
  343. maxHeight={overlayProps.style.maxHeight}
  344. maxHeightProp={maxMenuHeight}
  345. data-menu-has-header={!!menuTitle || clearable}
  346. data-menu-has-footer={!!menuFooter}
  347. >
  348. <FocusScope contain={overlayIsOpen}>
  349. {(menuTitle || clearable) && (
  350. <MenuHeader size={size}>
  351. <MenuTitle>{menuTitle}</MenuTitle>
  352. <MenuHeaderTrailingItems>
  353. {loading && <StyledLoadingIndicator size={12} mini />}
  354. {clearable && (
  355. <ClearButton onClick={clearSelection} size="zero" borderless>
  356. {t('Clear')}
  357. </ClearButton>
  358. )}
  359. </MenuHeaderTrailingItems>
  360. </MenuHeader>
  361. )}
  362. {searchable && (
  363. <SearchInput
  364. placeholder={searchPlaceholder}
  365. value={search}
  366. onChange={e => updateSearch(e.target.value)}
  367. visualSize={size}
  368. {...searchKeyboardProps}
  369. />
  370. )}
  371. <OptionsWrap>{children}</OptionsWrap>
  372. {menuFooter && (
  373. <MenuFooter>
  374. {typeof menuFooter === 'function'
  375. ? menuFooter({closeOverlay: overlayState.close})
  376. : menuFooter}
  377. </MenuFooter>
  378. )}
  379. </FocusScope>
  380. </StyledOverlay>
  381. </StyledPositionWrapper>
  382. </ControlWrap>
  383. </SelectContext.Provider>
  384. );
  385. }
  386. const ControlWrap = styled('div')`
  387. position: relative;
  388. width: max-content;
  389. `;
  390. const TriggerLabel = styled('span')`
  391. ${p => p.theme.overflowEllipsis}
  392. text-align: left;
  393. `;
  394. const StyledBadge = styled(Badge)`
  395. flex-shrink: 0;
  396. top: auto;
  397. `;
  398. const headerVerticalPadding: Record<FormSize, string> = {
  399. xs: space(0.25),
  400. sm: space(0.5),
  401. md: space(0.75),
  402. };
  403. const MenuHeader = styled('div')<{size: FormSize}>`
  404. position: relative;
  405. display: flex;
  406. align-items: center;
  407. justify-content: space-between;
  408. padding: ${p => headerVerticalPadding[p.size]} ${space(1.5)};
  409. box-shadow: 0 1px 0 ${p => p.theme.translucentInnerBorder};
  410. line-height: ${p => p.theme.text.lineHeightBody};
  411. z-index: 2;
  412. font-size: ${p =>
  413. p.size !== 'xs' ? p.theme.fontSizeSmall : p.theme.fontSizeExtraSmall};
  414. color: ${p => p.theme.headingColor};
  415. `;
  416. const MenuHeaderTrailingItems = styled('div')`
  417. display: grid;
  418. grid-auto-flow: column;
  419. gap: ${space(0.5)};
  420. `;
  421. const MenuTitle = styled('span')`
  422. font-size: inherit; /* Inherit font size from MenuHeader */
  423. font-weight: 600;
  424. white-space: nowrap;
  425. margin-right: ${space(2)};
  426. `;
  427. const StyledLoadingIndicator = styled(LoadingIndicator)`
  428. && {
  429. margin: ${space(0.5)} ${space(0.5)} ${space(0.5)} ${space(1)};
  430. height: ${space(1)};
  431. width: ${space(1)};
  432. }
  433. `;
  434. const ClearButton = styled(Button)`
  435. font-size: inherit; /* Inherit font size from MenuHeader */
  436. font-weight: 400;
  437. color: ${p => p.theme.subText};
  438. padding: 0 ${space(0.5)};
  439. margin: 0 -${space(0.5)};
  440. `;
  441. const searchVerticalPadding: Record<FormSize, string> = {
  442. xs: space(0.25),
  443. sm: space(0.5),
  444. md: space(0.5),
  445. };
  446. const SearchInput = styled('input')<{visualSize: FormSize}>`
  447. appearance: none;
  448. width: calc(100% - ${space(0.5)} * 2);
  449. border: solid 1px ${p => p.theme.innerBorder};
  450. border-radius: ${p => p.theme.borderRadius};
  451. background: ${p => p.theme.backgroundSecondary};
  452. font-size: ${p =>
  453. p.visualSize !== 'xs' ? p.theme.fontSizeMedium : p.theme.fontSizeSmall};
  454. /* Subtract 1px to account for border width */
  455. padding: ${p => searchVerticalPadding[p.visualSize]} calc(${space(1)} - 1px);
  456. margin: ${space(0.5)} ${space(0.5)};
  457. /* Add 1px to top margin if immediately preceded by menu header, to account for the
  458. header's shadow border */
  459. [data-menu-has-header='true'] > & {
  460. margin-top: calc(${space(0.5)} + 1px);
  461. }
  462. &:focus,
  463. &.focus-visible {
  464. outline: none;
  465. border-color: ${p => p.theme.focusBorder};
  466. box-shadow: ${p => p.theme.focusBorder} 0 0 0 1px;
  467. background: transparent;
  468. }
  469. `;
  470. const withUnits = value => (typeof value === 'string' ? value : `${value}px`);
  471. const StyledOverlay = styled(Overlay, {
  472. shouldForwardProp: prop => isPropValid(prop),
  473. })<{
  474. maxHeightProp: string | number;
  475. maxHeight?: string | number;
  476. maxWidth?: string | number;
  477. width?: string | number;
  478. }>`
  479. /* Should be a flex container so that when maxHeight is set (to avoid page overflow),
  480. ListBoxWrap/GridListWrap will also shrink to fit */
  481. display: flex;
  482. flex-direction: column;
  483. overflow: hidden;
  484. max-height: ${p =>
  485. p.maxHeight
  486. ? `min(${withUnits(p.maxHeight)}, ${withUnits(p.maxHeightProp)})`
  487. : withUnits(p.maxHeightProp)};
  488. ${p => p.width && `width: ${withUnits(p.width)};`}
  489. ${p => p.maxWidth && `max-width: ${withUnits(p.maxWidth)};`}
  490. `;
  491. const StyledPositionWrapper = styled(PositionWrapper, {
  492. shouldForwardProp: prop => isPropValid(prop),
  493. })<{visible?: boolean}>`
  494. min-width: 100%;
  495. display: ${p => (p.visible ? 'block' : 'none')};
  496. `;
  497. const OptionsWrap = styled('div')`
  498. display: flex;
  499. flex-direction: column;
  500. min-height: 0;
  501. `;
  502. const MenuFooter = styled('div')`
  503. box-shadow: 0 -1px 0 ${p => p.theme.translucentInnerBorder};
  504. padding: ${space(1)} ${space(1.5)};
  505. z-index: 2;
  506. `;