mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
83140f7369
commit
7ae72e1195
@ -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}
|
||||
|
103
public/app/core/components/NavBar/Next/NavBarItemMenu.tsx
Normal file
103
public/app/core/components/NavBar/Next/NavBarItemMenu.tsx
Normal 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;
|
||||
`,
|
||||
};
|
||||
}
|
@ -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>
|
||||
);
|
||||
|
@ -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({
|
||||
|
@ -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}>
|
||||
|
@ -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);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user