diff --git a/package.json b/package.json index 66abad7357d..417430711c2 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,10 @@ "@kusto/monaco-kusto": "4.0.6", "@microsoft/api-extractor": "7.18.16", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.1", + "@react-types/button": "3.4.1", + "@react-types/menu": "3.4.1", + "@react-types/overlays": "3.5.1", + "@react-types/shared": "3.9.0", "@rtsao/plugin-proposal-class-properties": "7.0.1-patch.1", "@swc/core": "1.2.103", "@swc/helpers": "0.2.13", @@ -243,8 +247,15 @@ "@opentelemetry/exporter-collector": "0.23.0", "@opentelemetry/semantic-conventions": "1.0.0", "@popperjs/core": "2.5.4", + "@react-aria/button": "3.3.4", "@react-aria/focus": "3.5.0", + "@react-aria/interactions": "3.6.0", + "@react-aria/menu": "3.3.0", "@react-aria/overlays": "3.7.2", + "@react-aria/utils": "3.9.0", + "@react-stately/collections": "3.3.4", + "@react-stately/menu": "3.2.3", + "@react-stately/tree": "3.2.0", "@reduxjs/toolkit": "1.6.1", "@sentry/browser": "6.15.0", "@sentry/types": "6.15.0", diff --git a/packages/grafana-data/src/types/navModel.ts b/packages/grafana-data/src/types/navModel.ts index 4ef66f69dbf..23457500d37 100644 --- a/packages/grafana-data/src/types/navModel.ts +++ b/packages/grafana-data/src/types/navModel.ts @@ -16,6 +16,7 @@ export interface NavModelItem { section?: NavSection; showOrgSwitcher?: boolean; onClick?: () => void; + menuItemType?: NavMenuItemType; } export enum NavSection { @@ -24,6 +25,11 @@ export enum NavSection { Config = 'config', } +export enum NavMenuItemType { + Section = 'section', + Item = 'item', +} + /** * Interface used to describe different kinds of page titles and page navigation. Navmodels are usually generated in the backend and stored in Redux. */ diff --git a/public/app/core/components/NavBar/NavBar.tsx b/public/app/core/components/NavBar/NavBar.tsx index c23ab43fa55..81430427f64 100644 --- a/public/app/core/components/NavBar/NavBar.tsx +++ b/public/app/core/components/NavBar/NavBar.tsx @@ -13,6 +13,7 @@ import { OrgSwitcher } from '../OrgSwitcher'; import NavBarItem from './NavBarItem'; import { NavBarSection } from './NavBarSection'; import { NavBarMenu } from './NavBarMenu'; +import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu'; const homeUrl = config.appSubUrl || '/'; @@ -24,6 +25,7 @@ const searchItem: NavModelItem = { id: SEARCH_ITEM_ID, onClick: onOpenSearch, text: 'Search dashboards', + icon: 'search', }; export const NavBar: FC = React.memo(() => { @@ -58,15 +60,10 @@ export const NavBar: FC = React.memo(() => { - + - - + + @@ -76,10 +73,7 @@ export const NavBar: FC = React.memo(() => { {link.icon && } {link.img && {`${link.text}} @@ -94,13 +88,8 @@ export const NavBar: FC = React.memo(() => { {link.icon && } {link.img && {`${link.text}} diff --git a/public/app/core/components/NavBar/NavBarDropdown.test.tsx b/public/app/core/components/NavBar/NavBarDropdown.test.tsx deleted file mode 100644 index b220cb4362e..00000000000 --- a/public/app/core/components/NavBar/NavBarDropdown.test.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { BrowserRouter } from 'react-router-dom'; -import NavBarDropdown from './NavBarDropdown'; - -describe('NavBarDropdown', () => { - const mockHeaderText = 'MyHeaderText'; - const mockHeaderUrl = '/route'; - const mockOnHeaderClick = jest.fn(); - const mockItems = [ - { - text: 'First link', - }, - { - text: 'Second link', - }, - ]; - - it('displays the header text', () => { - render(); - const text = screen.getByText(mockHeaderText); - expect(text).toBeInTheDocument(); - }); - - it('attaches the header url to the header text if provided', () => { - render( - - - - ); - const link = screen.getByRole('link', { name: mockHeaderText }); - expect(link).toBeInTheDocument(); - expect(link).toHaveAttribute('href', mockHeaderUrl); - }); - - it('calls the onHeaderClick function when the header is clicked', () => { - render(); - const text = screen.getByText(mockHeaderText); - expect(text).toBeInTheDocument(); - userEvent.click(text); - expect(mockOnHeaderClick).toHaveBeenCalled(); - }); - - it('displays the items', () => { - render(); - mockItems.forEach(({ text }) => { - const childItem = screen.getByText(text); - expect(childItem).toBeInTheDocument(); - }); - }); -}); diff --git a/public/app/core/components/NavBar/NavBarDropdown.tsx b/public/app/core/components/NavBar/NavBarDropdown.tsx deleted file mode 100644 index a3141f56ec1..00000000000 --- a/public/app/core/components/NavBar/NavBarDropdown.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import React from 'react'; -import { css } from '@emotion/css'; -import { GrafanaTheme2, NavModelItem } from '@grafana/data'; -import { IconName, useTheme2 } from '@grafana/ui'; -import { NavBarMenuItem } from './NavBarMenuItem'; - -interface Props { - headerTarget?: HTMLAnchorElement['target']; - headerText: string; - headerUrl?: string; - isVisible?: boolean; - items?: NavModelItem[]; - onHeaderClick?: () => void; - reverseDirection?: boolean; - subtitleText?: string; -} - -const NavBarDropdown = ({ - headerTarget, - headerText, - headerUrl, - isVisible, - items = [], - onHeaderClick, - reverseDirection = false, - subtitleText, -}: Props) => { - const filteredItems = items.filter((item) => !item.hideFromMenu); - const theme = useTheme2(); - const styles = getStyles(theme, reverseDirection, filteredItems, isVisible); - - return ( -
    - - {filteredItems.map((child, index) => ( - - ))} - {subtitleText &&
  • {subtitleText}
  • } -
