Navigation: Refactor existing menu to allow for scrolling (#47076)

Co-authored-by: Ashley Harrison <ashley.harrison@grafana.com>
Co-authored-by: Joao Silva <joao.silva@grafana.com>
This commit is contained in:
Maria Alexandra 2022-04-01 11:24:52 +01:00 committed by GitHub
parent 83140f7369
commit 7ae72e1195
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 247 additions and 88 deletions

View File

@ -8,7 +8,7 @@ import { locationService } from '@grafana/runtime';
import { NavBarMenuItem } from './NavBarMenuItem';
import { getNavBarItemWithoutMenuStyles, NavBarItemWithoutMenu } from './NavBarItemWithoutMenu';
import { NavBarItemMenuTrigger } from './NavBarItemMenuTrigger';
import { NavBarItemMenu } from '../NavBarItemMenu';
import { NavBarItemMenu } from './NavBarItemMenu';
import { getNavModelItemKey } from '../utils';
import { useLingui } from '@lingui/react';
import menuItemTranslations from '../navBarItem-translations';
@ -82,7 +82,12 @@ const NavBarItem = ({
} else {
return (
<li className={cx(styles.container, className)}>
<NavBarItemMenuTrigger item={section} isActive={isActive} label={linkText}>
<NavBarItemMenuTrigger
item={section}
isActive={isActive}
label={linkText}
reverseMenuDirection={reverseMenuDirection}
>
<NavBarItemMenu
items={items}
reverseMenuDirection={reverseMenuDirection}

View File

@ -0,0 +1,103 @@
import React, { ReactElement, useEffect, useRef } from 'react';
import { css } from '@emotion/css';
import { useTheme2 } from '@grafana/ui';
import { GrafanaTheme2, NavMenuItemType, NavModelItem } from '@grafana/data';
import { SpectrumMenuProps } from '@react-types/menu';
import { useMenu } from '@react-aria/menu';
import { useTreeState } from '@react-stately/tree';
import { mergeProps } from '@react-aria/utils';
import { getNavModelItemKey } from '../utils';
import { useNavBarItemMenuContext } from '../context';
import { NavBarItemMenuItem } from '../NavBarItemMenuItem';
export interface NavBarItemMenuProps extends SpectrumMenuProps<NavModelItem> {
onNavigate: (item: NavModelItem) => void;
adjustHeightForBorder: boolean;
reverseMenuDirection?: boolean;
}
export function NavBarItemMenu(props: NavBarItemMenuProps): ReactElement | null {
const { reverseMenuDirection, adjustHeightForBorder, disabledKeys, onNavigate, ...rest } = props;
const contextProps = useNavBarItemMenuContext();
const completeProps = {
...mergeProps(contextProps, rest),
};
const { menuHasFocus, menuProps: contextMenuProps = {} } = contextProps;
const theme = useTheme2();
const styles = getStyles(theme, reverseMenuDirection);
const state = useTreeState<NavModelItem>({ ...rest, disabledKeys });
const ref = useRef(null);
const { menuProps } = useMenu(completeProps, { ...state }, ref);
const allItems = [...state.collection];
const items = allItems.filter((item) => item.value.menuItemType === NavMenuItemType.Item);
const section = allItems.find((item) => item.value.menuItemType === NavMenuItemType.Section);
useEffect(() => {
if (menuHasFocus && !state.selectionManager.isFocused) {
state.selectionManager.setFocusedKey(section?.key ?? '');
state.selectionManager.setFocused(true);
} else if (!menuHasFocus) {
state.selectionManager.setFocused(false);
state.selectionManager.setFocusedKey('');
state.selectionManager.clearSelection();
}
}, [menuHasFocus, state.selectionManager, reverseMenuDirection, section?.key]);
if (!section) {
return null;
}
const menuSubTitle = section.value.subTitle;
const sectionComponent = (
<NavBarItemMenuItem key={section.key} item={section} state={state} onNavigate={onNavigate} />
);
const itemComponents = items.map((item) => (
<NavBarItemMenuItem key={getNavModelItemKey(item.value)} item={item} state={state} onNavigate={onNavigate} />
));
const subTitleComponent = menuSubTitle && (
<li key={menuSubTitle} className={styles.subtitle}>
{menuSubTitle}
</li>
);
const menu = [sectionComponent, itemComponents, subTitleComponent];
return (
<ul className={styles.menu} ref={ref} {...mergeProps(menuProps, contextMenuProps)} tabIndex={menuHasFocus ? 0 : -1}>
{reverseMenuDirection ? menu.reverse() : menu}
</ul>
);
}
function getStyles(theme: GrafanaTheme2, reverseDirection?: boolean) {
return {
menu: css`
background-color: ${theme.colors.background.primary};
border: 1px solid ${theme.components.panel.borderColor};
bottom: ${reverseDirection ? 0 : 'auto'};
box-shadow: ${theme.shadows.z3};
display: flex;
flex-direction: column;
left: 100%;
list-style: none;
min-width: 140px;
top: ${reverseDirection ? 'auto' : 0};
transition: ${theme.transitions.create('opacity')};
z-index: ${theme.zIndex.sidemenu};
`,
subtitle: css`
background-color: transparent;
border-${reverseDirection ? 'bottom' : 'top'}: 1px solid ${theme.colors.border.weak};
color: ${theme.colors.text.secondary};
font-size: ${theme.typography.bodySmall.fontSize};
font-weight: ${theme.typography.bodySmall.fontWeight};
padding: ${theme.spacing(1)} ${theme.spacing(2)} ${theme.spacing(1)};
text-align: left;
white-space: nowrap;
`,
};
}

View File

@ -8,10 +8,10 @@ import { useMenuTrigger } from '@react-aria/menu';
import { useFocusWithin, useHover, useKeyboard } from '@react-aria/interactions';
import { useButton } from '@react-aria/button';
import { useDialog } from '@react-aria/dialog';
import { DismissButton, useOverlay } from '@react-aria/overlays';
import { DismissButton, OverlayContainer, useOverlay, useOverlayPosition } from '@react-aria/overlays';
import { FocusScope } from '@react-aria/focus';
import { NavBarItemMenuContext } from '../context';
import { NavBarItemMenuContext, useNavBarContext } from '../context';
import { NavFeatureHighlight } from '../NavFeatureHighlight';
import { reportExperimentView } from '@grafana/runtime';
@ -20,11 +20,13 @@ export interface NavBarItemMenuTriggerProps extends MenuTriggerProps {
item: NavModelItem;
isActive?: boolean;
label: string;
reverseMenuDirection: boolean;
}
export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactElement {
const { item, isActive, label, children: menu, ...rest } = props;
const { item, isActive, label, children: menu, reverseMenuDirection, ...rest } = props;
const [menuHasFocus, setMenuHasFocus] = useState(false);
const { menuIdOpen, setMenuIdOpen } = useNavBarContext();
const theme = useTheme2();
const styles = getStyles(theme, isActive);
@ -45,23 +47,23 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE
onHoverChange: (isHovering) => {
if (isHovering) {
state.open();
setMenuIdOpen(ref.current?.id || null);
} else {
state.close();
}
},
});
const { focusWithinProps } = useFocusWithin({
onFocusWithinChange: (isFocused) => {
if (isFocused) {
state.open();
}
if (!isFocused) {
state.close();
setMenuHasFocus(false);
}
},
});
useEffect(() => {
// close the menu when changing submenus
// or when the state of the overlay changes (i.e hovering outside)
if (menuIdOpen !== ref.current?.id || !state.isOpen) {
state.close();
setMenuHasFocus(false);
} else {
state.open();
}
}, [menuIdOpen, state]);
const { keyboardProps } = useKeyboard({
onKeyDown: (e) => {
@ -72,6 +74,9 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE
}
setMenuHasFocus(true);
break;
case 'Tab':
setMenuIdOpen(null);
break;
default:
break;
}
@ -133,7 +138,7 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE
);
}
const overlayRef = React.useRef(null);
const overlayRef = React.useRef<HTMLDivElement>(null);
const { dialogProps } = useDialog({}, overlayRef);
const { overlayProps } = useOverlay(
{
@ -144,29 +149,55 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE
overlayRef
);
let { overlayProps: overlayPositionProps } = useOverlayPosition({
targetRef: ref,
overlayRef,
placement: reverseMenuDirection ? 'right bottom' : 'right top',
isOpen: state.isOpen,
});
const { focusWithinProps } = useFocusWithin({
onFocusWithin: (e) => {
if (e.target.id === ref.current?.id) {
// If focussing on the trigger itself, set the menu id that is open
setMenuIdOpen(ref.current?.id);
state.open();
}
},
onBlurWithin: (e) => {
if (e.target?.getAttribute('role') === 'menuitem' && !overlayRef.current?.contains(e.relatedTarget)) {
// If it is blurring from a menuitem to an element outside the current overlay
// close the menu that is open
setMenuIdOpen(null);
}
},
});
return (
<div className={cx(styles.element, 'dropdown')} {...focusWithinProps} {...hoverProps}>
{element}
{state.isOpen && (
<NavBarItemMenuContext.Provider
value={{
menuProps,
menuHasFocus,
onClose: () => state.close(),
onLeft: () => {
setMenuHasFocus(false);
ref.current?.focus();
},
}}
>
<FocusScope restoreFocus>
<div {...overlayProps} {...dialogProps} ref={overlayRef}>
<DismissButton onDismiss={() => state.close()} />
{menu}
<DismissButton onDismiss={() => state.close()} />
</div>
</FocusScope>
</NavBarItemMenuContext.Provider>
<OverlayContainer>
<NavBarItemMenuContext.Provider
value={{
menuProps,
menuHasFocus,
onClose: () => state.close(),
onLeft: () => {
setMenuHasFocus(false);
ref.current?.focus();
},
}}
>
<FocusScope restoreFocus>
<div {...overlayProps} {...overlayPositionProps} {...dialogProps} {...hoverProps} ref={overlayRef}>
<DismissButton onDismiss={() => state.close()} />
{menu}
<DismissButton onDismiss={() => state.close()} />
</div>
</FocusScope>
</NavBarItemMenuContext.Provider>
</OverlayContainer>
)}
</div>
);

View File

@ -76,12 +76,6 @@ export function getNavBarItemWithoutMenuStyles(theme: GrafanaTheme2, isActive?:
'&:hover': {
backgroundColor: theme.colors.action.hover,
color: theme.colors.text.primary,
// TODO don't use a hardcoded class here, use isVisible in NavBarDropdown
'.navbar-dropdown': {
opacity: 1,
visibility: 'visible',
},
},
}),
element: css({

View File

@ -14,6 +14,8 @@ import { NavBarMenu } from './NavBarMenu';
import NavBarItem from './NavBarItem';
import { useSelector } from 'react-redux';
import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu';
import { FocusScope } from '@react-aria/focus';
import { NavBarContext } from '../context';
const onOpenSearch = () => {
locationService.partial({ search: 'open' });
@ -57,6 +59,7 @@ export const NavBarNext = React.memo(() => {
);
const activeItem = isSearchActive(location) ? searchItem : getActiveItem(navTree, location.pathname);
const [menuOpen, setMenuOpen] = useState(false);
const [menuIdOpen, setMenuIdOpen] = useState<string | null>(null);
if (kiosk !== KioskMode.Off) {
return null;
@ -65,55 +68,64 @@ export const NavBarNext = React.memo(() => {
return (
<div className={styles.navWrapper}>
<nav className={cx(styles.sidemenu, 'sidemenu')} data-testid="sidemenu" aria-label="Main menu">
<div className={styles.mobileSidemenuLogo} onClick={() => setMenuOpen(!menuOpen)} key="hamburger">
<Icon name="bars" size="xl" />
</div>
<NavBarContext.Provider
value={{
menuIdOpen: menuIdOpen,
setMenuIdOpen: setMenuIdOpen,
}}
>
<FocusScope>
<div className={styles.mobileSidemenuLogo} onClick={() => setMenuOpen(!menuOpen)} key="hamburger">
<Icon name="bars" size="xl" />
</div>
<ul className={styles.itemList}>
<NavBarItemWithoutMenu
isActive={isMatchOrChildMatch(homeItem, activeItem)}
label="Home"
className={styles.grafanaLogo}
url={homeItem.url}
>
<Icon name="grafana" size="xl" />
</NavBarItemWithoutMenu>
<NavBarItem className={styles.search} isActive={activeItem === searchItem} link={searchItem}>
<Icon name="search" size="xl" />
</NavBarItem>
{coreItems.map((link, index) => (
<NavBarItem
key={`${link.id}-${index}`}
isActive={isMatchOrChildMatch(link, activeItem)}
link={{ ...link, subTitle: undefined, onClick: undefined }}
>
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
</NavBarItem>
))}
{pluginItems.length > 0 &&
pluginItems.map((link, index) => (
<NavBarItem key={`${link.id}-${index}`} isActive={isMatchOrChildMatch(link, activeItem)} link={link}>
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
<ul className={styles.itemList}>
<NavBarItemWithoutMenu
isActive={isMatchOrChildMatch(homeItem, activeItem)}
label="Home"
className={styles.grafanaLogo}
url={homeItem.url}
>
<Icon name="grafana" size="xl" />
</NavBarItemWithoutMenu>
<NavBarItem className={styles.search} isActive={activeItem === searchItem} link={searchItem}>
<Icon name="search" size="xl" />
</NavBarItem>
))}
{configItems.map((link, index) => (
<NavBarItem
key={`${link.id}-${index}`}
isActive={isMatchOrChildMatch(link, activeItem)}
reverseMenuDirection
link={link}
className={cx({ [styles.verticalSpacer]: index === 0 })}
>
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
</NavBarItem>
))}
</ul>
{coreItems.map((link, index) => (
<NavBarItem
key={`${link.id}-${index}`}
isActive={isMatchOrChildMatch(link, activeItem)}
link={{ ...link, subTitle: undefined, onClick: undefined }}
>
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
</NavBarItem>
))}
{pluginItems.length > 0 &&
pluginItems.map((link, index) => (
<NavBarItem key={`${link.id}-${index}`} isActive={isMatchOrChildMatch(link, activeItem)} link={link}>
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
</NavBarItem>
))}
{configItems.map((link, index) => (
<NavBarItem
key={`${link.id}-${index}`}
isActive={isMatchOrChildMatch(link, activeItem)}
reverseMenuDirection
link={link}
className={cx({ [styles.verticalSpacer]: index === 0 })}
>
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
</NavBarItem>
))}
</ul>
</FocusScope>
</NavBarContext.Provider>
</nav>
{showSwitcherModal && <OrgSwitcher onDismiss={toggleSwitcherModal} />}
<div className={styles.menuWrapper}>

View File

@ -16,3 +16,17 @@ export const NavBarItemMenuContext = createContext<NavBarItemMenuContextProps>({
export function useNavBarItemMenuContext(): NavBarItemMenuContextProps {
return useContext(NavBarItemMenuContext);
}
export interface NavBarContextProps {
menuIdOpen: string | null;
setMenuIdOpen: (id: string | null) => void;
}
export const NavBarContext = createContext<NavBarContextProps>({
menuIdOpen: null,
setMenuIdOpen: () => undefined,
});
export function useNavBarContext(): NavBarContextProps {
return useContext(NavBarContext);
}