1import React, { ReactElement, useCallback, useMemo, useState, useRef, useImperativeHandle } from 'react'; 2import { css, cx } from '@emotion/css'; 3import { GrafanaTheme2, LinkTarget } from '@grafana/data'; 4import { useStyles2 } from '../../themes'; 5import { Icon } from '../Icon/Icon'; 6import { IconName } from '../../types'; 7import { SubMenu } from './SubMenu'; 8import { getFocusStyles } from '../../themes/mixins'; 9 10/** @internal */ 11export type MenuItemElement = HTMLAnchorElement & HTMLButtonElement & HTMLDivElement; 12 13/** @internal */ 14export interface MenuItemProps<T = any> { 15 /** Label of the menu item */ 16 label: string; 17 /** Aria label for accessibility support */ 18 ariaLabel?: string; 19 /** Aria checked for accessibility support */ 20 ariaChecked?: boolean; 21 /** Target of the menu item (i.e. new window) */ 22 target?: LinkTarget; 23 /** Icon of the menu item */ 24 icon?: IconName; 25 /** Role of the menu item */ 26 role?: string; 27 /** Url of the menu item */ 28 url?: string; 29 /** Handler for the click behaviour */ 30 onClick?: (event?: React.SyntheticEvent<HTMLElement>, payload?: T) => void; 31 /** Custom MenuItem styles*/ 32 className?: string; 33 /** Active */ 34 active?: boolean; 35 36 tabIndex?: number; 37 38 /** List of menu items for the subMenu */ 39 childItems?: Array<ReactElement<MenuItemProps>>; 40} 41 42/** @internal */ 43export const MenuItem = React.memo( 44 React.forwardRef<MenuItemElement, MenuItemProps>((props, ref) => { 45 const { 46 url, 47 icon, 48 label, 49 ariaLabel, 50 ariaChecked, 51 target, 52 onClick, 53 className, 54 active, 55 childItems, 56 role = 'menuitem', 57 tabIndex = -1, 58 } = props; 59 const styles = useStyles2(getStyles); 60 const [isActive, setIsActive] = useState(active); 61 const [isSubMenuOpen, setIsSubMenuOpen] = useState(false); 62 const [openedWithArrow, setOpenedWithArrow] = useState(false); 63 const onMouseEnter = useCallback(() => { 64 setIsSubMenuOpen(true); 65 setIsActive(true); 66 }, []); 67 const onMouseLeave = useCallback(() => { 68 setIsSubMenuOpen(false); 69 setIsActive(false); 70 }, []); 71 const hasSubMenu = useMemo(() => childItems && childItems.length > 0, [childItems]); 72 const Wrapper = hasSubMenu ? 'div' : url === undefined ? 'button' : 'a'; 73 const itemStyle = cx( 74 { 75 [styles.item]: true, 76 [styles.activeItem]: isActive, 77 }, 78 className 79 ); 80 81 const localRef = useRef<MenuItemElement>(null); 82 useImperativeHandle(ref, () => localRef.current!); 83 84 const handleKeys = (event: React.KeyboardEvent) => { 85 switch (event.key) { 86 case 'ArrowRight': 87 event.preventDefault(); 88 event.stopPropagation(); 89 if (hasSubMenu) { 90 setIsSubMenuOpen(true); 91 setOpenedWithArrow(true); 92 setIsActive(true); 93 } 94 break; 95 default: 96 break; 97 } 98 }; 99 100 const closeSubMenu = () => { 101 setIsSubMenuOpen(false); 102 setIsActive(false); 103 localRef?.current?.focus(); 104 }; 105 106 return ( 107 <Wrapper 108 target={target} 109 className={itemStyle} 110 rel={target === '_blank' ? 'noopener noreferrer' : undefined} 111 href={url} 112 onClick={ 113 onClick 114 ? (event) => { 115 if (!(event.ctrlKey || event.metaKey || event.shiftKey) && onClick) { 116 event.preventDefault(); 117 event.stopPropagation(); 118 onClick(event); 119 } 120 } 121 : undefined 122 } 123 onMouseEnter={onMouseEnter} 124 onMouseLeave={onMouseLeave} 125 onKeyDown={handleKeys} 126 role={url === undefined ? role : undefined} 127 data-role="menuitem" // used to identify menuitem in Menu.tsx 128 ref={localRef} 129 aria-label={ariaLabel} 130 aria-checked={ariaChecked} 131 tabIndex={tabIndex} 132 > 133 {icon && <Icon name={icon} className={styles.icon} aria-hidden />} 134 {label} 135 {hasSubMenu && ( 136 <SubMenu 137 items={childItems} 138 isOpen={isSubMenuOpen} 139 openedWithArrow={openedWithArrow} 140 setOpenedWithArrow={setOpenedWithArrow} 141 close={closeSubMenu} 142 /> 143 )} 144 </Wrapper> 145 ); 146 }) 147); 148MenuItem.displayName = 'MenuItem'; 149 150/** @internal */ 151const getStyles = (theme: GrafanaTheme2) => { 152 return { 153 item: css` 154 background: none; 155 cursor: pointer; 156 white-space: nowrap; 157 color: ${theme.colors.text.primary}; 158 display: flex; 159 padding: 5px 12px 5px 10px; 160 margin: 0; 161 border: none; 162 width: 100%; 163 position: relative; 164 165 &:hover, 166 &:focus, 167 &:focus-visible { 168 background: ${theme.colors.action.hover}; 169 color: ${theme.colors.text.primary}; 170 text-decoration: none; 171 } 172 173 &:focus-visible { 174 ${getFocusStyles(theme)} 175 } 176 `, 177 activeItem: css` 178 background: ${theme.colors.action.selected}; 179 `, 180 icon: css` 181 opacity: 0.7; 182 margin-right: 10px; 183 color: ${theme.colors.text.secondary}; 184 `, 185 }; 186}; 187