uui/components/overlays/DropdownMenu.tsx (237 lines of code) (raw):

import React, { useRef, useContext, useState } from 'react'; import { cx, withMods, uuiMod, UuiContext, IHasChildren, VPanelProps, IHasIcon, ICanRedirect, IHasCaption, IDisableable, IAnalyticableClick, IHasCX, IClickable, DropdownBodyProps, IDropdownTogglerProps, DropdownProps, } from '@epam/uui-core'; import { Text, FlexRow, Anchor, IconContainer, Dropdown, FlexSpacer, DropdownContainerProps } from '@epam/uui-components'; import { DropdownContainer } from './DropdownContainer'; import { Switch } from '../inputs/Switch'; import { IconButton } from '../buttons'; import { systemIcons } from '../../icons/icons'; import css from './DropdownMenu.module.scss'; export interface IDropdownMenuItemProps extends IDropdownTogglerProps, IHasCaption, IHasIcon, ICanRedirect, IHasCX, IDisableable, IAnalyticableClick { isSelected?: boolean; isActive?: boolean; indent?: boolean; } export interface DropdownMenuContainerProps extends VPanelProps, IHasChildren, DropdownBodyProps, Pick<DropdownContainerProps, 'focusLock'> { closeOnKey?: React.KeyboardEvent<HTMLElement>['key']; minWidth?: number; } export enum IDropdownControlKeys { ENTER = 'Enter', ESCAPE = 'Escape', LEFT_ARROW = 'ArrowLeft', RIGHT_ARROW = 'ArrowRight', UP_ARROW = 'ArrowUp', DOWN_ARROW = 'ArrowDown' } function DropdownMenuContainer(props: DropdownMenuContainerProps) { const menuRef = useRef<HTMLMenuElement>(null); const [currentlyFocused, setFocused] = useState<number>(0); const menuItems: HTMLElement[] = menuRef.current ? Array.from(menuRef.current.querySelectorAll(`[role="menuitem"]:not(.${uuiMod.disabled})`)) : []; const changeFocus = (nextFocusedIndex: number) => { if (menuItems.length > 0) { setFocused(nextFocusedIndex); menuItems[nextFocusedIndex].focus(); } }; const handleArrowKeys = (e: React.KeyboardEvent<HTMLMenuElement>) => { const lastMenuItemsIndex = menuItems.length - 1; if (e.key === IDropdownControlKeys.UP_ARROW) { changeFocus(currentlyFocused > 0 ? currentlyFocused - 1 : lastMenuItemsIndex); e.preventDefault(); } else if (e.key === IDropdownControlKeys.DOWN_ARROW) { changeFocus(currentlyFocused < lastMenuItemsIndex ? currentlyFocused + 1 : 0); e.preventDefault(); } else if (e.key === props.closeOnKey && props.onClose) { e.stopPropagation(); props.onClose(); } }; return ( <DropdownContainer { ...props } rawProps={ { ...props.rawProps, role: 'menu' } } ref={ menuRef } width={ props.minWidth } lockProps={ { onKeyDown: handleArrowKeys } } cx={ [props.cx] } /> ); } export const DropdownMenuBody = withMods<DropdownMenuContainerProps, DropdownMenuContainerProps>( DropdownMenuContainer, () => ['uui-dropdownMenu-body'], (props) => { return ({ closeOnKey: IDropdownControlKeys.ESCAPE, ...props }); }, ); export const DropdownMenuButton = React.forwardRef<any, IDropdownMenuItemProps>((props, ref) => { const context = useContext(UuiContext); const { icon, iconPosition, onIconClick, caption, isDisabled, isSelected, isActive, link, href, onClick, toggleDropdownOpening, isDropdown, isOpen, target, } = props; const handleClick = (event: React.MouseEvent<HTMLElement>) => { if (isDisabled || !onClick) return; onClick(event); context.uuiAnalytics.sendEvent(props.clickAnalyticsEvent); }; const handleOpenDropdown = (event: React.KeyboardEvent<HTMLElement>) => { if (event.key === IDropdownControlKeys.RIGHT_ARROW && isDropdown) { toggleDropdownOpening(true); } else if (event.key === IDropdownControlKeys.ENTER && onClick) { onClick(event); } }; const getMenuButtonContent = () => { const isIconBefore = Boolean(icon && iconPosition !== 'right'); const isIconAfter = Boolean(icon && iconPosition === 'right'); const iconElement = ( <IconButton icon={ icon } color={ isActive ? 'primary' : 'neutral' } onClick={ onIconClick } isDisabled={ isDisabled } cx={ cx(css.icon, iconPosition === 'right' ? css.iconAfter : css.iconBefore) } /> ); return ( <> { isIconBefore && iconElement } <Text cx={ props.indent && css.indent }>{ caption }</Text> { isIconAfter && ( <> <FlexSpacer /> { iconElement } </> ) } </> ); }; const isAnchor = Boolean(link || href); const itemClassNames = cx(props.cx, css.itemRoot, isDisabled && uuiMod.disabled, isActive && uuiMod.active, isOpen && uuiMod.opened); return isAnchor ? ( <Anchor cx={ cx(css.link, itemClassNames) } link={ link } href={ href } rawProps={ { role: 'menuitem', tabIndex: isDisabled ? -1 : 0 } } onClick={ handleClick } isLinkActive={ isActive } isDisabled={ isDisabled } target={ target } > { getMenuButtonContent() } </Anchor> ) : ( <FlexRow rawProps={ { tabIndex: isDisabled ? -1 : 0, role: 'menuitem', onKeyDown: isDisabled ? null : handleOpenDropdown, } } cx={ itemClassNames } onClick={ handleClick } ref={ ref } > { getMenuButtonContent() } { isSelected && ( <> <FlexSpacer /> <IconContainer icon={ systemIcons.accept } cx={ css.selectedMark } /> </> ) } </FlexRow> ); }); export function DropdownMenuSplitter(props: IHasCX) { return ( <div className={ cx(props.cx, css.splitterRoot) }> <hr className={ css.splitter } /> </div> ); } interface IDropdownMenuHeader extends IHasCX, IHasCaption {} export function DropdownMenuHeader(props: IDropdownMenuHeader) { return ( <div className={ cx('uui-dropdown-menu-header', props.cx, css.headerRoot) }> <span className={ css.header }>{ props.caption }</span> </div> ); } interface IDropdownSubMenu extends IHasChildren, IHasCaption, IHasIcon, IDropdownMenuItemProps { openOnHover?: boolean; } export function DropdownSubMenu(props: IDropdownSubMenu) { const subMenuModifiers: DropdownProps['modifiers'] = [ { name: 'offset', options: { offset: ({ placement }) => { if ( placement === 'right-start' || placement === 'left-start' ) { return [-6, 0]; } else { return [6, 0]; } }, }, }, ]; const isRtl = window?.document.dir === 'rtl'; return ( <Dropdown openOnHover={ props.openOnHover || true } closeOnMouseLeave="boundary" openDelay={ 400 } closeDelay={ 400 } placement={ isRtl ? 'left-start' : 'right-start' } modifiers={ subMenuModifiers } renderBody={ (dropdownProps) => !props.isDisabled && (<DropdownMenuBody closeOnKey={ IDropdownControlKeys.LEFT_ARROW } { ...props } { ...dropdownProps } />) } renderTarget={ ({ toggleDropdownOpening, ...targetProps }) => ( <DropdownMenuButton cx={ cx(isRtl ? css.submenuRootItemRtl : css.submenuRootItem) } icon={ systemIcons.foldingArrow } iconPosition="right" isDropdown={ true } toggleDropdownOpening={ toggleDropdownOpening } { ...props } { ...targetProps } /> ) } /> ); } interface IDropdownMenuSwitchButton extends IHasCX, IHasCaption, IHasIcon, IDisableable, IAnalyticableClick, IClickable { onValueChange: (value: boolean) => void; isSelected: boolean; } export function DropdownMenuSwitchButton(props: IDropdownMenuSwitchButton) { const context = useContext(UuiContext); const { icon, caption, isDisabled, isSelected, onValueChange, } = props; const onHandleValueChange = (value: boolean) => { if (isDisabled || !onValueChange) return; onValueChange(value); context.uuiAnalytics.sendEvent(props.clickAnalyticsEvent); }; const handleKeySelect = (e: React.KeyboardEvent<HTMLElement>) => { if (e.key === IDropdownControlKeys.ENTER) { onHandleValueChange(!isSelected); } }; return ( <FlexRow cx={ cx(props.cx, css.itemRoot, isDisabled && uuiMod.disabled) } onClick={ () => onHandleValueChange(!isSelected) } rawProps={ { role: 'menuitem', onKeyDown: handleKeySelect, tabIndex: isDisabled ? -1 : 0 } } > { icon && <IconContainer icon={ icon } cx={ css.iconBefore } /> } <Text>{ caption }</Text> <FlexSpacer /> <Switch value={ isSelected } tabIndex={ -1 } onValueChange={ onHandleValueChange } /> </FlexRow> ); }