Navigation: Implement Keyboard Navigation (#41618)

* 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 <li> 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 <ashley.harrison@grafana.com>

* Apply unit tests suggestions from code review

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

* Update react-aria/menu package to latest version and apply PR suggestion

Co-authored-by: Ashley Harrison <ashley.harrison@grafana.com>
Co-authored-by: Hugo Häggmark <hugo.haggmark@grafana.com>
Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com>
Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>
This commit is contained in:
Maria Alexandra 2021-12-06 00:58:17 -05:00 committed by GitHub
parent bf744698a1
commit e468fcf518
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 911 additions and 401 deletions

View File

@ -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",

View File

@ -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.
*/

View File

@ -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(() => {
</div>
<NavBarSection>
<NavBarItem url={homeUrl} label="Home" className={styles.grafanaLogo} showMenu={false}>
<NavBarItemWithoutMenu label="Home" className={styles.grafanaLogo} url={homeUrl}>
<Branding.MenuLogo />
</NavBarItem>
<NavBarItem
className={styles.search}
isActive={activeItem === searchItem}
label={searchItem.text}
onClick={searchItem.onClick}
>
</NavBarItemWithoutMenu>
<NavBarItem className={styles.search} isActive={activeItem === searchItem} link={searchItem}>
<Icon name="search" size="xl" />
</NavBarItem>
</NavBarSection>
@ -76,10 +73,7 @@ export const NavBar: FC = React.memo(() => {
<NavBarItem
key={`${link.id}-${index}`}
isActive={isMatchOrChildMatch(link, activeItem)}
label={link.text}
menuItems={link.children}
target={link.target}
url={link.url}
link={{ ...link, subTitle: undefined, onClick: undefined }}
>
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
@ -94,13 +88,8 @@ export const NavBar: FC = React.memo(() => {
<NavBarItem
key={`${link.id}-${index}`}
isActive={isMatchOrChildMatch(link, activeItem)}
label={link.text}
menuItems={link.children}
menuSubTitle={link.subTitle}
onClick={link.onClick}
reverseMenuDirection
target={link.target}
url={link.url}
link={link}
>
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} alt={`${link.text} logo`} />}

View File

@ -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(<NavBarDropdown headerText={mockHeaderText} />);
const text = screen.getByText(mockHeaderText);
expect(text).toBeInTheDocument();
});
it('attaches the header url to the header text if provided', () => {
render(
<BrowserRouter>
<NavBarDropdown headerText={mockHeaderText} headerUrl={mockHeaderUrl} isVisible />
</BrowserRouter>
);
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(<NavBarDropdown headerText={mockHeaderText} onHeaderClick={mockOnHeaderClick} />);
const text = screen.getByText(mockHeaderText);
expect(text).toBeInTheDocument();
userEvent.click(text);
expect(mockOnHeaderClick).toHaveBeenCalled();
});
it('displays the items', () => {
render(<NavBarDropdown headerText={mockHeaderText} items={mockItems} />);
mockItems.forEach(({ text }) => {
const childItem = screen.getByText(text);
expect(childItem).toBeInTheDocument();
});
});
});

View File

@ -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 (
<ul className={`${styles.menu} navbar-dropdown`} role="menu">
<NavBarMenuItem
onClick={onHeaderClick}
styleOverrides={styles.header}
target={headerTarget}
text={headerText}
url={headerUrl}
/>
{filteredItems.map((child, index) => (
<NavBarMenuItem
key={index}
isDivider={child.divider}
icon={child.icon as IconName}
onClick={child.onClick}
styleOverrides={styles.item}
target={child.target}
text={child.text}
url={child.url}
/>
))}
{subtitleText && <li className={styles.subtitle}>{subtitleText}</li>}
</ul>
);
};
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;
`,
};
};

View File

@ -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<Props> = {}) {
jest.clearAllMocks();
const props = { ...defaults, ...overrides };
const { rerender } = render(
<BrowserRouter>
<NavBarItem {...props}>{props.children}</NavBarItem>
</BrowserRouter>
);
return { rerender };
}
describe('NavBarItem', () => {
it('renders the children', () => {
const mockLabel = 'Hello';
render(
<BrowserRouter>
<NavBarItem label={mockLabel}>
<div data-testid="mockChild" />
</NavBarItem>
</BrowserRouter>
);
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(
<BrowserRouter>
<NavBarItem label={mockLabel} url={mockUrl}>
<div data-testid="mockChild" />
</NavBarItem>
</BrowserRouter>
);
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(
<BrowserRouter>
<NavBarItem label={mockLabel} onClick={mockOnClick}>
<div data-testid="mockChild" />
</NavBarItem>
</BrowserRouter>
);
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');
});
});
});
});

View File

@ -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 = (
<button className={styles.element} onClick={onClick} aria-label={label}>
<span className={styles.icon}>{children}</span>
</button>
);
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('/') ? (
<Link
className={styles.element}
href={url}
target={target}
aria-label={label}
onClick={onClick}
aria-haspopup="true"
if (!target && url.startsWith('/')) {
locationService.push(url);
} else {
window.open(url, target);
}
};
return showMenu ? (
<li className={cx(styles.container, className)}>
<NavBarItemMenuTrigger item={section} isActive={isActive} label={link.text}>
<NavBarItemMenu
items={items}
reverseMenuDirection={reverseMenuDirection}
adjustHeightForBorder={adjustHeightForBorder}
disabledKeys={['divider', 'subtitle']}
aria-label={section.text}
onNavigate={onNavigate}
>
<span className={styles.icon}>{children}</span>
</Link>
) : (
<a href={url} target={target} className={styles.element} onClick={onClick} aria-label={label}>
<span className={styles.icon}>{children}</span>
</a>
);
}
{(item: NavModelItem) => {
if (item.menuItemType === NavMenuItemType.Section) {
return (
<Item key={getNavModelItemKey(item)} textValue={item.text}>
<NavBarMenuItem
target={item.target}
text={item.text}
url={item.url}
onClick={item.onClick}
styleOverrides={styles.header}
/>
</Item>
);
}
return (
<div className={cx(styles.container, className)}>
{element}
{showMenu && (
<NavBarDropdown
headerTarget={target}
headerText={label}
headerUrl={url}
items={menuItems}
onHeaderClick={onClick}
reverseDirection={reverseMenuDirection}
subtitleText={menuSubTitle}
/>
)}
</div>
return (
<Item key={getNavModelItemKey(item)} textValue={item.text}>
<NavBarMenuItem
isDivider={item.divider}
icon={item.icon as IconName}
onClick={item.onClick}
target={item.target}
text={item.text}
url={item.url}
styleOverrides={styles.item}
/>
</Item>
);
}}
</NavBarItemMenu>
</NavBarItemMenuTrigger>
</li>
) : (
<NavBarItemWithoutMenu
label={link.text}
className={className}
isActive={isActive}
url={link.url}
onClick={link.onClick}
target={link.target}
>
{children}
</NavBarItemWithoutMenu>
);
};
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;
`,
});

