diff --git a/packages/grafana-ui/src/components/Layout/Box/Box.tsx b/packages/grafana-ui/src/components/Layout/Box/Box.tsx index b42f40c1b75..9f65cce7048 100644 --- a/packages/grafana-ui/src/components/Layout/Box/Box.tsx +++ b/packages/grafana-ui/src/components/Layout/Box/Box.tsx @@ -17,7 +17,7 @@ export type BorderColor = keyof GrafanaTheme2['colors']['border'] | 'error' | 's export type BorderRadius = keyof ThemeShape['radius']; export type BoxShadow = keyof ThemeShadows; -interface BoxProps extends FlexProps, SizeProps, Omit, 'className' | 'style'> { +export interface BoxProps extends FlexProps, SizeProps, Omit, 'className' | 'style'> { // Margin props /** Sets the property `margin` */ margin?: ResponsiveProp; diff --git a/packages/grafana-ui/src/components/ScrollContainer/ScrollContainer.tsx b/packages/grafana-ui/src/components/ScrollContainer/ScrollContainer.tsx new file mode 100644 index 00000000000..90d3514f545 --- /dev/null +++ b/packages/grafana-ui/src/components/ScrollContainer/ScrollContainer.tsx @@ -0,0 +1,65 @@ +import { css } from '@emotion/css'; +import { Property } from 'csstype'; +import { forwardRef, PropsWithChildren, UIEventHandler } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; + +import { useStyles2 } from '../../themes'; +import { Box, BoxProps } from '../Layout/Box/Box'; + +import { ScrollIndicators } from './ScrollIndicators'; + +interface Props extends Omit { + showScrollIndicators?: boolean; + onScroll?: UIEventHandler; + overflowX?: Property.OverflowX; + overflowY?: Property.OverflowY; + scrollbarWidth?: Property.ScrollbarWidth; +} + +export const ScrollContainer = forwardRef>( + ( + { + children, + showScrollIndicators = false, + onScroll, + overflowX = 'auto', + overflowY = 'auto', + scrollbarWidth = 'thin', + ...rest + }, + ref + ) => { + const styles = useStyles2(getStyles, scrollbarWidth, overflowY, overflowX); + const defaults: Partial = { + maxHeight: '100%', + minHeight: 0, + }; + const boxProps = { ...defaults, ...rest }; + + return ( + +
+ {showScrollIndicators ? {children} : children} +
+
+ ); + } +); +ScrollContainer.displayName = 'ScrollContainer'; + +const getStyles = ( + theme: GrafanaTheme2, + scrollbarWidth: Props['scrollbarWidth'], + overflowY: Props['overflowY'], + overflowX: Props['overflowX'] +) => ({ + scroller: css({ + display: 'flex', + flex: 1, + flexDirection: 'column', + overflowX, + overflowY, + scrollbarWidth, + }), +}); diff --git a/packages/grafana-ui/src/components/ScrollContainer/ScrollIndicators.tsx b/packages/grafana-ui/src/components/ScrollContainer/ScrollIndicators.tsx new file mode 100644 index 00000000000..8d7819e7efe --- /dev/null +++ b/packages/grafana-ui/src/components/ScrollContainer/ScrollIndicators.tsx @@ -0,0 +1,101 @@ +import { css, cx } from '@emotion/css'; +import { useEffect, useRef, useState } from 'react'; +import * as React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; + +import { useStyles2 } from '../../themes'; + +export const ScrollIndicators = ({ children }: React.PropsWithChildren<{}>) => { + const [showScrollTopIndicator, setShowTopScrollIndicator] = useState(false); + const [showScrollBottomIndicator, setShowBottomScrollIndicator] = useState(false); + const scrollTopMarker = useRef(null); + const scrollBottomMarker = useRef(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 ( + <> +
+
+
+ {children} +
+
+
+ + ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + scrollContent: css({ + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + position: 'relative', + }), + scrollIndicator: css({ + height: theme.spacing(6), + left: 0, + opacity: 0, + pointerEvents: 'none', + position: 'absolute', + right: 0, + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + 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, + }), + scrollMarker: css({ + height: '1px', + left: 0, + pointerEvents: 'none', + position: 'absolute', + right: 0, + }), + scrollTopMarker: css({ + top: 0, + }), + scrollBottomMarker: css({ + bottom: 0, + }), + }; +}; diff --git a/packages/grafana-ui/src/unstable.ts b/packages/grafana-ui/src/unstable.ts index 7495a57315e..00891d3a93d 100644 --- a/packages/grafana-ui/src/unstable.ts +++ b/packages/grafana-ui/src/unstable.ts @@ -11,3 +11,4 @@ export * from './utils/skeleton'; export * from './components/Combobox/Combobox'; +export * from './components/ScrollContainer/ScrollContainer';