Navigation: Rough implementation of new navbar design (#46909)

This commit is contained in:
kay delaney 2022-03-30 18:05:52 +01:00 committed by GitHub
parent 4449439a41
commit f486b54b84
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 1190 additions and 237 deletions

View File

@ -30,7 +30,7 @@ export const CollapsableSection: FC<Props> = ({
}) => {
const [open, toggleOpen] = useState<boolean>(isOpen);
const styles = useStyles2(collapsableSectionStyles);
const tooltip = `Click to ${open ? 'collapse' : 'expand'}`;
const onClick = (e: React.MouseEvent) => {
if (e.target instanceof HTMLElement && e.target.tagName === 'A') {
return;
@ -48,7 +48,7 @@ export const CollapsableSection: FC<Props> = ({
return (
<>
<div onClick={onClick} className={cx(styles.header, className)} title={tooltip}>
<div onClick={onClick} className={cx(styles.header, className)}>
<button
id={`collapse-button-${id}`}
className={styles.button}
@ -88,9 +88,6 @@ const collapsableSectionStyles = (theme: GrafanaTheme2) => ({
padding: `${theme.spacing(0.5)} 0`,
'&:focus-within': getFocusStyles(theme),
}),
headerClosed: css({
borderBottom: `1px solid ${theme.colors.border.weak}`,
}),
button: css({
all: 'unset',
'&:focus-visible': {

View File

@ -43,7 +43,9 @@ export const focusCss = (theme: GrafanaTheme) => `
outline: 2px dotted transparent;
outline-offset: 2px;
box-shadow: 0 0 0 2px ${theme.colors.bodyBg}, 0 0 0px 4px ${theme.colors.formFocusOutline};
transition: all 0.2s cubic-bezier(0.19, 1, 0.22, 1);
transition-property: outline, outline-offset, box-shadow;
transition-duration: 0.2s;
transition-timing-function: cubic-bezier(0.19, 1, 0.22, 1);
`;
export function getMouseFocusStyles(theme: GrafanaTheme2): CSSObject {
@ -58,7 +60,9 @@ export function getFocusStyles(theme: GrafanaTheme2): CSSObject {
outline: '2px dotted transparent',
outlineOffset: '2px',
boxShadow: `0 0 0 2px ${theme.colors.background.canvas}, 0 0 0px 4px ${theme.colors.primary.main}`,
transition: `all 0.2s cubic-bezier(0.19, 1, 0.22, 1)`,
transitionTimingFunction: `cubic-bezier(0.19, 1, 0.22, 1)`,
transitionDuration: '0.2s',
transitionProperty: 'outline, outline-offset, box-shadow',
};
}

View File

@ -10,7 +10,7 @@ import { ConfigContext, ThemeProvider } from './core/utils/ConfigProvider';
import { RouteDescriptor } from './core/navigation/types';
import { contextSrv } from './core/services/context_srv';
import { NavBar } from './core/components/NavBar/NavBar';
import { NavBarNext } from './core/components/NavBar/NavBarNext';
import { NavBarNext } from './core/components/NavBar/Next/NavBarNext';
import { GrafanaRoute } from './core/navigation/GrafanaRoute';
import { AppNotificationList } from './core/components/AppNotifications/AppNotificationList';
import { SearchWrapper } from 'app/features/search';

View File

@ -6,9 +6,6 @@ import { useDialog } from '@react-aria/dialog';
import { useOverlay } from '@react-aria/overlays';
import { css } from '@emotion/css';
import { NavBarMenuItem } from './NavBarMenuItem';
import { useDispatch } from 'react-redux';
import { togglePin } from 'app/core/reducers/navBarTree';
import { getConfig } from 'app/core/config';
export interface Props {
activeItem?: NavModelItem;
@ -17,11 +14,6 @@ export interface Props {
}
export function NavBarMenu({ activeItem, navItems, onClose }: Props) {
const dispatch = useDispatch();
const toggleItemPin = (id: string) => {
dispatch(togglePin({ id }));
};
const theme = useTheme2();
const styles = getStyles(theme);
const ref = useRef(null);
@ -35,7 +27,6 @@ export function NavBarMenu({ activeItem, navItems, onClose }: Props) {
ref
);
const newNavigationEnabled = getConfig().featureToggles.newNavigation;
return (
<FocusScope contain restoreFocus autoFocus>
<div data-testid="navbarmenu" className={styles.container} ref={ref} {...overlayProps} {...dialogProps}>
@ -59,9 +50,6 @@ export function NavBarMenu({ activeItem, navItems, onClose }: Props) {
text={link.text}
url={link.url}
isMobile={true}
pinned={!link.hideFromNavbar}
canPin={newNavigationEnabled && link.id !== 'search'}
onTogglePin={() => link.id && toggleItemPin(link.id)}
/>
{link.children?.map(
(childLink) =>

View File

@ -1,7 +1,7 @@
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, IconButton, IconName, Link, useTheme2 } from '@grafana/ui';
import { css, cx } from '@emotion/css';
import { Icon, IconName, Link, useTheme2 } from '@grafana/ui';
import { css } from '@emotion/css';
export interface Props {
icon?: IconName;
@ -14,9 +14,6 @@ export interface Props {
url?: string;
adjustHeightForBorder?: boolean;
isMobile?: boolean;
canPin?: boolean;
pinned?: boolean;
onTogglePin?: () => void;
}
export function NavBarMenuItem({
@ -29,20 +26,10 @@ export function NavBarMenuItem({
text,
url,
isMobile = false,
canPin = false,
pinned = false,
onTogglePin,
}: Props) {
const theme = useTheme2();
const styles = getStyles(theme, isActive, styleOverrides);
const onClickPin = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
onTogglePin?.();
};
const linkContent = (
<div className={styles.linkContent}>
<div>
@ -78,17 +65,7 @@ export function NavBarMenuItem({
return isDivider ? (
<li data-testid="dropdown-child-divider" className={styles.divider} tabIndex={-1} aria-disabled />
) : (
<li className={styles.listItem}>
{element}
{canPin && (
<IconButton
name="anchor"
className={cx('pin-button', styles.pinButton, { [styles.visible]: pinned })}
onClick={onClickPin}
tooltip={`${pinned ? 'Unpin' : 'Pin'} menu item`}
/>
)}
</li>
<li className={styles.listItem}>{element}</li>
);
}

View File

@ -1,187 +0,0 @@
import React, { useState } from 'react';
import { useLocation } from 'react-router-dom';
import { css, cx } from '@emotion/css';
import { cloneDeep } from 'lodash';
import { GrafanaTheme2, NavModelItem, NavSection } from '@grafana/data';
import { Icon, IconName, useTheme2 } from '@grafana/ui';
import { locationService } from '@grafana/runtime';
import { getKioskMode } from 'app/core/navigation/kiosk';
import config from 'app/core/config';
import { KioskMode, StoreState } from 'app/types';
import { enrichConfigItems, getActiveItem, isMatchOrChildMatch, isSearchActive, SEARCH_ITEM_ID } from './utils';
import { OrgSwitcher } from '../OrgSwitcher';
import { NavBarSection } from './NavBarSection';
import { NavBarMenu } from './NavBarMenu';
import NavBarItem from './NavBarItem';
import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu';
import { Branding } from '../Branding/Branding';
import { useSelector } from 'react-redux';
const onOpenSearch = () => {
locationService.partial({ search: 'open' });
};
const searchItem: NavModelItem = {
id: SEARCH_ITEM_ID,
onClick: onOpenSearch,
text: 'Search dashboards',
icon: 'search',
};
export const NavBarNext = React.memo(() => {
const navBarTree = useSelector((state: StoreState) => state.navBarTree);
const homeUrl = config.appSubUrl || '/';
const theme = useTheme2();
const styles = getStyles(theme);
const location = useLocation();
const kiosk = getKioskMode();
const [showSwitcherModal, setShowSwitcherModal] = useState(false);
const toggleSwitcherModal = () => {
setShowSwitcherModal(!showSwitcherModal);
};
const navTree = cloneDeep(navBarTree);
// Here we need to hack in a "home" NavModelItem since this is constructed in the frontend
const homeLink: NavModelItem = {
text: 'Home',
url: config.appSubUrl || '/',
};
navTree.unshift(homeLink);
const coreItems = navTree.filter((item) => item.section === NavSection.Core);
const pluginItems = navTree.filter((item) => item.section === NavSection.Plugin);
const configItems = enrichConfigItems(
navTree.filter((item) => item.section === NavSection.Config),
location,
toggleSwitcherModal
);
const activeItem = isSearchActive(location) ? searchItem : getActiveItem(navTree, location.pathname);
const [menuOpen, setMenuOpen] = useState(false);
if (kiosk !== KioskMode.Off) {
return null;
}
return (
<nav className={cx(styles.sidemenu, 'sidemenu')} data-testid="sidemenu" aria-label="Main menu">
<div className={styles.mobileSidemenuLogo} onClick={() => setMenuOpen(!menuOpen)} key="hamburger">
<Icon name="bars" size="xl" />
</div>
<NavBarSection>
<NavBarItemWithoutMenu
isActive={isMatchOrChildMatch(homeLink, activeItem)}
label="Home"
className={styles.grafanaLogo}
url={homeUrl}
>
<Branding.MenuLogo />
</NavBarItemWithoutMenu>
<NavBarItem className={styles.search} isActive={activeItem === searchItem} link={searchItem}>
<Icon name="search" size="xl" />
</NavBarItem>
</NavBarSection>
<NavBarSection>
{coreItems.map((link, index) => (
<NavBarItem
key={`${link.id}-${index}`}
isActive={isMatchOrChildMatch(link, activeItem)}
link={{ ...link, subTitle: undefined, onClick: undefined }}
>
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
</NavBarItem>
))}
</NavBarSection>
<NavBarSection>
{pluginItems.map((link, index) => (
<NavBarItem key={`${link.id}-${index}`} isActive={isMatchOrChildMatch(link, activeItem)} link={link}>
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
</NavBarItem>
))}
</NavBarSection>
<div className={styles.spacer} />
<NavBarSection>
{configItems.map((link, index) => (
<NavBarItem
key={`${link.id}-${index}`}
isActive={isMatchOrChildMatch(link, activeItem)}
reverseMenuDirection
link={link}
>
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
</NavBarItem>
))}
</NavBarSection>
{showSwitcherModal && <OrgSwitcher onDismiss={toggleSwitcherModal} />}
{menuOpen && (
<NavBarMenu
activeItem={activeItem}
navItems={[searchItem, ...coreItems, ...pluginItems, ...configItems]}
onClose={() => setMenuOpen(false)}
/>
)}
</nav>
);
});
NavBarNext.displayName = 'NavBarNext';
const getStyles = (theme: GrafanaTheme2) => ({
search: css`
display: none;
margin-top: 0;
${theme.breakpoints.up('md')} {
display: block;
}
`,
sidemenu: css`
display: flex;
flex-direction: column;
position: fixed;
z-index: ${theme.zIndex.sidemenu};
${theme.breakpoints.up('md')} {
background: ${theme.colors.background.primary};
border-right: 1px solid ${theme.components.panel.borderColor};
position: relative;
width: ${theme.components.sidemenu.width}px;
}
.sidemenu-hidden & {
display: none;
}
`,
grafanaLogo: css`
align-items: center;
display: flex;
img {
height: ${theme.spacing(3)};
width: ${theme.spacing(3)};
}
justify-content: center;
`,
mobileSidemenuLogo: css`
align-items: center;
cursor: pointer;
display: flex;
flex-direction: row;
justify-content: space-between;
padding: ${theme.spacing(2)};
${theme.breakpoints.up('md')} {
display: none;
}
`,
spacer: css`
flex: 1;
`,
});

View File

@ -0,0 +1,135 @@
import React, { ReactNode } from 'react';
import { Item } from '@react-stately/collections';
import { css, cx } from '@emotion/css';
import { GrafanaTheme2, locationUtil, NavMenuItemType, NavModelItem } from '@grafana/data';
import { IconName, useTheme2 } from '@grafana/ui';
import { locationService } from '@grafana/runtime';
import { NavBarMenuItem } from './NavBarMenuItem';
import { getNavBarItemWithoutMenuStyles, NavBarItemWithoutMenu } from './NavBarItemWithoutMenu';
import { NavBarItemMenuTrigger } from './NavBarItemMenuTrigger';
import { NavBarItemMenu } from '../NavBarItemMenu';
import { getNavModelItemKey } from '../utils';
import { useLingui } from '@lingui/react';
import menuItemTranslations from '../navBarItem-translations';
export interface Props {
isActive?: boolean;
children: ReactNode;
className?: string;
reverseMenuDirection?: boolean;
showMenu?: boolean;
link: NavModelItem;
}
const NavBarItem = ({
isActive = false,
children,
className,
reverseMenuDirection = false,
showMenu = true,
link,
}: Props) => {
const { i18n } = useLingui();
const theme = useTheme2();
const menuItems = link.children ?? [];
// Spreading `menuItems` here as otherwise we'd be mutating props
const menuItemsSorted = reverseMenuDirection ? [...menuItems].reverse() : menuItems;
const filteredItems = menuItemsSorted
.filter((item) => !item.hideFromMenu)
.map((i) => ({ ...i, menuItemType: NavMenuItemType.Item }));
const adjustHeightForBorder = filteredItems.length === 0;
const styles = getStyles(theme, adjustHeightForBorder, isActive);
const section: NavModelItem = {
...link,
children: filteredItems,
menuItemType: NavMenuItemType.Section,
};
const items: NavModelItem[] = [section].concat(filteredItems);
const onNavigate = (item: NavModelItem) => {
const { url, target, onClick } = item;
if (!url) {
onClick?.();
return;
}
if (!target && url.startsWith('/')) {
locationService.push(locationUtil.stripBaseFromUrl(url));
} else {
window.open(url, target);
}
};
const translationKey = link.id && menuItemTranslations[link.id];
const linkText = translationKey ? i18n._(translationKey) : link.text;
if (!showMenu) {
return (
<NavBarItemWithoutMenu
label={link.text}
className={className}
isActive={isActive}
url={link.url}
onClick={link.onClick}
target={link.target}
highlightText={link.highlightText}
>
{children}
</NavBarItemWithoutMenu>
);
} else {
return (
<li className={cx(styles.container, className)}>
<NavBarItemMenuTrigger item={section} isActive={isActive} label={linkText}>
<NavBarItemMenu
items={items}
reverseMenuDirection={reverseMenuDirection}
adjustHeightForBorder={adjustHeightForBorder}
disabledKeys={['divider', 'subtitle']}
aria-label={section.text}
onNavigate={onNavigate}
>
{(item: NavModelItem) => {
const translationKey = item.id && menuItemTranslations[item.id];
const itemText = translationKey ? i18n._(translationKey) : item.text;
const isSection = item.menuItemType === NavMenuItemType.Section;
return (
<Item key={getNavModelItemKey(item)} textValue={item.text}>
<NavBarMenuItem
isDivider={!isSection && item.divider}
icon={isSection ? undefined : (item.icon as IconName)}
target={item.target}
text={itemText}
url={item.url}
onClick={item.onClick}
styleOverrides={cx(styles.primaryText, { [styles.header]: isSection })}
/>
</Item>
);
}}
</NavBarItemMenu>
</NavBarItemMenuTrigger>
</li>
);
}
};
export default NavBarItem;
const getStyles = (theme: GrafanaTheme2, adjustHeightForBorder: boolean, isActive?: boolean) => ({
...getNavBarItemWithoutMenuStyles(theme, isActive),
primaryText: css({
color: theme.colors.text.primary,
}),
header: css({
height: `calc(${theme.spacing(6)} - ${adjustHeightForBorder ? 2 : 1}px)`,
fontSize: theme.typography.h4.fontSize,
fontWeight: theme.typography.h4.fontWeight,
padding: `${theme.spacing(1)} ${theme.spacing(2)}`,
whiteSpace: 'nowrap',
width: '100%',
}),
});

View File

@ -0,0 +1,217 @@
import React, { ReactElement, useEffect, useState } from 'react';
import { css, cx } from '@emotion/css';
import { Icon, IconName, Link, useTheme2 } from '@grafana/ui';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { MenuTriggerProps } from '@react-types/menu';
import { useMenuTriggerState } from '@react-stately/menu';
import { useMenuTrigger } from '@react-aria/menu';
import { useFocusWithin, useHover, useKeyboard } from '@react-aria/interactions';
import { useButton } from '@react-aria/button';
import { useDialog } from '@react-aria/dialog';
import { DismissButton, useOverlay } from '@react-aria/overlays';
import { FocusScope } from '@react-aria/focus';
import { NavBarItemMenuContext } from '../context';
import { NavFeatureHighlight } from '../NavFeatureHighlight';
import { reportExperimentView } from '@grafana/runtime';
export interface NavBarItemMenuTriggerProps extends MenuTriggerProps {
children: ReactElement;
item: NavModelItem;
isActive?: boolean;
label: string;
}
export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactElement {
const { item, isActive, label, children: menu, ...rest } = props;
const [menuHasFocus, setMenuHasFocus] = useState(false);
const theme = useTheme2();
const styles = getStyles(theme, isActive);
// Create state based on the incoming props
const state = useMenuTriggerState({ ...rest });
// Get props for the menu trigger and menu elements
const ref = React.useRef<HTMLElement>(null);
const { menuTriggerProps, menuProps } = useMenuTrigger({}, state, ref);
useEffect(() => {
if (item.highlightId) {
reportExperimentView(`feature-highlights-${item.highlightId}-nav`, 'test', '');
}
}, [item.highlightId]);
const { hoverProps } = useHover({
onHoverChange: (isHovering) => {
if (isHovering) {
state.open();
} else {
state.close();
}
},
});
const { focusWithinProps } = useFocusWithin({
onFocusWithinChange: (isFocused) => {
if (isFocused) {
state.open();
}
if (!isFocused) {
state.close();
setMenuHasFocus(false);
}
},
});
const { keyboardProps } = useKeyboard({
onKeyDown: (e) => {
switch (e.key) {
case 'ArrowRight':
if (!state.isOpen) {
state.open();
}
setMenuHasFocus(true);
break;
default:
break;
}
},
});
// Get props for the button based on the trigger props from useMenuTrigger
const { buttonProps } = useButton(menuTriggerProps, ref);
const Wrapper = item.highlightText ? NavFeatureHighlight : React.Fragment;
const itemContent = (
<Wrapper>
<span className={styles.icon}>
{item?.icon && <Icon name={item.icon as IconName} size="xl" />}
{item?.img && <img src={item.img} alt={`${item.text} logo`} />}
</span>
</Wrapper>
);
let element = (
<button
className={styles.element}
{...buttonProps}
{...keyboardProps}
ref={ref as React.RefObject<HTMLButtonElement>}
onClick={item?.onClick}
aria-label={label}
>
{itemContent}
</button>
);
if (item?.url) {
element =
!item.target && item.url.startsWith('/') ? (
<Link
{...buttonProps}
{...keyboardProps}
ref={ref as React.RefObject<HTMLAnchorElement>}
href={item.url}
target={item.target}
onClick={item?.onClick}
className={styles.element}
aria-label={label}
>
{itemContent}
</Link>
) : (
<a
href={item.url}
target={item.target}
onClick={item?.onClick}
{...buttonProps}
{...keyboardProps}
ref={ref as React.RefObject<HTMLAnchorElement>}
className={styles.element}
aria-label={label}
>
{itemContent}
</a>
);
}
const overlayRef = React.useRef(null);
const { dialogProps } = useDialog({}, overlayRef);
const { overlayProps } = useOverlay(
{
onClose: () => state.close(),
isOpen: state.isOpen,
isDismissable: true,
},
overlayRef
);
return (
<div className={cx(styles.element, 'dropdown')} {...focusWithinProps} {...hoverProps}>
{element}
{state.isOpen && (
<NavBarItemMenuContext.Provider
value={{
menuProps,
menuHasFocus,
onClose: () => state.close(),
onLeft: () => {
setMenuHasFocus(false);
ref.current?.focus();
},
}}
>
<FocusScope restoreFocus>
<div {...overlayProps} {...dialogProps} ref={overlayRef}>
<DismissButton onDismiss={() => state.close()} />
{menu}
<DismissButton onDismiss={() => state.close()} />
</div>
</FocusScope>
</NavBarItemMenuContext.Provider>
)}
</div>
);
}
const getStyles = (theme: GrafanaTheme2, isActive?: boolean) => ({
element: css({
backgroundColor: 'transparent',
border: 'none',
color: 'inherit',
display: 'grid',
padding: 0,
placeContent: 'center',
height: theme.spacing(6),
width: theme.spacing(7),
'&::before': {
display: isActive ? 'block' : 'none',
content: '" "',
position: 'absolute',
left: theme.spacing(1),
top: theme.spacing(1.5),
bottom: theme.spacing(1.5),
width: theme.spacing(0.5),
borderRadius: theme.shape.borderRadius(1),
backgroundImage: theme.colors.gradients.brandVertical,
},
'&:focus-visible': {
backgroundColor: theme.colors.action.hover,
boxShadow: 'none',
color: theme.colors.text.primary,
outline: `${theme.shape.borderRadius(1)} solid ${theme.colors.primary.main}`,
outlineOffset: `-${theme.shape.borderRadius(1)}`,
transition: 'none',
},
}),
icon: css({
height: '100%',
width: '100%',
img: {
borderRadius: '50%',
height: theme.spacing(3),
width: theme.spacing(3),
},
}),
});

View File

@ -0,0 +1,128 @@
import React, { ReactNode } from 'react';
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Link, useTheme2 } from '@grafana/ui';
import { NavFeatureHighlight } from '../NavFeatureHighlight';
export interface NavBarItemWithoutMenuProps {
label: string;
children: ReactNode;
className?: string;
elClassName?: string;
url?: string;
target?: string;
isActive?: boolean;
onClick?: () => void;
highlightText?: string;
}
export function NavBarItemWithoutMenu({
label,
children,
url,
target,
isActive = false,
onClick,
highlightText,
className,
elClassName,
}: NavBarItemWithoutMenuProps) {
const theme = useTheme2();
const styles = getNavBarItemWithoutMenuStyles(theme, isActive);
const content = highlightText ? (
<NavFeatureHighlight>
<span className={styles.icon}>{children}</span>
</NavFeatureHighlight>
) : (
<span className={styles.icon}>{children}</span>
);
const elStyle = cx(styles.element, elClassName);
const renderContents = () => {
if (!url) {
return (
<button className={elStyle} onClick={onClick} aria-label={label}>
{content}
</button>
);
} else if (!target && url.startsWith('/')) {
return (
<Link className={elStyle} href={url} target={target} aria-label={label} onClick={onClick} aria-haspopup="true">
{content}
</Link>
);
} else {
return (
<a href={url} target={target} className={elStyle} onClick={onClick} aria-label={label}>
{content}
</a>
);
}
};
return <div className={cx(styles.container, className)}>{renderContents()}</div>;
}
export function getNavBarItemWithoutMenuStyles(theme: GrafanaTheme2, isActive?: boolean) {
return {
container: css({
position: 'relative',
color: isActive ? theme.colors.text.primary : theme.colors.text.secondary,
display: 'grid',
placeItems: 'center',
'&:hover': {
backgroundColor: theme.colors.action.hover,
color: theme.colors.text.primary,
// TODO don't use a hardcoded class here, use isVisible in NavBarDropdown
'.navbar-dropdown': {
opacity: 1,
visibility: 'visible',
},
},
}),
element: css({
backgroundColor: 'transparent',
border: 'none',
color: 'inherit',
display: 'block',
padding: 0,
textAlign: 'center',
'&::before': {
display: isActive ? 'block' : 'none',
content: "' '",
position: 'absolute',
left: theme.spacing(1),
top: theme.spacing(1.5),
bottom: theme.spacing(1.5),
width: theme.spacing(0.5),
borderRadius: theme.shape.borderRadius(1),
backgroundImage: theme.colors.gradients.brandVertical,
},
'&:focus-visible': {
backgroundColor: theme.colors.action.hover,
boxShadow: 'none',
color: theme.colors.text.primary,
outline: `${theme.shape.borderRadius(1)} solid ${theme.colors.primary.main}`,
outlineOffset: `-${theme.shape.borderRadius(1)}`,
transition: 'none',
},
}),
icon: css({
height: '100%',
width: '100%',
img: {
borderRadius: '50%',
height: theme.spacing(3),
width: theme.spacing(3),
},
}),
};
}

View File

@ -0,0 +1,291 @@
import React, { useRef } from 'react';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { CollapsableSection, CustomScrollbar, Icon, IconName, useStyles2 } from '@grafana/ui';
import { FocusScope } from '@react-aria/focus';
import { useDialog } from '@react-aria/dialog';
import { useOverlay } from '@react-aria/overlays';
import { css, cx, keyframes } from '@emotion/css';
import { NavBarMenuItem } from './NavBarMenuItem';
import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu';
import { isMatchOrChildMatch } from '../utils';
export interface Props {
activeItem?: NavModelItem;
navItems: NavModelItem[];
onClose: () => void;
}
export function NavBarMenu({ activeItem, navItems, onClose }: Props) {
const styles = useStyles2(getStyles);
const ref = useRef(null);
const { dialogProps } = useDialog({}, ref);
const { overlayProps } = useOverlay(
{
isDismissable: true,
isOpen: true,
onClose,
},
ref
);
return (
<FocusScope contain restoreFocus autoFocus>
<div data-testid="navbarmenu" className={styles.container} ref={ref} {...overlayProps} {...dialogProps}>
<nav className={styles.content}>
<CustomScrollbar hideHorizontalTrack>
<ul className={styles.itemList}>
{navItems.map((link) => (
<NavItem link={link} onClose={onClose} activeItem={activeItem} key={link.text} />
))}
</ul>
</CustomScrollbar>
</nav>
</div>
</FocusScope>
);
}
NavBarMenu.displayName = 'NavBarMenu';
const getStyles = (theme: GrafanaTheme2) => {
const fadeIn = keyframes`
from {
background-color: ${theme.colors.background.primary};
width: ${theme.spacing(7)};
}
to {
background-color: ${theme.colors.background.canvas};
width: 300px;
}`;
return {
container: css({
animation: `150ms ease-in 0s 1 normal forwards ${fadeIn}`,
bottom: 0,
display: 'flex',
flexDirection: 'column',
left: 0,
whiteSpace: 'nowrap',
marginTop: theme.spacing(1),
marginRight: theme.spacing(1.5),
right: 0,
zIndex: 9999,
top: 0,
[theme.breakpoints.up('md')]: {
borderRight: `1px solid ${theme.colors.border.weak}`,
right: 'unset',
},
}),
content: css({
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
}),
itemList: css({
display: 'grid',
gridAutoRows: `minmax(${theme.spacing(6)}, auto)`,
}),
};
};
function NavItem({
link,
activeItem,
onClose,
}: {
link: NavModelItem;
activeItem?: NavModelItem;
onClose: () => void;
}) {
const styles = useStyles2(getNavItemStyles);
if (linkHasChildren(link)) {
return (
<CollapsibleNavItem link={link} isActive={isMatchOrChildMatch(link, activeItem)}>
<ul>
{link.children.map(
(childLink) =>
!childLink.divider && (
<NavBarMenuItem
key={`${link.text}-${childLink.text}`}
isActive={activeItem === childLink}
isDivider={childLink.divider}
onClick={() => {
childLink.onClick?.();
onClose();
}}
styleOverrides={styles.item}
target={childLink.target}
text={childLink.text}
url={childLink.url}
isMobile={true}
/>
)
)}
</ul>
</CollapsibleNavItem>
);
} else if (link.id === 'saved-items') {
return (
<CollapsibleNavItem link={link} isActive={isMatchOrChildMatch(link, activeItem)} className={styles.savedItems}>
<em className={styles.savedItemsText}>No saved items</em>
</CollapsibleNavItem>
);
} else {
return (
<li className={styles.flex}>
<NavBarItemWithoutMenu
className={styles.itemWithoutMenu}
elClassName={styles.fullWidth}
label={link.text}
url={link.url}
target={link.target}
onClick={() => {
link.onClick?.();
onClose();
}}
isActive={link === activeItem}
>
<div className={styles.savedItemsMenuItemWrapper}>
{link.img && (
<img src={link.img} alt={`${link.text} logo`} height="24" width="24" style={{ borderRadius: '50%' }} />
)}
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
<span className={styles.linkText}>{link.text}</span>
</div>
</NavBarItemWithoutMenu>
</li>
);
}
}
const getNavItemStyles = (theme: GrafanaTheme2) => ({
item: css({
padding: `${theme.spacing(1)} 0`,
'&::before': {
display: 'none',
},
}),
savedItems: css({
background: theme.colors.background.secondary,
}),
savedItemsText: css({
display: 'block',
paddingBottom: theme.spacing(2),
color: theme.colors.text.secondary,
}),
flex: css({
display: 'flex',
}),
itemWithoutMenu: css({
position: 'relative',
placeItems: 'inherit',
justifyContent: 'start',
display: 'flex',
flexGrow: 1,
alignItems: 'center',
}),
fullWidth: css({
width: '100%',
}),
savedItemsMenuItemWrapper: css({
display: 'grid',
gridAutoFlow: 'column',
gridTemplateColumns: `${theme.spacing(7)} auto`,
alignItems: 'center',
}),
linkText: css({
fontSize: theme.typography.pxToRem(14),
justifySelf: 'start',
}),
});
function CollapsibleNavItem({
link,
isActive,
children,
className,
}: {
link: NavModelItem;
isActive?: boolean;
children: React.ReactNode;
className?: string;
}) {
const styles = useStyles2(getCollapsibleStyles);
return (
<li className={cx(styles.menuItem, className)}>
<NavBarItemWithoutMenu
isActive={isActive}
label={link.text}
url={link.url}
target={link.target}
onClick={link.onClick}
className={styles.collapsibleMenuItem}
>
{link.img && (
<img src={link.img} alt={`${link.text} logo`} height="24" width="24" style={{ borderRadius: '50%' }} />
)}
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
</NavBarItemWithoutMenu>
<div className={styles.collapsibleSectionWrapper}>
<CollapsableSection
isOpen={false}
className={styles.collapseWrapper}
contentClassName={styles.collapseContent}
label={
<div className={cx(styles.labelWrapper, { [styles.primary]: isActive })}>
<span className={styles.linkText}>{link.text}</span>
</div>
}
>
{children}
</CollapsableSection>
</div>
</li>
);
}
const getCollapsibleStyles = (theme: GrafanaTheme2) => ({
menuItem: css({
position: 'relative',
display: 'flex',
}),
collapsibleMenuItem: css({
height: theme.spacing(6),
width: theme.spacing(7),
display: 'grid',
placeContent: 'center',
}),
collapsibleSectionWrapper: css({
display: 'flex',
flexGrow: 1,
alignSelf: 'start',
flexDirection: 'column',
}),
collapseWrapper: css({
borderRadius: theme.shape.borderRadius(2),
paddingRight: theme.spacing(4.25),
height: theme.spacing(6),
alignItems: 'center',
}),
collapseContent: css({
padding: 0,
paddingLeft: theme.spacing(1.25),
}),
labelWrapper: css({
fontSize: '15px',
color: theme.colors.text.secondary,
}),
primary: css({
color: theme.colors.text.primary,
}),
linkText: css({
fontSize: theme.typography.pxToRem(14),
justifySelf: 'start',
}),
});
function linkHasChildren(link: NavModelItem): link is NavModelItem & { children: NavModelItem[] } {
return Boolean(link.children && link.children.length > 0);
}

View File

@ -0,0 +1,166 @@
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, IconName, Link, useTheme2 } from '@grafana/ui';
import { css, cx } from '@emotion/css';
export interface Props {
icon?: IconName;
isActive?: boolean;
isDivider?: boolean;
onClick?: () => void;
styleOverrides?: string;
target?: HTMLAnchorElement['target'];
text: React.ReactNode;
url?: string;
adjustHeightForBorder?: boolean;
isMobile?: boolean;
}
export function NavBarMenuItem({
icon,
isActive,
isDivider,
onClick,
styleOverrides,
target,
text,
url,
isMobile = false,
}: Props) {
const theme = useTheme2();
const styles = getStyles(theme, isActive);
const elStyle = cx(styles.element, styleOverrides);
const linkContent = (
<div className={styles.linkContent}>
<span>{text}</span>
{target === '_blank' && (
<Icon data-testid="external-link-icon" name="external-link-alt" className={styles.externalLinkIcon} />
)}
</div>
);
let element = (
<button className={elStyle} onClick={onClick} tabIndex={-1}>
{linkContent}
</button>
);
if (url) {
element =
!target && url.startsWith('/') ? (
<Link className={elStyle} href={url} target={target} onClick={onClick} tabIndex={!isMobile ? -1 : 0}>
{linkContent}
</Link>
) : (
<a href={url} target={target} className={elStyle} onClick={onClick} tabIndex={!isMobile ? -1 : 0}>
{linkContent}
</a>
);
}
if (isMobile) {
return isDivider ? (
<li data-testid="dropdown-child-divider" className={styles.divider} tabIndex={-1} aria-disabled />
) : (
<li className={styles.listItem}>{element}</li>
);
}
return isDivider ? (
<div data-testid="dropdown-child-divider" className={styles.divider} tabIndex={-1} aria-disabled />
) : (
<div style={{ position: 'relative' }}>{element}</div>
);
}
NavBarMenuItem.displayName = 'NavBarMenuItem';
const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({
linkContent: css({
display: 'grid',
placeItems: 'center',
gridAutoFlow: 'column',
gap: '0.5rem',
}),
externalLinkIcon: css({
color: theme.colors.text.secondary,
gridColumnStart: 3,
}),
element: css({
alignItems: 'center',
background: 'none',
border: 'none',
color: isActive ? theme.colors.text.primary : theme.colors.text.secondary,
display: 'flex',
fontSize: 'inherit',
height: '100%',
padding: '5px 12px 5px 10px',
textAlign: 'left',
whiteSpace: 'nowrap',
'&:focus-visible + .pin-button': {
opacity: '100%',
},
'&:focus-visible': {
outline: 'none',
boxShadow: 'none',
'&::after': {
boxShadow: 'none',
outline: `${theme.shape.borderRadius} solid ${theme.colors.primary.main}`,
outlineOffset: `-${theme.shape.borderRadius(1)}`,
transition: 'none',
},
},
'&::before': {
display: isActive ? 'block' : 'none',
content: '" "',
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: theme.spacing(0.5),
borderRadius: theme.shape.borderRadius(1),
backgroundImage: theme.colors.gradients.brandVertical,
},
'&::after': {
position: 'absolute',
content: '" "',
left: 0,
top: 0,
bottom: 0,
right: 0,
},
}),
listItem: css({
position: 'relative',
display: 'flex',
alignItems: 'center',
'&:hover, &:focus-within': {
color: theme.colors.text.primary,
'> *:first-child::after': {
backgroundColor: theme.colors.action.hover,
},
},
'> .pin-button': {
opacity: 0,
},
'&:hover > .pin-button, &:focusVisible > .pin-button': {
opacity: '100%',
},
}),
divider: css({
borderBottom: `1px solid ${theme.colors.border.weak}`,
height: '1px',
margin: `${theme.spacing(1)} 0`,
overflow: 'hidden',
}),
});

View File

@ -4,7 +4,7 @@ import { Router } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import { locationService } from '@grafana/runtime';
import { configureStore } from 'app/store/configureStore';
import TestProvider from '../../../../test/helpers/TestProvider';
import TestProvider from '../../../../../test/helpers/TestProvider';
import { NavBarNext } from './NavBarNext';
jest.mock('app/core/services/context_srv', () => ({

View File

@ -0,0 +1,237 @@
import React, { useState } from 'react';
import { useLocation } from 'react-router-dom';
import { css, cx } from '@emotion/css';
import { cloneDeep } from 'lodash';
import { GrafanaTheme2, NavModelItem, NavSection } from '@grafana/data';
import { Icon, IconButton, IconName, useTheme2 } from '@grafana/ui';
import { config, locationService } from '@grafana/runtime';
import { getKioskMode } from 'app/core/navigation/kiosk';
import { KioskMode, StoreState } from 'app/types';
import { enrichConfigItems, getActiveItem, isMatchOrChildMatch, isSearchActive, SEARCH_ITEM_ID } from '../utils';
import { OrgSwitcher } from '../../OrgSwitcher';
import { NavBarMenu } from './NavBarMenu';
import NavBarItem from './NavBarItem';
import { useSelector } from 'react-redux';
import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu';
const onOpenSearch = () => {
locationService.partial({ search: 'open' });
};
const searchItem: NavModelItem = {
id: SEARCH_ITEM_ID,
onClick: onOpenSearch,
text: 'Search dashboards',
icon: 'search',
};
// Here we need to hack in a "home" NavModelItem since this is constructed in the frontend
const homeItem: NavModelItem = {
id: 'home',
text: 'Home',
url: config.appSubUrl || '/',
icon: 'grafana',
};
export const NavBarNext = React.memo(() => {
const navBarTree = useSelector((state: StoreState) => state.navBarTree);
const theme = useTheme2();
const styles = getStyles(theme);
const location = useLocation();
const kiosk = getKioskMode();
const [showSwitcherModal, setShowSwitcherModal] = useState(false);
const toggleSwitcherModal = () => {
setShowSwitcherModal(!showSwitcherModal);
};
const navTree = cloneDeep(navBarTree);
navTree.unshift(homeItem);
const coreItems = navTree.filter((item) => item.section === NavSection.Core);
const pluginItems = navTree.filter((item) => item.section === NavSection.Plugin);
const configItems = enrichConfigItems(
navTree.filter((item) => item.section === NavSection.Config),
location,
toggleSwitcherModal
);
const activeItem = isSearchActive(location) ? searchItem : getActiveItem(navTree, location.pathname);
const [menuOpen, setMenuOpen] = useState(false);
if (kiosk !== KioskMode.Off) {
return null;
}
return (
<div className={styles.navWrapper}>
<nav className={cx(styles.sidemenu, 'sidemenu')} data-testid="sidemenu" aria-label="Main menu">
<div className={styles.mobileSidemenuLogo} onClick={() => setMenuOpen(!menuOpen)} key="hamburger">
<Icon name="bars" size="xl" />
</div>
<ul className={styles.itemList}>
<NavBarItemWithoutMenu
isActive={isMatchOrChildMatch(homeItem, activeItem)}
label="Home"
className={styles.grafanaLogo}
url={homeItem.url}
>
<Icon name="grafana" size="xl" />
</NavBarItemWithoutMenu>
<NavBarItem className={styles.search} isActive={activeItem === searchItem} link={searchItem}>
<Icon name="search" size="xl" />
</NavBarItem>
{coreItems.map((link, index) => (
<NavBarItem
key={`${link.id}-${index}`}
isActive={isMatchOrChildMatch(link, activeItem)}
link={{ ...link, subTitle: undefined, onClick: undefined }}
>
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
</NavBarItem>
))}
{pluginItems.length > 0 &&
pluginItems.map((link, index) => (
<NavBarItem key={`${link.id}-${index}`} isActive={isMatchOrChildMatch(link, activeItem)} link={link}>
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
</NavBarItem>
))}
{configItems.map((link, index) => (
<NavBarItem
key={`${link.id}-${index}`}
isActive={isMatchOrChildMatch(link, activeItem)}
reverseMenuDirection
link={link}
className={cx({ [styles.verticalSpacer]: index === 0 })}
>
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
</NavBarItem>
))}
</ul>
</nav>
{showSwitcherModal && <OrgSwitcher onDismiss={toggleSwitcherModal} />}
<div className={styles.menuWrapper}>
{menuOpen && (
<NavBarMenu
activeItem={activeItem}
navItems={[homeItem, searchItem, ...coreItems, ...pluginItems, ...configItems]}
onClose={() => setMenuOpen(false)}
/>
)}
<IconButton
name={menuOpen ? 'angle-left' : 'angle-right'}
className={cx(styles.menuToggle, { [styles.menuOpen]: menuOpen })}
size="xl"
onClick={() => setMenuOpen(!menuOpen)}
/>
</div>
</div>
);
});
NavBarNext.displayName = 'NavBarNext';
const getStyles = (theme: GrafanaTheme2) => ({
navWrapper: css({
position: 'relative',
display: 'flex',
}),
sidemenu: css({
label: 'sidemenu',
display: 'flex',
flexDirection: 'column',
backgroundColor: theme.colors.background.primary,
zIndex: theme.zIndex.sidemenu,
padding: `${theme.spacing(1)} 0`,
position: 'relative',
width: theme.spacing(7),
[theme.breakpoints.down('md')]: {
position: 'fixed',
paddingTop: '0px',
backgroundColor: 'inherit',
},
'.sidemenu-hidden &': {
visibility: 'hidden',
},
}),
mobileSidemenuLogo: css({
alignItems: 'center',
cursor: 'pointer',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
padding: theme.spacing(2),
[theme.breakpoints.up('md')]: {
display: 'none',
},
}),
itemList: css({
backgroundColor: 'inherit',
display: 'flex',
flexDirection: 'column',
height: '100%',
'> *': {
height: theme.spacing(6),
},
[theme.breakpoints.down('md')]: {
visibility: 'hidden',
},
}),
grafanaLogo: css({
alignItems: 'center',
display: 'flex',
img: {
height: theme.spacing(3),
width: theme.spacing(3),
},
justifyContent: 'center',
}),
search: css({
display: 'none',
marginTop: 0,
[theme.breakpoints.up('md')]: {
display: 'grid',
},
}),
verticalSpacer: css({
marginTop: 'auto',
}),
hideFromMobile: css({
[theme.breakpoints.down('md')]: {
display: 'none',
},
}),
menuWrapper: css({
position: 'fixed',
display: 'grid',
gridAutoFlow: 'column',
height: '100%',
zIndex: 9999,
}),
menuToggle: css({
position: 'absolute',
marginRight: 0,
top: '43px',
right: '0px',
zIndex: 9999,
transform: `translateX(calc(${theme.spacing(7)} + 50%))`,
background: 'gray',
borderRadius: '50%',
[theme.breakpoints.down('md')]: {
display: 'none',
},
}),
menuOpen: css({
transform: 'translateX(0%)',
}),
});

View File

@ -65,14 +65,14 @@ describe('VariablesUnknownTable', () => {
const { getUnknownsNetworkSpy } = await getTestContext();
userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i }));
await waitFor(() => expect(screen.getByTitle('Click to collapse')).toBeInTheDocument());
await waitFor(() => expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true'));
expect(getUnknownsNetworkSpy).toHaveBeenCalledTimes(1);
userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i }));
await waitFor(() => expect(screen.getByTitle('Click to expand')).toBeInTheDocument());
await waitFor(() => expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false'));
userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i }));
await waitFor(() => expect(screen.getByTitle('Click to collapse')).toBeInTheDocument());
await waitFor(() => expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true'));
expect(getUnknownsNetworkSpy).toHaveBeenCalledTimes(1);
});