Nav: Design changes in MegaMenu (#76735)

* refactor: move expand button to front

* refactor: add grid + adjust level 2

* refactor: remove grid + fix alignment for all three levels

* refactor: first iteration alignment

* refactor: styling of MegaMenuItemIcon

* refactor: remove styling of div in MegaMenuItemIcon

* refactor: styling object

* refactor: alignment of first level

* refactor: alignment of second level

* refactor: alignment of third level and active state

* feat: add connecting line for level 3

* feat: add comment

* refactor: remove unused code

* refactor: clean up styling

* refactor: clean up styling

* refactor: clean up styling

* some small tweaks

* remove unused props from getStyles

* fix dock button position and text ellipsing

* add padding to container to prevent icon button overlapping on left side

* add active overlay

* adjust font-weight, add padding to RHS

* add border-box for overlay

* adjust menu width to 300, don't use non-integer levels

* use height: 100%

* don't think we need minWidth

* make chevrons right/down

* make url required, cut down css

* move active state to container

* remove unused class

* add padding at top and bottom of menu as well

* adjust chevron size

---------

Co-authored-by: Ashley Harrison <ashley.harrison@grafana.com>
This commit is contained in:
Laura Benz 2023-10-19 17:41:58 +02:00 committed by GitHub
parent fcfbb3b2ca
commit d16a274e3a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 156 additions and 159 deletions

View File

@ -11,10 +11,10 @@ export interface Props {
export const FeatureHighlight = ({ children }: Props): JSX.Element => { export const FeatureHighlight = ({ children }: Props): JSX.Element => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
return ( return (
<div> <>
{children} {children}
<span className={styles.highlight} /> <span className={styles.highlight} />
</div> </>
); );
}; };

View File

@ -13,7 +13,7 @@ import { useSelector } from 'app/types';
import { MegaMenuItem } from './MegaMenuItem'; import { MegaMenuItem } from './MegaMenuItem';
import { enrichWithInteractionTracking, getActiveItem } from './utils'; import { enrichWithInteractionTracking, getActiveItem } from './utils';
export const MENU_WIDTH = '350px'; export const MENU_WIDTH = '300px';
export interface Props extends DOMAttributes { export interface Props extends DOMAttributes {
onClose: () => void; onClose: () => void;
@ -104,17 +104,18 @@ const getStyles = (theme: GrafanaTheme2) => ({
}, },
}), }),
itemList: css({ itemList: css({
boxSizing: 'border-box',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
listStyleType: 'none', listStyleType: 'none',
minWidth: MENU_WIDTH, padding: theme.spacing(1),
[theme.breakpoints.up('md')]: { [theme.breakpoints.up('md')]: {
width: MENU_WIDTH, width: MENU_WIDTH,
}, },
}), }),
dockMenuButton: css({ dockMenuButton: css({
color: theme.colors.text.disabled,
display: 'none', display: 'none',
marginRight: theme.spacing(2),
[theme.breakpoints.up('md')]: { [theme.breakpoints.up('md')]: {
display: 'inline-flex', display: 'inline-flex',

View File

@ -3,7 +3,7 @@ import React from 'react';
import { useLocalStorage } from 'react-use'; import { useLocalStorage } from 'react-use';
import { GrafanaTheme2, NavModelItem, toIconName } from '@grafana/data'; import { GrafanaTheme2, NavModelItem, toIconName } from '@grafana/data';
import { Button, Icon, useStyles2, Text } from '@grafana/ui'; import { useStyles2, Text, IconButton, Icon } from '@grafana/ui';
import { Indent } from '../../Indent/Indent'; import { Indent } from '../../Indent/Indent';
@ -18,55 +18,63 @@ interface Props {
level?: number; level?: number;
} }
// max level depth to render
const MAX_DEPTH = 2; const MAX_DEPTH = 2;
export function MegaMenuItem({ link, activeItem, level = 0, onClick }: Props) { export function MegaMenuItem({ link, activeItem, level = 0, onClick }: Props) {
const styles = useStyles2(getStyles);
const FeatureHighlightWrapper = link.highlightText ? FeatureHighlight : React.Fragment; const FeatureHighlightWrapper = link.highlightText ? FeatureHighlight : React.Fragment;
const isActive = link === activeItem; const isActive = link === activeItem;
const hasActiveChild = hasChildMatch(link, activeItem); const hasActiveChild = hasChildMatch(link, activeItem);
const [sectionExpanded, setSectionExpanded] = const [sectionExpanded, setSectionExpanded] =
useLocalStorage(`grafana.navigation.expanded[${link.text}]`, false) ?? Boolean(hasActiveChild); useLocalStorage(`grafana.navigation.expanded[${link.text}]`, false) ?? Boolean(hasActiveChild);
const showExpandButton = level < MAX_DEPTH && (linkHasChildren(link) || link.emptyMessage); const showExpandButton = level < MAX_DEPTH && Boolean(linkHasChildren(link) || link.emptyMessage);
const styles = useStyles2(getStyles);
if (!link.url) {
return null;
}
return ( return (
<li className={styles.listItem}> <li className={styles.listItem}>
<div className={styles.collapsibleSectionWrapper}> <div className={styles.menuItem}>
<MegaMenuItemText {level !== 0 && <Indent level={level === MAX_DEPTH ? level - 1 : level} spacing={3} />}
isActive={isActive} {level === MAX_DEPTH && <div className={styles.itemConnector} />}
onClick={() => { <div className={styles.collapseButtonWrapper}>
link.onClick?.(); {showExpandButton && (
onClick?.(); <IconButton
}} aria-label={`${sectionExpanded ? 'Collapse' : 'Expand'} section ${link.text}`}
target={link.target} className={styles.collapseButton}
url={link.url} onClick={() => setSectionExpanded(!sectionExpanded)}
> name={sectionExpanded ? 'angle-down' : 'angle-right'}
<div size="md"
className={cx(styles.labelWrapper, { />
[styles.isActive]: isActive, )}
})} </div>
<div className={styles.collapsibleSectionWrapper}>
<MegaMenuItemText
isActive={isActive}
onClick={() => {
link.onClick?.();
onClick?.();
}}
target={link.target}
url={link.url}
> >
<FeatureHighlightWrapper> <div
<div className={styles.iconWrapper}> className={cx(styles.labelWrapper, {
{level === 0 && link.icon && <Icon name={toIconName(link.icon) ?? 'link'} size="xl" />} [styles.hasActiveChild]: hasActiveChild,
</div> [styles.hasIcon]: Boolean(level === 0 && link.icon),
</FeatureHighlightWrapper> })}
<Indent level={Math.max(0, level - 1)} spacing={2} /> >
<Text truncate>{link.text}</Text> {level === 0 && link.icon && (
</div> <FeatureHighlightWrapper>
</MegaMenuItemText> <Icon className={styles.icon} name={toIconName(link.icon) ?? 'link'} size="lg" />
{showExpandButton && ( </FeatureHighlightWrapper>
<Button )}
aria-label={`${sectionExpanded ? 'Collapse' : 'Expand'} section ${link.text}`} <Text truncate>{link.text}</Text>
variant="secondary" </div>
fill="text" </MegaMenuItemText>
className={styles.collapseButton} </div>
onClick={() => setSectionExpanded(!sectionExpanded)}
>
<Icon name={sectionExpanded ? 'angle-up' : 'angle-down'} size="xl" />
</Button>
)}
</div> </div>
{showExpandButton && sectionExpanded && ( {showExpandButton && sectionExpanded && (
<ul className={styles.children}> <ul className={styles.children}>
@ -92,57 +100,73 @@ export function MegaMenuItem({ link, activeItem, level = 0, onClick }: Props) {
} }
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2) => ({
children: css({ icon: css({
width: theme.spacing(3),
}),
listItem: css({
flex: 1,
maxWidth: '100%',
}),
menuItem: css({
display: 'flex', display: 'flex',
listStyleType: 'none', alignItems: 'center',
flexDirection: 'column', gap: theme.spacing(1),
height: theme.spacing(4),
position: 'relative',
}),
collapseButtonWrapper: css({
display: 'flex',
justifyContent: 'center',
width: theme.spacing(3),
flexShrink: 0,
}),
itemConnector: css({
position: 'relative',
height: '100%',
width: theme.spacing(1.5),
'&::before': {
borderLeft: `1px solid ${theme.colors.border.medium}`,
content: '""',
height: '100%',
right: 0,
position: 'absolute',
transform: 'translateX(50%)',
},
}),
collapseButton: css({
color: theme.colors.text.disabled,
margin: 0,
}), }),
collapsibleSectionWrapper: css({ collapsibleSectionWrapper: css({
alignItems: 'center', alignItems: 'center',
display: 'flex', display: 'flex',
flex: 1,
height: '100%',
minWidth: 0,
}), }),
collapseButton: css({ labelWrapper: css({
color: theme.colors.text.disabled, display: 'flex',
padding: theme.spacing(0, 0.5), alignItems: 'center',
marginRight: theme.spacing(1), gap: theme.spacing(2),
paddingLeft: theme.spacing(1),
minWidth: 0,
}),
hasIcon: css({
paddingLeft: theme.spacing(0),
}),
hasActiveChild: css({
color: theme.colors.text.primary,
}),
children: css({
display: 'flex',
listStyleType: 'none',
flexDirection: 'column',
}), }),
emptyMessage: css({ emptyMessage: css({
color: theme.colors.text.secondary, color: theme.colors.text.secondary,
fontStyle: 'italic', fontStyle: 'italic',
padding: theme.spacing(1, 1.5, 1, 7), padding: theme.spacing(1, 1.5, 1, 7),
}), }),
iconWrapper: css({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}),
labelWrapper: css({
display: 'grid',
fontSize: theme.typography.pxToRem(14),
gridAutoFlow: 'column',
gridTemplateColumns: `${theme.spacing(7)} auto`,
alignItems: 'center',
fontWeight: theme.typography.fontWeightMedium,
}),
listItem: css({
flex: 1,
}),
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.radius.default,
backgroundImage: theme.colors.gradients.brandVertical,
},
}),
}); });
function linkHasChildren(link: NavModelItem): link is NavModelItem & { children: NavModelItem[] } { function linkHasChildren(link: NavModelItem): link is NavModelItem & { children: NavModelItem[] } {

View File

@ -10,67 +10,78 @@ export interface Props {
isActive?: boolean; isActive?: boolean;
onClick?: () => void; onClick?: () => void;
target?: HTMLAnchorElement['target']; target?: HTMLAnchorElement['target'];
url?: string; url: string;
} }
export function MegaMenuItemText({ children, isActive, onClick, target, url }: Props) { export function MegaMenuItemText({ children, isActive, onClick, target, url }: Props) {
const theme = useTheme2(); const theme = useTheme2();
const styles = getStyles(theme, isActive); const styles = getStyles(theme, isActive);
const LinkComponent = !target && url.startsWith('/') ? Link : 'a';
const linkContent = ( const linkContent = (
<div className={styles.linkContent}> <div className={styles.linkContent}>
{children} {children}
{target === '_blank' && ( {
<Icon data-testid="external-link-icon" name="external-link-alt" className={styles.externalLinkIcon} /> // As nav links are supposed to link to internal urls this option should be used with caution
)} target === '_blank' && <Icon data-testid="external-link-icon" name="external-link-alt" />
}
</div> </div>
); );
let element = ( return (
<button <LinkComponent
data-testid={selectors.components.NavMenu.item} data-testid={selectors.components.NavMenu.item}
className={cx(styles.button, styles.element)} className={cx(styles.container, {
[styles.containerActive]: isActive,
})}
href={url}
target={target}
onClick={onClick} onClick={onClick}
> >
{linkContent} {linkContent}
</button> </LinkComponent>
); );
if (url) {
element =
!target && url.startsWith('/') ? (
<Link
data-testid={selectors.components.NavMenu.item}
className={styles.element}
href={url}
target={target}
onClick={onClick}
>
{linkContent}
</Link>
) : (
<a
data-testid={selectors.components.NavMenu.item}
href={url}
target={target}
className={styles.element}
onClick={onClick}
>
{linkContent}
</a>
);
}
return <div className={styles.wrapper}>{element}</div>;
} }
MegaMenuItemText.displayName = 'MegaMenuItemText'; MegaMenuItemText.displayName = 'MegaMenuItemText';
const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({
button: css({ container: css({
backgroundColor: 'unset', alignItems: 'center',
borderStyle: 'unset', color: isActive ? theme.colors.text.primary : theme.colors.text.secondary,
height: '100%',
position: 'relative',
width: '100%',
'&:hover, &:focus-visible': {
color: theme.colors.text.primary,
textDecoration: 'underline',
},
'&:focus-visible': {
boxShadow: 'none',
outline: `2px solid ${theme.colors.primary.main}`,
outlineOffset: '-2px',
transition: 'none',
},
}),
containerActive: css({
backgroundColor: theme.colors.background.secondary,
borderTopRightRadius: theme.shape.radius.default,
borderBottomRightRadius: theme.shape.radius.default,
position: 'relative',
'&::before': {
backgroundImage: theme.colors.gradients.brandVertical,
borderRadius: theme.shape.radius.default,
content: '" "',
display: 'block',
height: '100%',
position: 'absolute',
transform: 'translateX(-50%)',
width: theme.spacing(0.5),
},
}), }),
linkContent: css({ linkContent: css({
alignItems: 'center', alignItems: 'center',
@ -79,43 +90,4 @@ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({
height: '100%', height: '100%',
width: '100%', width: '100%',
}), }),
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, 0),
width: '100%',
'&:hover, &:focus-visible': {
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.radius.default,
backgroundImage: theme.colors.gradients.brandVertical,
},
}),
wrapper: css({
boxSizing: 'border-box',
position: 'relative',
display: 'flex',
width: '100%',
}),
}); });