Navigation: Add scroll indicators (#48115)

* Attach nav item menus to a portal that's a sibling of the chevron to prevent incorrect stacking

* add scrollbar to navbar

* Make clickable area of grafana logo full size

* hide vertical track as well

* initial attempt at a scroll overlay

* fix indentation

* Extract into a separate component

* Add arrows

* fix unit tests

* Fix imports in new component

* add comment
This commit is contained in:
Ashley Harrison 2022-04-22 17:21:52 +01:00 committed by GitHub
parent 7dc28d4083
commit a7f02094b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 145 additions and 3 deletions

View File

@ -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');

View File

@ -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(() => {
<Branding.MenuLogo />
</NavBarItemWithoutMenu>
<CustomScrollbar hideVerticalTrack hideHorizontalTrack>
<NavBarScrollContainer>
<NavBarItem className={styles.search} isActive={activeItem === searchItem} link={searchItem}>
<Icon name="search" size="xl" />
</NavBarItem>
@ -130,7 +131,7 @@ export const NavBarNext = React.memo(() => {
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
</NavBarItem>
))}
</CustomScrollbar>
</NavBarScrollContainer>
{configItems.map((link, index) => (
<NavBarItem

View File

@ -0,0 +1,130 @@
import { css, cx } from '@emotion/css';
import React, { useEffect, useRef, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { CustomScrollbar, Icon, useTheme2 } from '@grafana/ui';
export interface Props {
children: React.ReactNode;
}
export const NavBarScrollContainer = ({ children }: Props) => {
const [showScrollTopIndicator, setShowTopScrollIndicator] = useState(false);
const [showScrollBottomIndicator, setShowBottomScrollIndicator] = useState(false);
const scrollTopMarker = useRef<HTMLDivElement>(null);
const scrollBottomMarker = useRef<HTMLDivElement>(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 (
<CustomScrollbar className={styles.scrollContainer} hideVerticalTrack hideHorizontalTrack>
<div
className={cx(styles.scrollTopIndicator, {
[styles.scrollIndicatorVisible]: showScrollTopIndicator,
})}
>
<Icon className={styles.scrollTopIcon} name="angle-up" />
</div>
<div className={styles.scrollContent}>
<div className={styles.scrollTopMarker} ref={scrollTopMarker}></div>
{children}
<div className={styles.scrollBottomMarker} ref={scrollBottomMarker}></div>
</div>
<div
className={cx(styles.scrollBottomIndicator, {
[styles.scrollIndicatorVisible]: showScrollBottomIndicator,
})}
>
<Icon className={styles.scrollBottomIcon} name="angle-down" />
</div>
</CustomScrollbar>
);
};
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%)',
}),
});