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>
This commit is contained in:
co-authored by
Ashley Harrison
parent
3a545007ca
commit
8440baab91
+2
-1
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user