View File

@ -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<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, adjustHeightForBorder, 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.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 = (
<NavBarItemMenuItem key={section.key} item={section} state={state} onNavigate={onNavigate} />
);
const subTitleComponent = (
<li key={menuSubTitle} className={styles.menuItem}>
<div className={styles.subtitle}>{menuSubTitle}</div>
</li>
);
return (
<ul
className={`${styles.menu} navbar-dropdown`}
ref={ref}
{...mergeProps(menuProps, contextMenuProps)}
tabIndex={menuHasFocus ? 0 : -1}
>
{!reverseMenuDirection ? sectionComponent : null}
{menuSubTitle && reverseMenuDirection ? subTitleComponent : null}
{items.map((item, index) => {
return (
<NavBarItemMenuItem key={getNavModelItemKey(item.value)} item={item} state={state} onNavigate={onNavigate} />
);
})}
{reverseMenuDirection ? sectionComponent : null}
{menuSubTitle && !reverseMenuDirection ? subTitleComponent : null}
</ul>
);
}
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;
`,
};
}

View File

@ -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<NavModelItem>;
state: TreeState<NavModelItem>;
onNavigate: (item: NavModelItem) => void;
}
export function NavBarItemMenuItem({ item, state, onNavigate }: NavBarItemMenuItemProps): ReactElement {
const { onClose } = useNavBarItemMenuContext();
const { key, rendered } = item;
const ref = useRef<HTMLLIElement>(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 (
<li {...mergeProps(menuItemProps, focusProps)} ref={ref} className={styles.menuItem}>
{rendered}
</li>
);
}
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;
}
`,
};
}

