DockedMegaMenu: Refactor and rename to simplify (#75872)

* tidy up some styles

* remove NavBarMenuItemWrapper + consolidate components

* lots of renaming

* use object syntax in FeatureHighlight

* fix a couple of missing find+replace

* adjust li positioning

* fix text truncation

* bit more tidy up

* refactor indent into it's own component

* memoize styles in Indent
This commit is contained in:
Ashley Harrison 2023-10-03 13:03:27 +01:00 committed by GitHub
parent 523d1b46d4
commit 18b237879d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 132 additions and 221 deletions

View File

@ -1185,9 +1185,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "5"],
[0, 0, 0, "Unexpected any. Specify a different type.", "6"]
],
"public/app/core/components/AppChrome/DockedMegaMenu/NavFeatureHighlight.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],
"public/app/core/components/AppChrome/MegaMenu/NavFeatureHighlight.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],

View File

@ -10,7 +10,7 @@ import { useStyles2, useTheme2 } from '@grafana/ui';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { KioskMode } from 'app/types';
import { DockedMegaMenu, MENU_WIDTH } from './DockedMegaMenu/DockedMegaMenu';
import { MegaMenu, MENU_WIDTH } from './DockedMegaMenu/MegaMenu';
import { TOGGLE_BUTTON_ID } from './NavToolbar/NavToolbar';
import { TOP_BAR_LEVEL_HEIGHT } from './types';
@ -58,7 +58,7 @@ export function AppChromeMenu({}: Props) {
timeout={{ enter: animationSpeed, exit: 0 }}
>
<FocusScope contain autoFocus>
<DockedMegaMenu className={styles.menu} onClose={onClose} ref={ref} {...overlayProps} {...dialogProps} />
<MegaMenu className={styles.menu} onClose={onClose} ref={ref} {...overlayProps} {...dialogProps} />
</FocusScope>
</CSSTransition>
<CSSTransition

View File

@ -8,7 +8,7 @@ export interface Props {
children: JSX.Element;
}
export const NavFeatureHighlight = ({ children }: Props): JSX.Element => {
export const FeatureHighlight = ({ children }: Props): JSX.Element => {
const styles = useStyles2(getStyles);
return (
<div>
@ -20,15 +20,15 @@ export const NavFeatureHighlight = ({ children }: Props): JSX.Element => {
const getStyles = (theme: GrafanaTheme2) => {
return {
highlight: css`
background-color: ${theme.colors.success.main};
border-radius: ${theme.shape.radius.circle};
width: 6px;
height: 6px;
display: inline-block;
position: absolute;
top: 50%;
transform: translateY(-50%);
`,
highlight: css({
backgroundColor: theme.colors.success.main,
borderRadius: theme.shape.radius.circle,
width: '6px',
height: '6px',
display: 'inline-block;',
position: 'absolute',
top: '50%',
transform: 'translateY(-50%)',
}),
};
};

View File

@ -9,7 +9,7 @@ import { locationService } from '@grafana/runtime';
import { TestProvider } from '../../../../../test/helpers/TestProvider';
import { DockedMegaMenu } from './DockedMegaMenu';
import { MegaMenu } from './MegaMenu';
const setup = () => {
const navBarTree: NavModelItem[] = [
@ -40,7 +40,7 @@ const setup = () => {
return render(
<TestProvider storeState={{ navBarTree }} grafanaContext={grafanaContext}>
<Router history={locationService.getHistory()}>
<DockedMegaMenu onClose={() => {}} />
<MegaMenu onClose={() => {}} />
</Router>
</TestProvider>
);

View File

@ -8,7 +8,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { CustomScrollbar, Icon, IconButton, useStyles2 } from '@grafana/ui';
import { useSelector } from 'app/types';
import { NavBarMenuItemWrapper } from './NavBarMenuItemWrapper';
import { MegaMenuItem } from './MegaMenuItem';
import { enrichWithInteractionTracking, getActiveItem } from './utils';
export const MENU_WIDTH = '350px';
@ -17,7 +17,7 @@ export interface Props extends DOMAttributes {
onClose: () => void;
}
export const DockedMegaMenu = React.memo(
export const MegaMenu = React.memo(
forwardRef<HTMLDivElement, Props>(({ onClose, ...restProps }, ref) => {
const navBarTree = useSelector((state) => state.navBarTree);
const styles = useStyles2(getStyles);
@ -49,7 +49,7 @@ export const DockedMegaMenu = React.memo(
<CustomScrollbar showScrollIndicators hideHorizontalTrack>
<ul className={styles.itemList}>
{navItems.map((link) => (
<NavBarMenuItemWrapper link={link} onClose={onClose} activeItem={activeItem} key={link.text} />
<MegaMenuItem link={link} onClose={onClose} activeItem={activeItem} key={link.text} />
))}
</ul>
</CustomScrollbar>
@ -59,7 +59,7 @@ export const DockedMegaMenu = React.memo(
})
);
DockedMegaMenu.displayName = 'DockedMegaMenu';
MegaMenu.displayName = 'MegaMenu';
const getStyles = (theme: GrafanaTheme2) => ({
content: css({
@ -82,6 +82,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
display: 'grid',
gridAutoRows: `minmax(${theme.spacing(6)}, auto)`,
gridTemplateColumns: `minmax(${MENU_WIDTH}, auto)`,
listStyleType: 'none',
minWidth: MENU_WIDTH,
}),
});

View File

@ -3,38 +3,36 @@ import React from 'react';
import { useLocalStorage } from 'react-use';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { Button, Icon, useStyles2 } from '@grafana/ui';
import { Button, Icon, useStyles2, Text } from '@grafana/ui';
import { NavBarItemIcon } from './NavBarItemIcon';
import { NavBarMenuItem } from './NavBarMenuItem';
import { NavFeatureHighlight } from './NavFeatureHighlight';
import { Indent } from '../../Indent/Indent';
import { FeatureHighlight } from './FeatureHighlight';
import { MegaMenuItemIcon } from './MegaMenuItemIcon';
import { MegaMenuItemText } from './MegaMenuItemText';
import { hasChildMatch } from './utils';
export function NavBarMenuSection({
link,
activeItem,
children,
className,
onClose,
}: {
interface Props {
link: NavModelItem;
activeItem?: NavModelItem;
children: React.ReactNode;
className?: string;
onClose?: () => void;
}) {
level?: number;
}
export function MegaMenuItem({ link, activeItem, level = 0, onClose }: Props) {
const styles = useStyles2(getStyles);
const FeatureHighlightWrapper = link.highlightText ? NavFeatureHighlight : React.Fragment;
const FeatureHighlightWrapper = link.highlightText ? FeatureHighlight : React.Fragment;
const isActive = link === activeItem;
const hasActiveChild = hasChildMatch(link, activeItem);
const [sectionExpanded, setSectionExpanded] =
useLocalStorage(`grafana.navigation.expanded[${link.text}]`, false) ?? Boolean(hasActiveChild);
const showExpandButton = linkHasChildren(link) || link.emptyMessage;
return (
<>
<div className={cx(styles.collapsibleSectionWrapper, className)}>
<NavBarMenuItem
isActive={link === activeItem}
<li>
<div className={styles.collapsibleSectionWrapper}>
<MegaMenuItemText
isActive={isActive}
onClick={() => {
link.onClick?.();
onClose?.();
@ -49,12 +47,13 @@ export function NavBarMenuSection({
})}
>
<FeatureHighlightWrapper>
<NavBarItemIcon link={link} />
<div className={styles.iconWrapper}>{level === 0 && <MegaMenuItemIcon link={link} />}</div>
</FeatureHighlightWrapper>
{link.text}
<Indent level={Math.max(0, level - 1)} spacing={2} />
<Text truncate>{link.text}</Text>
</div>
</NavBarMenuItem>
{children && (
</MegaMenuItemText>
{showExpandButton && (
<Button
aria-label={`${sectionExpanded ? 'Collapse' : 'Expand'} section ${link.text}`}
variant="secondary"
@ -66,12 +65,35 @@ export function NavBarMenuSection({
</Button>
)}
</div>
{sectionExpanded && children}
</>
{showExpandButton && sectionExpanded && (
<ul className={styles.children}>
{linkHasChildren(link) ? (
link.children
.filter((childLink) => !childLink.isCreateAction)
.map((childLink) => (
<MegaMenuItem
key={`${link.text}-${childLink.text}`}
link={childLink}
activeItem={activeItem}
onClose={onClose}
level={level + 1}
/>
))
) : (
<div className={styles.emptyMessage}>{link.emptyMessage}</div>
)}
</ul>
)}
</li>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
children: css({
display: 'flex',
listStyleType: 'none',
flexDirection: 'column',
}),
collapsibleSectionWrapper: css({
alignItems: 'center',
display: 'flex',
@ -81,18 +103,22 @@ const getStyles = (theme: GrafanaTheme2) => ({
padding: theme.spacing(0, 0.5),
marginRight: theme.spacing(1),
}),
collapseWrapperActive: css({
backgroundColor: theme.colors.action.disabledBackground,
emptyMessage: css({
color: theme.colors.text.secondary,
fontStyle: 'italic',
padding: theme.spacing(1, 1.5, 1, 7),
}),
collapseContent: css({
padding: 0,
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`,
placeItems: 'center',
alignItems: 'center',
fontWeight: theme.typography.fontWeightMedium,
}),
isActive: css({
@ -115,3 +141,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
color: theme.colors.text.primary,
}),
});
function linkHasChildren(link: NavModelItem): link is NavModelItem & { children: NavModelItem[] } {
return Boolean(link.children && link.children.length > 0);
}

View File

@ -10,7 +10,7 @@ interface NavBarItemIconProps {
link: NavModelItem;
}
export function NavBarItemIcon({ link }: NavBarItemIconProps) {
export function MegaMenuItemIcon({ link }: NavBarItemIconProps) {
const theme = useTheme2();
const styles = getStyles(theme);

View File

@ -15,7 +15,7 @@ export interface Props {
url?: string;
}
export function NavBarMenuItem({ children, icon, isActive, isChild, onClick, target, url }: Props) {
export function MegaMenuItemText({ children, icon, isActive, isChild, onClick, target, url }: Props) {
const theme = useTheme2();
const styles = getStyles(theme, isActive, isChild);
@ -23,7 +23,7 @@ export function NavBarMenuItem({ children, icon, isActive, isChild, onClick, tar
<div className={styles.linkContent}>
{icon && <Icon data-testid="dropdown-child-icon" name={icon} />}
<div className={styles.linkText}>{children}</div>
{children}
{target === '_blank' && (
<Icon data-testid="external-link-icon" name="external-link-alt" className={styles.externalLinkIcon} />
@ -66,10 +66,10 @@ export function NavBarMenuItem({ children, icon, isActive, isChild, onClick, tar
);
}
return <li className={styles.listItem}>{element}</li>;
return <div className={styles.wrapper}>{element}</div>;
}
NavBarMenuItem.displayName = 'NavBarMenuItem';
MegaMenuItemText.displayName = 'MegaMenuItemText';
const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive'], isChild: Props['isActive']) => ({
button: css({
@ -83,11 +83,6 @@ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive'], isChild: P
height: '100%',
width: '100%',
}),
linkText: css({
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
}),
externalLinkIcon: css({
color: theme.colors.text.secondary,
}),
@ -127,7 +122,7 @@ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive'], isChild: P
backgroundImage: theme.colors.gradients.brandVertical,
},
}),
listItem: css({
wrapper: css({
boxSizing: 'border-box',
position: 'relative',
display: 'flex',

View File

@ -1,112 +0,0 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { NavBarMenuItem } from './NavBarMenuItem';
import { NavBarMenuSection } from './NavBarMenuSection';
import { isMatchOrChildMatch } from './utils';
export function NavBarMenuItemWrapper({
link,
activeItem,
onClose,
}: {
link: NavModelItem;
activeItem?: NavModelItem;
onClose: () => void;
}) {
const styles = useStyles2(getStyles);
if (link.emptyMessage && !linkHasChildren(link)) {
return (
<NavBarMenuSection onClose={onClose} link={link} activeItem={activeItem}>
<ul className={styles.children}>
<div className={styles.emptyMessage}>{link.emptyMessage}</div>
</ul>
</NavBarMenuSection>
);
}
return (
<NavBarMenuSection onClose={onClose} link={link} activeItem={activeItem}>
{linkHasChildren(link) && (
<ul className={styles.children}>
{link.children.map((childLink) => {
return linkHasChildren(childLink) ? (
<NavBarMenuItemWrapper
key={`${link.text}-${childLink.text}`}
link={childLink}
activeItem={activeItem}
onClose={onClose}
/>
) : (
!childLink.isCreateAction && (
<NavBarMenuItem
key={`${link.text}-${childLink.text}`}
isActive={isMatchOrChildMatch(childLink, activeItem)}
isChild
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);
}

View File

@ -0,0 +1,26 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2, ThemeSpacingTokens } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { getResponsiveStyle, ResponsiveProp } from '@grafana/ui/src/components/Layout/utils/responsiveness';
interface IndentProps {
children?: React.ReactNode;
level: number;
spacing: ResponsiveProp<ThemeSpacingTokens>;
}
export function Indent({ children, spacing, level }: IndentProps) {
const styles = useStyles2(getStyles, spacing, level);
return <span className={css(styles.indentor)}>{children}</span>;
}
const getStyles = (theme: GrafanaTheme2, spacing: IndentProps['spacing'], level: IndentProps['level']) => ({
indentor: css(
getResponsiveStyle(theme, spacing, (val) => ({
paddingLeft: theme.spacing(val * level),
}))
),
});

View File

@ -8,8 +8,8 @@ import { GrafanaTheme2 } from '@grafana/data';
import { IconButton, useStyles2 } from '@grafana/ui';
import { getSvgSize } from '@grafana/ui/src/components/Icon/utils';
import { Text } from '@grafana/ui/src/components/Text/Text';
import { Indent } from 'app/core/components/Indent/Indent';
import { Trans } from 'app/core/internationalization';
import { Indent } from 'app/features/browse-dashboards/components/Indent';
import { childrenByParentUIDSelector, rootItemsSelector } from 'app/features/browse-dashboards/state';
import { DashboardsTreeItem } from 'app/features/browse-dashboards/types';
import { DashboardViewItem } from 'app/features/search/types';
@ -153,7 +153,7 @@ function Row({ index, style: virtualStyles, data }: RowProps) {
if (item.kind === 'ui' && item.uiKind === 'pagination-placeholder') {
return (
<span style={virtualStyles} className={styles.row}>
<Indent level={level} />
<Indent level={level} spacing={2} />
<Skeleton width={SKELETON_WIDTHS[index % SKELETON_WIDTHS.length]} />
</span>
);
@ -190,7 +190,7 @@ function Row({ index, style: virtualStyles, data }: RowProps) {
id={getDOMId(idPrefix, item.uid)}
>
<div className={styles.rowBody}>
<Indent level={level} />
<Indent level={level} spacing={2} />
{foldersAreOpenable ? (
<IconButton
size={CHEVRON_SIZE}

View File

@ -10,13 +10,7 @@ import { useStyles2 } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { DashboardViewItem } from 'app/features/search/types';
import {
DashboardsTreeCellProps,
DashboardsTreeColumn,
DashboardsTreeItem,
INDENT_AMOUNT_CSS_VAR,
SelectionState,
} from '../types';
import { DashboardsTreeCellProps, DashboardsTreeColumn, DashboardsTreeItem, SelectionState } from '../types';
import CheckboxCell from './CheckboxCell';
import CheckboxHeaderCell from './CheckboxHeaderCell';
@ -126,7 +120,7 @@ export function DashboardsTree({
);
return (
<div {...getTableProps()} className={styles.tableRoot} role="table">
<div {...getTableProps()} role="table">
{headerGroups.map((headerGroup) => {
const { key, ...headerGroupProps } = headerGroup.getHeaderGroupProps({
style: { width },
@ -213,15 +207,6 @@ function VirtualListRow({ index, style, data }: VirtualListRowProps) {
const getStyles = (theme: GrafanaTheme2) => {
return {
tableRoot: css({
// Responsively
[INDENT_AMOUNT_CSS_VAR]: theme.spacing(1),
[theme.breakpoints.up('md')]: {
[INDENT_AMOUNT_CSS_VAR]: theme.spacing(3),
},
}),
// Column flex properties (cell sizing) are set by customFlexTableLayout.ts
row: css({

View File

@ -1,20 +0,0 @@
import React from 'react';
import { useTheme2 } from '@grafana/ui';
import { INDENT_AMOUNT_CSS_VAR } from '../types';
interface IndentProps {
children?: React.ReactNode;
level: number;
}
export function Indent({ children, level }: IndentProps) {
const theme = useTheme2();
// DashboardsTree responsively sets the value of INDENT_AMOUNT_CSS_VAR
// but we also have a fallback just in case it's not set for some reason...
const space = `var(${INDENT_AMOUNT_CSS_VAR}, ${theme.spacing(2)})`;
return <span style={{ paddingLeft: `calc(${space} * ${level})` }}>{children}</span>;
}

View File

@ -9,11 +9,10 @@ import { Icon, IconButton, Link, Spinner, useStyles2, Text } from '@grafana/ui';
import { getSvgSize } from '@grafana/ui/src/components/Icon/utils';
import { getIconForKind } from 'app/features/search/service/utils';
import { Indent } from '../../../core/components/Indent/Indent';
import { useChildrenByParentUIDState } from '../state';
import { DashboardsTreeItem } from '../types';
import { Indent } from './Indent';
const CHEVRON_SIZE = 'md';
const ICON_SIZE = 'sm';
@ -31,7 +30,13 @@ export function NameCell({ row: { original: data }, onFolderClick }: NameCellPro
if (item.kind === 'ui') {
return (
<>
<Indent level={level} />
<Indent
level={level}
spacing={{
xs: 1,
md: 3,
}}
/>
<span className={styles.folderButtonSpacer} />
{item.uiKind === 'empty-folder' ? (
<em className={styles.emptyText}>
@ -48,7 +53,13 @@ export function NameCell({ row: { original: data }, onFolderClick }: NameCellPro
return (
<>
<Indent level={level} />
<Indent
level={level}
spacing={{
xs: 1,
md: 3,
}}
/>
{item.kind === 'folder' ? (
<IconButton

View File

@ -42,8 +42,6 @@ export interface DashboardsTreeItem<T extends DashboardViewItemWithUIItems = Das
parentUID?: string;
}
export const INDENT_AMOUNT_CSS_VAR = '--dashboards-tree-indentation';
interface RendererUserProps {
// Note: userProps for cell renderers (e.g. second argument in `cell.render('Cell', foo)` )
// aren't typed, so we must be careful when accessing this