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 { NavBarMenuItem } from './NavBarMenuItem';
|
||||||
import { getNavBarItemWithoutMenuStyles, NavBarItemWithoutMenu } from './NavBarItemWithoutMenu';
|
import { getNavBarItemWithoutMenuStyles, NavBarItemWithoutMenu } from './NavBarItemWithoutMenu';
|
||||||
import { NavBarItemMenuTrigger } from './NavBarItemMenuTrigger';
|
import { NavBarItemMenuTrigger } from './NavBarItemMenuTrigger';
|
||||||
import { NavBarItemMenu } from '../NavBarItemMenu';
|
import { NavBarItemMenu } from './NavBarItemMenu';
|
||||||
import { getNavModelItemKey } from '../utils';
|
import { getNavModelItemKey } from '../utils';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import menuItemTranslations from '../navBarItem-translations';
|
import menuItemTranslations from '../navBarItem-translations';
|
||||||
@ -82,7 +82,12 @@ const NavBarItem = ({
|
|||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<li className={cx(styles.container, className)}>
|
<li className={cx(styles.container, className)}>
|
||||||
<NavBarItemMenuTrigger item={section} isActive={isActive} label={linkText}>
|
<NavBarItemMenuTrigger
|
||||||
|
item={section}
|
||||||
|
isActive={isActive}
|
||||||
|
label={linkText}
|
||||||
|
reverseMenuDirection={reverseMenuDirection}
|
||||||
|
>
|
||||||
<NavBarItemMenu
|
<NavBarItemMenu
|
||||||
items={items}
|
items={items}
|
||||||
reverseMenuDirection={reverseMenuDirection}
|
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 { useFocusWithin, useHover, useKeyboard } from '@react-aria/interactions';
|
||||||
import { useButton } from '@react-aria/button';
|
import { useButton } from '@react-aria/button';
|
||||||
import { useDialog } from '@react-aria/dialog';
|
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 { FocusScope } from '@react-aria/focus';
|
||||||
|
|
||||||
import { NavBarItemMenuContext } from '../context';
|
import { NavBarItemMenuContext, useNavBarContext } from '../context';
|
||||||
import { NavFeatureHighlight } from '../NavFeatureHighlight';
|
import { NavFeatureHighlight } from '../NavFeatureHighlight';
|
||||||
import { reportExperimentView } from '@grafana/runtime';
|
import { reportExperimentView } from '@grafana/runtime';
|
||||||
|
|
||||||
@ -20,11 +20,13 @@ export interface NavBarItemMenuTriggerProps extends MenuTriggerProps {
|
|||||||
item: NavModelItem;
|
item: NavModelItem;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
|
reverseMenuDirection: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactElement {
|
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 [menuHasFocus, setMenuHasFocus] = useState(false);
|
||||||
|
const { menuIdOpen, setMenuIdOpen } = useNavBarContext();
|
||||||
const theme = useTheme2();
|
const theme = useTheme2();
|
||||||
const styles = getStyles(theme, isActive);
|
const styles = getStyles(theme, isActive);
|
||||||
|
|
||||||
@ -45,23 +47,23 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE
|
|||||||
onHoverChange: (isHovering) => {
|
onHoverChange: (isHovering) => {
|
||||||
if (isHovering) {
|
if (isHovering) {
|
||||||
state.open();
|
state.open();
|
||||||
|
setMenuIdOpen(ref.current?.id || null);
|
||||||
} else {
|
} else {
|
||||||
state.close();
|
state.close();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { focusWithinProps } = useFocusWithin({
|
useEffect(() => {
|
||||||
onFocusWithinChange: (isFocused) => {
|
// close the menu when changing submenus
|
||||||
if (isFocused) {
|
// or when the state of the overlay changes (i.e hovering outside)
|
||||||
state.open();
|
if (menuIdOpen !== ref.current?.id || !state.isOpen) {
|
||||||
}
|
|
||||||
if (!isFocused) {
|
|
||||||
state.close();
|
state.close();
|
||||||
setMenuHasFocus(false);
|
setMenuHasFocus(false);
|
||||||
|
} else {
|
||||||
|
state.open();
|
||||||
}
|
}
|
||||||
},
|
}, [menuIdOpen, state]);
|
||||||
});
|
|
||||||
|
|
||||||
const { keyboardProps } = useKeyboard({
|
const { keyboardProps } = useKeyboard({
|
||||||
onKeyDown: (e) => {
|
onKeyDown: (e) => {
|
||||||
@ -72,6 +74,9 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE
|
|||||||
}
|
}
|
||||||
setMenuHasFocus(true);
|
setMenuHasFocus(true);
|
||||||
break;
|
break;
|
||||||
|
case 'Tab':
|
||||||
|
setMenuIdOpen(null);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
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 { dialogProps } = useDialog({}, overlayRef);
|
||||||
const { overlayProps } = useOverlay(
|
const { overlayProps } = useOverlay(
|
||||||
{
|
{
|
||||||
@ -144,10 +149,35 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE
|
|||||||
overlayRef
|
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 (
|
return (
|
||||||
<div className={cx(styles.element, 'dropdown')} {...focusWithinProps} {...hoverProps}>
|
<div className={cx(styles.element, 'dropdown')} {...focusWithinProps} {...hoverProps}>
|
||||||
{element}
|
{element}
|
||||||
{state.isOpen && (
|
{state.isOpen && (
|
||||||
|
<OverlayContainer>
|
||||||
<NavBarItemMenuContext.Provider
|
<NavBarItemMenuContext.Provider
|
||||||
value={{
|
value={{
|
||||||
menuProps,
|
menuProps,
|
||||||
@ -160,13 +190,14 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FocusScope restoreFocus>
|
<FocusScope restoreFocus>
|
||||||
<div {...overlayProps} {...dialogProps} ref={overlayRef}>
|
<div {...overlayProps} {...overlayPositionProps} {...dialogProps} {...hoverProps} ref={overlayRef}>
|
||||||
<DismissButton onDismiss={() => state.close()} />
|
<DismissButton onDismiss={() => state.close()} />
|
||||||
{menu}
|
{menu}
|
||||||
<DismissButton onDismiss={() => state.close()} />
|
<DismissButton onDismiss={() => state.close()} />
|
||||||
</div>
|
</div>
|
||||||
</FocusScope>
|
</FocusScope>
|
||||||
</NavBarItemMenuContext.Provider>
|
</NavBarItemMenuContext.Provider>
|
||||||
|
</OverlayContainer>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -76,12 +76,6 @@ export function getNavBarItemWithoutMenuStyles(theme: GrafanaTheme2, isActive?:
|
|||||||
'&:hover': {
|
'&:hover': {
|
||||||
backgroundColor: theme.colors.action.hover,
|
backgroundColor: theme.colors.action.hover,
|
||||||
color: theme.colors.text.primary,
|
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({
|
element: css({
|
||||||
|
@ -14,6 +14,8 @@ import { NavBarMenu } from './NavBarMenu';
|
|||||||
import NavBarItem from './NavBarItem';
|
import NavBarItem from './NavBarItem';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu';
|
import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu';
|
||||||
|
import { FocusScope } from '@react-aria/focus';
|
||||||
|
import { NavBarContext } from '../context';
|
||||||
|
|
||||||
const onOpenSearch = () => {
|
const onOpenSearch = () => {
|
||||||
locationService.partial({ search: 'open' });
|
locationService.partial({ search: 'open' });
|
||||||
@ -57,6 +59,7 @@ export const NavBarNext = React.memo(() => {
|
|||||||
);
|
);
|
||||||
const activeItem = isSearchActive(location) ? searchItem : getActiveItem(navTree, location.pathname);
|
const activeItem = isSearchActive(location) ? searchItem : getActiveItem(navTree, location.pathname);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [menuIdOpen, setMenuIdOpen] = useState<string | null>(null);
|
||||||
|
|
||||||
if (kiosk !== KioskMode.Off) {
|
if (kiosk !== KioskMode.Off) {
|
||||||
return null;
|
return null;
|
||||||
@ -65,6 +68,13 @@ export const NavBarNext = React.memo(() => {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.navWrapper}>
|
<div className={styles.navWrapper}>
|
||||||
<nav className={cx(styles.sidemenu, 'sidemenu')} data-testid="sidemenu" aria-label="Main menu">
|
<nav className={cx(styles.sidemenu, 'sidemenu')} data-testid="sidemenu" aria-label="Main menu">
|
||||||
|
<NavBarContext.Provider
|
||||||
|
value={{
|
||||||
|
menuIdOpen: menuIdOpen,
|
||||||
|
setMenuIdOpen: setMenuIdOpen,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FocusScope>
|
||||||
<div className={styles.mobileSidemenuLogo} onClick={() => setMenuOpen(!menuOpen)} key="hamburger">
|
<div className={styles.mobileSidemenuLogo} onClick={() => setMenuOpen(!menuOpen)} key="hamburger">
|
||||||
<Icon name="bars" size="xl" />
|
<Icon name="bars" size="xl" />
|
||||||
</div>
|
</div>
|
||||||
@ -114,6 +124,8 @@ export const NavBarNext = React.memo(() => {
|
|||||||
</NavBarItem>
|
</NavBarItem>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</FocusScope>
|
||||||
|
</NavBarContext.Provider>
|
||||||
</nav>
|
</nav>
|
||||||
{showSwitcherModal && <OrgSwitcher onDismiss={toggleSwitcherModal} />}
|
{showSwitcherModal && <OrgSwitcher onDismiss={toggleSwitcherModal} />}
|
||||||
<div className={styles.menuWrapper}>
|
<div className={styles.menuWrapper}>
|
||||||
|
@ -16,3 +16,17 @@ export const NavBarItemMenuContext = createContext<NavBarItemMenuContextProps>({
|
|||||||
export function useNavBarItemMenuContext(): NavBarItemMenuContextProps {
|
export function useNavBarItemMenuContext(): NavBarItemMenuContextProps {
|
||||||
return useContext(NavBarItemMenuContext);
|
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