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)
This commit is contained in:
Ashley Harrison 2023-12-11 11:41:14 +00:00 committed by GitHub
parent 868b790406
commit 05dcc7a441
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 84 additions and 79 deletions

View File

@ -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 && (
<SectionNav model={state.sectionNav} />
)}
{config.featureToggles.dockedMegaMenu && !state.chromeless && state.megaMenu === 'docked' && (
<DockedMegaMenu className={styles.dockedMegaMenu} onClose={() => chrome.setMegaMenu('closed')} />
{config.featureToggles.dockedMegaMenu && !state.chromeless && state.megaMenuDocked && state.megaMenuOpen && (
<DockedMegaMenu className={styles.dockedMegaMenu} onClose={() => chrome.setMegaMenuOpen(false)} />
)}
<div className={styles.pageContainer} id="pageContent">
{children}
</div>
</div>
</main>
{!state.chromeless && (
{!state.chromeless && !state.megaMenuDocked && (
<>
{config.featureToggles.dockedMegaMenu && state.megaMenu !== 'docked' ? (
{config.featureToggles.dockedMegaMenu ? (
<AppChromeMenu />
) : (
<MegaMenu searchBarHidden={searchBarHidden} onClose={() => chrome.setMegaMenu('closed')} />
<MegaMenu searchBarHidden={searchBarHidden} onClose={() => chrome.setMegaMenuOpen(false)} />
)}
<CommandPalette />
</>

View File

@ -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 }}
>
<FocusScope contain autoFocus restoreFocus>
<FocusScope contain autoFocus>
<MegaMenu className={styles.menu} onClose={onClose} ref={ref} {...overlayProps} {...dialogProps} />
</FocusScope>
</CSSTransition>

View File

@ -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<AppChromeState>({
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 = () => {

View File

@ -36,7 +36,7 @@ const setup = () => {
];
const grafanaContext = getGrafanaContextMock();
grafanaContext.chrome.setMegaMenu('open');
grafanaContext.chrome.setMegaMenuOpen(true);
return render(
<TestProvider storeState={{ navBarTree }} grafanaContext={grafanaContext}>

View File

@ -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(
)}
<MegaMenuItem
link={link}
onClick={state.megaMenu === 'open' ? onClose : undefined}
onClick={state.megaMenuDocked ? undefined : onClose}
activeItem={activeItem}
/>
</Stack>

View File

@ -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);

View File

@ -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;
};

View File

@ -30,7 +30,7 @@ const setup = () => {
];
const grafanaContext = getGrafanaContextMock();
grafanaContext.chrome.setMegaMenu('open');
grafanaContext.chrome.setMegaMenuOpen(true);
return render(
<TestProvider storeState={{ navBarTree }} grafanaContext={grafanaContext}>

View File

@ -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 (
<OverlayContainer>

View File

@ -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 (
<div data-testid={Components.NavToolbar.container} className={styles.pageToolbar}>
<div className={styles.menuButton}>
@ -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"