View File

@ -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 = (
<button
className={styles.element}
{...buttonProps}
{...keyboardProps}
ref={ref}
onClick={item?.onClick}
aria-label={label}
>
<span className={styles.icon}>
{item?.icon && <Icon name={item.icon as IconName} size="xl" />}
{item?.img && <img src={item.img} alt={`${item.text} logo`} />}
</span>
</button>
);
if (item?.url) {
element =
!item.target && item.url.startsWith('/') ? (
<Link
{...buttonProps}
{...keyboardProps}
ref={ref}
href={item.url}
target={item.target}
onClick={item?.onClick}
className={styles.element}
aria-label={label}
>
<span className={styles.icon}>
{item?.icon && <Icon name={item.icon as IconName} size="xl" />}
{item?.img && <img src={item.img} alt={`${item.text} logo`} />}
</span>
</Link>
) : (
<a
href={item.url}
target={item.target}
onClick={item?.onClick}
{...buttonProps}
{...keyboardProps}
ref={ref}
className={styles.element}
aria-label={label}
>
<span className={styles.icon}>
{item?.icon && <Icon name={item.icon as IconName} size="xl" />}
{item?.img && <img src={item.img} alt={`${item.text} logo`} />}
</span>
</a>
);
}
const overlayRef = React.useRef(null);
const { overlayProps } = useOverlay(
{
onClose: () => state.close(),
shouldCloseOnBlur: true,
isOpen: state.isOpen,
isDismissable: true,
},
overlayRef
);
return (
<div className={cx(styles.element, 'dropdown')} {...focusWithinProps} {...hoverProps}>
{element}
{state.isOpen && (
<NavBarItemMenuContext.Provider value={{ menuProps, menuHasFocus, onClose: () => state.close() }}>
<FocusScope restoreFocus>
<div {...overlayProps} ref={overlayRef}>
<DismissButton onDismiss={() => state.close()} />
{menu}
<DismissButton onDismiss={() => state.close()} />
</div>
</FocusScope>
</NavBarItemMenuContext.Provider>
)}
</div>
);
}
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)};
}
`,
});

View File

@ -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 (
<li className={cx(styles.container, className)}>
{!url && (
<button className={styles.element} onClick={onClick} aria-label={label}>
<span className={styles.icon}>{children}</span>
</button>
)}
{url && (
<>
{!target && url.startsWith('/') ? (
<Link
className={styles.element}
href={url}
target={target}
aria-label={label}
onClick={onClick}
aria-haspopup="true"
>
<span className={styles.icon}>{children}</span>
</Link>
) : (
<a href={url} target={target} className={styles.element} onClick={onClick} aria-label={label}>
<span className={styles.icon}>{children}</span>
</a>
)}
</>
)}
</li>
);
}
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)};
}
`,
};
}

View File

@ -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}
/>
)
)}

View File

