diff --git a/public/app/core/components/NavBar/Next/NavBarNext.test.tsx b/public/app/core/components/NavBar/Next/NavBarNext.test.tsx index 5d0788750b9..9c3c96f4de1 100644 --- a/public/app/core/components/NavBar/Next/NavBarNext.test.tsx +++ b/public/app/core/components/NavBar/Next/NavBarNext.test.tsx @@ -36,6 +36,17 @@ const setup = () => { }; describe('Render', () => { + beforeEach(() => { + // IntersectionObserver isn't available in test environment + const mockIntersectionObserver = jest.fn(); + mockIntersectionObserver.mockReturnValue({ + observe: () => null, + unobserve: () => null, + disconnect: () => null, + }); + window.IntersectionObserver = mockIntersectionObserver; + }); + it('should render component', async () => { setup(); const sidemenu = await screen.findByTestId('sidemenu'); diff --git a/public/app/core/components/NavBar/Next/NavBarNext.tsx b/public/app/core/components/NavBar/Next/NavBarNext.tsx index 4e9992e98d7..1b97427d1a4 100644 --- a/public/app/core/components/NavBar/Next/NavBarNext.tsx +++ b/public/app/core/components/NavBar/Next/NavBarNext.tsx @@ -7,7 +7,7 @@ import { useLocation } from 'react-router-dom'; import { GrafanaTheme2, NavModelItem, NavSection } from '@grafana/data'; import { config, locationService } from '@grafana/runtime'; -import { CustomScrollbar, Icon, IconName, useTheme2 } from '@grafana/ui'; +import { Icon, IconName, useTheme2 } from '@grafana/ui'; import { Branding } from 'app/core/components/Branding/Branding'; import { getKioskMode } from 'app/core/navigation/kiosk'; import { KioskMode, StoreState } from 'app/types'; @@ -20,6 +20,7 @@ import NavBarItem from './NavBarItem'; import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu'; import { NavBarMenu } from './NavBarMenu'; import { NavBarMenuPortalContainer } from './NavBarMenuPortalContainer'; +import { NavBarScrollContainer } from './NavBarScrollContainer'; import { NavBarToggle } from './NavBarToggle'; const onOpenSearch = () => { @@ -103,7 +104,7 @@ export const NavBarNext = React.memo(() => { - + @@ -130,7 +131,7 @@ export const NavBarNext = React.memo(() => { {link.img && {`${link.text}} ))} - + {configItems.map((link, index) => ( { + const [showScrollTopIndicator, setShowTopScrollIndicator] = useState(false); + const [showScrollBottomIndicator, setShowBottomScrollIndicator] = useState(false); + const scrollTopMarker = useRef(null); + const scrollBottomMarker = useRef(null); + const theme = useTheme2(); + const styles = getStyles(theme); + + // Here we observe the top and bottom markers to determine if we should show the scroll indicators + useEffect(() => { + const intersectionObserver = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.target === scrollTopMarker.current) { + setShowTopScrollIndicator(!entry.isIntersecting); + } else if (entry.target === scrollBottomMarker.current) { + setShowBottomScrollIndicator(!entry.isIntersecting); + } + }); + }); + [scrollTopMarker, scrollBottomMarker].forEach((ref) => { + if (ref.current) { + intersectionObserver.observe(ref.current); + } + }); + return () => intersectionObserver.disconnect(); + }, []); + + return ( + +
+ +
+
+
+ {children} +
+
+
+ +
+
+ ); +}; + +NavBarScrollContainer.displayName = 'NavBarScrollContainer'; + +const getStyles = (theme: GrafanaTheme2) => ({ + 'scrollTopMarker, scrollBottomMarker': css({ + height: theme.spacing(1), + left: 0, + position: 'absolute', + pointerEvents: 'none', + right: 0, + }), + scrollTopMarker: css({ + top: 0, + }), + scrollBottomMarker: css({ + bottom: 0, + }), + scrollContent: css({ + position: 'relative', + }), + // override the scroll container position so that the scroll indicators + // are positioned at the top and bottom correctly. + // react-custom-scrollbars doesn't provide any way for us to hook in nicely, + // so we have to override with !important. feelsbad. + scrollContainer: css` + .scrollbar-view { + position: static !important; + } + `, + scrollTopIndicator: css({ + background: `linear-gradient(0deg, transparent, ${theme.colors.background.canvas})`, + height: theme.spacing(6), + left: 0, + opacity: 0, + pointerEvents: 'none', + position: 'absolute', + right: 0, + top: 0, + transition: theme.transitions.create('opacity'), + zIndex: theme.zIndex.sidemenu, + }), + scrollBottomIndicator: css({ + background: `linear-gradient(0deg, ${theme.colors.background.canvas}, transparent)`, + bottom: 0, + height: theme.spacing(6), + left: 0, + opacity: 0, + pointerEvents: 'none', + position: 'absolute', + right: 0, + transition: theme.transitions.create('opacity'), + zIndex: theme.zIndex.sidemenu, + }), + scrollIndicatorVisible: css({ + opacity: 1, + }), + scrollTopIcon: css({ + left: '50%', + position: 'absolute', + top: 0, + transform: 'translateX(-50%)', + }), + scrollBottomIcon: css({ + bottom: 0, + left: '50%', + position: 'absolute', + transform: 'translateX(-50%)', + }), +});