mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
868b790406
commit
05dcc7a441
@ -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 />
|
||||
</>
|
||||
|
@ -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>
|
||||
|
@ -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 = () => {
|
||||
|
@ -36,7 +36,7 @@ const setup = () => {
|
||||
];
|
||||
|
||||
const grafanaContext = getGrafanaContextMock();
|
||||
grafanaContext.chrome.setMegaMenu('open');
|
||||
grafanaContext.chrome.setMegaMenuOpen(true);
|
||||
|
||||
return render(
|
||||
<TestProvider storeState={{ navBarTree }} grafanaContext={grafanaContext}>
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -30,7 +30,7 @@ const setup = () => {
|
||||
];
|
||||
|
||||
const grafanaContext = getGrafanaContextMock();
|
||||
grafanaContext.chrome.setMegaMenu('open');
|
||||
grafanaContext.chrome.setMegaMenuOpen(true);
|
||||
|
||||
return render(
|
||||
<TestProvider storeState={{ navBarTree }} grafanaContext={grafanaContext}>
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user