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 React from 'react'; |
||||||
import { render, screen } from '@testing-library/react'; |
import { render, screen } from '@testing-library/react'; |
||||||
import userEvent from '@testing-library/user-event'; |
|
||||||
import { BrowserRouter } from 'react-router-dom'; |
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', () => { |
describe('NavBarItem', () => { |
||||||
it('renders the children', () => { |
describe('when url property is not set', () => { |
||||||
const mockLabel = 'Hello'; |
it('then it renders the menu trigger as a button', () => { |
||||||
render( |
getTestContext(); |
||||||
<BrowserRouter> |
|
||||||
<NavBarItem label={mockLabel}> |
expect(screen.getAllByRole('button')).toHaveLength(1); |
||||||
<div data-testid="mockChild" /> |
}); |
||||||
</NavBarItem> |
|
||||||
</BrowserRouter> |
describe('and clicking on the menu trigger button', () => { |
||||||
); |
it('then the onClick handler should be called', () => { |
||||||
|
getTestContext(); |
||||||
const child = screen.getByTestId('mockChild'); |
|
||||||
expect(child).toBeInTheDocument(); |
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', () => { |
userEvent.tab(); |
||||||
const mockLabel = 'Hello'; |
|
||||||
const mockUrl = '/route'; |
expect(screen.getByText('Parent Node')).toBeInTheDocument(); |
||||||
render( |
expect(screen.getByText('Child Node 1')).toBeInTheDocument(); |
||||||
<BrowserRouter> |
expect(screen.getByText('Child Node 2')).toBeInTheDocument(); |
||||||
<NavBarItem label={mockLabel} url={mockUrl}> |
}); |
||||||
<div data-testid="mockChild" /> |
}); |
||||||
</NavBarItem> |
|
||||||
</BrowserRouter> |
describe('and pressing arrow right on the menu trigger button', () => { |
||||||
); |
it('then the correct menu item should receive focus', () => { |
||||||
|
getTestContext(); |
||||||
const child = screen.getByTestId('mockChild'); |
|
||||||
expect(child).toBeInTheDocument(); |
userEvent.tab(); |
||||||
userEvent.click(child); |
expect(screen.getAllByRole('menuitem')).toHaveLength(3); |
||||||
expect(window.location.pathname).toEqual(mockUrl); |
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', () => { |
describe('when url property is set', () => { |
||||||
const mockLabel = 'Hello'; |
it('then it renders the menu trigger as a link', () => { |
||||||
const mockOnClick = jest.fn(); |
getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } }); |
||||||
render( |
|
||||||
<BrowserRouter> |
expect(screen.getAllByRole('link')).toHaveLength(1); |
||||||
<NavBarItem label={mockLabel} onClick={mockOnClick}> |
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://www.grafana.com'); |
||||||
<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.hover(screen.getByRole('link')); |
||||||
userEvent.click(child); |
|
||||||
expect(mockOnClick).toHaveBeenCalled(); |
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 React, { ReactNode } from 'react'; |
||||||
|
import { Item } from '@react-stately/collections'; |
||||||
import { css, cx } from '@emotion/css'; |
import { css, cx } from '@emotion/css'; |
||||||
import { GrafanaTheme2, NavModelItem } from '@grafana/data'; |
import { GrafanaTheme2, NavMenuItemType, NavModelItem } from '@grafana/data'; |
||||||
import { Link, useTheme2 } from '@grafana/ui'; |
import { IconName, useTheme2 } from '@grafana/ui'; |
||||||
import NavBarDropdown from './NavBarDropdown'; |
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 { |
export interface Props { |
||||||
isActive?: boolean; |
isActive?: boolean; |
||||||
children: ReactNode; |
children: ReactNode; |
||||||
className?: string; |
className?: string; |
||||||
label: string; |
|
||||||
menuItems?: NavModelItem[]; |
|
||||||
menuSubTitle?: string; |
|
||||||
onClick?: () => void; |
|
||||||
reverseMenuDirection?: boolean; |
reverseMenuDirection?: boolean; |
||||||
showMenu?: boolean; |
showMenu?: boolean; |
||||||
target?: HTMLAnchorElement['target']; |
link: NavModelItem; |
||||||
url?: string; |
|
||||||
} |
} |
||||||
|
|
||||||
const NavBarItem = ({ |
const NavBarItem = ({ |
||||||
isActive = false, |
isActive = false, |
||||||
children, |
children, |
||||||
className, |
className, |
||||||
label, |
|
||||||
menuItems = [], |
|
||||||
menuSubTitle, |
|
||||||
onClick, |
|
||||||
reverseMenuDirection = false, |
reverseMenuDirection = false, |
||||||
showMenu = true, |
showMenu = true, |
||||||
target, |
link, |
||||||
url, |
|
||||||
}: Props) => { |
}: Props) => { |
||||||
const theme = useTheme2(); |
const theme = useTheme2(); |
||||||
const styles = getStyles(theme, isActive); |
const menuItems = link.children ?? []; |
||||||
let element = ( |
const menuItemsSorted = reverseMenuDirection ? menuItems.reverse() : menuItems; |
||||||
<button className={styles.element} onClick={onClick} aria-label={label}> |
const filteredItems = menuItemsSorted |
||||||
<span className={styles.icon}>{children}</span> |
.filter((item) => !item.hideFromMenu) |
||||||
</button> |
.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) { |
return showMenu ? ( |
||||||
element = |
<li className={cx(styles.container, className)}> |
||||||
!target && url.startsWith('/') ? ( |
<NavBarItemMenuTrigger item={section} isActive={isActive} label={link.text}> |
||||||
<Link |
<NavBarItemMenu |
||||||
className={styles.element} |
items={items} |
||||||
href={url} |
reverseMenuDirection={reverseMenuDirection} |
||||||
target={target} |
adjustHeightForBorder={adjustHeightForBorder} |
||||||
aria-label={label} |
disabledKeys={['divider', 'subtitle']} |
||||||
onClick={onClick} |
aria-label={section.text} |
||||||
aria-haspopup="true" |
onNavigate={onNavigate} |
||||||
> |
> |
||||||
<span className={styles.icon}>{children}</span> |
{(item: NavModelItem) => { |
||||||
</Link> |
if (item.menuItemType === NavMenuItemType.Section) { |
||||||
) : ( |
return ( |
||||||
<a href={url} target={target} className={styles.element} onClick={onClick} aria-label={label}> |
<Item key={getNavModelItemKey(item)} textValue={item.text}> |
||||||
<span className={styles.icon}>{children}</span> |
<NavBarMenuItem |
||||||
</a> |
target={item.target} |
||||||
); |
text={item.text} |
||||||
} |
url={item.url} |
||||||
|
onClick={item.onClick} |
||||||
|
styleOverrides={styles.header} |
||||||
|
/> |
||||||
|
</Item> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
return ( |
return ( |
||||||
<div className={cx(styles.container, className)}> |
<Item key={getNavModelItemKey(item)} textValue={item.text}> |
||||||
{element} |
<NavBarMenuItem |
||||||
{showMenu && ( |
isDivider={item.divider} |
||||||
<NavBarDropdown |
icon={item.icon as IconName} |
||||||
headerTarget={target} |
onClick={item.onClick} |
||||||
headerText={label} |
target={item.target} |
||||||
headerUrl={url} |
text={item.text} |
||||||
items={menuItems} |
url={item.url} |
||||||
onHeaderClick={onClick} |
styleOverrides={styles.item} |
||||||
reverseDirection={reverseMenuDirection} |
/> |
||||||
subtitleText={menuSubTitle} |
</Item> |
||||||
/> |
); |
||||||
)} |
}} |
||||||
</div> |
</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; |
export default NavBarItem; |
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({ |
const getStyles = ( |
||||||
container: css` |
theme: GrafanaTheme2, |
||||||
position: relative; |
adjustHeightForBorder: boolean, |
||||||
color: ${isActive ? theme.colors.text.primary : theme.colors.text.secondary}; |
isActive?: boolean, |
||||||
|
reverseMenuDirection?: boolean |
||||||
&:hover { |
) => ({ |
||||||
background-color: ${theme.colors.action.hover}; |
...getNavBarItemWithoutMenuStyles(theme, isActive), |
||||||
color: ${theme.colors.text.primary}; |
header: css` |
||||||
|
background-color: ${theme.colors.background.secondary}; |
||||||
// TODO don't use a hardcoded class here, use isVisible in NavBarDropdown
|
color: ${theme.colors.text.primary}; |
||||||
.navbar-dropdown { |
height: ${theme.components.sidemenu.width - (adjustHeightForBorder ? 2 : 1)}px; |
||||||
opacity: 1; |
font-size: ${theme.typography.h4.fontSize}; |
||||||
visibility: visible; |
font-weight: ${theme.typography.h4.fontWeight}; |
||||||
} |
padding: ${theme.spacing(1)} ${theme.spacing(2)}; |
||||||
} |
white-space: nowrap; |
||||||
`,
|
|
||||||
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%; |
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