- ); -}; - -export default NavBarDropdown; - -const getStyles = ( - theme: GrafanaTheme2, - reverseDirection: Props['reverseDirection'], - filteredItems: Props['items'], - isVisible: Props['isVisible'] -) => { - const adjustHeightForBorder = filteredItems!.length === 0; - - return { - header: css` - background-color: ${theme.colors.background.secondary}; - color: ${theme.colors.text.primary}; - height: ${theme.components.sidemenu.width - (adjustHeightForBorder ? 2 : 1)}px; - font-size: ${theme.typography.h4.fontSize}; - font-weight: ${theme.typography.h4.fontWeight}; - padding: ${theme.spacing(1)} ${theme.spacing(2)}; - white-space: nowrap; - width: 100%; - `, - item: css` - color: ${theme.colors.text.primary}; - `, - 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: ${reverseDirection ? 'column-reverse' : 'column'}; - left: 100%; - list-style: none; - min-width: 140px; - opacity: ${isVisible ? 1 : 0}; - position: absolute; - top: ${reverseDirection ? 'auto' : 0}; - transition: ${theme.transitions.create('opacity')}; - visibility: ${isVisible ? 'visible' : 'hidden'}; - z-index: ${theme.zIndex.sidemenu}; - `, - subtitle: css` - 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)}; - white-space: nowrap; - `, - }; -}; diff --git a/public/app/core/components/NavBar/NavBarItem.test.tsx b/public/app/core/components/NavBar/NavBarItem.test.tsx index 402b2a589f7..49a47820b9a 100644 --- a/public/app/core/components/NavBar/NavBarItem.test.tsx +++ b/public/app/core/components/NavBar/NavBarItem.test.tsx @@ -1,55 +1,144 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { BrowserRouter } from 'react-router-dom'; -import NavBarItem from './NavBarItem'; +import NavBarItem, { Props } from './NavBarItem'; +import userEvent from '@testing-library/user-event'; + +const onClickMock = jest.fn(); +const defaults: Props = { + children: undefined, + link: { + text: 'Parent Node', + onClick: onClickMock, + children: [ + { text: 'Child Node 1', onClick: onClickMock, children: [] }, + { text: 'Child Node 2', onClick: onClickMock, children: [] }, + ], + }, +}; + +function getTestContext(overrides: Partial = {}) { + jest.clearAllMocks(); + const props = { ...defaults, ...overrides }; + + const { rerender } = render( + + {props.children} + + ); + + return { rerender }; +} describe('NavBarItem', () => { - it('renders the children', () => { - const mockLabel = 'Hello'; - render( - - -
- - - ); + describe('when url property is not set', () => { + it('then it renders the menu trigger as a button', () => { + getTestContext(); - const child = screen.getByTestId('mockChild'); - expect(child).toBeInTheDocument(); + expect(screen.getAllByRole('button')).toHaveLength(1); + }); + + describe('and clicking on the menu trigger button', () => { + it('then the onClick handler should be called', () => { + getTestContext(); + + userEvent.click(screen.getByRole('button')); + + expect(onClickMock).toHaveBeenCalledTimes(1); + }); + }); + + describe('and hovering over the menu trigger button', () => { + it('then the menu items should be visible', () => { + getTestContext(); + + userEvent.hover(screen.getByRole('button')); + + expect(screen.getByRole('menuitem', { name: 'Parent Node' })).toBeInTheDocument(); + expect(screen.getByText('Child Node 1')).toBeInTheDocument(); + expect(screen.getByText('Child Node 2')).toBeInTheDocument(); + }); + }); + + describe('and tabbing to the menu trigger button', () => { + it('then the menu items should be visible', () => { + getTestContext(); + + userEvent.tab(); + + expect(screen.getByText('Parent Node')).toBeInTheDocument(); + expect(screen.getByText('Child Node 1')).toBeInTheDocument(); + expect(screen.getByText('Child Node 2')).toBeInTheDocument(); + }); + }); + + describe('and pressing arrow right on the menu trigger button', () => { + it('then the correct menu item should receive focus', () => { + getTestContext(); + + userEvent.tab(); + expect(screen.getAllByRole('menuitem')).toHaveLength(3); + expect(screen.getByRole('menuitem', { name: 'Parent Node' })).toHaveAttribute('tabIndex', '-1'); + expect(screen.getAllByRole('menuitem')[1]).toHaveAttribute('tabIndex', '-1'); + expect(screen.getAllByRole('menuitem')[2]).toHaveAttribute('tabIndex', '-1'); + + userEvent.keyboard('{arrowright}'); + expect(screen.getAllByRole('menuitem')).toHaveLength(3); + expect(screen.getAllByRole('menuitem')[0]).toHaveAttribute('tabIndex', '0'); + expect(screen.getAllByRole('menuitem')[1]).toHaveAttribute('tabIndex', '-1'); + expect(screen.getAllByRole('menuitem')[2]).toHaveAttribute('tabIndex', '-1'); + }); + }); }); - it('wraps the children in a link to the url if provided', () => { - const mockLabel = 'Hello'; - const mockUrl = '/route'; - render( - - -
- - - ); + describe('when url property is set', () => { + it('then it renders the menu trigger as a link', () => { + getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } }); - const child = screen.getByTestId('mockChild'); - expect(child).toBeInTheDocument(); - userEvent.click(child); - expect(window.location.pathname).toEqual(mockUrl); - }); + expect(screen.getAllByRole('link')).toHaveLength(1); + expect(screen.getByRole('link')).toHaveAttribute('href', 'https://www.grafana.com'); + }); - it('wraps the children in an onClick if provided', () => { - const mockLabel = 'Hello'; - const mockOnClick = jest.fn(); - render( - - -
- - - ); + describe('and hovering over the menu trigger link', () => { + it('then the menu items should be visible', () => { + getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } }); - const child = screen.getByTestId('mockChild'); - expect(child).toBeInTheDocument(); - userEvent.click(child); - expect(mockOnClick).toHaveBeenCalled(); + userEvent.hover(screen.getByRole('link')); + + expect(screen.getByText('Parent Node')).toBeInTheDocument(); + expect(screen.getByText('Child Node 1')).toBeInTheDocument(); + expect(screen.getByText('Child Node 2')).toBeInTheDocument(); + }); + }); + + describe('and tabbing to the menu trigger link', () => { + it('then the menu items should be visible', () => { + getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } }); + + userEvent.tab(); + + expect(screen.getByText('Parent Node')).toBeInTheDocument(); + expect(screen.getByText('Child Node 1')).toBeInTheDocument(); + expect(screen.getByText('Child Node 2')).toBeInTheDocument(); + }); + }); + + describe('and pressing arrow right on the menu trigger link', () => { + it('then the correct menu item should receive focus', () => { + getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } }); + + userEvent.tab(); + expect(screen.getAllByRole('menuitem')).toHaveLength(3); + expect(screen.getAllByRole('menuitem')[0]).toHaveAttribute('tabIndex', '-1'); + expect(screen.getAllByRole('menuitem')[1]).toHaveAttribute('tabIndex', '-1'); + expect(screen.getAllByRole('menuitem')[2]).toHaveAttribute('tabIndex', '-1'); + + userEvent.keyboard('{arrowright}'); + expect(screen.getAllByRole('menuitem')).toHaveLength(3); + expect(screen.getAllByRole('menuitem')[0]).toHaveAttribute('tabIndex', '0'); + expect(screen.getAllByRole('menuitem')[1]).toHaveAttribute('tabIndex', '-1'); + expect(screen.getAllByRole('menuitem')[2]).toHaveAttribute('tabIndex', '-1'); + }); + }); }); }); diff --git a/public/app/core/components/NavBar/NavBarItem.tsx b/public/app/core/components/NavBar/NavBarItem.tsx index b12a34346a5..c049451ca70 100644 --- a/public/app/core/components/NavBar/NavBarItem.tsx +++ b/public/app/core/components/NavBar/NavBarItem.tsx @@ -1,139 +1,146 @@ import React, { ReactNode } from 'react'; +import { Item } from '@react-stately/collections'; import { css, cx } from '@emotion/css'; -import { GrafanaTheme2, NavModelItem } from '@grafana/data'; -import { Link, useTheme2 } from '@grafana/ui'; -import NavBarDropdown from './NavBarDropdown'; +import { GrafanaTheme2, NavMenuItemType, NavModelItem } from '@grafana/data'; +import { IconName, useTheme2 } from '@grafana/ui'; +import { locationService } from '@grafana/runtime'; + +import { NavBarMenuItem } from './NavBarMenuItem'; +import { getNavBarItemWithoutMenuStyles, NavBarItemWithoutMenu } from './NavBarItemWithoutMenu'; +import { NavBarItemMenuTrigger } from './NavBarItemMenuTrigger'; +import { NavBarItemMenu } from './NavBarItemMenu'; +import { getNavModelItemKey } from './utils'; export interface Props { isActive?: boolean; children: ReactNode; className?: string; - label: string; - menuItems?: NavModelItem[]; - menuSubTitle?: string; - onClick?: () => void; reverseMenuDirection?: boolean; showMenu?: boolean; - target?: HTMLAnchorElement['target']; - url?: string; + link: NavModelItem; } const NavBarItem = ({ isActive = false, children, className, - label, - menuItems = [], - menuSubTitle, - onClick, reverseMenuDirection = false, showMenu = true, - target, - url, + link, }: Props) => { const theme = useTheme2(); - const styles = getStyles(theme, isActive); - let element = ( - - ); + const menuItems = link.children ?? []; + const menuItemsSorted = reverseMenuDirection ? menuItems.reverse() : menuItems; + const filteredItems = menuItemsSorted + .filter((item) => !item.hideFromMenu) + .map((i) => ({ ...i, menuItemType: NavMenuItemType.Item })); + const adjustHeightForBorder = filteredItems.length === 0; + const styles = getStyles(theme, adjustHeightForBorder, isActive, reverseMenuDirection); + const section: NavModelItem = { + ...link, + children: filteredItems, + menuItemType: NavMenuItemType.Section, + }; + const items: NavModelItem[] = [section].concat(filteredItems); + const onNavigate = (item: NavModelItem) => { + const { url, target, onClick } = item; + if (!url) { + onClick?.(); + return; + } - if (url) { - element = - !target && url.startsWith('/') ? ( - + + - {children} - - ) : ( - - {children} - - ); - } + {(item: NavModelItem) => { + if (item.menuItemType === NavMenuItemType.Section) { + return ( + + + + ); + } - return ( -
- {element} - {showMenu && ( - - )} -
+ return ( + + + + ); + }} +
+
+ + ) : ( + + {children} + ); }; export default NavBarItem; -const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({ - container: css` - position: relative; - color: ${isActive ? theme.colors.text.primary : theme.colors.text.secondary}; - - &:hover { - background-color: ${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` - background-color: transparent; - border: none; - color: inherit; - display: block; - line-height: ${theme.components.sidemenu.width}px; - padding: 0; - text-align: center; - width: ${theme.components.sidemenu.width}px; - - &::before { - display: ${isActive ? 'block' : 'none'}; - content: ' '; - position: absolute; - left: 0; - top: 0; - bottom: 0; - width: 4px; - border-radius: 2px; - background-image: ${theme.colors.gradients.brandVertical}; - } - - &:focus-visible { - background-color: ${theme.colors.action.hover}; - box-shadow: none; - color: ${theme.colors.text.primary}; - outline: 2px solid ${theme.colors.primary.main}; - outline-offset: -2px; - transition: none; - } - `, - icon: css` - height: 100%; +const getStyles = ( + theme: GrafanaTheme2, + adjustHeightForBorder: boolean, + isActive?: boolean, + reverseMenuDirection?: boolean +) => ({ + ...getNavBarItemWithoutMenuStyles(theme, isActive), + header: css` + background-color: ${theme.colors.background.secondary}; + color: ${theme.colors.text.primary}; + height: ${theme.components.sidemenu.width - (adjustHeightForBorder ? 2 : 1)}px; + font-size: ${theme.typography.h4.fontSize}; + font-weight: ${theme.typography.h4.fontWeight}; + padding: ${theme.spacing(1)} ${theme.spacing(2)}; + white-space: nowrap; width: 100%; - - img { - border-radius: 50%; - height: ${theme.spacing(3)}; - width: ${theme.spacing(3)}; - } `, + item: css` + color: ${theme.colors.text.primary}; + `, + subtitle: css` + border-${reverseMenuDirection ? '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)}; + white-space: nowrap; + `, }); diff --git a/public/app/core/components/NavBar/NavBarItemMenu.tsx b/public/app/core/components/NavBar/NavBarItemMenu.tsx new file mode 100644 index 00000000000..ea815bc93f7 --- /dev/null +++ b/public/app/core/components/NavBar/NavBarItemMenu.tsx @@ -0,0 +1,128 @@ +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 { + 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, adjustHeightForBorder, 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.isFocused) { + state.selectionManager.setFocused(false); + state.selectionManager.clearSelection(); + } + }, [menuHasFocus, state.selectionManager, reverseMenuDirection, section?.key]); + + if (!section) { + return null; + } + + const menuSubTitle = section.value.subTitle; + + const sectionComponent = ( + + ); + + const subTitleComponent = ( +
  • +
    {menuSubTitle}
    +
  • + ); + + return ( +
      + {!reverseMenuDirection ? sectionComponent : null} + {menuSubTitle && reverseMenuDirection ? subTitleComponent : null} + {items.map((item, index) => { + return ( + + ); + })} + {reverseMenuDirection ? sectionComponent : null} + {menuSubTitle && !reverseMenuDirection ? subTitleComponent : null} +
    + ); +} + +function getStyles( + theme: GrafanaTheme2, + adjustHeightForBorder: boolean, + reverseDirection?: boolean, + isFocused?: 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; + position: absolute; + top: ${reverseDirection ? 'auto' : 0}; + transition: ${theme.transitions.create('opacity')}; + z-index: ${theme.zIndex.sidemenu}; + list-style: none; + `, + menuItem: css` + background-color: ${isFocused ? theme.colors.action.hover : 'transparent'}; + color: ${isFocused ? 'white' : theme.colors.text.primary}; + + &:focus-visible { + background-color: ${theme.colors.action.hover}; + box-shadow: none; + color: ${theme.colors.text.primary}; + outline: 2px solid ${theme.colors.primary.main}; + // Need to add condition, header is 0, otherwise -2 + outline-offset: -0px; + transition: none; + } + `, + subtitle: css` + 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)}; + white-space: nowrap; + `, + }; +} diff --git a/public/app/core/components/NavBar/NavBarItemMenuItem.tsx b/public/app/core/components/NavBar/NavBarItemMenuItem.tsx new file mode 100644 index 00000000000..745a58c3b81 --- /dev/null +++ b/public/app/core/components/NavBar/NavBarItemMenuItem.tsx @@ -0,0 +1,72 @@ +import React, { ReactElement, useRef, useState } from 'react'; +import { css } from '@emotion/css'; +import { useTheme2 } from '@grafana/ui'; +import { GrafanaTheme2, NavModelItem } from '@grafana/data'; +import { useMenuItem } from '@react-aria/menu'; +import { useFocus } from '@react-aria/interactions'; +import { TreeState } from '@react-stately/tree'; +import { mergeProps } from '@react-aria/utils'; +import { Node } from '@react-types/shared'; + +import { useNavBarItemMenuContext } from './context'; + +export interface NavBarItemMenuItemProps { + item: Node; + state: TreeState; + onNavigate: (item: NavModelItem) => void; +} + +export function NavBarItemMenuItem({ item, state, onNavigate }: NavBarItemMenuItemProps): ReactElement { + const { onClose } = useNavBarItemMenuContext(); + const { key, rendered } = item; + const ref = useRef(null); + const isDisabled = state.disabledKeys.has(key); + + // style to the focused menu item + const [isFocused, setFocused] = useState(false); + const { focusProps } = useFocus({ onFocusChange: setFocused, isDisabled }); + const theme = useTheme2(); + const styles = getStyles(theme, isFocused); + const onAction = () => { + onNavigate(item.value); + onClose(); + }; + + let { menuItemProps } = useMenuItem( + { + isDisabled, + 'aria-label': item['aria-label'], + key, + closeOnSelect: true, + onClose, + onAction, + }, + state, + ref + ); + + return ( +
  • + {rendered} +
  • + ); +} + +function getStyles(theme: GrafanaTheme2, isFocused: boolean) { + return { + menuItem: css` + background-color: ${isFocused ? theme.colors.action.hover : 'transparent'}; + color: ${isFocused ? 'white' : theme.colors.text.primary}; + + &:focus-visible { + background-color: ${theme.colors.action.hover}; + box-shadow: none; + color: ${theme.colors.text.primary}; + outline: 2px solid ${theme.colors.primary.main}; + // Need to add condition, header is 0, otherwise -2 + outline-offset: -0px; + transition: none; + } + `, + }; +} diff --git a/public/app/core/components/NavBar/NavBarItemMenuTrigger.tsx b/public/app/core/components/NavBar/NavBarItemMenuTrigger.tsx new file mode 100644 index 00000000000..cdca934c0d7 --- /dev/null +++ b/public/app/core/components/NavBar/NavBarItemMenuTrigger.tsx @@ -0,0 +1,218 @@ +import React, { ReactElement, useState } from 'react'; +import { css, cx } from '@emotion/css'; +import { Icon, IconName, Link, useTheme2 } from '@grafana/ui'; +import { GrafanaTheme2, NavModelItem } from '@grafana/data'; +import { MenuTriggerProps } from '@react-types/menu'; +import { useMenuTriggerState } from '@react-stately/menu'; +import { useMenuTrigger } from '@react-aria/menu'; +import { useFocusVisible, useFocusWithin, useHover, useKeyboard } from '@react-aria/interactions'; +import { useButton } from '@react-aria/button'; +import { DismissButton, useOverlay } from '@react-aria/overlays'; +import { FocusScope } from '@react-aria/focus'; + +import { NavBarItemMenuContext } from './context'; + +export interface NavBarItemMenuTriggerProps extends MenuTriggerProps { + children: ReactElement; + item: NavModelItem; + isActive?: boolean; + label: string; +} + +export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactElement { + const { item, isActive, label, children: menu, ...rest } = props; + const [menuHasFocus, setMenuHasFocus] = useState(false); + const theme = useTheme2(); + const styles = getStyles(theme, isActive); + + // Create state based on the incoming props + const state = useMenuTriggerState({ ...rest }); + + // Get props for the menu trigger and menu elements + const ref = React.useRef(null); + const { menuTriggerProps, menuProps } = useMenuTrigger({}, state, ref); + + // style to the focused menu item + let { isFocusVisible } = useFocusVisible({ isTextInput: false }); + + const { hoverProps } = useHover({ + onHoverChange: (isHovering) => { + if (isHovering) { + state.open(); + } else { + state.close(); + } + }, + }); + + const { focusWithinProps } = useFocusWithin({ + onFocusWithinChange: (isFocused) => { + if (isFocused && isFocusVisible) { + state.open(); + } + if (!isFocused) { + state.close(); + setMenuHasFocus(false); + } + }, + }); + + const { keyboardProps } = useKeyboard({ + onKeyDown: (e) => { + switch (e.key) { + case 'ArrowRight': + if (!state.isOpen) { + state.open(); + } + setMenuHasFocus(true); + break; + default: + break; + } + }, + }); + + // Get props for the button based on the trigger props from useMenuTrigger + const { buttonProps } = useButton(menuTriggerProps, ref); + + let element = ( + + ); + + if (item?.url) { + element = + !item.target && item.url.startsWith('/') ? ( + + + {item?.icon && } + {item?.img && {`${item.text}} + + + ) : ( + + + {item?.icon && } + {item?.img && {`${item.text}} + + + ); + } + + const overlayRef = React.useRef(null); + const { overlayProps } = useOverlay( + { + onClose: () => state.close(), + shouldCloseOnBlur: true, + isOpen: state.isOpen, + isDismissable: true, + }, + overlayRef + ); + + return ( +
    + {element} + {state.isOpen && ( + state.close() }}> + +
    + state.close()} /> + {menu} + state.close()} /> +
    +
    +
    + )} +
    + ); +} + +const getStyles = (theme: GrafanaTheme2, isActive?: boolean) => ({ + container: css` + position: relative; + color: ${isActive ? theme.colors.text.primary : theme.colors.text.secondary}; + list-style: none; + + &:hover { + background-color: ${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` + background-color: transparent; + border: none; + color: inherit; + display: block; + line-height: ${theme.components.sidemenu.width}px; + padding: 0; + text-align: center; + width: ${theme.components.sidemenu.width}px; + + &::before { + display: ${isActive ? 'block' : 'none'}; + content: ' '; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + border-radius: 2px; + background-image: ${theme.colors.gradients.brandVertical}; + } + + &:focus-visible { + background-color: ${theme.colors.action.hover}; + box-shadow: none; + color: ${theme.colors.text.primary}; + outline: 2px solid ${theme.colors.primary.main}; + outline-offset: -2px; + transition: none; + } + `, + icon: css` + height: 100%; + width: 100%; + + img { + border-radius: 50%; + height: ${theme.spacing(3)}; + width: ${theme.spacing(3)}; + } + `, +}); diff --git a/public/app/core/components/NavBar/NavBarItemWithoutMenu.tsx b/public/app/core/components/NavBar/NavBarItemWithoutMenu.tsx new file mode 100644 index 00000000000..0076852adb1 --- /dev/null +++ b/public/app/core/components/NavBar/NavBarItemWithoutMenu.tsx @@ -0,0 +1,118 @@ +import { GrafanaTheme2 } from '../../../../../packages/grafana-data'; +import { css, cx } from '@emotion/css'; +import React, { ReactNode } from 'react'; +import { Link, useTheme2 } from '../../../../../packages/grafana-ui'; + +export interface NavBarItemWithoutMenuProps { + label: string; + children: ReactNode; + className?: string; + url?: string; + target?: string; + isActive?: boolean; + onClick?: () => void; +} + +export function NavBarItemWithoutMenu({ + label, + children, + className, + url, + target, + isActive = false, + onClick, +}: NavBarItemWithoutMenuProps) { + const theme = useTheme2(); + const styles = getNavBarItemWithoutMenuStyles(theme, isActive); + + return ( +
  • + {!url && ( + + )} + {url && ( + <> + {!target && url.startsWith('/') ? ( + + {children} + + ) : ( + + {children} + + )} + + )} +
  • + ); +} + +export function getNavBarItemWithoutMenuStyles(theme: GrafanaTheme2, isActive?: boolean) { + return { + container: css` + position: relative; + color: ${isActive ? theme.colors.text.primary : theme.colors.text.secondary}; + + &:hover { + background-color: ${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` + background-color: transparent; + border: none; + color: inherit; + display: block; + line-height: ${theme.components.sidemenu.width}px; + padding: 0; + text-align: center; + width: ${theme.components.sidemenu.width}px; + + &::before { + display: ${isActive ? 'block' : 'none'}; + content: ' '; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + border-radius: 2px; + background-image: ${theme.colors.gradients.brandVertical}; + } + + &:focus-visible { + background-color: ${theme.colors.action.hover}; + box-shadow: none; + color: ${theme.colors.text.primary}; + outline: 2px solid ${theme.colors.primary.main}; + outline-offset: 2px; + transition: none; + } + `, + icon: css` + height: 100%; + width: 100%; + + img { + border-radius: 50%; + height: ${theme.spacing(3)}; + width: ${theme.spacing(3)}; + } + `, + }; +} diff --git a/public/app/core/components/NavBar/NavBarMenu.tsx b/public/app/core/components/NavBar/NavBarMenu.tsx index a8124e6d6af..c5bb21fac35 100644 --- a/public/app/core/components/NavBar/NavBarMenu.tsx +++ b/public/app/core/components/NavBar/NavBarMenu.tsx @@ -47,6 +47,7 @@ export function NavBarMenu({ activeItem, navItems, onClose }: Props) { target={link.target} text={link.text} url={link.url} + isMobile={true} /> {link.children?.map( (childLink, childIndex) => @@ -64,6 +65,7 @@ export function NavBarMenu({ activeItem, navItems, onClose }: Props) { target={childLink.target} text={childLink.text} url={childLink.url} + isMobile={true} /> ) )} diff --git a/public/app/core/components/NavBar/NavBarMenuItem.tsx b/public/app/core/components/NavBar/NavBarMenuItem.tsx index 79c9997a6df..cb37fe82707 100644 --- a/public/app/core/components/NavBar/NavBarMenuItem.tsx +++ b/public/app/core/components/NavBar/NavBarMenuItem.tsx @@ -12,9 +12,21 @@ export interface Props { target?: HTMLAnchorElement['target']; text: string; url?: string; + adjustHeightForBorder?: boolean; + isMobile?: boolean; } -export function NavBarMenuItem({ icon, isActive, isDivider, onClick, styleOverrides, target, text, url }: Props) { +export function NavBarMenuItem({ + icon, + isActive, + isDivider, + onClick, + styleOverrides, + target, + text, + url, + isMobile = false, +}: Props) { const theme = useTheme2(); const styles = getStyles(theme, isActive, styleOverrides); @@ -31,7 +43,7 @@ export function NavBarMenuItem({ icon, isActive, isDivider, onClick, styleOverri ); let element = ( - ); @@ -39,17 +51,28 @@ export function NavBarMenuItem({ icon, isActive, isDivider, onClick, styleOverri if (url) { element = !target && url.startsWith('/') ? ( - + {linkContent} ) : ( - + {linkContent} ); } + if (isMobile) { + return isDivider ? ( +
  • + ) : ( +
  • {element}
  • + ); + } - return isDivider ?
  • :
  • {element}
  • ; + return isDivider ? ( +
    + ) : ( + <>{element} + ); } NavBarMenuItem.displayName = 'NavBarMenuItem'; @@ -99,6 +122,7 @@ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive'], styleOverr border-radius: 2px; background-image: ${theme.colors.gradients.brandVertical}; } + ${styleOverrides}; `, externalLinkIcon: css` diff --git a/public/app/core/components/NavBar/NavBarNext.tsx b/public/app/core/components/NavBar/NavBarNext.tsx index 73bb74788f4..41d9f8fed02 100644 --- a/public/app/core/components/NavBar/NavBarNext.tsx +++ b/public/app/core/components/NavBar/NavBarNext.tsx @@ -5,14 +5,15 @@ import { cloneDeep } from 'lodash'; import { GrafanaTheme2, NavModelItem, NavSection } from '@grafana/data'; import { Icon, IconName, useTheme2 } from '@grafana/ui'; import { locationService } from '@grafana/runtime'; -import { Branding } from 'app/core/components/Branding/Branding'; import config from 'app/core/config'; import { KioskMode } from 'app/types'; import { enrichConfigItems, getActiveItem, isMatchOrChildMatch, isSearchActive, SEARCH_ITEM_ID } from './utils'; import { OrgSwitcher } from '../OrgSwitcher'; import { NavBarSection } from './NavBarSection'; -import NavBarItem from './NavBarItem'; import { NavBarMenu } from './NavBarMenu'; +import NavBarItem from './NavBarItem'; +import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu'; +import { Branding } from '../Branding/Branding'; const onOpenSearch = () => { locationService.partial({ search: 'open' }); @@ -22,6 +23,7 @@ const searchItem: NavModelItem = { id: SEARCH_ITEM_ID, onClick: onOpenSearch, text: 'Search dashboards', + icon: 'search', }; export const NavBarNext: FC = React.memo(() => { @@ -56,20 +58,10 @@ export const NavBarNext: FC = React.memo(() => {
    - setMenuOpen(!menuOpen)} - label="Main menu" - className={styles.grafanaLogo} - showMenu={false} - > + setMenuOpen(!menuOpen)}> - - + + @@ -79,10 +71,7 @@ export const NavBarNext: FC = React.memo(() => { {link.icon && } {link.img && {`${link.text}} @@ -93,16 +82,7 @@ export const NavBarNext: FC = React.memo(() => { {pluginItems.length > 0 && ( {pluginItems.map((link, index) => ( - + {link.icon && } {link.img && {`${link.text}} @@ -117,13 +97,8 @@ export const NavBarNext: FC = React.memo(() => { {link.icon && } {link.img && {`${link.text}} diff --git a/public/app/core/components/NavBar/NavBarSection.tsx b/public/app/core/components/NavBar/NavBarSection.tsx index 9aa93207a73..f816cd5d05e 100644 --- a/public/app/core/components/NavBar/NavBarSection.tsx +++ b/public/app/core/components/NavBar/NavBarSection.tsx @@ -15,9 +15,9 @@ export function NavBarSection({ children, className }: Props) { const styles = getStyles(theme, newNavigationEnabled); return ( -
    +
      {children} -
    + ); } diff --git a/public/app/core/components/NavBar/context.tsx b/public/app/core/components/NavBar/context.tsx new file mode 100644 index 00000000000..76ff069f76e --- /dev/null +++ b/public/app/core/components/NavBar/context.tsx @@ -0,0 +1,16 @@ +import { createContext, HTMLAttributes, useContext } from 'react'; + +export interface NavBarItemMenuContextProps { + menuHasFocus: boolean; + onClose: () => void; + menuProps?: HTMLAttributes; +} + +export const NavBarItemMenuContext = createContext({ + menuHasFocus: false, + onClose: () => undefined, +}); + +export function useNavBarItemMenuContext(): NavBarItemMenuContextProps { + return useContext(NavBarItemMenuContext); +} diff --git a/public/app/core/components/NavBar/utils.ts b/public/app/core/components/NavBar/utils.ts index 3b122872ffc..80b8bca775f 100644 --- a/public/app/core/components/NavBar/utils.ts +++ b/public/app/core/components/NavBar/utils.ts @@ -136,3 +136,7 @@ export const isSearchActive = (location: Location) => { const query = new URLSearchParams(location.search); return query.get('search') === 'open'; }; + +export function getNavModelItemKey(item: NavModelItem) { + return item.id ?? item.text; +} diff --git a/yarn.lock b/yarn.lock index c7508c68b81..815bbc58296 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6107,7 +6107,7 @@ __metadata: languageName: node linkType: hard -"@react-aria/interactions@npm:^3.5.1, @react-aria/interactions@npm:^3.6.0": +"@react-aria/interactions@npm:3.6.0, @react-aria/interactions@npm:^3.5.1, @react-aria/interactions@npm:^3.6.0": version: 3.6.0 resolution: "@react-aria/interactions@npm:3.6.0" dependencies: @@ -6224,6 +6224,21 @@ __metadata: languageName: node linkType: hard +"@react-aria/utils@npm:3.9.0, @react-aria/utils@npm:^3.8.2, @react-aria/utils@npm:^3.9.0": + version: 3.9.0 + resolution: "@react-aria/utils@npm:3.9.0" + dependencies: + "@babel/runtime": ^7.6.2 + "@react-aria/ssr": ^3.1.0 + "@react-stately/utils": ^3.2.2 + "@react-types/shared": ^3.9.0 + clsx: ^1.1.1 + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 + checksum: ef465effe2d007870342e27e73b179ef51dc21403918a8807931a785606e6199b67e3b6b5e1f0a23ecc8200f4bd8dba558bcdfb1eb6a322955d55c7089ef1136 + languageName: node + linkType: hard + "@react-aria/utils@npm:^3.10.0": version: 3.10.0 resolution: "@react-aria/utils@npm:3.10.0" @@ -6239,21 +6254,6 @@ __metadata: languageName: node linkType: hard -"@react-aria/utils@npm:^3.8.2, @react-aria/utils@npm:^3.9.0": - version: 3.9.0 - resolution: "@react-aria/utils@npm:3.9.0" - dependencies: - "@babel/runtime": ^7.6.2 - "@react-aria/ssr": ^3.1.0 - "@react-stately/utils": ^3.2.2 - "@react-types/shared": ^3.9.0 - clsx: ^1.1.1 - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 - checksum: ef465effe2d007870342e27e73b179ef51dc21403918a8807931a785606e6199b67e3b6b5e1f0a23ecc8200f4bd8dba558bcdfb1eb6a322955d55c7089ef1136 - languageName: node - linkType: hard - "@react-aria/visually-hidden@npm:^3.2.3": version: 3.2.3 resolution: "@react-aria/visually-hidden@npm:3.2.3" @@ -6268,7 +6268,7 @@ __metadata: languageName: node linkType: hard -"@react-stately/collections@npm:^3.3.3": +"@react-stately/collections@npm:3.3.4, @react-stately/collections@npm:^3.3.3": version: 3.3.4 resolution: "@react-stately/collections@npm:3.3.4" dependencies: @@ -6336,7 +6336,7 @@ __metadata: languageName: node linkType: hard -"@react-stately/tree@npm:^3.2.0": +"@react-stately/tree@npm:3.2.0, @react-stately/tree@npm:^3.2.0": version: 3.2.0 resolution: "@react-stately/tree@npm:3.2.0" dependencies: @@ -6373,7 +6373,7 @@ __metadata: languageName: node linkType: hard -"@react-types/button@npm:^3.4.1": +"@react-types/button@npm:3.4.1, @react-types/button@npm:^3.4.1": version: 3.4.1 resolution: "@react-types/button@npm:3.4.1" dependencies: @@ -6395,7 +6395,7 @@ __metadata: languageName: node linkType: hard -"@react-types/menu@npm:^3.3.0": +"@react-types/menu@npm:3.4.1, @react-types/menu@npm:^3.3.0": version: 3.4.1 resolution: "@react-types/menu@npm:3.4.1" dependencies: @@ -6407,7 +6407,7 @@ __metadata: languageName: node linkType: hard -"@react-types/overlays@npm:^3.5.1": +"@react-types/overlays@npm:3.5.1, @react-types/overlays@npm:^3.5.1": version: 3.5.1 resolution: "@react-types/overlays@npm:3.5.1" dependencies: @@ -6418,6 +6418,15 @@ __metadata: languageName: node linkType: hard +"@react-types/shared@npm:3.9.0, @react-types/shared@npm:^3.8.0, @react-types/shared@npm:^3.9.0": + version: 3.9.0 + resolution: "@react-types/shared@npm:3.9.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 + checksum: 0be11ba4234767d47707dff8601605f46bfd9e3e07dcd3213119517f9a627cca4b71e9030478d3941241d8a6be8b8bc00e92278342daad8f7397bc9eee290f1c + languageName: node + linkType: hard + "@react-types/shared@npm:^3.10.0": version: 3.10.0 resolution: "@react-types/shared@npm:3.10.0" @@ -6427,15 +6436,6 @@ __metadata: languageName: node linkType: hard -"@react-types/shared@npm:^3.8.0, @react-types/shared@npm:^3.9.0": - version: 3.9.0 - resolution: "@react-types/shared@npm:3.9.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 - checksum: 0be11ba4234767d47707dff8601605f46bfd9e3e07dcd3213119517f9a627cca4b71e9030478d3941241d8a6be8b8bc00e92278342daad8f7397bc9eee290f1c - languageName: node - linkType: hard - "@reduxjs/toolkit@npm:1.6.1": version: 1.6.1 resolution: "@reduxjs/toolkit@npm:1.6.1" @@ -19393,8 +19393,19 @@ __metadata: "@opentelemetry/semantic-conventions": 1.0.0 "@pmmmwh/react-refresh-webpack-plugin": ^0.5.1 "@popperjs/core": 2.5.4 + "@react-aria/button": 3.3.4 "@react-aria/focus": 3.5.0 + "@react-aria/interactions": 3.6.0 + "@react-aria/menu": 3.3.0 "@react-aria/overlays": 3.7.2 + "@react-aria/utils": 3.9.0 + "@react-stately/collections": 3.3.4 + "@react-stately/menu": 3.2.3 + "@react-stately/tree": 3.2.0 + "@react-types/button": 3.4.1 + "@react-types/menu": 3.4.1 + "@react-types/overlays": 3.5.1 + "@react-types/shared": 3.9.0 "@reduxjs/toolkit": 1.6.1 "@rtsao/plugin-proposal-class-properties": 7.0.1-patch.1 "@sentry/browser": 6.15.0