123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293 |
- 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 {TreeProps, 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<typeof useOverlay>['state'];
- interface DropdownMenuContextValue {
- /**
- * Menu state (from @react-aria's useTreeState) of the parent menu. To be used to
- * close the current submenu.
- */
- parentMenuState?: TreeState<MenuItemProps>;
- /**
- * 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<DropdownMenuContextValue>({});
- export interface DropdownMenuListProps
- extends Omit<
- AriaMenuOptions<MenuItemProps>,
- | 'selectionMode'
- | 'selectedKeys'
- | 'defaultSelectedKeys'
- | 'onSelectionChange'
- | 'disallowEmptySelection'
- >,
- TreeProps<MenuItemProps> {
- overlayPositionProps: React.HTMLAttributes<HTMLDivElement>;
- /**
- * 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<MenuItemProps>({...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<MenuItemProps>, isLastNode: boolean) => {
- return (
- <DropdownMenuItem
- node={node}
- state={state}
- onClose={onClose}
- closeOnSelect={closeOnSelect}
- showDivider={showDividers && !isLastNode}
- />
- );
- };
- // Render a submenu whose trigger button is a menu item
- const renderItemWithSubmenu = (node: Node<MenuItemProps>, isLastNode: boolean) => {
- if (!node.value?.children) {
- return null;
- }
- const trigger = triggerProps => (
- <DropdownMenuItem
- renderAs="div"
- node={node}
- state={state}
- showDivider={showDividers && !isLastNode}
- closeOnSelect={false}
- {...omit(triggerProps, [
- 'onClick',
- 'onDragStart',
- 'onKeyDown',
- 'onKeyUp',
- 'onMouseDown',
- 'onPointerDown',
- 'onPointerUp',
- ])}
- />
- );
- return (
- <DropdownMenu
- isOpen={state.selectionManager.isSelected(node.key)}
- items={node.value.children}
- trigger={trigger}
- onClose={onClose}
- closeOnSelect={closeOnSelect}
- menuTitle={node.value.submenuTitle}
- menuWiderThanTrigger={false}
- isDismissable={false}
- shouldCloseOnBlur={false}
- shouldCloseOnInteractOutside={() => 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<MenuItemProps>[]) =>
- 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 = (
- <DropdownMenuSection node={node}>
- {renderCollection([...node.childNodes])}
- </DropdownMenuSection>
- );
- } else {
- itemToRender = node.value?.isSubmenu
- ? renderItemWithSubmenu(node, isLastNode)
- : renderItem(node, isLastNode);
- }
- return (
- <Fragment key={node.key}>
- {itemToRender}
- {showSeparator && <Separator {...separatorProps} />}
- </Fragment>
- );
- });
- const theme = useTheme();
- const contextValue = useMemo(
- () => ({
- rootOverlayState: rootOverlayState ?? overlayState,
- parentMenuState: state,
- }),
- [rootOverlayState, overlayState, state]
- );
- return (
- <FocusScope restoreFocus autoFocus>
- <PositionWrapper zIndex={theme.zIndex.dropdown} {...overlayPositionProps}>
- <DropdownMenuContext.Provider value={contextValue}>
- <StyledOverlay>
- {menuTitle && <MenuTitle>{menuTitle}</MenuTitle>}
- <DropdownMenuListWrap
- ref={menuRef}
- hasTitle={!!menuTitle}
- {...mergeProps(modifiedMenuProps, keyboardProps)}
- style={{
- maxHeight: overlayPositionProps.style?.maxHeight,
- minWidth,
- }}
- >
- {renderCollection(stateCollection)}
- </DropdownMenuListWrap>
- </StyledOverlay>
- </DropdownMenuContext.Provider>
- </PositionWrapper>
- </FocusScope>
- );
- }
- 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)};
- `;
|