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 { CommandPalette } from 'app/features/commandPalette/CommandPalette';
import { KioskMode } from 'app/types'; import { KioskMode } from 'app/types';
import { DockedMegaMenu } from './DockedMegaMenu/DockedMegaMenu'; import { AppChromeMenu } from './AppChromeMenu';
import { MegaMenu } from './MegaMenu/MegaMenu'; import { MegaMenu } from './MegaMenu/MegaMenu';
import { NavToolbar } from './NavToolbar/NavToolbar'; import { NavToolbar } from './NavToolbar/NavToolbar';
import { SectionNav } from './SectionNav/SectionNav'; import { SectionNav } from './SectionNav/SectionNav';
@ -19,11 +19,10 @@ import { TOP_BAR_LEVEL_HEIGHT } from './types';
export interface Props extends PropsWithChildren<{}> {} export interface Props extends PropsWithChildren<{}> {}
export function AppChrome({ children }: Props) { export function AppChrome({ children }: Props) {
const styles = useStyles2(getStyles);
const { chrome } = useGrafana(); const { chrome } = useGrafana();
const state = chrome.useState(); const state = chrome.useState();
const searchBarHidden = state.searchBarHidden || state.kioskMode === KioskMode.TV; const searchBarHidden = state.searchBarHidden || state.kioskMode === KioskMode.TV;
const styles = useStyles2(getStyles);
const contentClass = cx({ const contentClass = cx({
[styles.content]: true, [styles.content]: true,
@ -34,7 +33,6 @@ export function AppChrome({ children }: Props) {
// Chromeless routes are without topNav, mega menu, search & command palette // Chromeless routes are without topNav, mega menu, search & command palette
// We check chromeless twice here instead of having a separate path so {children} // 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. // doesn't get re-mounted when chromeless goes from true to false.
return ( return (
<div <div
className={classNames('main-view', { className={classNames('main-view', {
@ -61,18 +59,20 @@ export function AppChrome({ children }: Props) {
</div> </div>
</> </>
)} )}
<main className={contentClass} id="pageContent"> <main className={contentClass}>
<div className={styles.panes}> <div className={styles.panes}>
{state.layout === PageLayoutType.Standard && state.sectionNav && !config.featureToggles.dockedMegaMenu && ( {state.layout === PageLayoutType.Standard && state.sectionNav && !config.featureToggles.dockedMegaMenu && (
<SectionNav model={state.sectionNav} /> <SectionNav model={state.sectionNav} />
)} )}
<div className={styles.pageContainer}>{children}</div> <div className={styles.pageContainer} id="pageContent">
{children}
</div>
</div> </div>
</main> </main>
{!state.chromeless && ( {!state.chromeless && (
<> <>
{config.featureToggles.dockedMegaMenu ? ( {config.featureToggles.dockedMegaMenu ? (
<DockedMegaMenu searchBarHidden={searchBarHidden} onClose={() => chrome.setMegaMenu(false)} /> <AppChromeMenu />
) : ( ) : (
<MegaMenu searchBarHidden={searchBarHidden} onClose={() => chrome.setMegaMenu(false)} /> <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 { useDialog } from '@react-aria/dialog';
import { FocusScope } from '@react-aria/focus'; import { FocusScope } from '@react-aria/focus';
import { OverlayContainer, useOverlay } from '@react-aria/overlays'; 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 CSSTransition from 'react-transition-group/CSSTransition';
import { GrafanaTheme2, NavModelItem } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { CustomScrollbar, Icon, IconButton, useTheme2 } from '@grafana/ui'; import { useStyles2, useTheme2 } from '@grafana/ui';
import { useGrafana } from 'app/core/context/GrafanaContext'; 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 function AppChromeMenu({}: Props) {
export interface Props {
activeItem?: NavModelItem;
navItems: NavModelItem[];
searchBarHidden?: boolean;
onClose: () => void;
}
export function NavBarMenu({ activeItem, navItems, searchBarHidden, onClose }: Props) {
const theme = useTheme2(); const theme = useTheme2();
const styles = getStyles(theme, searchBarHidden);
const animationSpeed = theme.transitions.duration.shortest;
const animStyles = getAnimStyles(theme, animationSpeed);
const { chrome } = useGrafana(); const { chrome } = useGrafana();
const state = chrome.useState(); const state = chrome.useState();
const searchBarHidden = state.searchBarHidden || state.kioskMode === KioskMode.TV;
const ref = useRef(null); const ref = useRef(null);
const backdropRef = useRef(null); const backdropRef = useRef(null);
const { dialogProps } = useDialog({}, ref); const animationSpeed = theme.transitions.duration.shortest;
const [isOpen, setIsOpen] = useState(false); const animationStyles = useStyles2(getAnimStyles, animationSpeed);
const onMenuClose = () => setIsOpen(false); const isOpen = state.megaMenuOpen;
const onClose = () => chrome.setMegaMenu(false);
const { overlayProps, underlayProps } = useOverlay( const { overlayProps, underlayProps } = useOverlay(
{ {
isDismissable: true, isDismissable: true,
isOpen: 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 ref
); );
const { dialogProps } = useDialog({}, ref);
useEffect(() => { const styles = useStyles2(getStyles, searchBarHidden);
if (state.megaMenuOpen) {
setIsOpen(true);
}
}, [state.megaMenuOpen]);
return ( return (
<OverlayContainer> <div className={styles.wrapper}>
<CSSTransition <OverlayContainer>
nodeRef={ref} <CSSTransition
in={isOpen} nodeRef={ref}
unmountOnExit={true} in={isOpen}
classNames={animStyles.overlay} unmountOnExit={true}
timeout={{ enter: animationSpeed, exit: 0 }} classNames={animationStyles.overlay}
onExited={onClose} timeout={{ enter: animationSpeed, exit: 0 }}
> >
<FocusScope contain autoFocus> <FocusScope contain autoFocus>
<div data-testid="navbarmenu" ref={ref} {...overlayProps} {...dialogProps} className={styles.container}> <DockedMegaMenu className={styles.menu} onClose={onClose} ref={ref} {...overlayProps} {...dialogProps} />
<div className={styles.mobileHeader}> </FocusScope>
<Icon name="bars" size="xl" /> </CSSTransition>
<IconButton <CSSTransition
aria-label="Close navigation menu" nodeRef={backdropRef}
tooltip="Close menu" in={isOpen}
name="times" unmountOnExit={true}
onClick={onMenuClose} classNames={animationStyles.backdrop}
size="xl" timeout={{ enter: animationSpeed, exit: 0 }}
variant="secondary" >
/> <div ref={backdropRef} className={styles.backdrop} {...underlayProps} />
</div> </CSSTransition>
<nav className={styles.content}> </OverlayContainer>
<CustomScrollbar showScrollIndicators hideHorizontalTrack> </div>
<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>
); );
} }
NavBarMenu.displayName = 'NavBarMenu';
const getStyles = (theme: GrafanaTheme2, searchBarHidden?: boolean) => { const getStyles = (theme: GrafanaTheme2, searchBarHidden?: boolean) => {
const topPosition = (searchBarHidden ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2) + 1; 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, top: topPosition,
}, },
}), }),
container: css({ menu: css({
display: 'flex', display: 'flex',
bottom: 0, bottom: 0,
flexDirection: 'column', flexDirection: 'column',
left: 0, left: 0,
marginRight: theme.spacing(1.5),
right: 0, right: 0,
// Needs to below navbar should we change the navbarFixed? add add a new level? // Needs to below navbar should we change the navbarFixed? add add a new level?
zIndex: theme.zIndex.modal, zIndex: theme.zIndex.modal,
@ -135,32 +108,17 @@ const getStyles = (theme: GrafanaTheme2, searchBarHidden?: boolean) => {
flex: '1 1 0', flex: '1 1 0',
[theme.breakpoints.up('md')]: { [theme.breakpoints.up('md')]: {
borderRight: `1px solid ${theme.colors.border.weak}`,
right: 'unset', right: 'unset',
borderRight: `1px solid ${theme.colors.border.weak}`,
top: topPosition, top: topPosition,
}, },
}), }),
content: css({ wrapper: css({
display: 'flex', position: 'fixed',
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', display: 'grid',
gridAutoRows: `minmax(${theme.spacing(6)}, auto)`, gridAutoFlow: 'column',
gridTemplateColumns: `minmax(${MENU_WIDTH}, auto)`, height: '100%',
minWidth: MENU_WIDTH, zIndex: theme.zIndex.sidemenu,
}), }),
}; };
}; };

View File

@ -1,50 +1,87 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { DOMAttributes } from '@react-types/shared';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import React from 'react'; import React, { forwardRef } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { useTheme2 } from '@grafana/ui'; import { CustomScrollbar, Icon, IconButton, useStyles2 } from '@grafana/ui';
import { useSelector } from 'app/types'; import { useSelector } from 'app/types';
import { NavBarMenu } from './NavBarMenu'; import { NavBarMenuItemWrapper } from './NavBarMenuItemWrapper';
import { enrichWithInteractionTracking, getActiveItem } from './utils'; import { enrichWithInteractionTracking, getActiveItem } from './utils';
export interface Props { export const MENU_WIDTH = '350px';
export interface Props extends DOMAttributes {
onClose: () => void; onClose: () => void;
searchBarHidden?: boolean;
} }
export const DockedMegaMenu = React.memo<Props>(({ onClose, searchBarHidden }) => { export const DockedMegaMenu = React.memo(
const navBarTree = useSelector((state) => state.navBarTree); forwardRef<HTMLDivElement, Props>(({ onClose, ...restProps }, ref) => {
const theme = useTheme2(); const navBarTree = useSelector((state) => state.navBarTree);
const styles = getStyles(theme); const styles = useStyles2(getStyles);
const location = useLocation(); const location = useLocation();
const navTree = cloneDeep(navBarTree); const navTree = cloneDeep(navBarTree);
// Remove profile + help from tree // Remove profile + help from tree
const navItems = navTree const navItems = navTree
.filter((item) => item.id !== 'profile' && item.id !== 'help') .filter((item) => item.id !== 'profile' && item.id !== 'help')
.map((item) => enrichWithInteractionTracking(item, true)); .map((item) => enrichWithInteractionTracking(item, true));
const activeItem = getActiveItem(navItems, location.pathname); const activeItem = getActiveItem(navItems, location.pathname);
return ( return (
<div className={styles.menuWrapper}> <div data-testid="navbarmenu" ref={ref} {...restProps}>
<NavBarMenu activeItem={activeItem} navItems={navItems} onClose={onClose} searchBarHidden={searchBarHidden} /> <div className={styles.mobileHeader}>
</div> <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'; DockedMegaMenu.displayName = 'DockedMegaMenu';
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2) => ({
menuWrapper: css({ content: css({
position: 'fixed', 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', display: 'grid',
gridAutoFlow: 'column', gridAutoRows: `minmax(${theme.spacing(6)}, auto)`,
height: '100%', gridTemplateColumns: `minmax(${MENU_WIDTH}, auto)`,
zIndex: theme.zIndex.sidemenu, minWidth: MENU_WIDTH,
}), }),
}); });

View File

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