NavBarMenu: Section as links and design tweak (#55538)

* scaffold new component + remove storing of expanded state

* some padding fixes

* simplify!

* move browse back to being a child of dashboards

* behaviour working

* improve child matcher to look recursively

* increase NavBarMenu zIndex to ensure it overlays explore drawer

* some renaming

* fix unit test

* make dashboards a top level item again and make chevrons their own buttons

* remove active background state

* Finished tweaks

* remove theme change

* Remove exit animation

* align button centrally + fix empty message alignment

* only show the empty message if there are no children

* ensure overflowing menu items truncate correctly

Co-authored-by: Ashley Harrison <ashley.harrison@grafana.com>
pull/55554/head
Torkel Ödegaard 3 years ago committed by GitHub
parent 3a545007ca
commit 8440baab91
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      pkg/api/index.go
  2. 4
      public/app/core/components/MegaMenu/MegaMenu.test.tsx
  3. 21
      public/app/core/components/MegaMenu/NavBarMenu.tsx
  4. 122
      public/app/core/components/MegaMenu/NavBarMenuItem.tsx
  5. 112
      public/app/core/components/MegaMenu/NavBarMenuItemWrapper.tsx
  6. 116
      public/app/core/components/MegaMenu/NavBarMenuSection.tsx
  7. 14
      public/app/core/components/NavBar/utils.ts
  8. 2
      public/app/core/components/PageNew/SectionNavItem.tsx
  9. 3
      public/app/features/dashboard/containers/DashboardPage.tsx

@ -105,7 +105,7 @@ func (hs *HTTPServer) getAppLinks(c *models.ReqContext) ([]*dtos.NavLink, error)
}
if hs.Features.IsEnabled(featuremgmt.FlagTopnav) {
appLink.Url = path.Join(hs.Cfg.AppSubURL, "a", plugin.ID)
appLink.Url = hs.Cfg.AppSubURL + "/a/" + plugin.ID
} else {
appLink.Url = path.Join(hs.Cfg.AppSubURL, plugin.DefaultNavURL)
}
@ -521,6 +521,7 @@ func (hs *HTTPServer) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b
Text: "Browse", Id: "dashboards/browse", Url: hs.Cfg.AppSubURL + "/dashboards", Icon: "sitemap",
})
}
dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
Text: "Playlists", Id: "dashboards/playlists", Url: hs.Cfg.AppSubURL + "/playlists", Icon: "presentation-play",
})

