mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
879ee82b83
commit
58e17f8144
@ -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;
|
||||
}
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
@ -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,
|
||||
}),
|
||||
};
|
||||
};
|
@ -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} />
|
||||
|
@ -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>
|
||||
|
@ -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];
|
||||
|
@ -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%)',
|
||||
}),
|
||||
});
|
@ -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 (
|
||||
|
Loading…
Reference in New Issue
Block a user