mirror of
https://github.com/grafana/grafana.git
synced 2025-09-20 11:23:00 +08: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:
@ -1,5 +1,4 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
import classNames from 'classnames';
|
|
||||||
import React, { FC, RefCallback, useCallback, useEffect, useRef } from 'react';
|
import React, { FC, RefCallback, useCallback, useEffect, useRef } from 'react';
|
||||||
import Scrollbars, { positionValues } from 'react-custom-scrollbars-2';
|
import Scrollbars, { positionValues } from 'react-custom-scrollbars-2';
|
||||||
|
|
||||||
@ -7,6 +6,8 @@ import { GrafanaTheme2 } from '@grafana/data';
|
|||||||
|
|
||||||
import { useStyles2 } from '../../themes';
|
import { useStyles2 } from '../../themes';
|
||||||
|
|
||||||
|
import { ScrollIndicators } from './ScrollIndicators';
|
||||||
|
|
||||||
export type ScrollbarPosition = positionValues;
|
export type ScrollbarPosition = positionValues;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -21,6 +22,7 @@ interface Props {
|
|||||||
scrollRefCallback?: RefCallback<HTMLDivElement>;
|
scrollRefCallback?: RefCallback<HTMLDivElement>;
|
||||||
scrollTop?: number;
|
scrollTop?: number;
|
||||||
setScrollTop?: (position: ScrollbarPosition) => void;
|
setScrollTop?: (position: ScrollbarPosition) => void;
|
||||||
|
showScrollIndicators?: boolean;
|
||||||
autoHeightMin?: number | string;
|
autoHeightMin?: number | string;
|
||||||
updateAfterMountMs?: number;
|
updateAfterMountMs?: number;
|
||||||
onScroll?: React.UIEventHandler;
|
onScroll?: React.UIEventHandler;
|
||||||
@ -41,6 +43,7 @@ export const CustomScrollbar: FC<Props> = ({
|
|||||||
hideHorizontalTrack,
|
hideHorizontalTrack,
|
||||||
hideVerticalTrack,
|
hideVerticalTrack,
|
||||||
scrollRefCallback,
|
scrollRefCallback,
|
||||||
|
showScrollIndicators = false,
|
||||||
updateAfterMountMs,
|
updateAfterMountMs,
|
||||||
scrollTop,
|
scrollTop,
|
||||||
onScroll,
|
onScroll,
|
||||||
@ -119,7 +122,9 @@ export const CustomScrollbar: FC<Props> = ({
|
|||||||
<Scrollbars
|
<Scrollbars
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={classNames(styles.customScrollbar, className)}
|
className={cx(styles.customScrollbar, className, {
|
||||||
|
[styles.scrollbarWithScrollIndicators]: showScrollIndicators,
|
||||||
|
})}
|
||||||
onScrollStop={onScrollStop}
|
onScrollStop={onScrollStop}
|
||||||
autoHeight={true}
|
autoHeight={true}
|
||||||
autoHide={autoHide}
|
autoHide={autoHide}
|
||||||
@ -136,7 +141,7 @@ export const CustomScrollbar: FC<Props> = ({
|
|||||||
renderView={renderView}
|
renderView={renderView}
|
||||||
onScroll={onScroll}
|
onScroll={onScroll}
|
||||||
>
|
>
|
||||||
{children}
|
{showScrollIndicators ? <ScrollIndicators>{children}</ScrollIndicators> : children}
|
||||||
</Scrollbars>
|
</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}>
|
<nav className={styles.content}>
|
||||||
<CustomScrollbar hideHorizontalTrack>
|
<CustomScrollbar showScrollIndicators hideHorizontalTrack>
|
||||||
<ul className={styles.itemList}>
|
<ul className={styles.itemList}>
|
||||||
{navItems.map((link) => (
|
{navItems.map((link) => (
|
||||||
<NavItem link={link} onClose={onMenuClose} activeItem={activeItem} key={link.text} />
|
<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 { GrafanaTheme2, NavModelItem, NavSection } from '@grafana/data';
|
||||||
import { config, locationService, reportInteraction } from '@grafana/runtime';
|
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 { getKioskMode } from 'app/core/navigation/kiosk';
|
||||||
import { KioskMode, StoreState } from 'app/types';
|
import { KioskMode, StoreState } from 'app/types';
|
||||||
|
|
||||||
@ -18,7 +18,6 @@ import { NavBarItemIcon } from './NavBarItemIcon';
|
|||||||
import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu';
|
import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu';
|
||||||
import { NavBarMenu } from './NavBarMenu';
|
import { NavBarMenu } from './NavBarMenu';
|
||||||
import { NavBarMenuPortalContainer } from './NavBarMenuPortalContainer';
|
import { NavBarMenuPortalContainer } from './NavBarMenuPortalContainer';
|
||||||
import { NavBarScrollContainer } from './NavBarScrollContainer';
|
|
||||||
import { NavBarToggle } from './NavBarToggle';
|
import { NavBarToggle } from './NavBarToggle';
|
||||||
import { NavBarContext } from './context';
|
import { NavBarContext } from './context';
|
||||||
import {
|
import {
|
||||||
@ -124,7 +123,7 @@ export const NavBar = React.memo(() => {
|
|||||||
<NavBarItemIcon link={homeItem} />
|
<NavBarItemIcon link={homeItem} />
|
||||||
</NavBarItemWithoutMenu>
|
</NavBarItemWithoutMenu>
|
||||||
|
|
||||||
<NavBarScrollContainer>
|
<CustomScrollbar hideHorizontalTrack hideVerticalTrack showScrollIndicators>
|
||||||
<ul className={styles.itemList}>
|
<ul className={styles.itemList}>
|
||||||
<NavBarItem className={styles.search} isActive={activeItem === searchItem} link={searchItem} />
|
<NavBarItem className={styles.search} isActive={activeItem === searchItem} link={searchItem} />
|
||||||
|
|
||||||
@ -155,7 +154,7 @@ export const NavBar = React.memo(() => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</NavBarScrollContainer>
|
</CustomScrollbar>
|
||||||
</FocusScope>
|
</FocusScope>
|
||||||
</NavBarContext.Provider>
|
</NavBarContext.Provider>
|
||||||
</nav>
|
</nav>
|
||||||
|
@ -7,10 +7,9 @@ import { SpectrumMenuProps } from '@react-types/menu';
|
|||||||
import React, { ReactElement, useEffect, useRef } from 'react';
|
import React, { ReactElement, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { GrafanaTheme2, NavMenuItemType, NavModelItem } from '@grafana/data';
|
import { GrafanaTheme2, NavMenuItemType, NavModelItem } from '@grafana/data';
|
||||||
import { useTheme2 } from '@grafana/ui';
|
import { CustomScrollbar, useTheme2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { NavBarItemMenuItem } from './NavBarItemMenuItem';
|
import { NavBarItemMenuItem } from './NavBarItemMenuItem';
|
||||||
import { NavBarScrollContainer } from './NavBarScrollContainer';
|
|
||||||
import { useNavBarItemMenuContext } from './context';
|
import { useNavBarItemMenuContext } from './context';
|
||||||
import menuItemTranslations from './navBarItem-translations';
|
import menuItemTranslations from './navBarItem-translations';
|
||||||
import { getNavModelItemKey } from './utils';
|
import { getNavModelItemKey } from './utils';
|
||||||
@ -78,9 +77,9 @@ export function NavBarItemMenu(props: NavBarItemMenuProps): ReactElement | null
|
|||||||
|
|
||||||
const contents = [itemComponents, subTitleComponent];
|
const contents = [itemComponents, subTitleComponent];
|
||||||
const contentComponent = (
|
const contentComponent = (
|
||||||
<NavBarScrollContainer key="scrollContainer">
|
<CustomScrollbar hideHorizontalTrack hideVerticalTrack showScrollIndicators key="scrollContainer">
|
||||||
{reverseMenuDirection ? contents.reverse() : contents}
|
{reverseMenuDirection ? contents.reverse() : contents}
|
||||||
</NavBarScrollContainer>
|
</CustomScrollbar>
|
||||||
);
|
);
|
||||||
|
|
||||||
const menu = [headerComponent, contentComponent];
|
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}`} />}
|
{main.img && <img className={styles.sectionImg} src={main.img} alt={`logo of ${main.text}`} />}
|
||||||
{props.model.main.text}
|
{props.model.main.text}
|
||||||
</h2>
|
</h2>
|
||||||
<CustomScrollbar>
|
<CustomScrollbar showScrollIndicators>
|
||||||
<div className={styles.items} role="tablist">
|
<div className={styles.items} role="tablist">
|
||||||
{directChildren.map((child, index) => {
|
{directChildren.map((child, index) => {
|
||||||
return (
|
return (
|
||||||
|
Reference in New Issue
Block a user