mirror of https://github.com/grafana/grafana
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>pull/42762/head
parent
bf744698a1
commit
e468fcf518
@ -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> |
||||
); |
||||
|
||||
const child = screen.getByTestId('mockChild'); |
||||
expect(child).toBeInTheDocument(); |
||||
}); |
||||
describe('when url property is not set', () => { |
||||
it('then it renders the menu trigger as a button', () => { |
||||
getTestContext(); |
||||
|
||||
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(); |
||||
|
||||
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> |
||||
); |
||||
|
||||
const child = screen.getByTestId('mockChild'); |
||||
expect(child).toBeInTheDocument(); |
||||
userEvent.click(child); |
||||
expect(window.location.pathname).toEqual(mockUrl); |
||||
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 an onClick if provided', () => { |
||||
const mockLabel = 'Hello'; |
||||
const mockOnClick = jest.fn(); |
||||
render( |
||||
<BrowserRouter> |
||||
<NavBarItem label={mockLabel} onClick={mockOnClick}> |
||||
<div data-testid="mockChild" /> |
||||
</NavBarItem> |
||||
</BrowserRouter> |
||||
); |
||||
|
||||
const child = screen.getByTestId('mockChild'); |
||||
expect(child).toBeInTheDocument(); |
||||
userEvent.click(child); |
||||
expect(mockOnClick).toHaveBeenCalled(); |
||||
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' } }); |
||||
|
||||
expect(screen.getAllByRole('link')).toHaveLength(1); |
||||
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://www.grafana.com'); |
||||
}); |
||||
|
||||
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' } }); |
||||
|
||||
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 (!target && url.startsWith('/')) { |
||||
locationService.push(url); |
||||
} else { |
||||
window.open(url, target); |
||||
} |
||||
}; |
||||
|
||||
if (url) { |
||||
element = |
||||
!target && url.startsWith('/') ? ( |
||||
<Link |
||||
className={styles.element} |
||||
href={url} |
||||
target={target} |
||||
aria-label={label} |
||||
onClick={onClick} |
||||
aria-haspopup="true" |
||||
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; |
||||
`,
|
||||
}); |
||||
|
@ -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; |
||||
`,
|
||||
}; |
||||
} |
@ -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; |
||||
} |
||||
`,
|
||||
}; |
||||
} |
@ -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)}; |
||||
} |
||||
`,
|
||||
}); |
@ -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)}; |
||||
} |
||||
`,
|
||||
}; |
||||
} |
@ -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); |
||||
} |
Loading…
Reference in new issue