list.tsx 8.6 KB

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