From e468fcf518f7dd9f56f313b88e9fb1205ad7f040 Mon Sep 17 00:00:00 2001
From: Maria Alexandra <239999+axelavargas@users.noreply.github.com>
Date: Mon, 6 Dec 2021 00:58:17 -0500
Subject: [PATCH] Navigation: Implement Keyboard Navigation (#41618)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Navigation: Start creating new NavBarMenu component
* Navigation: Apply new NavBarMenu to NavBarNext
* Navigation: Remove everything to do with .sidemenu-open--xs
* Navigation: Ensure search is passed to NavBarMenu
* Navigation: Standardise NavBarMenuItem
* This extra check isn't needed anymore
* Navigation: Refactor
out of NavBarMenu
* Navigation: Combine NavBarMenuItem with DropdownChild
* use spread syntax since performance shouldn't be a concern for such small arrays
* Improve active item logic
* Ensure unique keys
* Remove this duplicate code
* Add unit tests for getActiveItem
* Add tests for NavBarMenu
* Rename mobileMenuOpen -> menuOpen in NavBarNext (since it can be used for mobile menu or megamenu)
* just use index to key the items
* Use exact versions of @react-aria packages
* Navigation: Make the dropdown header a NavBarMenuItem
* Navigation: Stop using dropdown-menu for styles
* Navigation: Add react-aria relevant packages
* Navigation: Refactor NavBarDropdown to support react aria
* Navigation: apply keyboard navigation to NavBar component
* Navigation: UseHover hook for triggering submenu on navbar
* Navigation: rename testMenu component to NavBarItemButton
* WIP
* some hacks
* Refactor: clean up keybinding events
* Navigation: render subtitle on item menu and disable it
* Navigation: Adds react-aria types (#42113)
* Refactor: refactor out to NavBarItemWithoutMenu
* Refactor: cleaning up stuff
* Refactor: comment out unused code
* Chore: Removes section and uses items only
* Chore: fix NavBarNext
* Chore: adds tests
* Refactor: minimize props api
* Refactor: various refactors
* Refactor: rename enableAllItems
* Refactor: remove unused code
* Refactor: fix clicking on menuitems
* Refactor: use recommended onAction instead
* Navigation: Fix a11y issues on NavBar
* Navigation: Fix a11y navBar Next
* Navigation: Remove unnecessary label prop, use link.text instead
* Apply suggestions from code review
Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com>
Co-authored-by: Ashley Harrison
* Apply unit tests suggestions from code review
Co-authored-by: Alex Khomenko
* Update react-aria/menu package to latest version and apply PR suggestion
Co-authored-by: Ashley Harrison
Co-authored-by: Hugo Häggmark
Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com>
Co-authored-by: Alex Khomenko
---
package.json | 11 +
packages/grafana-data/src/types/navModel.ts | 6 +
public/app/core/components/NavBar/NavBar.tsx | 25 +-
.../components/NavBar/NavBarDropdown.test.tsx | 52 ----
.../core/components/NavBar/NavBarDropdown.tsx | 108 ---------
.../components/NavBar/NavBarItem.test.tsx | 173 ++++++++++----
.../app/core/components/NavBar/NavBarItem.tsx | 225 +++++++++---------
.../core/components/NavBar/NavBarItemMenu.tsx | 128 ++++++++++
.../components/NavBar/NavBarItemMenuItem.tsx | 72 ++++++
.../NavBar/NavBarItemMenuTrigger.tsx | 218 +++++++++++++++++
.../NavBar/NavBarItemWithoutMenu.tsx | 118 +++++++++
.../app/core/components/NavBar/NavBarMenu.tsx | 2 +
.../core/components/NavBar/NavBarMenuItem.tsx | 34 ++-
.../app/core/components/NavBar/NavBarNext.tsx | 45 +---
.../core/components/NavBar/NavBarSection.tsx | 4 +-
public/app/core/components/NavBar/context.tsx | 16 ++
public/app/core/components/NavBar/utils.ts | 4 +
yarn.lock | 71 +++---
18 files changed, 911 insertions(+), 401 deletions(-)
delete mode 100644 public/app/core/components/NavBar/NavBarDropdown.test.tsx
delete mode 100644 public/app/core/components/NavBar/NavBarDropdown.tsx
create mode 100644 public/app/core/components/NavBar/NavBarItemMenu.tsx
create mode 100644 public/app/core/components/NavBar/NavBarItemMenuItem.tsx
create mode 100644 public/app/core/components/NavBar/NavBarItemMenuTrigger.tsx
create mode 100644 public/app/core/components/NavBar/NavBarItemWithoutMenu.tsx
create mode 100644 public/app/core/components/NavBar/context.tsx
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 &&
}
@@ -94,13 +88,8 @@ export const NavBar: FC = React.memo(() => {
{link.icon && }
{link.img &&
}
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?.icon && }
+ {item?.img &&
}
+
+
+ );
+ }
+
+ 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 = (
-