@ -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 = (
<button className={styles.element} onClick={onClick}>
<button className={styles.element} onClick={onClick} tabIndex={-1}>
{linkContent}
</button>
);
@ -39,17 +51,28 @@ export function NavBarMenuItem({ icon, isActive, isDivider, onClick, styleOverri
if (url) {
element =
!target && url.startsWith('/') ? (
<Link className={styles.element} href={url} target={target} onClick={onClick}>
<Link className={styles.element} href={url} target={target} onClick={onClick} tabIndex={!isMobile ? -1 : 0}>
{linkContent}
</Link>
) : (
<a href={url} target={target} className={styles.element} onClick={onClick}>
<a href={url} target={target} className={styles.element} onClick={onClick} tabIndex={!isMobile ? -1 : 0}>
{linkContent}
</a>
);
}
if (isMobile) {
return isDivider ? (
<li data-testid="dropdown-child-divider" className={styles.divider} tabIndex={-1} aria-disabled />
) : (
<li>{element}</li>
);
}
return isDivider ? <li data-testid="dropdown-child-divider" className={styles.divider} /> : <li>{element}</li>;
return isDivider ? (
<div data-testid="dropdown-child-divider" className={styles.divider} tabIndex={-1} aria-disabled />
) : (
<>{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`

View File

@ -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(() => {
</div>
<NavBarSection>
<NavBarItem
onClick={() => setMenuOpen(!menuOpen)}
label="Main menu"
className={styles.grafanaLogo}
showMenu={false}
>
<NavBarItemWithoutMenu label="Main menu" className={styles.grafanaLogo} onClick={() => setMenuOpen(!menuOpen)}>
<Branding.MenuLogo />
</NavBarItem>
<NavBarItem
className={styles.search}
isActive={activeItem === searchItem}
label={searchItem.text}
onClick={searchItem.onClick}
>
</NavBarItemWithoutMenu>
<NavBarItem className={styles.search} isActive={activeItem === searchItem} link={searchItem}>
<Icon name="search" size="xl" />
</NavBarItem>
</NavBarSection>
@ -79,10 +71,7 @@ export const NavBarNext: FC = React.memo(() => {
<NavBarItem
key={`${link.id}-${index}`}
isActive={isMatchOrChildMatch(link, activeItem)}
label={link.text}
menuItems={link.children}
target={link.target}
url={link.url}
link={{ ...link, subTitle: undefined, onClick: undefined }}
>
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
@ -93,16 +82,7 @@ export const NavBarNext: FC = React.memo(() => {
{pluginItems.length > 0 && (
<NavBarSection>
{pluginItems.map((link, index) => (
<NavBarItem
key={`${link.id}-${index}`}
isActive={isMatchOrChildMatch(link, activeItem)}
label={link.text}
menuItems={link.children}
menuSubTitle={link.subTitle}
onClick={link.onClick}
target={link.target}
url={link.url}
>
<NavBarItem key={`${link.id}-${index}`} isActive={isMatchOrChildMatch(link, activeItem)} link={link}>
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
</NavBarItem>
@ -117,13 +97,8 @@ export const NavBarNext: FC = React.memo(() => {
<NavBarItem
key={`${link.id}-${index}`}
isActive={isMatchOrChildMatch(link, activeItem)}
label={link.text}
menuItems={link.children}
menuSubTitle={link.subTitle}
onClick={link.onClick}
reverseMenuDirection
target={link.target}
url={link.url}
link={link}
>
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} alt={`${link.text} logo`} />}

View File

@ -15,9 +15,9 @@ export function NavBarSection({ children, className }: Props) {
const styles = getStyles(theme, newNavigationEnabled);
return (
<div data-testid="navbar-section" className={cx(styles.container, className)}>
<ul data-testid="navbar-section" className={cx(styles.container, className)}>
{children}
</div>
</ul>
);
}

View File

@ -0,0 +1,16 @@
import { createContext, HTMLAttributes, useContext } from 'react';
export interface NavBarItemMenuContextProps {
menuHasFocus: boolean;
onClose: () => void;
menuProps?: HTMLAttributes<HTMLElement>;
}
export const NavBarItemMenuContext = createContext<NavBarItemMenuContextProps>({
menuHasFocus: false,
onClose: () => undefined,
});
export function useNavBarItemMenuContext(): NavBarItemMenuContextProps {
return useContext(NavBarItemMenuContext);
}

View File

@ -136,3 +136,7 @@ export const isSearchActive = (location: Location<unknown>) => {
const query = new URLSearchParams(location.search);
return query.get('search') === 'open';
};
export function getNavModelItemKey(item: NavModelItem) {
return item.id ?? item.text;
}

View File

@ -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