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 &&
}
))}
-
+
{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 (
+
+
+
+
+
+
+
+
+
+ );
+};
+
+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%)',
+ }),
+});