mirror of https://github.com/grafana/grafana
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
parent
3a545007ca
commit
8440baab91
@ -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, |
||||
}), |
||||
}); |
Loading…
Reference in new issue