CustomScrollbar: Add optional scroll indicators to CustomScrollbar (#54705)

* Add general scroll indicator behaviour to CustomScrollbar

* Extract ScrollIndicators logic into it's own file

* add scroll indicators to MegaMenu
This commit is contained in:
Ashley Harrison 2022-09-12 11:18:45 +01:00 committed by GitHub
parent 879ee82b83
commit 58e17f8144
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 126 additions and 145 deletions

View File

@ -1,5 +1,4 @@
import { css } from '@emotion/css';
import classNames from 'classnames';
import { css, cx } from '@emotion/css';
import React, { FC, RefCallback, useCallback, useEffect, useRef } from 'react';
import Scrollbars, { positionValues } from 'react-custom-scrollbars-2';
@ -7,6 +6,8 @@ import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes';
import { ScrollIndicators } from './ScrollIndicators';
export type ScrollbarPosition = positionValues;
interface Props {
@ -21,6 +22,7 @@ interface Props {
scrollRefCallback?: RefCallback<HTMLDivElement>;
scrollTop?: number;
setScrollTop?: (position: ScrollbarPosition) => void;
showScrollIndicators?: boolean;
autoHeightMin?: number | string;
updateAfterMountMs?: number;
onScroll?: React.UIEventHandler;
@ -41,6 +43,7 @@ export const CustomScrollbar: FC<Props> = ({
hideHorizontalTrack,
hideVerticalTrack,
scrollRefCallback,
showScrollIndicators = false,
updateAfterMountMs,
scrollTop,
onScroll,
@ -119,7 +122,9 @@ export const CustomScrollbar: FC<Props> = ({
<Scrollbars
data-testid={testId}
ref={ref}
className={classNames(styles.customScrollbar, className)}
className={cx(styles.customScrollbar, className, {
[styles.scrollbarWithScrollIndicators]: showScrollIndicators,
})}
onScrollStop={onScrollStop}
autoHeight={true}
autoHide={autoHide}
@ -136,7 +141,7 @@ export const CustomScrollbar: FC<Props> = ({
renderView={renderView}
onScroll={onScroll}
>
{children}
{showScrollIndicators ? <ScrollIndicators>{children}</ScrollIndicators> : children}
</Scrollbars>
);
};
@ -188,5 +193,14 @@ const getStyles = (theme: GrafanaTheme2) => {
}
}
`,
// 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.
scrollbarWithScrollIndicators: css`
.scrollbar-view {
position: static !important;
}
`,
};
};

View File

@ -0,0 +1,100 @@
import { css, cx } from '@emotion/css';
import classNames from 'classnames';
import React, { FC, useEffect, useRef, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes';
import { Icon } from '../Icon/Icon';
export const ScrollIndicators: FC = ({ children }) => {
const [showScrollTopIndicator, setShowTopScrollIndicator] = useState(false);
const [showScrollBottomIndicator, setShowBottomScrollIndicator] = useState(false);
const scrollTopMarker = useRef<HTMLDivElement>(null);
const scrollBottomMarker = useRef<HTMLDivElement>(null);
const styles = useStyles2(getStyles);
// 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 (
<>
<div
className={cx(styles.scrollIndicator, styles.scrollTopIndicator, {
[styles.scrollIndicatorVisible]: showScrollTopIndicator,
})}
>
<Icon className={classNames(styles.scrollIcon, styles.scrollTopIcon)} name="angle-up" />
</div>
<div className={styles.scrollContent}>
<div ref={scrollTopMarker} />
{children}
<div ref={scrollBottomMarker} />
</div>
<div
className={cx(styles.scrollIndicator, styles.scrollBottomIndicator, {
[styles.scrollIndicatorVisible]: showScrollBottomIndicator,
})}
>
<Icon className={classNames(styles.scrollIcon, styles.scrollBottomIcon)} name="angle-down" />
</div>
</>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
scrollContent: css({
flex: 1,
position: 'relative',
}),
scrollIndicator: css({
height: theme.spacing(6),
left: 0,
opacity: 0,
pointerEvents: 'none',
position: 'absolute',
right: 0,
transition: theme.transitions.create('opacity'),
zIndex: 1,
}),
scrollTopIndicator: css({
background: `linear-gradient(0deg, transparent, ${theme.colors.background.canvas})`,
top: 0,
}),
scrollBottomIndicator: css({
background: `linear-gradient(180deg, transparent, ${theme.colors.background.canvas})`,
bottom: 0,
}),
scrollIndicatorVisible: css({
opacity: 1,
}),
scrollIcon: css({
left: '50%',
position: 'absolute',
transform: 'translateX(-50%)',
}),
scrollTopIcon: css({
top: 0,
}),
scrollBottomIcon: css({
bottom: 0,
}),
};
};

View File

@ -82,7 +82,7 @@ export function NavBarMenu({ activeItem, navItems, searchBarHidden, onClose }: P
}}
/>
<nav className={styles.content}>
<CustomScrollbar hideHorizontalTrack>
<CustomScrollbar showScrollIndicators hideHorizontalTrack>
<ul className={styles.itemList}>
{navItems.map((link) => (
<NavItem link={link} onClose={onMenuClose} activeItem={activeItem} key={link.text} />

View File

@ -7,7 +7,7 @@ import { useLocation } from 'react-router-dom';
import { GrafanaTheme2, NavModelItem, NavSection } from '@grafana/data';
import { config, locationService, reportInteraction } from '@grafana/runtime';
import { Icon, useTheme2 } from '@grafana/ui';
import { CustomScrollbar, Icon, useTheme2 } from '@grafana/ui';
import { getKioskMode } from 'app/core/navigation/kiosk';
import { KioskMode, StoreState } from 'app/types';
@ -18,7 +18,6 @@ import { NavBarItemIcon } from './NavBarItemIcon';
import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu';
import { NavBarMenu } from './NavBarMenu';
import { NavBarMenuPortalContainer } from './NavBarMenuPortalContainer';
import { NavBarScrollContainer } from './NavBarScrollContainer';
import { NavBarToggle } from './NavBarToggle';
import { NavBarContext } from './context';
import {
@ -124,7 +123,7 @@ export const NavBar = React.memo(() => {
<NavBarItemIcon link={homeItem} />
</NavBarItemWithoutMenu>
<NavBarScrollContainer>
<CustomScrollbar hideHorizontalTrack hideVerticalTrack showScrollIndicators>
<ul className={styles.itemList}>
<NavBarItem className={styles.search} isActive={activeItem === searchItem} link={searchItem} />
@ -155,7 +154,7 @@ export const NavBar = React.memo(() => {
/>
))}
</ul>
</NavBarScrollContainer>
</CustomScrollbar>
</FocusScope>
</NavBarContext.Provider>
</nav>

View File

@ -7,10 +7,9 @@ import { SpectrumMenuProps } from '@react-types/menu';
import React, { ReactElement, useEffect, useRef } from 'react';
import { GrafanaTheme2, NavMenuItemType, NavModelItem } from '@grafana/data';
import { useTheme2 } from '@grafana/ui';
import { CustomScrollbar, useTheme2 } from '@grafana/ui';
import { NavBarItemMenuItem } from './NavBarItemMenuItem';
import { NavBarScrollContainer } from './NavBarScrollContainer';
import { useNavBarItemMenuContext } from './context';
import menuItemTranslations from './navBarItem-translations';
import { getNavModelItemKey } from './utils';
@ -78,9 +77,9 @@ export function NavBarItemMenu(props: NavBarItemMenuProps): ReactElement | null
const contents = [itemComponents, subTitleComponent];
const contentComponent = (
<NavBarScrollContainer key="scrollContainer">
<CustomScrollbar hideHorizontalTrack hideVerticalTrack showScrollIndicators key="scrollContainer">
{reverseMenuDirection ? contents.reverse() : contents}
</NavBarScrollContainer>
</CustomScrollbar>
);
const menu = [headerComponent, contentComponent];

View File

@ -1,131 +0,0 @@
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({
flex: 1,
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%)',
}),
});

View File

@ -23,7 +23,7 @@ export function SectionNav(props: Props) {
{main.img && <img className={styles.sectionImg} src={main.img} alt={`logo of ${main.text}`} />}
{props.model.main.text}
</h2>
<CustomScrollbar>
<CustomScrollbar showScrollIndicators>
<div className={styles.items} role="tablist">
{directChildren.map((child, index) => {
return (