mirror of https://github.com/grafana/grafana
Navigation: Refactor mobile menu into it's own component (#41308)
* 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: Hide divider in NavBarMenu + tweak color on section headerpull/41479/head
parent
3be452f995
commit
90d2d1f4da
@ -1,74 +0,0 @@ |
||||
import React from 'react'; |
||||
import { css } from '@emotion/css'; |
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { Icon, IconName, Link, useTheme2 } from '@grafana/ui'; |
||||
|
||||
export interface Props { |
||||
isDivider?: boolean; |
||||
icon?: IconName; |
||||
onClick?: () => void; |
||||
target?: HTMLAnchorElement['target']; |
||||
text: string; |
||||
url?: string; |
||||
} |
||||
|
||||
const DropdownChild = ({ isDivider = false, icon, onClick, target, text, url }: Props) => { |
||||
const theme = useTheme2(); |
||||
const styles = getStyles(theme); |
||||
|
||||
const linkContent = ( |
||||
<div className={styles.linkContent}> |
||||
<div> |
||||
{icon && <Icon data-testid="dropdown-child-icon" name={icon} className={styles.icon} />} |
||||
{text} |
||||
</div> |
||||
{target === '_blank' && ( |
||||
<Icon data-testid="external-link-icon" name="external-link-alt" className={styles.externalLinkIcon} /> |
||||
)} |
||||
</div> |
||||
); |
||||
|
||||
let element = ( |
||||
<button className={styles.element} onClick={onClick}> |
||||
{linkContent} |
||||
</button> |
||||
); |
||||
if (url) { |
||||
element = |
||||
!target && url.startsWith('/') ? ( |
||||
<Link className={styles.element} onClick={onClick} href={url}> |
||||
{linkContent} |
||||
</Link> |
||||
) : ( |
||||
<a className={styles.element} href={url} target={target} rel="noopener" onClick={onClick}> |
||||
{linkContent} |
||||
</a> |
||||
); |
||||
} |
||||
|
||||
return isDivider ? <li data-testid="dropdown-child-divider" className="divider" /> : <li>{element}</li>; |
||||
}; |
||||
|
||||
export default DropdownChild; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
element: css` |
||||
background-color: transparent; |
||||
border: none; |
||||
display: flex; |
||||
width: 100%; |
||||
`,
|
||||
externalLinkIcon: css` |
||||
color: ${theme.colors.text.secondary}; |
||||
margin-left: ${theme.spacing(1)}; |
||||
`,
|
||||
icon: css` |
||||
margin-right: ${theme.spacing(1)}; |
||||
`,
|
||||
linkContent: css` |
||||
display: flex; |
||||
flex: 1; |
||||
flex-direction: row; |
||||
justify-content: space-between; |
||||
`,
|
||||
}); |
@ -0,0 +1,31 @@ |
||||
import React from 'react'; |
||||
import { NavModelItem } from '@grafana/data'; |
||||
import { render, screen } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
import { NavBarMenu } from './NavBarMenu'; |
||||
|
||||
describe('NavBarMenu', () => { |
||||
const mockOnClose = jest.fn(); |
||||
const mockNavItems: NavModelItem[] = []; |
||||
|
||||
beforeEach(() => { |
||||
render(<NavBarMenu onClose={mockOnClose} navItems={mockNavItems} />); |
||||
}); |
||||
|
||||
it('should render the component', () => { |
||||
const sidemenu = screen.getByTestId('navbarmenu'); |
||||
expect(sidemenu).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('has a close button', () => { |
||||
const closeButton = screen.getByRole('button', { name: 'Close navigation menu' }); |
||||
expect(closeButton).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('clicking the close button calls the onClose callback', () => { |
||||
const closeButton = screen.getByRole('button', { name: 'Close navigation menu' }); |
||||
expect(closeButton).toBeInTheDocument(); |
||||
userEvent.click(closeButton); |
||||
expect(mockOnClose).toHaveBeenCalled(); |
||||
}); |
||||
}); |
@ -0,0 +1,121 @@ |
||||
import React, { useRef } from 'react'; |
||||
import { GrafanaTheme2, NavModelItem } from '@grafana/data'; |
||||
import { CustomScrollbar, Icon, IconButton, IconName, useTheme2 } from '@grafana/ui'; |
||||
import { FocusScope } from '@react-aria/focus'; |
||||
import { useOverlay } from '@react-aria/overlays'; |
||||
import { css } from '@emotion/css'; |
||||
import { NavBarMenuItem } from './NavBarMenuItem'; |
||||
|
||||
export interface Props { |
||||
activeItem?: NavModelItem; |
||||
navItems: NavModelItem[]; |
||||
onClose: () => void; |
||||
} |
||||
|
||||
export function NavBarMenu({ activeItem, navItems, onClose }: Props) { |
||||
const theme = useTheme2(); |
||||
const styles = getStyles(theme); |
||||
const ref = useRef(null); |
||||
const { overlayProps } = useOverlay( |
||||
{ |
||||
isDismissable: true, |
||||
isOpen: true, |
||||
onClose, |
||||
}, |
||||
ref |
||||
); |
||||
|
||||
return ( |
||||
<FocusScope contain restoreFocus autoFocus> |
||||
<div data-testid="navbarmenu" className={styles.container} ref={ref} {...overlayProps}> |
||||
<div className={styles.header}> |
||||
<Icon name="bars" size="xl" /> |
||||
<IconButton aria-label="Close navigation menu" name="times" onClick={onClose} size="xl" variant="secondary" /> |
||||
</div> |
||||
<nav className={styles.content}> |
||||
<CustomScrollbar> |
||||
<ul> |
||||
{navItems.map((link, index) => ( |
||||
<div className={styles.section} key={index}> |
||||
<NavBarMenuItem |
||||
isActive={activeItem === link} |
||||
onClick={() => { |
||||
link.onClick?.(); |
||||
onClose(); |
||||
}} |
||||
styleOverrides={styles.sectionHeader} |
||||
target={link.target} |
||||
text={link.text} |
||||
url={link.url} |
||||
/> |
||||
{link.children?.map( |
||||
(childLink, childIndex) => |
||||
!childLink.divider && ( |
||||
<NavBarMenuItem |
||||
key={childIndex} |
||||
icon={childLink.icon as IconName} |
||||
isActive={activeItem === childLink} |
||||
isDivider={childLink.divider} |
||||
onClick={() => { |
||||
childLink.onClick?.(); |
||||
onClose(); |
||||
}} |
||||
styleOverrides={styles.item} |
||||
target={childLink.target} |
||||
text={childLink.text} |
||||
url={childLink.url} |
||||
/> |
||||
) |
||||
)} |
||||
</div> |
||||
))} |
||||
</ul> |
||||
</CustomScrollbar> |
||||
</nav> |
||||
</div> |
||||
</FocusScope> |
||||
); |
||||
} |
||||
|
||||
NavBarMenu.displayName = 'NavBarMenu'; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
container: css` |
||||
background-color: ${theme.colors.background.canvas}; |
||||
bottom: 0; |
||||
display: flex; |
||||
flex-direction: column; |
||||
left: 0; |
||||
min-width: 300px; |
||||
position: fixed; |
||||
right: 0; |
||||
top: 0; |
||||
|
||||
${theme.breakpoints.up('md')} { |
||||
border-right: 1px solid ${theme.colors.border.weak}; |
||||
right: unset; |
||||
} |
||||
`,
|
||||
content: css` |
||||
display: flex; |
||||
flex-direction: column; |
||||
overflow: auto; |
||||
`,
|
||||
header: css` |
||||
border-bottom: 1px solid ${theme.colors.border.weak}; |
||||
display: flex; |
||||
justify-content: space-between; |
||||
padding: ${theme.spacing(2)}; |
||||
`,
|
||||
item: css` |
||||
padding: ${theme.spacing(1)} ${theme.spacing(2)}; |
||||
`,
|
||||
section: css` |
||||
border-bottom: 1px solid ${theme.colors.border.weak}; |
||||
`,
|
||||
sectionHeader: css` |
||||
color: ${theme.colors.text.primary}; |
||||
font-size: ${theme.typography.h5.fontSize}; |
||||
padding: ${theme.spacing(1)} ${theme.spacing(2)}; |
||||
`,
|
||||
}); |
@ -0,0 +1,117 @@ |
||||
import React from 'react'; |
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { Icon, IconName, Link, useTheme2 } from '@grafana/ui'; |
||||
import { css } from '@emotion/css'; |
||||
|
||||
export interface Props { |
||||
icon?: IconName; |
||||
isActive?: boolean; |
||||
isDivider?: boolean; |
||||
onClick?: () => void; |
||||
styleOverrides?: string; |
||||
target?: HTMLAnchorElement['target']; |
||||
text: string; |
||||
url?: string; |
||||
} |
||||
|
||||
export function NavBarMenuItem({ icon, isActive, isDivider, onClick, styleOverrides, target, text, url }: Props) { |
||||
const theme = useTheme2(); |
||||
const styles = getStyles(theme, isActive, styleOverrides); |
||||
|
||||
const linkContent = ( |
||||
<div className={styles.linkContent}> |
||||
<div> |
||||
{icon && <Icon data-testid="dropdown-child-icon" name={icon} className={styles.icon} />} |
||||
{text} |
||||
</div> |
||||
{target === '_blank' && ( |
||||
<Icon data-testid="external-link-icon" name="external-link-alt" className={styles.externalLinkIcon} /> |
||||
)} |
||||
</div> |
||||
); |
||||
|
||||
let element = ( |
||||
<button className={styles.element} onClick={onClick}> |
||||
{linkContent} |
||||
</button> |
||||
); |
||||
|
||||
if (url) { |
||||
element = |
||||
!target && url.startsWith('/') ? ( |
||||
<Link className={styles.element} href={url} target={target} onClick={onClick}> |
||||
{linkContent} |
||||
</Link> |
||||
) : ( |
||||
<a href={url} target={target} className={styles.element} onClick={onClick}> |
||||
{linkContent} |
||||
</a> |
||||
); |
||||
} |
||||
|
||||
return isDivider ? <li data-testid="dropdown-child-divider" className={styles.divider} /> : <li>{element}</li>; |
||||
} |
||||
|
||||
NavBarMenuItem.displayName = 'NavBarMenuItem'; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive'], styleOverrides: Props['styleOverrides']) => ({ |
||||
divider: css` |
||||
border-bottom: 1px solid ${theme.colors.border.weak}; |
||||
height: 1px; |
||||
margin: ${theme.spacing(1)} 0; |
||||
overflow: hidden; |
||||
`,
|
||||
element: css` |
||||
align-items: center; |
||||
background: none; |
||||
border: none; |
||||
color: ${isActive ? theme.colors.text.primary : theme.colors.text.secondary}; |
||||
display: flex; |
||||
font-size: inherit; |
||||
height: 100%; |
||||
padding: 5px 12px 5px 10px; |
||||
position: relative; |
||||
text-align: left; |
||||
white-space: nowrap; |
||||
width: 100%; |
||||
|
||||
&:hover, |
||||
&:focus-visible { |
||||
background-color: ${theme.colors.action.hover}; |
||||
color: ${theme.colors.text.primary}; |
||||
} |
||||
|
||||
&:focus-visible { |
||||
box-shadow: none; |
||||
outline: 2px solid ${theme.colors.primary.main}; |
||||
outline-offset: -2px; |
||||
transition: none; |
||||
} |
||||
|
||||
&::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}; |
||||
} |
||||
${styleOverrides}; |
||||
`,
|
||||
externalLinkIcon: css` |
||||
color: ${theme.colors.text.secondary}; |
||||
margin-left: ${theme.spacing(1)}; |
||||
`,
|
||||
icon: css` |
||||
margin-right: ${theme.spacing(1)}; |
||||
`,
|
||||
linkContent: css` |
||||
display: flex; |
||||
flex: 1; |
||||
flex-direction: row; |
||||
justify-content: space-between; |
||||
`,
|
||||
}); |
Loading…
Reference in new issue