diff --git a/public/app/core/components/NavBar/Next/NavBarItem.tsx b/public/app/core/components/NavBar/Next/NavBarItem.tsx index 61c3b9400a2..752e8807431 100644 --- a/public/app/core/components/NavBar/Next/NavBarItem.tsx +++ b/public/app/core/components/NavBar/Next/NavBarItem.tsx @@ -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 (
  • - + { + 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({ ...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 = ( + + ); + + const itemComponents = items.map((item) => ( + + )); + + const subTitleComponent = menuSubTitle && ( +
  • + {menuSubTitle} +
  • + ); + + const menu = [sectionComponent, itemComponents, subTitleComponent]; + + return ( + + ); +} + +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; + `, + }; +} diff --git a/public/app/core/components/NavBar/Next/NavBarItemMenuTrigger.tsx b/public/app/core/components/NavBar/Next/NavBarItemMenuTrigger.tsx index edd29e0cd48..9d81447e735 100644 --- a/public/app/core/components/NavBar/Next/NavBarItemMenuTrigger.tsx +++ b/public/app/core/components/NavBar/Next/NavBarItemMenuTrigger.tsx @@ -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(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 (
    {element} {state.isOpen && ( - state.close(), - onLeft: () => { - setMenuHasFocus(false); - ref.current?.focus(); - }, - }} - > - -
    - state.close()} /> - {menu} - state.close()} /> -
    -
    -
    + + state.close(), + onLeft: () => { + setMenuHasFocus(false); + ref.current?.focus(); + }, + }} + > + +
    + state.close()} /> + {menu} + state.close()} /> +
    +
    +
    +
    )}
    ); diff --git a/public/app/core/components/NavBar/Next/NavBarItemWithoutMenu.tsx b/public/app/core/components/NavBar/Next/NavBarItemWithoutMenu.tsx index f4de8e34142..35a700d6c1c 100644 --- a/public/app/core/components/NavBar/Next/NavBarItemWithoutMenu.tsx +++ b/public/app/core/components/NavBar/Next/NavBarItemWithoutMenu.tsx @@ -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({ diff --git a/public/app/core/components/NavBar/Next/NavBarNext.tsx b/public/app/core/components/NavBar/Next/NavBarNext.tsx index f5b2a1895c0..6a86d4b71d4 100644 --- a/public/app/core/components/NavBar/Next/NavBarNext.tsx +++ b/public/app/core/components/NavBar/Next/NavBarNext.tsx @@ -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(null); if (kiosk !== KioskMode.Off) { return null; @@ -65,55 +68,64 @@ export const NavBarNext = React.memo(() => { return (
    {showSwitcherModal && }
    diff --git a/public/app/core/components/NavBar/context.tsx b/public/app/core/components/NavBar/context.tsx index e2c5b0c0759..2beb99d8ab6 100644 --- a/public/app/core/components/NavBar/context.tsx +++ b/public/app/core/components/NavBar/context.tsx @@ -16,3 +16,17 @@ export const NavBarItemMenuContext = createContext({ export function useNavBarItemMenuContext(): NavBarItemMenuContextProps { return useContext(NavBarItemMenuContext); } + +export interface NavBarContextProps { + menuIdOpen: string | null; + setMenuIdOpen: (id: string | null) => void; +} + +export const NavBarContext = createContext({ + menuIdOpen: null, + setMenuIdOpen: () => undefined, +}); + +export function useNavBarContext(): NavBarContextProps { + return useContext(NavBarContext); +}