@react-ui-org/react-ui
Version:
React UI is a themeable UI library for React apps.
422 lines (387 loc) • 14.3 kB
JSX
import PropTypes from 'prop-types';
import React, {
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
useCallback,
} from 'react';
import { TranslationsContext } from '../../providers/translations';
import { withGlobalProps } from '../../providers/globalProps';
import { classNames } from '../../helpers/classNames/classNames';
import { transferProps } from '../../helpers/transferProps';
import { getElementsPositionDifference } from './_helpers/getElementsPositionDifference';
import { useLoadResize } from './_hooks/useLoadResizeHook';
import { useScrollPosition } from './_hooks/useScrollPositionHook';
import styles from './ScrollView.module.scss';
// Function `getElementsPositionDifference` sometimes returns floating point values that results
// in inaccurate detection of start/end. It is necessary to accept this inaccuracy and take
// every value less or equal to 1px as start/end.
const EDGE_DETECTION_INACCURACY_PX = 1;
export const ScrollView = React.forwardRef((props, ref) => {
const {
arrows,
arrowsScrollStep,
autoScroll,
children,
endShadowBackground,
endShadowInitialOffset,
endShadowSize,
id,
debounce,
direction,
nextArrowColor,
nextArrowElement,
nextArrowInitialOffset,
prevArrowColor,
prevArrowElement,
prevArrowInitialOffset,
scrollbar,
shadows,
startShadowBackground,
startShadowInitialOffset,
startShadowSize,
...restProps
} = props;
const translations = useContext(TranslationsContext);
const [isAutoScrollInProgress, setIsAutoScrollInProgress] = useState(false);
const [isScrolledAtStart, setIsScrolledAtStart] = useState(false);
const [isScrolledAtEnd, setIsScrolledAtEnd] = useState(false);
const scrollPositionStart = direction === 'horizontal' ? 'left' : 'top';
const scrollPositionEnd = direction === 'horizontal' ? 'right' : 'bottom';
const scrollViewContentEl = useRef(null);
const blankRef = useRef(null);
const scrollViewViewportEl = ref ?? blankRef;
const handleScrollViewState = useCallback((currentPosition) => {
const isScrolledAtStartActive = currentPosition[scrollPositionStart]
<= -1 * EDGE_DETECTION_INACCURACY_PX;
const isScrolledAtEndActive = currentPosition[scrollPositionEnd]
>= EDGE_DETECTION_INACCURACY_PX;
setIsScrolledAtStart((prevIsScrolledAtStart) => {
if (isScrolledAtStartActive !== prevIsScrolledAtStart) {
return isScrolledAtStartActive;
}
return prevIsScrolledAtStart;
});
setIsScrolledAtEnd((prevIsScrolledAtEnd) => {
if (isScrolledAtEndActive !== prevIsScrolledAtEnd) {
return isScrolledAtEndActive;
}
return prevIsScrolledAtEnd;
});
}, [scrollPositionStart, scrollPositionEnd]);
/**
* It handles scroll event fired on `scrollViewViewportEl` element. If autoScroll is in progress,
* and element it scrolled to the end of viewport, `isAutoScrollInProgress` is set to `false`.
*/
const handleScrollWhenAutoScrollIsInProgress = () => {
const currentPosition = getElementsPositionDifference(
scrollViewContentEl,
scrollViewViewportEl,
);
if (currentPosition[scrollPositionEnd] <= EDGE_DETECTION_INACCURACY_PX) {
setIsAutoScrollInProgress(false);
scrollViewViewportEl.current.removeEventListener('scroll', handleScrollWhenAutoScrollIsInProgress);
}
};
/**
* If autoScroll is enabled, it automatically scrolls viewport element to the end of the
* viewport when content is changed. It is performed only when viewport element is
* scrolled to the end of the viewport or when viewport element is in any position but
* autoScroll triggered by previous change is still in progress.
*/
const handleScrollWhenAutoScrollIsEnabled = (forceAutoScroll = false) => {
if (autoScroll === 'off') {
return () => {};
}
const scrollViewContentElement = scrollViewContentEl.current;
const scrollViewViewportElement = scrollViewViewportEl.current;
const differenceX = direction === 'horizontal' ? scrollViewContentElement.offsetWidth : 0;
const differenceY = direction !== 'horizontal' ? scrollViewContentElement.offsetHeight : 0;
if (autoScroll === 'always' || forceAutoScroll) {
scrollViewViewportElement.scrollBy(differenceX, differenceY);
} else if (!isScrolledAtEnd || isAutoScrollInProgress) {
setIsAutoScrollInProgress(true);
scrollViewViewportElement.scrollBy(differenceX, differenceY);
// Handler `handleScrollWhenAutoScrollIsInProgress` sets `isAutoScrollInProgress` to `false`
// when viewport element is scrolled to the end of the viewport
scrollViewViewportElement.addEventListener('scroll', handleScrollWhenAutoScrollIsInProgress);
return () => {
scrollViewViewportElement.removeEventListener('scroll', handleScrollWhenAutoScrollIsInProgress);
};
}
return () => {};
};
useEffect(
() => {
handleScrollViewState(
getElementsPositionDifference(scrollViewContentEl, scrollViewViewportEl),
);
},
[], // eslint-disable-line react-hooks/exhaustive-deps
);
useLoadResize(
(currentPosition) => {
handleScrollViewState(currentPosition);
handleScrollWhenAutoScrollIsEnabled(true);
},
[isScrolledAtStart, isScrolledAtEnd],
scrollViewContentEl,
scrollViewViewportEl,
debounce,
);
useScrollPosition(
(currentPosition) => (handleScrollViewState(currentPosition)),
scrollViewContentEl,
scrollViewViewportEl,
debounce,
);
const autoScrollChildrenKeys = autoScroll !== 'off' && children && React.Children
.map(children, (child) => child.key)
.reduce((reducedKeys, childKey) => reducedKeys + childKey, '');
const autoScrollChildrenLength = autoScroll !== 'off' && children && children.length;
useLayoutEffect(
handleScrollWhenAutoScrollIsEnabled,
// eslint-disable-next-line react-hooks/exhaustive-deps
[autoScroll, autoScrollChildrenKeys, autoScrollChildrenLength],
);
// ResizeObserver to detect when content or viewport dimensions change due to style changes
useLayoutEffect(() => {
const contentElement = scrollViewContentEl.current;
const viewportElement = scrollViewViewportEl.current;
if (!contentElement || !viewportElement) {
return () => {};
}
const resizeObserver = new ResizeObserver(() => {
handleScrollViewState(
getElementsPositionDifference(scrollViewContentEl, scrollViewViewportEl),
);
});
// Observe both content and viewport for dimension changes
resizeObserver.observe(contentElement);
resizeObserver.observe(viewportElement);
return () => {
resizeObserver.disconnect();
};
}, [scrollViewContentEl, scrollViewViewportEl, handleScrollViewState]);
const arrowHandler = (contentEl, viewportEl, scrollViewDirection, shiftDirection, step) => {
const offset = shiftDirection === 'next' ? step : -1 * step;
const differenceX = scrollViewDirection === 'horizontal' ? offset : 0;
const differenceY = scrollViewDirection !== 'horizontal' ? offset : 0;
viewportEl.current.scrollBy(differenceX, differenceY);
};
return (
<div
{...transferProps(restProps)}
className={classNames(
styles.root,
isScrolledAtStart && styles.isRootScrolledAtStart,
isScrolledAtEnd && styles.isRootScrolledAtEnd,
!scrollbar && styles.hasRootScrollbarDisabled,
direction === 'horizontal' ? styles.isRootHorizontal : styles.isRootVertical,
)}
id={id}
style={{
'--rui-local-end-shadow-background': endShadowBackground,
'--rui-local-end-shadow-direction': direction === 'horizontal' ? 'to left' : 'to top',
'--rui-local-end-shadow-initial-offset': endShadowInitialOffset,
'--rui-local-end-shadow-size': endShadowSize,
'--rui-local-next-arrow-color': nextArrowColor,
'--rui-local-next-arrow-initial-offset': nextArrowInitialOffset,
'--rui-local-prev-arrow-color': prevArrowColor,
'--rui-local-prev-arrow-initial-offset': prevArrowInitialOffset,
'--rui-local-start-shadow-background': startShadowBackground,
'--rui-local-start-shadow-direction': direction === 'horizontal' ? 'to right' : 'to bottom',
'--rui-local-start-shadow-initial-offset': startShadowInitialOffset,
'--rui-local-start-shadow-size': startShadowSize,
}}
>
<div
className={styles.viewport}
ref={scrollViewViewportEl}
>
<div
className={styles.content}
id={id && `${id}__content`}
ref={scrollViewContentEl}
>
{children}
</div>
</div>
{shadows && (
<div
aria-hidden
className={styles.scrollingShadows}
/>
)}
{arrows && (
<>
<button
className={styles.arrowPrev}
id={id && `${id}__arrowPrevButton`}
onClick={() => arrowHandler(
scrollViewContentEl,
scrollViewViewportEl,
direction,
'prev',
arrowsScrollStep,
)}
title={translations.ScrollView.previous}
type="button"
>
{prevArrowElement || (
<span
aria-hidden
className={styles.arrowIcon}
/>
)}
</button>
<button
className={styles.arrowNext}
id={id && `${id}__arrowNextButton`}
onClick={() => arrowHandler(
scrollViewContentEl,
scrollViewViewportEl,
direction,
'next',
arrowsScrollStep,
)}
title={translations.ScrollView.next}
type="button"
>
{nextArrowElement || (
<span
aria-hidden
className={styles.arrowIcon}
/>
)}
</button>
</>
)}
</div>
);
});
ScrollView.defaultProps = {
arrows: false,
arrowsScrollStep: 200,
autoScroll: 'off',
children: null,
debounce: 50,
direction: 'vertical',
endShadowBackground: 'linear-gradient(var(--rui-local-end-shadow-direction), rgba(255 255 255 / 1), rgba(255 255 255 / 0))',
endShadowInitialOffset: '-1rem',
endShadowSize: '2em',
id: undefined,
nextArrowColor: undefined,
nextArrowElement: null,
nextArrowInitialOffset: '-0.5rem',
prevArrowColor: undefined,
prevArrowElement: null,
prevArrowInitialOffset: '-0.5rem',
scrollbar: true,
shadows: true,
startShadowBackground: 'linear-gradient(var(--rui-local-start-shadow-direction), rgba(255 255 255 / 1), rgba(255 255 255 / 0))',
startShadowInitialOffset: '-1rem',
startShadowSize: '2em',
};
ScrollView.propTypes = {
/**
* If `true`, display the arrow controls.
*/
arrows: PropTypes.bool,
/**
* Portion to scroll by when the arrows are clicked, in px.
*/
arrowsScrollStep: PropTypes.number,
/**
* The auto-scroll mechanism requires having the `key` prop set for every child present in `children`
* because it detects changes of those keys. Without the keys, the auto-scroll will not work.
*
* Option `always` means the auto-scroll scrolls to the end every time the content changes.
* Option `detectEnd` means the auto-scroll scrolls to the end only when the content is changed
* and the user has scrolled at the end of the viewport at the moment of the change.
*
* See https://reactjs.org/docs/lists-and-keys.html#keys
*/
autoScroll: PropTypes.oneOf(['always', 'detectEnd', 'off']),
/**
* Content to be scrollable.
*/
children: PropTypes.node,
/**
* Delay in ms before the display of arrows and scrolling shadows is evaluated during interaction.
*/
debounce: PropTypes.number,
/**
* Direction of scrolling.
*/
direction: PropTypes.oneOf(['horizontal', 'vertical']),
/**
* Custom background of the end scrolling shadow. Can be a CSS gradient or an image `url()`.
*/
endShadowBackground: PropTypes.string,
/**
* Initial offset of the end scrolling shadow (transitioned). If set, the end scrolling shadow slides in
* by this distance.
*/
endShadowInitialOffset: PropTypes.string,
/**
* Size of the end scrolling shadow. Accepts any valid CSS length value.
*/
endShadowSize: PropTypes.string,
/**
* ID of the root HTML element. It also serves as base for nested elements:
* * `<ID>__content`
* * `<ID>__arrowPrevButton`
* * `<ID>__arrowNextButton`
*/
id: PropTypes.string,
/**
* Text color of the end arrow control. Accepts any valid CSS color value.
*/
nextArrowColor: PropTypes.string,
/**
* Custom HTML or React Component to replace the default next-arrow control.
*/
nextArrowElement: PropTypes.node,
/**
* Initial offset of the end arrow control (transitioned). If set, the next arrow slides in by this distance.
*/
nextArrowInitialOffset: PropTypes.string,
/**
* Text color of the start arrow control. Accepts any valid CSS color value.
*/
prevArrowColor: PropTypes.string,
/**
* Custom HTML or React Component to replace the default prev-arrow control.
*/
prevArrowElement: PropTypes.node,
/**
* Initial offset of the start arrow control (transitioned). If set, the prev arrow slides in by this distance.
*/
prevArrowInitialOffset: PropTypes.string,
/**
* If `false`, the system scrollbar will be hidden.
*/
scrollbar: PropTypes.bool,
/**
* If `true`, display scrolling shadows.
*/
shadows: PropTypes.bool,
/**
* Custom background of the start scrolling shadow. Can be a CSS gradient or an image `url()`.
*/
startShadowBackground: PropTypes.string,
/**
* Initial offset of the start scrolling shadow (transitioned). If set, the start scrolling shadow slides in
* by this distance.
*/
startShadowInitialOffset: PropTypes.string,
/**
* Size of the start scrolling shadow. Accepts any valid CSS length value.
*/
startShadowSize: PropTypes.string,
};
export const ScrollViewWithGlobalProps = withGlobalProps(ScrollView, 'ScrollView');
export default ScrollViewWithGlobalProps;