Navigation: Refactor MegaMenu to separate out overlay/animation logic (#75365)

* user essentials mob! 🔱

lastFile:public/app/core/components/AppChrome/DockedMegaMenu/DockedMegaMenu.tsx

* mob start [ci-skip] [ci skip] [skip ci]

* user essentials mob! 🔱

lastFile:public/app/core/components/AppChrome/DockedMegaMenu/DockedMegaMenu.tsx

* user essentials mob! 🔱

lastFile:public/app/core/components/AppChrome/AppChrome.tsx

* fix border css + scroll

* import width from DockedMegaMenu

* extract logic out into AppChromeMenu

* fix bug with hamburger icon

* rename some styles

* one more rename

* remove janky state logic

* prevent react-aria closing the overlay when interacting with the toggle button

* don't need boolean

---------

Co-authored-by: eledobleefe <laura.fernandez@grafana.com>
This commit is contained in:
Ashley Harrison 2023-09-27 17:05:40 +01:00 committed by GitHub
parent bbdd1fc3b1
commit efaa779c2e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 129 additions and 131 deletions

View File

@ -9,7 +9,7 @@ import { useGrafana } from 'app/core/context/GrafanaContext';
import { CommandPalette } from 'app/features/commandPalette/CommandPalette';
import { KioskMode } from 'app/types';
import { DockedMegaMenu } from './DockedMegaMenu/DockedMegaMenu';
import { AppChromeMenu } from './AppChromeMenu';
import { MegaMenu } from './MegaMenu/MegaMenu';
import { NavToolbar } from './NavToolbar/NavToolbar';
import { SectionNav } from './SectionNav/SectionNav';
@ -19,11 +19,10 @@ import { TOP_BAR_LEVEL_HEIGHT } from './types';
export interface Props extends PropsWithChildren<{}> {}
export function AppChrome({ children }: Props) {
const styles = useStyles2(getStyles);
const { chrome } = useGrafana();
const state = chrome.useState();
const searchBarHidden = state.searchBarHidden || state.kioskMode === KioskMode.TV;
const styles = useStyles2(getStyles);
const contentClass = cx({
[styles.content]: true,
@ -34,7 +33,6 @@ export function AppChrome({ children }: Props) {
// Chromeless routes are without topNav, mega menu, search & command palette
// We check chromeless twice here instead of having a separate path so {children}
// doesn't get re-mounted when chromeless goes from true to false.
return (
<div
className={classNames('main-view', {
@ -61,18 +59,20 @@ export function AppChrome({ children }: Props) {
</div>
</>
)}
<main className={contentClass} id="pageContent">
<main className={contentClass}>
<div className={styles.panes}>
{state.layout === PageLayoutType.Standard && state.sectionNav && !config.featureToggles.dockedMegaMenu && (
<SectionNav model={state.sectionNav} />
)}
<div className={styles.pageContainer}>{children}</div>
<div className={styles.pageContainer} id="pageContent">
{children}
</div>
</div>
</main>
{!state.chromeless && (
<>
{config.featureToggles.dockedMegaMenu ? (
<DockedMegaMenu searchBarHidden={searchBarHidden} onClose={() => chrome.setMegaMenu(false)} />
<AppChromeMenu />
) : (
<MegaMenu searchBarHidden={searchBarHidden} onClose={() => chrome.setMegaMenu(false)} />
)}

View File

@ -2,105 +2,79 @@ import { css } from '@emotion/css';
import { useDialog } from '@react-aria/dialog';
import { FocusScope } from '@react-aria/focus';
import { OverlayContainer, useOverlay } from '@react-aria/overlays';
import React, { useEffect, useRef, useState } from 'react';
import React, { useRef } from 'react';
import CSSTransition from 'react-transition-group/CSSTransition';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { CustomScrollbar, Icon, IconButton, useTheme2 } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2, useTheme2 } from '@grafana/ui';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { KioskMode } from 'app/types';
import { TOP_BAR_LEVEL_HEIGHT } from '../types';
import { DockedMegaMenu, MENU_WIDTH } from './DockedMegaMenu/DockedMegaMenu';
import { TOGGLE_BUTTON_ID } from './NavToolbar/NavToolbar';
import { TOP_BAR_LEVEL_HEIGHT } from './types';
import { NavBarMenuItemWrapper } from './NavBarMenuItemWrapper';
interface Props {}
const MENU_WIDTH = '350px';
export interface Props {
activeItem?: NavModelItem;
navItems: NavModelItem[];
searchBarHidden?: boolean;
onClose: () => void;
}
export function NavBarMenu({ activeItem, navItems, searchBarHidden, onClose }: Props) {
export function AppChromeMenu({}: Props) {
const theme = useTheme2();
const styles = getStyles(theme, searchBarHidden);
const animationSpeed = theme.transitions.duration.shortest;
const animStyles = getAnimStyles(theme, animationSpeed);
const { chrome } = useGrafana();
const state = chrome.useState();
const searchBarHidden = state.searchBarHidden || state.kioskMode === KioskMode.TV;
const ref = useRef(null);
const backdropRef = useRef(null);
const { dialogProps } = useDialog({}, ref);
const [isOpen, setIsOpen] = useState(false);
const animationSpeed = theme.transitions.duration.shortest;
const animationStyles = useStyles2(getAnimStyles, animationSpeed);
const onMenuClose = () => setIsOpen(false);
const isOpen = state.megaMenuOpen;
const onClose = () => chrome.setMegaMenu(false);
const { overlayProps, underlayProps } = useOverlay(
{
isDismissable: true,
isOpen: true,
onClose: onMenuClose,
onClose,
shouldCloseOnInteractOutside: (element) => {
// don't close when clicking on the menu toggle, let the toggle button handle that
// this prevents some nasty flickering when the menu is open and the toggle button is clicked
const isMenuToggle = document.getElementById(TOGGLE_BUTTON_ID)?.contains(element);
return !isMenuToggle;
},
},
ref
);
useEffect(() => {
if (state.megaMenuOpen) {
setIsOpen(true);
}
}, [state.megaMenuOpen]);
const { dialogProps } = useDialog({}, ref);
const styles = useStyles2(getStyles, searchBarHidden);
return (
<OverlayContainer>
<CSSTransition
nodeRef={ref}
in={isOpen}
unmountOnExit={true}
classNames={animStyles.overlay}
timeout={{ enter: animationSpeed, exit: 0 }}
onExited={onClose}
>
<FocusScope contain autoFocus>
<div data-testid="navbarmenu" ref={ref} {...overlayProps} {...dialogProps} className={styles.container}>
<div className={styles.mobileHeader}>
<Icon name="bars" size="xl" />
<IconButton
aria-label="Close navigation menu"
tooltip="Close menu"
name="times"
onClick={onMenuClose}
size="xl"
variant="secondary"
/>
</div>
<nav className={styles.content}>
<CustomScrollbar showScrollIndicators hideHorizontalTrack>
<ul className={styles.itemList}>
{navItems.map((link) => (
<NavBarMenuItemWrapper link={link} onClose={onMenuClose} activeItem={activeItem} key={link.text} />
))}
</ul>
</CustomScrollbar>
</nav>
</div>
</FocusScope>
</CSSTransition>
<CSSTransition
nodeRef={backdropRef}
in={isOpen}
unmountOnExit={true}
classNames={animStyles.backdrop}
timeout={{ enter: animationSpeed, exit: 0 }}
>
<div ref={backdropRef} className={styles.backdrop} {...underlayProps} />
</CSSTransition>
</OverlayContainer>
<div className={styles.wrapper}>
<OverlayContainer>
<CSSTransition
nodeRef={ref}
in={isOpen}
unmountOnExit={true}
classNames={animationStyles.overlay}
timeout={{ enter: animationSpeed, exit: 0 }}
>
<FocusScope contain autoFocus>
<DockedMegaMenu className={styles.menu} onClose={onClose} ref={ref} {...overlayProps} {...dialogProps} />
</FocusScope>
</CSSTransition>
<CSSTransition
nodeRef={backdropRef}
in={isOpen}
unmountOnExit={true}
classNames={animationStyles.backdrop}
timeout={{ enter: animationSpeed, exit: 0 }}
>
<div ref={backdropRef} className={styles.backdrop} {...underlayProps} />
</CSSTransition>
</OverlayContainer>
</div>
);
}
NavBarMenu.displayName = 'NavBarMenu';
const getStyles = (theme: GrafanaTheme2, searchBarHidden?: boolean) => {
const topPosition = (searchBarHidden ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2) + 1;
@ -119,12 +93,11 @@ const getStyles = (theme: GrafanaTheme2, searchBarHidden?: boolean) => {
top: topPosition,
},
}),
container: css({
menu: css({
display: 'flex',
bottom: 0,
flexDirection: 'column',
left: 0,
marginRight: theme.spacing(1.5),
right: 0,
// Needs to below navbar should we change the navbarFixed? add add a new level?
zIndex: theme.zIndex.modal,
@ -135,32 +108,17 @@ const getStyles = (theme: GrafanaTheme2, searchBarHidden?: boolean) => {
flex: '1 1 0',
[theme.breakpoints.up('md')]: {
borderRight: `1px solid ${theme.colors.border.weak}`,
right: 'unset',
borderRight: `1px solid ${theme.colors.border.weak}`,
top: topPosition,
},
}),
content: css({
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
minHeight: 0,
}),
mobileHeader: css({
display: 'flex',
justifyContent: 'space-between',
padding: theme.spacing(1, 1, 1, 2),
borderBottom: `1px solid ${theme.colors.border.weak}`,
[theme.breakpoints.up('md')]: {
display: 'none',
},
}),
itemList: css({
wrapper: css({
position: 'fixed',
display: 'grid',
gridAutoRows: `minmax(${theme.spacing(6)}, auto)`,
gridTemplateColumns: `minmax(${MENU_WIDTH}, auto)`,
minWidth: MENU_WIDTH,
gridAutoFlow: 'column',
height: '100%',
zIndex: theme.zIndex.sidemenu,
}),
};
};

View File

@ -1,50 +1,87 @@
import { css } from '@emotion/css';
import { DOMAttributes } from '@react-types/shared';
import { cloneDeep } from 'lodash';
import React from 'react';
import React, { forwardRef } from 'react';
import { useLocation } from 'react-router-dom';
import { GrafanaTheme2 } from '@grafana/data';
import { useTheme2 } from '@grafana/ui';
import { CustomScrollbar, Icon, IconButton, useStyles2 } from '@grafana/ui';
import { useSelector } from 'app/types';
import { NavBarMenu } from './NavBarMenu';
import { NavBarMenuItemWrapper } from './NavBarMenuItemWrapper';
import { enrichWithInteractionTracking, getActiveItem } from './utils';
export interface Props {
export const MENU_WIDTH = '350px';
export interface Props extends DOMAttributes {
onClose: () => void;
searchBarHidden?: boolean;
}
export const DockedMegaMenu = React.memo<Props>(({ onClose, searchBarHidden }) => {
const navBarTree = useSelector((state) => state.navBarTree);
const theme = useTheme2();
const styles = getStyles(theme);
const location = useLocation();
export const DockedMegaMenu = React.memo(
forwardRef<HTMLDivElement, Props>(({ onClose, ...restProps }, ref) => {
const navBarTree = useSelector((state) => state.navBarTree);
const styles = useStyles2(getStyles);
const location = useLocation();
const navTree = cloneDeep(navBarTree);
const navTree = cloneDeep(navBarTree);
// Remove profile + help from tree
const navItems = navTree
.filter((item) => item.id !== 'profile' && item.id !== 'help')
.map((item) => enrichWithInteractionTracking(item, true));
// Remove profile + help from tree
const navItems = navTree
.filter((item) => item.id !== 'profile' && item.id !== 'help')
.map((item) => enrichWithInteractionTracking(item, true));
const activeItem = getActiveItem(navItems, location.pathname);
const activeItem = getActiveItem(navItems, location.pathname);
return (
<div className={styles.menuWrapper}>
<NavBarMenu activeItem={activeItem} navItems={navItems} onClose={onClose} searchBarHidden={searchBarHidden} />
</div>
);
});
return (
<div data-testid="navbarmenu" ref={ref} {...restProps}>
<div className={styles.mobileHeader}>
<Icon name="bars" size="xl" />
<IconButton
aria-label="Close navigation menu"
tooltip="Close menu"
name="times"
onClick={onClose}
size="xl"
variant="secondary"
/>
</div>
<nav className={styles.content}>
<CustomScrollbar showScrollIndicators hideHorizontalTrack>
<ul className={styles.itemList}>
{navItems.map((link) => (
<NavBarMenuItemWrapper link={link} onClose={onClose} activeItem={activeItem} key={link.text} />
))}
</ul>
</CustomScrollbar>
</nav>
</div>
);
})
);
DockedMegaMenu.displayName = 'DockedMegaMenu';
const getStyles = (theme: GrafanaTheme2) => ({
menuWrapper: css({
position: 'fixed',
content: css({
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
minHeight: 0,
}),
mobileHeader: css({
display: 'flex',
justifyContent: 'space-between',
padding: theme.spacing(1, 1, 1, 2),
borderBottom: `1px solid ${theme.colors.border.weak}`,
[theme.breakpoints.up('md')]: {
display: 'none',
},
}),
itemList: css({
display: 'grid',
gridAutoFlow: 'column',
height: '100%',
zIndex: theme.zIndex.sidemenu,
gridAutoRows: `minmax(${theme.spacing(6)}, auto)`,
gridTemplateColumns: `minmax(${MENU_WIDTH}, auto)`,
minWidth: MENU_WIDTH,
}),
});

View File

@ -14,6 +14,8 @@ import { TOP_BAR_LEVEL_HEIGHT } from '../types';
import { NavToolbarSeparator } from './NavToolbarSeparator';
export const TOGGLE_BUTTON_ID = 'mega-menu-toggle';
export interface Props {
onToggleSearchBar(): void;
onToggleMegaMenu(): void;
@ -41,6 +43,7 @@ export function NavToolbar({
<div data-testid={Components.NavToolbar.container} className={styles.pageToolbar}>
<div className={styles.menuButton}>
<IconButton
id={TOGGLE_BUTTON_ID}
name="bars"
tooltip={t('navigation.toolbar.toggle-menu', 'Toggle menu')}
tooltipPlacement="bottom"