control.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705
  1. import {
  2. createContext,
  3. Fragment,
  4. useCallback,
  5. useEffect,
  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 {FocusScope} from '@react-aria/focus';
  14. import {useKeyboard} from '@react-aria/interactions';
  15. import {mergeProps} from '@react-aria/utils';
  16. import type {ListState} from '@react-stately/list';
  17. import type {OverlayTriggerState} from '@react-stately/overlays';
  18. import Badge from 'sentry/components/badge/badge';
  19. import {Button} from 'sentry/components/button';
  20. import type {DropdownButtonProps} from 'sentry/components/dropdownButton';
  21. import DropdownButton from 'sentry/components/dropdownButton';
  22. import LoadingIndicator from 'sentry/components/loadingIndicator';
  23. import {Overlay, PositionWrapper} from 'sentry/components/overlay';
  24. import {t} from 'sentry/locale';
  25. import {space} from 'sentry/styles/space';
  26. import {defined} from 'sentry/utils';
  27. import type {FormSize} from 'sentry/utils/theme';
  28. import type {UseOverlayProps} from 'sentry/utils/useOverlay';
  29. import useOverlay from 'sentry/utils/useOverlay';
  30. import usePrevious from 'sentry/utils/usePrevious';
  31. import type {SingleListProps} from './list';
  32. import type {SelectKey, SelectOption} from './types';
  33. // autoFocus react attribute is sync called on render, this causes
  34. // layout thrashing and is bad for performance. This thin wrapper function
  35. // will defer the focus call until the next frame, after the browser and react
  36. // have had a chance to update the DOM, splitting the perf cost across frames.
  37. function nextFrameCallback(cb: () => void) {
  38. if ('requestAnimationFrame' in window) {
  39. window.requestAnimationFrame(() => cb());
  40. } else {
  41. setTimeout(() => {
  42. cb();
  43. }, 1);
  44. }
  45. }
  46. export interface SelectContextValue {
  47. overlayIsOpen: boolean;
  48. /**
  49. * Function to be called once when a list is initialized, to register its state in
  50. * SelectContext. In composite selectors, where there can be multiple lists, the
  51. * `index` parameter is the list's index number (the order in which it appears). In
  52. * non-composite selectors, where there's only one list, that list's index is 0.
  53. */
  54. registerListState: (index: number, listState: ListState<any>) => void;
  55. /**
  56. * Function to be called when a list's selection state changes. We need a complete
  57. * list of all selected options to label the trigger button. The `index` parameter
  58. * indentifies the list, in the same way as in `registerListState`.
  59. */
  60. saveSelectedOptions: (
  61. index: number,
  62. newSelectedOptions: SelectOption<SelectKey> | SelectOption<SelectKey>[]
  63. ) => void;
  64. /**
  65. * Search string to determine whether an option should be rendered in the select list.
  66. */
  67. search: string;
  68. /**
  69. * The control's overlay state. Useful for opening/closing the menu from inside the
  70. * selector.
  71. */
  72. overlayState?: OverlayTriggerState;
  73. }
  74. export const SelectContext = createContext<SelectContextValue>({
  75. registerListState: () => {},
  76. saveSelectedOptions: () => {},
  77. overlayIsOpen: false,
  78. search: '',
  79. });
  80. export interface ControlProps
  81. extends Omit<
  82. React.BaseHTMLAttributes<HTMLDivElement>,
  83. // omit keys from SingleListProps because those will be passed to <List /> instead
  84. keyof Omit<
  85. SingleListProps<SelectKey>,
  86. 'children' | 'items' | 'grid' | 'compositeIndex' | 'label'
  87. >
  88. >,
  89. Pick<
  90. UseOverlayProps,
  91. | 'isOpen'
  92. | 'onClose'
  93. | 'offset'
  94. | 'position'
  95. | 'isDismissable'
  96. | 'shouldCloseOnBlur'
  97. | 'shouldCloseOnInteractOutside'
  98. | 'onInteractOutside'
  99. | 'preventOverflowOptions'
  100. | 'flipOptions'
  101. > {
  102. children?: React.ReactNode;
  103. className?: string;
  104. /**
  105. * If true, there will be a "Clear" button in the menu header.
  106. */
  107. clearable?: boolean;
  108. /**
  109. * Whether to disable the search input's filter function (applicable only when
  110. * `searchable` is true). This is useful for implementing custom search behaviors,
  111. * like fetching new options on search (via the onSearch() prop).
  112. */
  113. disableSearchFilter?: boolean;
  114. disabled?: boolean;
  115. /**
  116. * Message to be displayed when all options have been filtered out (via search).
  117. */
  118. emptyMessage?: string;
  119. /**
  120. * Whether to render a grid list rather than a list box.
  121. *
  122. * Unlike list boxes, grid lists are two-dimensional. Users can press Arrow Up/Down to
  123. * move between rows (options), and Arrow Left/Right to move between "columns". This
  124. * is useful when the select options have smaller, interactive elements
  125. * (buttons/links) inside. Grid lists allow users to focus on those child elements
  126. * using the Arrow Left/Right keys and interact with them, which isn't possible with
  127. * list boxes.
  128. */
  129. grid?: boolean;
  130. /**
  131. * If true, all select options will be hidden. This should only be used on a temporary
  132. * basis in conjunction with `menuBody` to display special views/states (e.g. a
  133. * secondary date range selector).
  134. */
  135. hideOptions?: boolean;
  136. /**
  137. * If true, there will be a loading indicator in the menu header.
  138. */
  139. loading?: boolean;
  140. maxMenuHeight?: number | string;
  141. maxMenuWidth?: number | string;
  142. /**
  143. * Optional content to display below the menu's header and above the options.
  144. */
  145. menuBody?: React.ReactNode | ((actions: {closeOverlay: () => void}) => JSX.Element);
  146. /**
  147. * Footer to be rendered at the bottom of the menu.
  148. */
  149. menuFooter?:
  150. | React.ReactNode
  151. | ((actions: {closeOverlay: () => void}) => React.ReactNode);
  152. /**
  153. * Items to be displayed in the trailing (right) side of the menu's header.
  154. */
  155. menuHeaderTrailingItems?:
  156. | React.ReactNode
  157. | ((actions: {closeOverlay: () => void}) => React.ReactNode);
  158. /**
  159. * Title to display in the menu's header. Keep the title as short as possible.
  160. */
  161. menuTitle?: React.ReactNode;
  162. menuWidth?: number | string;
  163. /**
  164. * Called when the clear button is clicked (applicable only when `clearable` is
  165. * true).
  166. */
  167. onClear?: () => void;
  168. /**
  169. * Called when the search input's value changes (applicable only when `searchable`
  170. * is true).
  171. */
  172. onSearch?: (value: string) => void;
  173. /**
  174. * The search input's placeholder text (applicable only when `searchable` is true).
  175. */
  176. searchPlaceholder?: string;
  177. /**
  178. * If true, there will be a search box on top of the menu, useful for quickly finding
  179. * menu items.
  180. */
  181. searchable?: boolean;
  182. size?: FormSize;
  183. /**
  184. * Optional replacement for the default trigger button. Note that the replacement must
  185. * forward `props` and `ref` its outer wrap, otherwise many accessibility features
  186. * won't work correctly.
  187. */
  188. trigger?: (
  189. props: Omit<React.HTMLAttributes<HTMLElement>, 'children'>,
  190. isOpen: boolean
  191. ) => React.ReactNode;
  192. /**
  193. * Label text inside the default trigger button. This is optional — by default the
  194. * selected option's label will be used.
  195. */
  196. triggerLabel?: React.ReactNode;
  197. /**
  198. * Props to be passed to the default trigger button.
  199. */
  200. triggerProps?: DropdownButtonProps;
  201. }
  202. /**
  203. * Controls Select's open state and exposes SelectContext to all chidlren.
  204. */
  205. export function Control({
  206. // Control props
  207. trigger,
  208. triggerLabel: triggerLabelProp,
  209. triggerProps,
  210. isOpen,
  211. onClose,
  212. isDismissable,
  213. onInteractOutside,
  214. shouldCloseOnInteractOutside,
  215. shouldCloseOnBlur,
  216. preventOverflowOptions,
  217. flipOptions,
  218. disabled,
  219. position = 'bottom-start',
  220. offset,
  221. hideOptions,
  222. menuTitle,
  223. maxMenuHeight = '32rem',
  224. maxMenuWidth,
  225. menuWidth,
  226. menuHeaderTrailingItems,
  227. menuBody,
  228. menuFooter,
  229. // Select props
  230. size = 'md',
  231. searchable = false,
  232. searchPlaceholder = 'Search…',
  233. disableSearchFilter = false,
  234. onSearch,
  235. clearable = false,
  236. onClear,
  237. loading = false,
  238. grid = false,
  239. children,
  240. ...wrapperProps
  241. }: ControlProps) {
  242. const wrapperRef = useRef<HTMLDivElement>(null);
  243. // Set up list states (in composite selects, each region has its own state, that way
  244. // selection values are contained within each region).
  245. const [listStates, setListStates] = useState<ListState<any>[]>([]);
  246. const registerListState = useCallback<SelectContextValue['registerListState']>(
  247. (index, listState) => {
  248. setListStates(current => [
  249. ...current.slice(0, index),
  250. listState,
  251. ...current.slice(index + 1),
  252. ]);
  253. },
  254. []
  255. );
  256. /**
  257. * Search/filter value, used to filter out the list of displayed elements
  258. */
  259. const [search, setSearch] = useState('');
  260. const [searchInputValue, setSearchInputValue] = useState(search);
  261. const searchRef = useRef<HTMLInputElement>(null);
  262. const updateSearch = useCallback(
  263. (newValue: string) => {
  264. onSearch?.(newValue);
  265. setSearchInputValue(newValue);
  266. if (!disableSearchFilter) {
  267. setSearch(newValue);
  268. return;
  269. }
  270. },
  271. [onSearch, disableSearchFilter]
  272. );
  273. const {keyboardProps: searchKeyboardProps} = useKeyboard({
  274. onKeyDown: e => {
  275. // When the search input is focused, and the user presses Arrow Down,
  276. // we should move the focus to the menu items list.
  277. if (e.key === 'ArrowDown') {
  278. e.preventDefault(); // Prevent scroll action
  279. overlayRef.current
  280. ?.querySelector<HTMLLIElement>(`li[role="${grid ? 'row' : 'option'}"]`)
  281. ?.focus();
  282. }
  283. // Continue propagation, otherwise the overlay won't close on Esc key press
  284. e.continuePropagation();
  285. },
  286. });
  287. /**
  288. * Clears selection values across all list states
  289. */
  290. const clearSelection = useCallback(() => {
  291. listStates.forEach(listState => listState.selectionManager.clearSelection());
  292. onClear?.();
  293. }, [onClear, listStates]);
  294. // Manage overlay position
  295. const {
  296. isOpen: overlayIsOpen,
  297. state: overlayState,
  298. update: updateOverlay,
  299. triggerRef,
  300. triggerProps: overlayTriggerProps,
  301. overlayRef,
  302. overlayProps,
  303. } = useOverlay({
  304. disableTrigger: disabled,
  305. type: grid ? 'menu' : 'listbox',
  306. position,
  307. offset,
  308. isOpen,
  309. isDismissable,
  310. onInteractOutside,
  311. shouldCloseOnInteractOutside,
  312. shouldCloseOnBlur,
  313. preventOverflowOptions,
  314. flipOptions,
  315. onOpenChange: open => {
  316. nextFrameCallback(() => {
  317. if (open) {
  318. // Focus on search box if present
  319. if (searchable) {
  320. searchRef.current?.focus();
  321. return;
  322. }
  323. const firstSelectedOption = overlayRef.current?.querySelector<HTMLLIElement>(
  324. `li[role="${grid ? 'row' : 'option'}"][aria-selected="true"]`
  325. );
  326. // Focus on first selected item
  327. if (firstSelectedOption) {
  328. firstSelectedOption.focus();
  329. return;
  330. }
  331. // If no item is selected, focus on first item instead
  332. overlayRef.current
  333. ?.querySelector<HTMLLIElement>(`li[role="${grid ? 'row' : 'option'}"]`)
  334. ?.focus();
  335. return;
  336. }
  337. // On close
  338. onClose?.();
  339. // Clear search string
  340. setSearchInputValue('');
  341. setSearch('');
  342. // Only restore focus if it's already here or lost to the body.
  343. // This prevents focus from being stolen from other elements.
  344. if (
  345. document.activeElement === document.body ||
  346. wrapperRef.current?.contains(document.activeElement)
  347. ) {
  348. triggerRef.current?.focus();
  349. }
  350. });
  351. },
  352. });
  353. // Recalculate overlay position when its main content changes
  354. const prevMenuBody = usePrevious(menuBody);
  355. const prevHideOptions = usePrevious(hideOptions);
  356. useEffect(() => {
  357. if (
  358. // Don't update when the content inside `menuBody` changes. We should only update
  359. // when `menuBody` itself appears/disappears.
  360. ((!prevMenuBody && !menuBody) || (!!prevMenuBody && !!menuBody)) &&
  361. prevHideOptions === hideOptions
  362. ) {
  363. return;
  364. }
  365. updateOverlay?.();
  366. // eslint-disable-next-line react-hooks/exhaustive-deps
  367. }, [menuBody, hideOptions]);
  368. /**
  369. * The menu's full width, before any option has been filtered out. Used to maintain a
  370. * constant width while the user types into the search box.
  371. */
  372. const [menuFullWidth, setMenuFullWidth] = useState<number>();
  373. // When search box is focused, read the menu's width and lock it at that value to
  374. // prevent visual jumps during search
  375. const onSearchFocus = useCallback(
  376. () => setMenuFullWidth(overlayRef.current?.offsetWidth),
  377. [overlayRef]
  378. );
  379. // When search box is blurred, release the lock the menu's width
  380. const onSearchBlur = useCallback(
  381. () => !search && setMenuFullWidth(undefined),
  382. [search]
  383. );
  384. /**
  385. * A list of selected options across all select regions, to be used to generate the
  386. * trigger label.
  387. */
  388. const [selectedOptions, setSelectedOptions] = useState<
  389. Array<SelectOption<SelectKey> | SelectOption<SelectKey>[]>
  390. >([]);
  391. const saveSelectedOptions = useCallback<SelectContextValue['saveSelectedOptions']>(
  392. (index, newSelectedOptions) => {
  393. setSelectedOptions(current => [
  394. ...current.slice(0, index),
  395. newSelectedOptions,
  396. ...current.slice(index + 1),
  397. ]);
  398. },
  399. []
  400. );
  401. /**
  402. * Trigger label, generated from current selection values. If more than one option is
  403. * selected, then a count badge will appear.
  404. */
  405. const triggerLabel: React.ReactNode = useMemo(() => {
  406. if (defined(triggerLabelProp)) {
  407. return triggerLabelProp;
  408. }
  409. const options = selectedOptions.flat().filter(Boolean);
  410. if (options.length === 0) {
  411. return <TriggerLabel>{t('None')}</TriggerLabel>;
  412. }
  413. return (
  414. <Fragment>
  415. <TriggerLabel>{options[0]?.label}</TriggerLabel>
  416. {options.length > 1 && <StyledBadge text={`+${options.length - 1}`} />}
  417. </Fragment>
  418. );
  419. }, [triggerLabelProp, selectedOptions]);
  420. const {keyboardProps: triggerKeyboardProps} = useKeyboard({
  421. onKeyDown: e => {
  422. // Open the select menu when user presses Arrow Up/Down.
  423. if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
  424. e.preventDefault(); // Prevent scroll
  425. overlayState.open();
  426. } else {
  427. e.continuePropagation();
  428. }
  429. },
  430. });
  431. const showClearButton = useMemo(
  432. () => selectedOptions.flat().length > 0,
  433. [selectedOptions]
  434. );
  435. const contextValue = useMemo(
  436. () => ({
  437. registerListState,
  438. saveSelectedOptions,
  439. overlayState,
  440. overlayIsOpen,
  441. search,
  442. }),
  443. [registerListState, saveSelectedOptions, overlayState, overlayIsOpen, search]
  444. );
  445. const theme = useTheme();
  446. return (
  447. <SelectContext.Provider value={contextValue}>
  448. <ControlWrap {...wrapperProps}>
  449. {trigger ? (
  450. trigger(mergeProps(triggerKeyboardProps, overlayTriggerProps), overlayIsOpen)
  451. ) : (
  452. <DropdownButton
  453. size={size}
  454. {...mergeProps(triggerProps, triggerKeyboardProps, overlayTriggerProps)}
  455. isOpen={overlayIsOpen}
  456. disabled={disabled}
  457. >
  458. {triggerLabel}
  459. </DropdownButton>
  460. )}
  461. <StyledPositionWrapper
  462. zIndex={theme.zIndex?.tooltip}
  463. visible={overlayIsOpen}
  464. {...overlayProps}
  465. >
  466. <StyledOverlay
  467. width={menuWidth ?? menuFullWidth}
  468. minWidth={overlayProps.style.minWidth}
  469. maxWidth={maxMenuWidth}
  470. maxHeight={overlayProps.style.maxHeight}
  471. maxHeightProp={maxMenuHeight}
  472. data-menu-has-header={!!menuTitle || clearable}
  473. data-menu-has-search={searchable}
  474. data-menu-has-footer={!!menuFooter}
  475. >
  476. <FocusScope contain={overlayIsOpen}>
  477. {(menuTitle ||
  478. menuHeaderTrailingItems ||
  479. (clearable && showClearButton)) && (
  480. <MenuHeader size={size}>
  481. <MenuTitle>{menuTitle}</MenuTitle>
  482. <MenuHeaderTrailingItems>
  483. {loading && <StyledLoadingIndicator size={12} mini />}
  484. {typeof menuHeaderTrailingItems === 'function'
  485. ? menuHeaderTrailingItems({closeOverlay: overlayState.close})
  486. : menuHeaderTrailingItems}
  487. {clearable && showClearButton && (
  488. <ClearButton onClick={clearSelection} size="zero" borderless>
  489. {t('Clear')}
  490. </ClearButton>
  491. )}
  492. </MenuHeaderTrailingItems>
  493. </MenuHeader>
  494. )}
  495. {searchable && (
  496. <SearchInput
  497. ref={searchRef}
  498. placeholder={searchPlaceholder}
  499. value={searchInputValue}
  500. onFocus={onSearchFocus}
  501. onBlur={onSearchBlur}
  502. onChange={e => updateSearch(e.target.value)}
  503. visualSize={size}
  504. {...searchKeyboardProps}
  505. />
  506. )}
  507. {typeof menuBody === 'function'
  508. ? menuBody({closeOverlay: overlayState.close})
  509. : menuBody}
  510. {!hideOptions && <OptionsWrap>{children}</OptionsWrap>}
  511. {menuFooter && (
  512. <MenuFooter>
  513. {typeof menuFooter === 'function'
  514. ? menuFooter({closeOverlay: overlayState.close})
  515. : menuFooter}
  516. </MenuFooter>
  517. )}
  518. </FocusScope>
  519. </StyledOverlay>
  520. </StyledPositionWrapper>
  521. </ControlWrap>
  522. </SelectContext.Provider>
  523. );
  524. }
  525. const ControlWrap = styled('div')`
  526. position: relative;
  527. width: max-content;
  528. `;
  529. const TriggerLabel = styled('span')`
  530. ${p => p.theme.overflowEllipsis}
  531. text-align: left;
  532. line-height: normal;
  533. `;
  534. const StyledBadge = styled(Badge)`
  535. flex-shrink: 0;
  536. top: auto;
  537. `;
  538. const headerVerticalPadding: Record<FormSize, string> = {
  539. xs: space(0.25),
  540. sm: space(0.5),
  541. md: space(0.75),
  542. };
  543. const MenuHeader = styled('div')<{size: FormSize}>`
  544. position: relative;
  545. display: flex;
  546. align-items: center;
  547. justify-content: space-between;
  548. padding: ${p => headerVerticalPadding[p.size]} ${space(1.5)};
  549. box-shadow: 0 1px 0 ${p => p.theme.translucentInnerBorder};
  550. [data-menu-has-search='true'] > & {
  551. padding-bottom: 0;
  552. box-shadow: none;
  553. }
  554. line-height: ${p => p.theme.text.lineHeightBody};
  555. z-index: 2;
  556. font-size: ${p =>
  557. p.size !== 'xs' ? p.theme.fontSizeSmall : p.theme.fontSizeExtraSmall};
  558. color: ${p => p.theme.headingColor};
  559. `;
  560. const MenuHeaderTrailingItems = styled('div')`
  561. display: grid;
  562. grid-auto-flow: column;
  563. gap: ${space(0.5)};
  564. `;
  565. const MenuTitle = styled('span')`
  566. font-size: inherit; /* Inherit font size from MenuHeader */
  567. font-weight: ${p => p.theme.fontWeightBold};
  568. white-space: nowrap;
  569. margin-right: ${space(2)};
  570. `;
  571. const StyledLoadingIndicator = styled(LoadingIndicator)`
  572. && {
  573. margin: 0 ${space(0.5)} 0 ${space(1)};
  574. height: 12px;
  575. width: 12px;
  576. }
  577. `;
  578. const ClearButton = styled(Button)`
  579. font-size: inherit; /* Inherit font size from MenuHeader */
  580. font-weight: ${p => p.theme.fontWeightNormal};
  581. color: ${p => p.theme.subText};
  582. padding: 0 ${space(0.5)};
  583. margin: -${space(0.25)} -${space(0.5)};
  584. `;
  585. const searchVerticalPadding: Record<FormSize, string> = {
  586. xs: space(0.25),
  587. sm: space(0.5),
  588. md: space(0.5),
  589. };
  590. const SearchInput = styled('input')<{visualSize: FormSize}>`
  591. appearance: none;
  592. width: calc(100% - ${space(0.5)} * 2);
  593. border: solid 1px ${p => p.theme.innerBorder};
  594. border-radius: ${p => p.theme.borderRadius};
  595. background: ${p => p.theme.backgroundSecondary};
  596. font-size: ${p =>
  597. p.visualSize !== 'xs' ? p.theme.fontSizeMedium : p.theme.fontSizeSmall};
  598. /* Subtract 1px to account for border width */
  599. padding: ${p => searchVerticalPadding[p.visualSize]} calc(${space(1)} - 1px);
  600. margin: ${space(0.5)} ${space(0.5)};
  601. /* Add 1px to top margin if immediately preceded by menu header, to account for the
  602. header's shadow border */
  603. [data-menu-has-header='true'] > & {
  604. margin-top: calc(${space(0.5)} + 1px);
  605. }
  606. &:focus,
  607. &:focus-visible {
  608. outline: none;
  609. border-color: ${p => p.theme.focusBorder};
  610. box-shadow: ${p => p.theme.focusBorder} 0 0 0 1px;
  611. background: transparent;
  612. }
  613. `;
  614. const withUnits = value => (typeof value === 'string' ? value : `${value}px`);
  615. const StyledOverlay = styled(Overlay, {
  616. shouldForwardProp: prop => typeof prop === 'string' && isPropValid(prop),
  617. })<{
  618. maxHeightProp: string | number;
  619. maxHeight?: string | number;
  620. maxWidth?: string | number;
  621. minWidth?: string | number;
  622. width?: string | number;
  623. }>`
  624. /* Should be a flex container so that when maxHeight is set (to avoid page overflow),
  625. ListBoxWrap/GridListWrap will also shrink to fit */
  626. display: flex;
  627. flex-direction: column;
  628. overflow: hidden;
  629. ${p => p.width && `width: ${withUnits(p.width)};`}
  630. ${p => p.minWidth && `min-width: ${withUnits(p.minWidth)};`}
  631. max-width: ${p => (p.maxWidth ? `min(${withUnits(p.maxWidth)}, 100%)` : `100%`)};
  632. max-height: ${p =>
  633. p.maxHeight
  634. ? `min(${withUnits(p.maxHeight)}, ${withUnits(p.maxHeightProp)})`
  635. : withUnits(p.maxHeightProp)};
  636. `;
  637. const StyledPositionWrapper = styled(PositionWrapper, {
  638. shouldForwardProp: prop => isPropValid(prop),
  639. })<{visible?: boolean}>`
  640. min-width: 100%;
  641. display: ${p => (p.visible ? 'block' : 'none')};
  642. `;
  643. const OptionsWrap = styled('div')`
  644. display: flex;
  645. flex-direction: column;
  646. min-height: 0;
  647. `;
  648. const MenuFooter = styled('div')`
  649. box-shadow: 0 -1px 0 ${p => p.theme.translucentInnerBorder};
  650. padding: ${space(1)} ${space(1.5)};
  651. z-index: 2;
  652. `;