From 05dcc7a44150dfe37c6ce365d28c66ab4d0076af Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Mon, 11 Dec 2023 11:41:14 +0000 Subject: [PATCH] Navigation: Independent docked state (#78954) * initial start * more progress * behaviour working? * only use media query when docked local storage state is true * close menu when undocking * remove unneeded animation code and fix focus when toggling between docked/undocked * better feature toggle handling (can go back and forth) * remove restoreFocus (for now) --- .../core/components/AppChrome/AppChrome.tsx | 42 ++++++++-------- .../components/AppChrome/AppChromeMenu.tsx | 17 ++----- .../components/AppChrome/AppChromeService.tsx | 48 +++++++++++++------ .../DockedMegaMenu/MegaMenu.test.tsx | 2 +- .../AppChrome/DockedMegaMenu/MegaMenu.tsx | 15 +++--- .../AppChrome/DockedMegaMenu/MegaMenuItem.tsx | 2 +- .../AppChrome/DockedMegaMenu/utils.ts | 7 ++- .../AppChrome/MegaMenu/MegaMenu.test.tsx | 2 +- .../AppChrome/MegaMenu/NavBarMenu.tsx | 4 +- .../AppChrome/NavToolbar/NavToolbar.tsx | 24 ++-------- 10 files changed, 84 insertions(+), 79 deletions(-) diff --git a/public/app/core/components/AppChrome/AppChrome.tsx b/public/app/core/components/AppChrome/AppChrome.tsx index 5f7bc35fe96..cfa0d0d09a4 100644 --- a/public/app/core/components/AppChrome/AppChrome.tsx +++ b/public/app/core/components/AppChrome/AppChrome.tsx @@ -6,10 +6,13 @@ import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; import { useStyles2, LinkButton, useTheme2 } from '@grafana/ui'; import config from 'app/core/config'; import { useGrafana } from 'app/core/context/GrafanaContext'; +import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange'; +import store from 'app/core/store'; import { CommandPalette } from 'app/features/commandPalette/CommandPalette'; import { KioskMode } from 'app/types'; import { AppChromeMenu } from './AppChromeMenu'; +import { DOCKED_LOCAL_STORAGE_KEY, DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY } from './AppChromeService'; import { MegaMenu as DockedMegaMenu } from './DockedMegaMenu/MegaMenu'; import { MegaMenu } from './MegaMenu/MegaMenu'; import { NavToolbar } from './NavToolbar/NavToolbar'; @@ -26,6 +29,20 @@ export function AppChrome({ children }: Props) { const theme = useTheme2(); const styles = useStyles2(getStyles); + const dockedMenuBreakpoint = theme.breakpoints.values.xl; + const dockedMenuLocalStorageState = store.getBool(DOCKED_LOCAL_STORAGE_KEY, true); + useMediaQueryChange({ + breakpoint: dockedMenuBreakpoint, + onChange: (e) => { + if (config.featureToggles.dockedMegaMenu && dockedMenuLocalStorageState) { + chrome.setMegaMenuDocked(e.matches, false); + chrome.setMegaMenuOpen( + e.matches ? store.getBool(DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, state.megaMenuOpen) : false + ); + } + }, + }); + const contentClass = cx({ [styles.content]: true, [styles.contentNoSearchBar]: searchBarHidden, @@ -33,20 +50,7 @@ export function AppChrome({ children }: Props) { }); const handleMegaMenu = () => { - switch (state.megaMenu) { - case 'closed': - chrome.setMegaMenu('open'); - break; - case 'open': - chrome.setMegaMenu('closed'); - break; - case 'docked': - // on large screens, clicking the button when the menu is docked should close the menu - // on smaller screens, the docked menu is hidden, so clicking the button should open the menu - const isLargeScreen = window.innerWidth >= theme.breakpoints.values.xl; - isLargeScreen ? chrome.setMegaMenu('closed') : chrome.setMegaMenu('open'); - break; - } + chrome.setMegaMenuOpen(!state.megaMenuOpen); }; // Chromeless routes are without topNav, mega menu, search & command palette @@ -83,20 +87,20 @@ export function AppChrome({ children }: Props) { {state.layout === PageLayoutType.Standard && state.sectionNav && !config.featureToggles.dockedMegaMenu && ( )} - {config.featureToggles.dockedMegaMenu && !state.chromeless && state.megaMenu === 'docked' && ( - chrome.setMegaMenu('closed')} /> + {config.featureToggles.dockedMegaMenu && !state.chromeless && state.megaMenuDocked && state.megaMenuOpen && ( + chrome.setMegaMenuOpen(false)} /> )}
{children}
- {!state.chromeless && ( + {!state.chromeless && !state.megaMenuDocked && ( <> - {config.featureToggles.dockedMegaMenu && state.megaMenu !== 'docked' ? ( + {config.featureToggles.dockedMegaMenu ? ( ) : ( - chrome.setMegaMenu('closed')} /> + chrome.setMegaMenuOpen(false)} /> )} diff --git a/public/app/core/components/AppChrome/AppChromeMenu.tsx b/public/app/core/components/AppChrome/AppChromeMenu.tsx index d5718520bce..838ccc7d8ff 100644 --- a/public/app/core/components/AppChrome/AppChromeMenu.tsx +++ b/public/app/core/components/AppChrome/AppChromeMenu.tsx @@ -2,7 +2,7 @@ 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 } from 'react'; +import React, { useRef } from 'react'; import CSSTransition from 'react-transition-group/CSSTransition'; import { GrafanaTheme2 } from '@grafana/data'; @@ -20,22 +20,15 @@ export function AppChromeMenu({}: Props) { const theme = useTheme2(); const { chrome } = useGrafana(); const state = chrome.useState(); - const prevMegaMenuState = useRef(state.megaMenu); const searchBarHidden = state.searchBarHidden || state.kioskMode === KioskMode.TV; - useEffect(() => { - prevMegaMenuState.current = state.megaMenu; - }, [state.megaMenu]); - const ref = useRef(null); const backdropRef = useRef(null); - // we don't want to show the opening animation when transitioning between docked + open - const animationSpeed = - prevMegaMenuState.current === 'docked' && state.megaMenu === 'open' ? 0 : theme.transitions.duration.shortest; + const animationSpeed = theme.transitions.duration.shortest; const animationStyles = useStyles2(getAnimStyles, animationSpeed); - const isOpen = state.megaMenu === 'open'; - const onClose = () => chrome.setMegaMenu('closed'); + const isOpen = state.megaMenuOpen && !state.megaMenuDocked; + const onClose = () => chrome.setMegaMenuOpen(false); const { overlayProps, underlayProps } = useOverlay( { @@ -64,7 +57,7 @@ export function AppChromeMenu({}: Props) { classNames={animationStyles.overlay} timeout={{ enter: animationSpeed, exit: 0 }} > - + diff --git a/public/app/core/components/AppChrome/AppChromeService.tsx b/public/app/core/components/AppChrome/AppChromeService.tsx index a7a89bfceb2..d8d57782084 100644 --- a/public/app/core/components/AppChrome/AppChromeService.tsx +++ b/public/app/core/components/AppChrome/AppChromeService.tsx @@ -11,35 +11,40 @@ import { KioskMode } from 'app/types'; import { RouteDescriptor } from '../../navigation/types'; -export type MegaMenuState = 'open' | 'closed' | 'docked'; - export interface AppChromeState { chromeless?: boolean; sectionNav: NavModel; pageNav?: NavModelItem; actions?: React.ReactNode; searchBarHidden?: boolean; - megaMenu: MegaMenuState; + megaMenuOpen: boolean; + megaMenuDocked: boolean; kioskMode: KioskMode | null; layout: PageLayoutType; } -const DOCKED_LOCAL_STORAGE_KEY = 'grafana.navigation.docked'; +export const DOCKED_LOCAL_STORAGE_KEY = 'grafana.navigation.docked'; +export const DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY = 'grafana.navigation.open'; export class AppChromeService { searchBarStorageKey = 'SearchBar_Hidden'; private currentRoute?: RouteDescriptor; private routeChangeHandled = true; + private megaMenuDocked = Boolean( + config.featureToggles.dockedMegaMenu && + store.getBool( + DOCKED_LOCAL_STORAGE_KEY, + Boolean(config.featureToggles.dockedMegaMenu && window.innerWidth >= config.theme2.breakpoints.values.xxl) + ) + ); + readonly state = new BehaviorSubject({ chromeless: true, // start out hidden to not flash it on pages without chrome sectionNav: { node: { text: t('nav.home.title', 'Home') }, main: { text: '' } }, searchBarHidden: store.getBool(this.searchBarStorageKey, false), - megaMenu: - config.featureToggles.dockedMegaMenu && - store.getBool(DOCKED_LOCAL_STORAGE_KEY, window.innerWidth >= config.theme2.breakpoints.values.xxl) - ? 'docked' - : 'closed', + megaMenuOpen: this.megaMenuDocked && store.getBool(DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, true), + megaMenuDocked: this.megaMenuDocked, kioskMode: null, layout: PageLayoutType.Canvas, }); @@ -102,14 +107,29 @@ export class AppChromeService { return useObservable(this.state, this.state.getValue()); } - public setMegaMenu = (newMegaMenuState: AppChromeState['megaMenu']) => { + public setMegaMenuOpen = (newOpenState: boolean) => { + const { megaMenuDocked } = this.state.getValue(); if (config.featureToggles.dockedMegaMenu) { - store.set(DOCKED_LOCAL_STORAGE_KEY, newMegaMenuState === 'docked'); - reportInteraction('grafana_mega_menu_state', { state: newMegaMenuState }); + if (megaMenuDocked) { + store.set(DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, newOpenState); + } + reportInteraction('grafana_mega_menu_open', { state: newOpenState }); } else { - reportInteraction('grafana_toggle_menu_clicked', { action: newMegaMenuState === 'open' ? 'open' : 'close' }); + reportInteraction('grafana_toggle_menu_clicked', { action: newOpenState ? 'open' : 'close' }); } - this.update({ megaMenu: newMegaMenuState }); + this.update({ + megaMenuOpen: newOpenState, + }); + }; + + public setMegaMenuDocked = (newDockedState: boolean, updatePersistedState = true) => { + if (updatePersistedState) { + store.set(DOCKED_LOCAL_STORAGE_KEY, newDockedState); + } + reportInteraction('grafana_mega_menu_docked', { state: newDockedState }); + this.update({ + megaMenuDocked: newDockedState, + }); }; public onToggleSearchBar = () => { diff --git a/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenu.test.tsx b/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenu.test.tsx index 5b603aeb12d..43f9bc20180 100644 --- a/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenu.test.tsx +++ b/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenu.test.tsx @@ -36,7 +36,7 @@ const setup = () => { ]; const grafanaContext = getGrafanaContextMock(); - grafanaContext.chrome.setMegaMenu('open'); + grafanaContext.chrome.setMegaMenuOpen(true); return render( diff --git a/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenu.tsx b/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenu.tsx index 61fc62ca533..7006f9cacd0 100644 --- a/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenu.tsx +++ b/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenu.tsx @@ -30,16 +30,19 @@ export const MegaMenu = React.memo( // Remove profile + help from tree const navItems = navTree .filter((item) => item.id !== 'profile' && item.id !== 'help') - .map((item) => enrichWithInteractionTracking(item, state.megaMenu)); + .map((item) => enrichWithInteractionTracking(item, state.megaMenuDocked)); const activeItem = getActiveItem(navItems, location.pathname); const handleDockedMenu = () => { - chrome.setMegaMenu(state.megaMenu === 'docked' ? 'open' : 'docked'); + chrome.setMegaMenuDocked(!state.megaMenuDocked); + if (state.megaMenuDocked) { + chrome.setMegaMenuOpen(false); + } - // refocus on dock/undock button when changing state + // refocus on undock/menu open button when changing state setTimeout(() => { - document.getElementById('dock-menu-button')?.focus(); + document.getElementById(state.megaMenuDocked ? 'mega-menu-toggle' : 'dock-menu-button')?.focus(); }); }; @@ -65,7 +68,7 @@ export const MegaMenu = React.memo( id="dock-menu-button" className={styles.dockMenuButton} tooltip={ - state.megaMenu === 'docked' + state.megaMenuDocked ? t('navigation.megamenu.undock', 'Undock menu') : t('navigation.megamenu.dock', 'Dock menu') } @@ -76,7 +79,7 @@ export const MegaMenu = React.memo( )} diff --git a/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenuItem.tsx b/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenuItem.tsx index afe44fded86..59c5ba41aa4 100644 --- a/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenuItem.tsx +++ b/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenuItem.tsx @@ -25,7 +25,7 @@ const MAX_DEPTH = 2; export function MegaMenuItem({ link, activeItem, level = 0, onClick }: Props) { const { chrome } = useGrafana(); const state = chrome.useState(); - const menuIsDocked = state.megaMenu === 'docked'; + const menuIsDocked = state.megaMenuDocked; const location = useLocation(); const FeatureHighlightWrapper = link.highlightText ? FeatureHighlight : React.Fragment; const hasActiveChild = hasChildMatch(link, activeItem); diff --git a/public/app/core/components/AppChrome/DockedMegaMenu/utils.ts b/public/app/core/components/AppChrome/DockedMegaMenu/utils.ts index a7ef5ee3a50..9486c3ee5ce 100644 --- a/public/app/core/components/AppChrome/DockedMegaMenu/utils.ts +++ b/public/app/core/components/AppChrome/DockedMegaMenu/utils.ts @@ -6,7 +6,6 @@ import { ShowModalReactEvent } from '../../../../types/events'; import appEvents from '../../../app_events'; import { getFooterLinks } from '../../Footer/Footer'; import { HelpModal } from '../../help/HelpModal'; -import { MegaMenuState } from '../AppChromeService'; export const enrichHelpItem = (helpItem: NavModelItem) => { let menuItems = helpItem.children || []; @@ -30,19 +29,19 @@ export const enrichHelpItem = (helpItem: NavModelItem) => { return helpItem; }; -export const enrichWithInteractionTracking = (item: NavModelItem, megaMenuState: MegaMenuState) => { +export const enrichWithInteractionTracking = (item: NavModelItem, megaMenuDockedState: boolean) => { // creating a new object here to not mutate the original item object const newItem = { ...item }; const onClick = newItem.onClick; newItem.onClick = () => { reportInteraction('grafana_navigation_item_clicked', { path: newItem.url ?? newItem.id, - state: megaMenuState, + menuIsDocked: megaMenuDockedState, }); onClick?.(); }; if (newItem.children) { - newItem.children = newItem.children.map((item) => enrichWithInteractionTracking(item, megaMenuState)); + newItem.children = newItem.children.map((item) => enrichWithInteractionTracking(item, megaMenuDockedState)); } return newItem; }; diff --git a/public/app/core/components/AppChrome/MegaMenu/MegaMenu.test.tsx b/public/app/core/components/AppChrome/MegaMenu/MegaMenu.test.tsx index 9c56cdd3eee..5267d50c0f4 100644 --- a/public/app/core/components/AppChrome/MegaMenu/MegaMenu.test.tsx +++ b/public/app/core/components/AppChrome/MegaMenu/MegaMenu.test.tsx @@ -30,7 +30,7 @@ const setup = () => { ]; const grafanaContext = getGrafanaContextMock(); - grafanaContext.chrome.setMegaMenu('open'); + grafanaContext.chrome.setMegaMenuOpen(true); return render( diff --git a/public/app/core/components/AppChrome/MegaMenu/NavBarMenu.tsx b/public/app/core/components/AppChrome/MegaMenu/NavBarMenu.tsx index 6cdb9d308b3..6fc40275cef 100644 --- a/public/app/core/components/AppChrome/MegaMenu/NavBarMenu.tsx +++ b/public/app/core/components/AppChrome/MegaMenu/NavBarMenu.tsx @@ -47,10 +47,10 @@ export function NavBarMenu({ activeItem, navItems, searchBarHidden, onClose }: P ); useEffect(() => { - if (state.megaMenu === 'open') { + if (state.megaMenuOpen) { setIsOpen(true); } - }, [state.megaMenu]); + }, [state.megaMenuOpen]); return ( diff --git a/public/app/core/components/AppChrome/NavToolbar/NavToolbar.tsx b/public/app/core/components/AppChrome/NavToolbar/NavToolbar.tsx index 5d388544308..e508f8b80dc 100644 --- a/public/app/core/components/AppChrome/NavToolbar/NavToolbar.tsx +++ b/public/app/core/components/AppChrome/NavToolbar/NavToolbar.tsx @@ -1,11 +1,10 @@ import { css } from '@emotion/css'; -import React, { useState } from 'react'; +import React from 'react'; import { GrafanaTheme2, NavModelItem } from '@grafana/data'; import { Components } from '@grafana/e2e-selectors'; -import { Icon, IconButton, ToolbarButton, useStyles2, useTheme2 } from '@grafana/ui'; +import { Icon, IconButton, ToolbarButton, useStyles2 } from '@grafana/ui'; import { useGrafana } from 'app/core/context/GrafanaContext'; -import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange'; import { t } from 'app/core/internationalization'; import { HOME_NAV_ID } from 'app/core/reducers/navModel'; import { useSelector } from 'app/types'; @@ -40,22 +39,9 @@ export function NavToolbar({ const { chrome } = useGrafana(); const state = chrome.useState(); const homeNav = useSelector((state) => state.navIndex)[HOME_NAV_ID]; - const theme = useTheme2(); const styles = useStyles2(getStyles); const breadcrumbs = buildBreadcrumbs(sectionNav, pageNav, homeNav); - const dockMenuBreakpoint = theme.breakpoints.values.xl; - const [isTooSmallForDockedMenu, setIsTooSmallForDockedMenu] = useState( - !window.matchMedia(`(min-width: ${dockMenuBreakpoint}px)`).matches - ); - - useMediaQueryChange({ - breakpoint: dockMenuBreakpoint, - onChange: (e) => { - setIsTooSmallForDockedMenu(!e.matches); - }, - }); - return (
@@ -63,9 +49,9 @@ export function NavToolbar({ id={TOGGLE_BUTTON_ID} name="bars" tooltip={ - state.megaMenu === 'closed' || (state.megaMenu === 'docked' && isTooSmallForDockedMenu) - ? t('navigation.toolbar.open-menu', 'Open menu') - : t('navigation.toolbar.close-menu', 'Close menu') + state.megaMenuOpen + ? t('navigation.toolbar.close-menu', 'Close menu') + : t('navigation.toolbar.open-menu', 'Open menu') } tooltipPlacement="bottom" size="xl"