list.tsx 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. import {createContext, Fragment, useContext, useMemo, useRef} from 'react';
  2. import {useTheme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {FocusScope} from '@react-aria/focus';
  5. import {useKeyboard} from '@react-aria/interactions';
  6. import {AriaMenuOptions, useMenu} from '@react-aria/menu';
  7. import {useSeparator} from '@react-aria/separator';
  8. import {mergeProps} from '@react-aria/utils';
  9. import {TreeState, useTreeState} from '@react-stately/tree';
  10. import {Node} from '@react-types/shared';
  11. import omit from 'lodash/omit';
  12. import {Overlay, PositionWrapper} from 'sentry/components/overlay';
  13. import {space} from 'sentry/styles/space';
  14. import useOverlay from 'sentry/utils/useOverlay';
  15. import {DropdownMenu} from './index';
  16. import DropdownMenuItem, {MenuItemProps} from './item';
  17. import DropdownMenuSection from './section';
  18. type OverlayState = ReturnType<typeof useOverlay>['state'];
  19. interface DropdownMenuContextValue {
  20. /**
  21. * Menu state (from @react-aria's useTreeState) of the parent menu. To be used to
  22. * close the current submenu.
  23. */
  24. parentMenuState?: TreeState<MenuItemProps>;
  25. /**
  26. * Overlay state manager (from useOverlay) for the root (top-most) menu. To be used to
  27. * close the entire menu system.
  28. */
  29. rootOverlayState?: OverlayState;
  30. }
  31. export const DropdownMenuContext = createContext<DropdownMenuContextValue>({});
  32. export interface DropdownMenuListProps
  33. extends Omit<
  34. AriaMenuOptions<MenuItemProps>,
  35. | 'selectionMode'
  36. | 'selectedKeys'
  37. | 'defaultSelectedKeys'
  38. | 'onSelectionChange'
  39. | 'disallowEmptySelection'
  40. > {
  41. overlayPositionProps: React.HTMLAttributes<HTMLDivElement>;
  42. /**
  43. * The open state of the current overlay that contains this menu
  44. */
  45. overlayState: OverlayState;
  46. /**
  47. * Whether the menu should close when an item has been clicked/selected
  48. */
  49. closeOnSelect?: boolean;
  50. /*
  51. * Title to display on top of the menu
  52. */
  53. menuTitle?: string;
  54. /**
  55. * Minimum menu width
  56. */
  57. minWidth?: number;
  58. size?: MenuItemProps['size'];
  59. }
  60. function DropdownMenuList({
  61. closeOnSelect = true,
  62. onClose,
  63. minWidth,
  64. size,
  65. menuTitle,
  66. overlayState,
  67. overlayPositionProps,
  68. ...props
  69. }: DropdownMenuListProps) {
  70. const {rootOverlayState, parentMenuState} = useContext(DropdownMenuContext);
  71. const state = useTreeState<MenuItemProps>({...props, selectionMode: 'single'});
  72. const stateCollection = useMemo(() => [...state.collection], [state.collection]);
  73. // Implement focus states, keyboard navigation, aria-label,...
  74. const menuRef = useRef(null);
  75. const {menuProps} = useMenu({...props, selectionMode: 'single'}, state, menuRef);
  76. const {separatorProps} = useSeparator({elementType: 'li'});
  77. // If this is a submenu, pressing arrow left should close it (but not the
  78. // root menu).
  79. const {keyboardProps} = useKeyboard({
  80. onKeyDown: e => {
  81. if (e.key === 'ArrowLeft' && parentMenuState) {
  82. parentMenuState.selectionManager.clearSelection();
  83. return;
  84. }
  85. e.continuePropagation();
  86. },
  87. });
  88. /**
  89. * Whether this menu/submenu is the current focused one, which in a nested,
  90. * tree-like menu system should be the leaf submenu. This information is
  91. * used for controlling keyboard events. See ``modifiedMenuProps` below.
  92. */
  93. const hasFocus = useMemo(() => {
  94. // A submenu is a leaf when it does not contain any expanded submenu. This
  95. // logically follows from the tree-like structure and single-selection
  96. // nature of menus.
  97. const isLeafSubmenu = !stateCollection.some(node => {
  98. const isSection = node.hasChildNodes && !node.value?.isSubmenu;
  99. // A submenu with key [key] is expanded if
  100. // state.selectionManager.isSelected([key]) = true
  101. return isSection
  102. ? [...node.childNodes].some(child =>
  103. state.selectionManager.isSelected(`${child.key}`)
  104. )
  105. : state.selectionManager.isSelected(`${node.key}`);
  106. });
  107. return isLeafSubmenu;
  108. }, [stateCollection, state.selectionManager]);
  109. // Menu props from useMenu, modified to disable keyboard events if the
  110. // current menu does not have focus.
  111. const modifiedMenuProps = useMemo(
  112. () => ({
  113. ...menuProps,
  114. ...(!hasFocus && {
  115. onKeyUp: () => null,
  116. onKeyDown: () => null,
  117. }),
  118. }),
  119. [menuProps, hasFocus]
  120. );
  121. const showDividers = stateCollection.some(item => !!item.props.details);
  122. // Render a single menu item
  123. const renderItem = (node: Node<MenuItemProps>, isLastNode: boolean) => {
  124. return (
  125. <DropdownMenuItem
  126. node={node}
  127. state={state}
  128. onClose={onClose}
  129. closeOnSelect={closeOnSelect}
  130. showDivider={showDividers && !isLastNode}
  131. />
  132. );
  133. };
  134. // Render a submenu whose trigger button is a menu item
  135. const renderItemWithSubmenu = (node: Node<MenuItemProps>, isLastNode: boolean) => {
  136. if (!node.value?.children) {
  137. return null;
  138. }
  139. const trigger = triggerProps => (
  140. <DropdownMenuItem
  141. renderAs="div"
  142. node={node}
  143. state={state}
  144. showDivider={showDividers && !isLastNode}
  145. closeOnSelect={false}
  146. {...omit(triggerProps, [
  147. 'onClick',
  148. 'onDragStart',
  149. 'onKeyDown',
  150. 'onKeyUp',
  151. 'onMouseDown',
  152. 'onPointerDown',
  153. 'onPointerUp',
  154. ])}
  155. />
  156. );
  157. return (
  158. <DropdownMenu
  159. isOpen={state.selectionManager.isSelected(node.key)}
  160. items={node.value.children}
  161. trigger={trigger}
  162. onClose={onClose}
  163. closeOnSelect={closeOnSelect}
  164. menuTitle={node.value.submenuTitle}
  165. menuWiderThanTrigger={false}
  166. isDismissable={false}
  167. shouldCloseOnBlur={false}
  168. shouldCloseOnInteractOutside={() => false}
  169. preventOverflowOptions={{boundary: document.body, altAxis: true}}
  170. renderWrapAs="li"
  171. position="right-start"
  172. offset={-4}
  173. size={size}
  174. />
  175. );
  176. };
  177. // Render a collection of menu items
  178. const renderCollection = (collection: Node<MenuItemProps>[]) =>
  179. collection.map((node, i) => {
  180. const isLastNode = collection.length - 1 === i;
  181. const showSeparator =
  182. !isLastNode && (node.type === 'section' || collection[i + 1]?.type === 'section');
  183. let itemToRender: React.ReactNode;
  184. if (node.type === 'section') {
  185. itemToRender = (
  186. <DropdownMenuSection node={node}>
  187. {renderCollection([...node.childNodes])}
  188. </DropdownMenuSection>
  189. );
  190. } else {
  191. itemToRender = node.value?.isSubmenu
  192. ? renderItemWithSubmenu(node, isLastNode)
  193. : renderItem(node, isLastNode);
  194. }
  195. return (
  196. <Fragment key={node.key}>
  197. {itemToRender}
  198. {showSeparator && <Separator {...separatorProps} />}
  199. </Fragment>
  200. );
  201. });
  202. const theme = useTheme();
  203. const contextValue = useMemo(
  204. () => ({
  205. rootOverlayState: rootOverlayState ?? overlayState,
  206. parentMenuState: state,
  207. }),
  208. [rootOverlayState, overlayState, state]
  209. );
  210. return (
  211. <FocusScope restoreFocus autoFocus>
  212. <PositionWrapper zIndex={theme.zIndex.dropdown} {...overlayPositionProps}>
  213. <DropdownMenuContext.Provider value={contextValue}>
  214. <StyledOverlay>
  215. {menuTitle && <MenuTitle>{menuTitle}</MenuTitle>}
  216. <DropdownMenuListWrap
  217. ref={menuRef}
  218. hasTitle={!!menuTitle}
  219. {...mergeProps(modifiedMenuProps, keyboardProps)}
  220. style={{
  221. maxHeight: overlayPositionProps.style?.maxHeight,
  222. minWidth,
  223. }}
  224. >
  225. {renderCollection(stateCollection)}
  226. </DropdownMenuListWrap>
  227. </StyledOverlay>
  228. </DropdownMenuContext.Provider>
  229. </PositionWrapper>
  230. </FocusScope>
  231. );
  232. }
  233. export default DropdownMenuList;
  234. const StyledOverlay = styled(Overlay)`
  235. display: flex;
  236. flex-direction: column;
  237. `;
  238. const DropdownMenuListWrap = styled('ul')<{hasTitle: boolean}>`
  239. margin: 0;
  240. padding: ${space(0.5)} 0;
  241. font-size: ${p => p.theme.fontSizeMedium};
  242. overflow-x: hidden;
  243. overflow-y: auto;
  244. ${p => p.hasTitle && `padding-top: calc(${space(0.5)} + 1px);`}
  245. &:focus {
  246. outline: none;
  247. }
  248. `;
  249. const MenuTitle = styled('div')`
  250. flex-shrink: 0;
  251. font-weight: 600;
  252. font-size: ${p => p.theme.fontSizeSmall};
  253. color: ${p => p.theme.headingColor};
  254. white-space: nowrap;
  255. padding: ${space(0.75)} ${space(1.5)};
  256. box-shadow: 0 1px 0 0 ${p => p.theme.translucentInnerBorder};
  257. z-index: 2;
  258. `;
  259. const Separator = styled('li')`
  260. list-style-type: none;
  261. border-top: solid 1px ${p => p.theme.innerBorder};
  262. margin: ${space(0.5)} ${space(1.5)};
  263. `;