import {createContext, Fragment, useContext, useMemo, useRef} from 'react'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {FocusScope} from '@react-aria/focus'; import {useKeyboard} from '@react-aria/interactions'; import {AriaMenuOptions, useMenu} from '@react-aria/menu'; import {useSeparator} from '@react-aria/separator'; import {mergeProps} from '@react-aria/utils'; import {TreeState, useTreeState} from '@react-stately/tree'; import {Node} from '@react-types/shared'; import omit from 'lodash/omit'; import {Overlay, PositionWrapper} from 'sentry/components/overlay'; import {space} from 'sentry/styles/space'; import useOverlay from 'sentry/utils/useOverlay'; import {DropdownMenu} from './index'; import DropdownMenuItem, {MenuItemProps} from './item'; import DropdownMenuSection from './section'; type OverlayState = ReturnType['state']; interface DropdownMenuContextValue { /** * Menu state (from @react-aria's useTreeState) of the parent menu. To be used to * close the current submenu. */ parentMenuState?: TreeState; /** * Overlay state manager (from useOverlay) for the root (top-most) menu. To be used to * close the entire menu system. */ rootOverlayState?: OverlayState; } export const DropdownMenuContext = createContext({}); export interface DropdownMenuListProps extends Omit< AriaMenuOptions, | 'selectionMode' | 'selectedKeys' | 'defaultSelectedKeys' | 'onSelectionChange' | 'disallowEmptySelection' > { overlayPositionProps: React.HTMLAttributes; /** * The open state of the current overlay that contains this menu */ overlayState: OverlayState; /** * Whether the menu should close when an item has been clicked/selected */ closeOnSelect?: boolean; /* * Title to display on top of the menu */ menuTitle?: string; /** * Minimum menu width */ minWidth?: number; size?: MenuItemProps['size']; } function DropdownMenuList({ closeOnSelect = true, onClose, minWidth, size, menuTitle, overlayState, overlayPositionProps, ...props }: DropdownMenuListProps) { const {rootOverlayState, parentMenuState} = useContext(DropdownMenuContext); const state = useTreeState({...props, selectionMode: 'single'}); const stateCollection = useMemo(() => [...state.collection], [state.collection]); // Implement focus states, keyboard navigation, aria-label,... const menuRef = useRef(null); const {menuProps} = useMenu({...props, selectionMode: 'single'}, state, menuRef); const {separatorProps} = useSeparator({elementType: 'li'}); // If this is a submenu, pressing arrow left should close it (but not the // root menu). const {keyboardProps} = useKeyboard({ onKeyDown: e => { if (e.key === 'ArrowLeft' && parentMenuState) { parentMenuState.selectionManager.clearSelection(); return; } e.continuePropagation(); }, }); /** * Whether this menu/submenu is the current focused one, which in a nested, * tree-like menu system should be the leaf submenu. This information is * used for controlling keyboard events. See ``modifiedMenuProps` below. */ const hasFocus = useMemo(() => { // A submenu is a leaf when it does not contain any expanded submenu. This // logically follows from the tree-like structure and single-selection // nature of menus. const isLeafSubmenu = !stateCollection.some(node => { const isSection = node.hasChildNodes && !node.value?.isSubmenu; // A submenu with key [key] is expanded if // state.selectionManager.isSelected([key]) = true return isSection ? [...node.childNodes].some(child => state.selectionManager.isSelected(`${child.key}`) ) : state.selectionManager.isSelected(`${node.key}`); }); return isLeafSubmenu; }, [stateCollection, state.selectionManager]); // Menu props from useMenu, modified to disable keyboard events if the // current menu does not have focus. const modifiedMenuProps = useMemo( () => ({ ...menuProps, ...(!hasFocus && { onKeyUp: () => null, onKeyDown: () => null, }), }), [menuProps, hasFocus] ); const showDividers = stateCollection.some(item => !!item.props.details); // Render a single menu item const renderItem = (node: Node, isLastNode: boolean) => { return ( ); }; // Render a submenu whose trigger button is a menu item const renderItemWithSubmenu = (node: Node, isLastNode: boolean) => { if (!node.value?.children) { return null; } const trigger = triggerProps => ( ); return ( false} preventOverflowOptions={{boundary: document.body, altAxis: true}} renderWrapAs="li" position="right-start" offset={-4} size={size} /> ); }; // Render a collection of menu items const renderCollection = (collection: Node[]) => collection.map((node, i) => { const isLastNode = collection.length - 1 === i; const showSeparator = !isLastNode && (node.type === 'section' || collection[i + 1]?.type === 'section'); let itemToRender: React.ReactNode; if (node.type === 'section') { itemToRender = ( {renderCollection([...node.childNodes])} ); } else { itemToRender = node.value?.isSubmenu ? renderItemWithSubmenu(node, isLastNode) : renderItem(node, isLastNode); } return ( {itemToRender} {showSeparator && } ); }); const theme = useTheme(); const contextValue = useMemo( () => ({ rootOverlayState: rootOverlayState ?? overlayState, parentMenuState: state, }), [rootOverlayState, overlayState, state] ); return ( {menuTitle && {menuTitle}} {renderCollection(stateCollection)} ); } export default DropdownMenuList; const StyledOverlay = styled(Overlay)` display: flex; flex-direction: column; `; const DropdownMenuListWrap = styled('ul')<{hasTitle: boolean}>` margin: 0; padding: ${space(0.5)} 0; font-size: ${p => p.theme.fontSizeMedium}; overflow-x: hidden; overflow-y: auto; ${p => p.hasTitle && `padding-top: calc(${space(0.5)} + 1px);`} &:focus { outline: none; } `; const MenuTitle = styled('div')` flex-shrink: 0; font-weight: 600; font-size: ${p => p.theme.fontSizeSmall}; color: ${p => p.theme.headingColor}; white-space: nowrap; padding: ${space(0.75)} ${space(1.5)}; box-shadow: 0 1px 0 0 ${p => p.theme.translucentInnerBorder}; z-index: 2; `; const Separator = styled('li')` list-style-type: none; border-top: solid 1px ${p => p.theme.innerBorder}; margin: ${space(0.5)} ${space(1.5)}; `;