@ -56,8 +56,8 @@ describe('MegaMenu', () => {
setup();
expect(await screen.findByTestId('navbarmenu')).toBeInTheDocument();
expect(await screen.findByLabelText('Home')).toBeInTheDocument();
expect(screen.queryAllByLabelText('Section name').length).toBe(2);
expect(await screen.findByRole('link', { name: 'Home' })).toBeInTheDocument();
expect(await screen.findByRole('link', { name: 'Section name' })).toBeInTheDocument();
});
it('should filter out profile', async () => {

@ -11,9 +11,10 @@ import { CustomScrollbar, Icon, IconButton, useTheme2 } from '@grafana/ui';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { TOP_BAR_LEVEL_HEIGHT } from '../AppChrome/types';
import { NavItem } from '../NavBar/NavBarMenu';
import { NavBarToggle } from '../NavBar/NavBarToggle';
import { NavBarMenuItemWrapper } from './NavBarMenuItemWrapper';
const MENU_WIDTH = '350px';
export interface Props {
@ -59,7 +60,7 @@ export function NavBarMenu({ activeItem, navItems, searchBarHidden, onClose }: P
in={isOpen}
unmountOnExit={true}
classNames={animStyles.overlay}
timeout={animationSpeed}
timeout={{ enter: animationSpeed, exit: 0 }}
onExited={onClose}
>
<div data-testid="navbarmenu" ref={ref} {...overlayProps} {...dialogProps} className={styles.container}>
@ -86,7 +87,7 @@ export function NavBarMenu({ activeItem, navItems, searchBarHidden, onClose }: P
<CustomScrollbar showScrollIndicators hideHorizontalTrack>
<ul className={styles.itemList}>
{navItems.map((link) => (
<NavItem link={link} onClose={onMenuClose} activeItem={activeItem} key={link.text} />
<NavBarMenuItemWrapper link={link} onClose={onMenuClose} activeItem={activeItem} key={link.text} />
))}
</ul>
</CustomScrollbar>
@ -121,18 +122,17 @@ const getStyles = (theme: GrafanaTheme2, searchBarHidden?: boolean) => {
position: 'fixed',
right: 0,
top: topPosition,
zIndex: theme.zIndex.navbarFixed - 2,
zIndex: theme.zIndex.modalBackdrop,
}),
container: css({
display: 'flex',
bottom: 0,
flexDirection: 'column',
left: 0,
paddingTop: theme.spacing(1),
marginRight: theme.spacing(1.5),
right: 0,
// Needs to below navbar should we change the navbarFixed? add add a new level?
zIndex: theme.zIndex.navbarFixed - 1,
zIndex: theme.zIndex.modal,
position: 'fixed',
top: topPosition,
backgroundColor: theme.colors.background.primary,
@ -151,7 +151,7 @@ const getStyles = (theme: GrafanaTheme2, searchBarHidden?: boolean) => {
borderBottom: `1px solid ${theme.colors.border.weak}`,
display: 'flex',
justifyContent: 'space-between',
padding: theme.spacing(1, 2, 2),
padding: theme.spacing(1, 2),
[theme.breakpoints.up('md')]: {
display: 'none',
},
@ -159,11 +159,12 @@ const getStyles = (theme: GrafanaTheme2, searchBarHidden?: boolean) => {
itemList: css({
display: 'grid',
gridAutoRows: `minmax(${theme.spacing(6)}, auto)`,
gridTemplateColumns: `minmax(${MENU_WIDTH}, auto)`,
minWidth: MENU_WIDTH,
}),
menuCollapseIcon: css({
position: 'absolute',
top: '43px',
top: '20px',
right: '0px',
transform: `translateX(50%)`,
}),
@ -219,15 +220,11 @@ const getAnimStyles = (theme: GrafanaTheme2, animationDuration: number) => {
enter: css(backdropClosed),
enterActive: css(backdropTransition, backdropOpen),
enterDone: css(backdropOpen),
exit: css(backdropOpen),
exitActive: css(backdropTransition, backdropClosed),
},
overlay: {
enter: css(overlayClosed),
enterActive: css(overlayTransition, overlayOpen),
enterDone: css(overlayOpen),
exit: css(overlayOpen),
exitActive: css(overlayTransition, overlayClosed),
},
};
};

@ -0,0 +1,122 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, IconName, Link, useTheme2 } from '@grafana/ui';
export interface Props {
children: React.ReactNode;
icon?: IconName;
isActive?: boolean;
isChild?: boolean;
onClick?: () => void;
target?: HTMLAnchorElement['target'];
url?: string;
}
export function NavBarMenuItem({ children, icon, isActive, isChild, onClick, target, url }: Props) {
const theme = useTheme2();
const styles = getStyles(theme, isActive, isChild);
const linkContent = (
<div className={styles.linkContent}>
{icon && <Icon data-testid="dropdown-child-icon" name={icon} />}
<div className={styles.linkText}>{children}</div>
{target === '_blank' && (
<Icon data-testid="external-link-icon" name="external-link-alt" className={styles.externalLinkIcon} />
)}
</div>
);
let element = (
<button className={cx(styles.button, 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 <li className={styles.listItem}>{element}</li>;
}
NavBarMenuItem.displayName = 'NavBarMenuItem';
const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive'], isChild: Props['isActive']) => ({
button: css({
backgroundColor: 'unset',
borderStyle: 'unset',
}),
linkContent: css({
alignItems: 'center',
display: 'flex',
gap: '0.5rem',
height: '100%',
width: '100%',
}),
linkText: css({
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
}),
externalLinkIcon: css({
color: theme.colors.text.secondary,
}),
element: css({
alignItems: 'center',
boxSizing: 'border-box',
position: 'relative',
color: isActive ? theme.colors.text.primary : theme.colors.text.secondary,
padding: theme.spacing(1, 1, 1, isChild ? 5 : 0),
...(isChild && {
borderRadius: theme.shape.borderRadius(),
}),
width: '100%',
'&:hover, &:focus-visible': {
...(isChild && {
background: theme.colors.emphasize(theme.colors.background.primary, 0.03),
}),
textDecoration: 'underline',
color: theme.colors.text.primary,
},
'&:focus-visible': {
boxShadow: 'none',
outline: `2px solid ${theme.colors.primary.main}`,
outlineOffset: '-2px',
transition: 'none',
},
'&::before': {
display: isActive ? 'block' : 'none',
content: '" "',
height: theme.spacing(3),
position: 'absolute',
left: theme.spacing(1),
top: '50%',
transform: 'translateY(-50%)',
width: theme.spacing(0.5),
borderRadius: theme.shape.borderRadius(1),
backgroundImage: theme.colors.gradients.brandVertical,
},
}),
listItem: css({
boxSizing: 'border-box',
position: 'relative',
display: 'flex',
width: '100%',
...(isChild && {
padding: theme.spacing(0, 2),
}),
}),
});

@ -0,0 +1,112 @@
import { css } from '@emotion/css';
import { useLingui } from '@lingui/react';
import React from 'react';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { toIconName, useStyles2 } from '@grafana/ui';
import menuItemTranslations from '../NavBar/navBarItem-translations';
import { isMatchOrChildMatch } from '../NavBar/utils';
import { NavBarMenuItem } from './NavBarMenuItem';
import { NavBarMenuSection } from './NavBarMenuSection';
export function NavBarMenuItemWrapper({
link,
activeItem,
onClose,
}: {
link: NavModelItem;
activeItem?: NavModelItem;
onClose: () => void;
}) {
const { i18n } = useLingui();
const styles = useStyles2(getStyles);
if (link.emptyMessageId && !linkHasChildren(link)) {
const emptyMessageTranslated = i18n._(menuItemTranslations[link.emptyMessageId]);
return (
<NavBarMenuSection link={link}>
<ul className={styles.children}>
<div className={styles.emptyMessage}>{emptyMessageTranslated}</div>
</ul>
</NavBarMenuSection>
);
}
return (
<NavBarMenuSection onClose={onClose} link={link} activeItem={activeItem}>
{linkHasChildren(link) && (
<ul className={styles.children}>
{link.children.map((childLink) => {
const icon = childLink.icon ? toIconName(childLink.icon) : undefined;
return (
!childLink.divider && (
<NavBarMenuItem
key={`${link.text}-${childLink.text}`}
isActive={isMatchOrChildMatch(childLink, activeItem)}
isChild
icon={childLink.showIconInNavbar ? icon : undefined}
onClick={() => {
childLink.onClick?.();
onClose();
}}
target={childLink.target}
url={childLink.url}
>
{childLink.text}
</NavBarMenuItem>
)
);
})}
</ul>
)}
</NavBarMenuSection>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
children: css({
display: 'flex',
flexDirection: 'column',
}),
flex: css({
display: 'flex',
}),
itemWithoutMenu: css({
position: 'relative',
placeItems: 'inherit',
justifyContent: 'start',
display: 'flex',
flexGrow: 1,
alignItems: 'center',
}),
fullWidth: css({
height: '100%',
width: '100%',
}),
iconContainer: css({
display: 'flex',
placeContent: 'center',
}),
itemWithoutMenuContent: css({
display: 'grid',
gridAutoFlow: 'column',
gridTemplateColumns: `${theme.spacing(7)} auto`,
alignItems: 'center',
height: '100%',
}),
linkText: css({
fontSize: theme.typography.pxToRem(14),
justifySelf: 'start',
}),
emptyMessage: css({
color: theme.colors.text.secondary,
fontStyle: 'italic',
padding: theme.spacing(1, 1.5, 1, 7),
}),
});
function linkHasChildren(link: NavModelItem): link is NavModelItem & { children: NavModelItem[] } {
return Boolean(link.children && link.children.length > 0);
}

@ -0,0 +1,116 @@
import { css, cx } from '@emotion/css';
import React, { useState } from 'react';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { Button, Icon, useStyles2 } from '@grafana/ui';
import { NavBarItemIcon } from '../NavBar/NavBarItemIcon';
import { NavFeatureHighlight } from '../NavBar/NavFeatureHighlight';
import { hasChildMatch } from '../NavBar/utils';
import { NavBarMenuItem } from './NavBarMenuItem';
export function NavBarMenuSection({
link,
activeItem,
children,
className,
onClose,
}: {
link: NavModelItem;
activeItem?: NavModelItem;
children: React.ReactNode;
className?: string;
onClose?: () => void;
}) {
const styles = useStyles2(getStyles);
const FeatureHighlightWrapper = link.highlightText ? NavFeatureHighlight : React.Fragment;
const isActive = link === activeItem;
const hasActiveChild = hasChildMatch(link, activeItem);
const [sectionExpanded, setSectionExpanded] = useState(Boolean(hasActiveChild));
return (
<>
<div className={cx(styles.collapsibleSectionWrapper, className)}>
<NavBarMenuItem
isActive={link === activeItem}
onClick={() => {
link.onClick?.();
onClose?.();
}}
target={link.target}
url={link.url}
>
<div
className={cx(styles.labelWrapper, {
[styles.isActive]: isActive,
[styles.hasActiveChild]: hasActiveChild,
})}
>
<FeatureHighlightWrapper>
<NavBarItemIcon link={link} />
</FeatureHighlightWrapper>
{link.text}
</div>
</NavBarMenuItem>
{Boolean(link.children?.length) && (
<Button
aria-label={`${sectionExpanded ? 'Collapse' : 'Expand'} section`}
variant="secondary"
fill="text"
className={styles.collapseButton}
onClick={() => setSectionExpanded(!sectionExpanded)}
>
<Icon name={sectionExpanded ? 'angle-up' : 'angle-down'} size="xl" />
</Button>
)}
</div>
{sectionExpanded && children}
</>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
collapsibleSectionWrapper: css({
alignItems: 'center',
display: 'flex',
}),
collapseButton: css({
color: theme.colors.text.disabled,
padding: theme.spacing(0, 0.5),
marginRight: theme.spacing(1),
}),
collapseWrapperActive: css({
backgroundColor: theme.colors.action.disabledBackground,
}),
collapseContent: css({
padding: 0,
}),
labelWrapper: css({
display: 'grid',
fontSize: theme.typography.pxToRem(14),
gridAutoFlow: 'column',
gridTemplateColumns: `${theme.spacing(7)} auto`,
placeItems: 'center',
fontWeight: theme.typography.fontWeightMedium,
}),
isActive: css({
color: theme.colors.text.primary,
'&::before': {
display: 'block',
content: '" "',
height: theme.spacing(3),
position: 'absolute',
left: theme.spacing(1),
top: '50%',
transform: 'translateY(-50%)',
width: theme.spacing(0.5),
borderRadius: theme.shape.borderRadius(1),
backgroundImage: theme.colors.gradients.brandVertical,
},
}),
hasActiveChild: css({
color: theme.colors.text.primary,
}),
});

@ -100,7 +100,19 @@ export const enrichWithInteractionTracking = (item: NavModelItem, expandedState:
};
export const isMatchOrChildMatch = (itemToCheck: NavModelItem, searchItem?: NavModelItem) => {
return Boolean(itemToCheck === searchItem || itemToCheck.children?.some((child) => child === searchItem));
return Boolean(itemToCheck === searchItem || hasChildMatch(itemToCheck, searchItem));
};
export const hasChildMatch = (itemToCheck: NavModelItem, searchItem?: NavModelItem): boolean => {
return Boolean(
itemToCheck.children?.some((child) => {
if (child === searchItem) {
return true;
} else {
return hasChildMatch(child, searchItem);
}
})
);
};
const stripQueryParams = (url?: string) => {

@ -68,7 +68,7 @@ const getStyles = (theme: GrafanaTheme2) => {
activeStyle: css`
label: activeTabStyle;
color: ${theme.colors.text.primary};
background-color: ${theme.colors.action.disabledBackground};
background: ${theme.colors.emphasize(theme.colors.background.canvas, 0.03)};
border-radius: ${theme.shape.borderRadius(2)};
font-weight: ${theme.typography.fontWeightMedium};

@ -4,11 +4,10 @@ import { connect, ConnectedProps } from 'react-redux';
import { locationUtil, NavModel, NavModelItem, TimeRange, PageLayoutType } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { locationService } from '@grafana/runtime';
import { config, locationService } from '@grafana/runtime';
import { Themeable2, withTheme2 } from '@grafana/ui';
import { notifyApp } from 'app/core/actions';
import { Page } from 'app/core/components/Page/Page';
import { config } from 'app/core/config';
import { GrafanaContext } from 'app/core/context/GrafanaContext';
import { createErrorNotification } from 'app/core/copy/appNotification';
import { getKioskMode } from 'app/core/navigation/kiosk';

Loading…
Cancel
Save