mirror of
https://github.com/grafana/grafana.git
synced 2025-01-24 23:37:01 -06:00
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:
parent
bf744698a1
commit
e468fcf518
11
package.json
11
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",
|
||||
|
@ -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.
|
||||
*/
|
||||
|
@ -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`} />}
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
`,
|
||||
};
|
||||
};
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
`,
|
||||
});
|
||||
|
128
public/app/core/components/NavBar/NavBarItemMenu.tsx
Normal file
128
public/app/core/components/NavBar/NavBarItemMenu.tsx
Normal 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;
|
||||
`,
|
||||
};
|
||||
}
|
72
public/app/core/components/NavBar/NavBarItemMenuItem.tsx
Normal file
72
public/app/core/components/NavBar/NavBarItemMenuItem.tsx
Normal 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;
|
||||
}
|
||||
`,
|
||||
};
|
||||
}
|
218
public/app/core/components/NavBar/NavBarItemMenuTrigger.tsx
Normal file
218
public/app/core/components/NavBar/NavBarItemMenuTrigger.tsx
Normal 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)};
|
||||
}
|
||||
`,
|
||||
});
|
118
public/app/core/components/NavBar/NavBarItemWithoutMenu.tsx
Normal file
118
public/app/core/components/NavBar/NavBarItemWithoutMenu.tsx
Normal 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)};
|
||||
}
|
||||
`,
|
||||
};
|
||||
}
|
@ -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}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
@ -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`
|
||||
|
@ -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`} />}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
16
public/app/core/components/NavBar/context.tsx
Normal file
16
public/app/core/components/NavBar/context.tsx
Normal 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);
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
71
yarn.lock
71
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
|
||||
|
Loading…
Reference in New Issue
Block a user