import { css, cx } from '@emotion/css'; import { useDialog } from '@react-aria/dialog'; import { FocusScope } from '@react-aria/focus'; import { OverlayContainer, useOverlay } from '@react-aria/overlays'; import React, { useRef } from 'react'; import CSSTransition from 'react-transition-group/CSSTransition'; import { useLocalStorage } from 'react-use'; import { GrafanaTheme2, NavModelItem } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; import { CollapsableSection, CustomScrollbar, Icon, IconButton, toIconName, useStyles2, useTheme2 } from '@grafana/ui'; import { NavBarItemIcon } from './NavBarItemIcon'; import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu'; import { NavBarMenuItem } from './NavBarMenuItem'; import { NavBarToggle } from './NavBarToggle'; import { NavFeatureHighlight } from './NavFeatureHighlight'; import getNavTranslation from './navBarItem-translations'; import { isMatchOrChildMatch } from './utils'; const MENU_WIDTH = '350px'; export interface Props { activeItem?: NavModelItem; isOpen: boolean; navItems: NavModelItem[]; setMenuAnimationInProgress: (isInProgress: boolean) => void; onClose: () => void; } export function NavBarMenu({ activeItem, isOpen, navItems, onClose, setMenuAnimationInProgress }: Props) { const theme = useTheme2(); const styles = getStyles(theme); const ANIMATION_DURATION = theme.transitions.duration.standard; const animStyles = getAnimStyles(theme, ANIMATION_DURATION); const ref = useRef(null); const backdropRef = useRef(null); const { dialogProps } = useDialog({}, ref); const { overlayProps, underlayProps } = useOverlay( { isDismissable: true, isOpen, onClose, }, ref ); return ( setMenuAnimationInProgress(true)} onExited={() => setMenuAnimationInProgress(false)} appear={isOpen} in={isOpen} classNames={animStyles.overlay} timeout={ANIMATION_DURATION} >
{ reportInteraction('grafana_navigation_collapsed'); onClose(); }} />
); } NavBarMenu.displayName = 'NavBarMenu'; const getStyles = (theme: GrafanaTheme2) => ({ backdrop: css({ backdropFilter: 'blur(1px)', backgroundColor: theme.components.overlay.background, bottom: 0, left: 0, position: 'fixed', right: 0, top: 0, zIndex: theme.zIndex.modalBackdrop, }), container: css({ display: 'flex', bottom: 0, flexDirection: 'column', left: 0, paddingTop: theme.spacing(1), marginRight: theme.spacing(1.5), right: 0, zIndex: theme.zIndex.modal, position: 'fixed', top: 0, boxSizing: 'content-box', [theme.breakpoints.up('md')]: { borderRight: `1px solid ${theme.colors.border.weak}`, right: 'unset', }, }), content: css({ display: 'flex', flexDirection: 'column', overflow: 'auto', }), mobileHeader: css({ borderBottom: `1px solid ${theme.colors.border.weak}`, display: 'flex', justifyContent: 'space-between', padding: theme.spacing(1, 2, 2), [theme.breakpoints.up('md')]: { display: 'none', }, }), itemList: css({ display: 'grid', gridAutoRows: `minmax(${theme.spacing(6)}, auto)`, minWidth: MENU_WIDTH, }), menuCollapseIcon: css({ position: 'absolute', top: '43px', right: '0px', transform: `translateX(50%)`, }), }); const getAnimStyles = (theme: GrafanaTheme2, animationDuration: number) => { const commonTransition = { transitionDuration: `${animationDuration}ms`, transitionTimingFunction: theme.transitions.easing.easeInOut, [theme.breakpoints.down('md')]: { overflow: 'hidden', }, }; const overlayTransition = { ...commonTransition, transitionProperty: 'background-color, box-shadow, width', // this is needed to prevent a horizontal scrollbar during the animation on firefox '.scrollbar-view': { overflow: 'hidden !important', }, }; const backdropTransition = { ...commonTransition, transitionProperty: 'opacity', }; const overlayOpen = { backgroundColor: theme.colors.background.canvas, boxShadow: theme.shadows.z3, width: '100%', [theme.breakpoints.up('md')]: { width: MENU_WIDTH, }, }; const overlayClosed = { boxShadow: 'none', width: 0, [theme.breakpoints.up('md')]: { backgroundColor: theme.colors.background.primary, width: theme.spacing(7), }, }; const backdropOpen = { opacity: 1, }; const backdropClosed = { opacity: 0, }; return { backdrop: { appear: css(backdropClosed), appearActive: css(backdropTransition, backdropOpen), appearDone: css(backdropOpen), exit: css(backdropOpen), exitActive: css(backdropTransition, backdropClosed), }, overlay: { appear: css(overlayClosed), appearActive: css(overlayTransition, overlayOpen), appearDone: css(overlayOpen), exit: css(overlayOpen), exitActive: css(overlayTransition, overlayClosed), }, }; }; export function NavItem({ link, activeItem, onClose, }: { link: NavModelItem; activeItem?: NavModelItem; onClose: () => void; }) { const styles = useStyles2(getNavItemStyles); if (linkHasChildren(link)) { return (
    {link.children.map((childLink) => { const icon = childLink.icon ? toIconName(childLink.icon) : undefined; return ( !childLink.divider && ( { childLink.onClick?.(); onClose(); }} styleOverrides={styles.item} target={childLink.target} text={childLink.text} url={childLink.url} isMobile={true} /> ) ); })}
); } else if (link.emptyMessageId) { const emptyMessageTranslated = getNavTranslation(link.emptyMessageId); return (
    {emptyMessageTranslated}
); } else { const FeatureHighlightWrapper = link.highlightText ? NavFeatureHighlight : React.Fragment; return (
  • { link.onClick?.(); onClose(); }} isActive={link === activeItem} >
    {link.text}
  • ); } } const getNavItemStyles = (theme: GrafanaTheme2) => ({ children: css({ display: 'flex', flexDirection: 'column', }), item: css({ padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`, width: `calc(100% - ${theme.spacing(3)})`, '&::before': { display: 'none', }, }), flex: css({ display: 'flex', }), itemWithoutMenu: css({ position: 'relative', placeItems: 'inherit', justifyContent: 'start', display: 'flex', flexGrow: 1, alignItems: 'center', }), fullWidth: css({ height: '100%', width: '100%', }), iconContainer: css({ display: 'flex', placeContent: 'center', }), itemWithoutMenuContent: css({ display: 'grid', gridAutoFlow: 'column', gridTemplateColumns: `${theme.spacing(7)} auto`, alignItems: 'center', height: '100%', }), linkText: css({ fontSize: theme.typography.pxToRem(14), justifySelf: 'start', padding: theme.spacing(0.5, 4.25, 0.5, 0.5), }), emptyMessage: css({ color: theme.colors.text.secondary, fontStyle: 'italic', padding: theme.spacing(1, 1.5), }), }); function CollapsibleNavItem({ link, isActive, children, className, onClose, }: { link: NavModelItem; isActive?: boolean; children: React.ReactNode; className?: string; onClose: () => void; }) { const styles = useStyles2(getCollapsibleStyles); const [sectionExpanded, setSectionExpanded] = useLocalStorage(`grafana.navigation.expanded[${link.text}]`, false); const FeatureHighlightWrapper = link.highlightText ? NavFeatureHighlight : React.Fragment; return (
  • { link.onClick?.(); onClose(); }} className={styles.collapsibleMenuItem} elClassName={styles.collapsibleIcon} >
    setSectionExpanded(isOpen)} className={styles.collapseWrapper} contentClassName={styles.collapseContent} label={
    {link.text}
    } > {children}
  • ); } const getCollapsibleStyles = (theme: GrafanaTheme2) => ({ menuItem: css({ position: 'relative', display: 'grid', gridAutoFlow: 'column', gridTemplateColumns: `${theme.spacing(7)} minmax(calc(${MENU_WIDTH} - ${theme.spacing(7)}), auto)`, }), collapsibleMenuItem: css({ height: theme.spacing(6), width: theme.spacing(7), display: 'grid', }), collapsibleIcon: css({ display: 'grid', placeContent: 'center', }), collapsibleSectionWrapper: css({ display: 'flex', flexGrow: 1, alignSelf: 'start', flexDirection: 'column', }), collapseWrapper: css({ paddingLeft: theme.spacing(0.5), paddingRight: theme.spacing(4.25), minHeight: theme.spacing(6), overflowWrap: 'anywhere', alignItems: 'center', color: theme.colors.text.secondary, '&:hover, &:focus-within': { backgroundColor: theme.colors.action.hover, color: theme.colors.text.primary, }, '&:focus-within': { boxShadow: 'none', outline: `2px solid ${theme.colors.primary.main}`, outlineOffset: '-2px', transition: 'none', }, }), collapseContent: css({ padding: 0, }), labelWrapper: css({ fontSize: '15px', }), primary: css({ color: theme.colors.text.primary, }), linkText: css({ fontSize: theme.typography.pxToRem(14), justifySelf: 'start', }), }); function linkHasChildren(link: NavModelItem): link is NavModelItem & { children: NavModelItem[] } { return Boolean(link.children && link.children.length > 0); }