diff --git a/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx b/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx index afc2decad36..24fe7a67f77 100644 --- a/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx +++ b/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx @@ -30,7 +30,7 @@ export const CollapsableSection: FC = ({ }) => { const [open, toggleOpen] = useState(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 = ({ return ( <> -
+
+ ); + + if (item?.url) { + element = + !item.target && item.url.startsWith('/') ? ( + } + href={item.url} + target={item.target} + onClick={item?.onClick} + className={styles.element} + aria-label={label} + > + {itemContent} + + ) : ( + } + className={styles.element} + aria-label={label} + > + {itemContent} + + ); + } + + const overlayRef = React.useRef(null); + const { dialogProps } = useDialog({}, overlayRef); + const { overlayProps } = useOverlay( + { + onClose: () => state.close(), + isOpen: state.isOpen, + isDismissable: true, + }, + overlayRef + ); + + return ( +
+ {element} + {state.isOpen && ( + state.close(), + onLeft: () => { + setMenuHasFocus(false); + ref.current?.focus(); + }, + }} + > + +
+ state.close()} /> + {menu} + state.close()} /> +
+
+
+ )} +
+ ); +} + +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), + }, + }), +}); diff --git a/public/app/core/components/NavBar/Next/NavBarItemWithoutMenu.tsx b/public/app/core/components/NavBar/Next/NavBarItemWithoutMenu.tsx new file mode 100644 index 00000000000..f4de8e34142 --- /dev/null +++ b/public/app/core/components/NavBar/Next/NavBarItemWithoutMenu.tsx @@ -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 ? ( + + {children} + + ) : ( + {children} + ); + + const elStyle = cx(styles.element, elClassName); + + const renderContents = () => { + if (!url) { + return ( + + ); + } else if (!target && url.startsWith('/')) { + return ( + + {content} + + ); + } else { + return ( + + {content} + + ); + } + }; + + return
{renderContents()}
; +} + +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), + }, + }), + }; +} diff --git a/public/app/core/components/NavBar/Next/NavBarMenu.tsx b/public/app/core/components/NavBar/Next/NavBarMenu.tsx new file mode 100644 index 00000000000..6117402d64c --- /dev/null +++ b/public/app/core/components/NavBar/Next/NavBarMenu.tsx @@ -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 ( + +
+ +
+
+ ); +} + +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 ( + +
    + {link.children.map( + (childLink) => + !childLink.divider && ( + { + childLink.onClick?.(); + onClose(); + }} + styleOverrides={styles.item} + target={childLink.target} + text={childLink.text} + url={childLink.url} + isMobile={true} + /> + ) + )} +
+
+ ); + } else if (link.id === 'saved-items') { + return ( + + No saved items + + ); + } else { + return ( +
  • + { + link.onClick?.(); + onClose(); + }} + isActive={link === activeItem} + > +
    + {link.img && ( + {`${link.text} + )} + {link.icon && } + {link.text} +
    +
    +
  • + ); + } +} + +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 ( +
  • + + {link.img && ( + {`${link.text} + )} + {link.icon && } + +
    + + {link.text} +
    + } + > + {children} + +
  • + + ); +} + +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); +} diff --git a/public/app/core/components/NavBar/Next/NavBarMenuItem.tsx b/public/app/core/components/NavBar/Next/NavBarMenuItem.tsx new file mode 100644 index 00000000000..29928a993a0 --- /dev/null +++ b/public/app/core/components/NavBar/Next/NavBarMenuItem.tsx @@ -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 = ( +
    + {text} + {target === '_blank' && ( + + )} +
    + ); + + let element = ( + + ); + + if (url) { + element = + !target && url.startsWith('/') ? ( + + {linkContent} + + ) : ( + + {linkContent} + + ); + } + + if (isMobile) { + return isDivider ? ( +
  • + ) : ( +
  • {element}
  • + ); + } + + return isDivider ? ( +
    + ) : ( +
    {element}
    + ); +} + +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', + }), +}); diff --git a/public/app/core/components/NavBar/NavBarNext.test.tsx b/public/app/core/components/NavBar/Next/NavBarNext.test.tsx similarity index 95% rename from public/app/core/components/NavBar/NavBarNext.test.tsx rename to public/app/core/components/NavBar/Next/NavBarNext.test.tsx index 12d80526cb5..c7d21ddfa81 100644 --- a/public/app/core/components/NavBar/NavBarNext.test.tsx +++ b/public/app/core/components/NavBar/Next/NavBarNext.test.tsx @@ -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', () => ({ diff --git a/public/app/core/components/NavBar/Next/NavBarNext.tsx b/public/app/core/components/NavBar/Next/NavBarNext.tsx new file mode 100644 index 00000000000..c1526a91a7d --- /dev/null +++ b/public/app/core/components/NavBar/Next/NavBarNext.tsx @@ -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 ( +
    + + {showSwitcherModal && } +
    + {menuOpen && ( + setMenuOpen(false)} + /> + )} + setMenuOpen(!menuOpen)} + /> +
    +
    + ); +}); + +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%)', + }), +}); diff --git a/public/app/features/variables/inspect/VariablesUnknownTable.test.tsx b/public/app/features/variables/inspect/VariablesUnknownTable.test.tsx index 7fb888ca539..0173b108dc0 100644 --- a/public/app/features/variables/inspect/VariablesUnknownTable.test.tsx +++ b/public/app/features/variables/inspect/VariablesUnknownTable.test.tsx @@ -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); });