mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Navigation: Rough implementation of new navbar design (#46909)
This commit is contained in:
parent
4449439a41
commit
f486b54b84
@ -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': {
|
||||
|
@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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';
|
||||
|
@ -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) =>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
`,
|
||||
});
|
135
public/app/core/components/NavBar/Next/NavBarItem.tsx
Normal file
135
public/app/core/components/NavBar/Next/NavBarItem.tsx
Normal 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%',
|
||||
}),
|
||||
});
|
217
public/app/core/components/NavBar/Next/NavBarItemMenuTrigger.tsx
Normal file
217
public/app/core/components/NavBar/Next/NavBarItemMenuTrigger.tsx
Normal 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),
|
||||
},
|
||||
}),
|
||||
});
|
128
public/app/core/components/NavBar/Next/NavBarItemWithoutMenu.tsx
Normal file
128
public/app/core/components/NavBar/Next/NavBarItemWithoutMenu.tsx
Normal 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),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
291
public/app/core/components/NavBar/Next/NavBarMenu.tsx
Normal file
291
public/app/core/components/NavBar/Next/NavBarMenu.tsx
Normal 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);
|
||||
}
|
166
public/app/core/components/NavBar/Next/NavBarMenuItem.tsx
Normal file
166
public/app/core/components/NavBar/Next/NavBarMenuItem.tsx
Normal 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',
|
||||
}),
|
||||
});
|
@ -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', () => ({
|
237
public/app/core/components/NavBar/Next/NavBarNext.tsx
Normal file
237
public/app/core/components/NavBar/Next/NavBarNext.tsx
Normal 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%)',
|
||||
}),
|
||||
});
|
@ -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);
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user