@shopify/polaris
Version:
Shopify’s admin product component library
143 lines (140 loc) • 5.3 kB
JavaScript
import React, { forwardRef, useState, useRef, useCallback, useImperativeHandle, useEffect } from 'react';
import { debounce } from '../../utilities/debounce.js';
import { classNames, variationName } from '../../utilities/css.js';
import { scrollable } from '../shared.js';
import { useLazyRef } from '../../utilities/use-lazy-ref.js';
import { useComponentDidMount } from '../../utilities/use-component-did-mount.js';
import { ScrollableContext } from './context.js';
import styles from './Scrollable.css.js';
import { ScrollTo } from './components/ScrollTo/ScrollTo.js';
import { StickyManager } from '../../utilities/sticky-manager/sticky-manager.js';
import { StickyManagerContext } from '../../utilities/sticky-manager/context.js';
const MAX_SCROLL_HINT_DISTANCE = 100;
const LOW_RES_BUFFER = 2;
const ScrollableComponent = /*#__PURE__*/forwardRef(({
children,
className,
horizontal = true,
vertical = true,
shadow,
hint,
focusable,
scrollbarWidth = 'thin',
scrollbarGutter,
onScrolledToBottom,
...rest
}, forwardedRef) => {
const [topShadow, setTopShadow] = useState(false);
const [bottomShadow, setBottomShadow] = useState(false);
const stickyManager = useLazyRef(() => new StickyManager());
const scrollArea = useRef(null);
const scrollTo = useCallback((scrollY, options = {}) => {
const optionsBehavior = options.behavior || 'smooth';
const behavior = prefersReducedMotion() ? 'auto' : optionsBehavior;
// @ts-expect-error TS removed "instant" option but browsers support it.
scrollArea.current?.scrollTo({
top: scrollY,
behavior
});
}, []);
const defaultRef = useRef();
useImperativeHandle(forwardedRef || defaultRef, () => ({
scrollTo
}));
const handleScroll = useCallback(() => {
const currentScrollArea = scrollArea.current;
if (!currentScrollArea) {
return;
}
requestAnimationFrame(() => {
const {
scrollTop,
clientHeight,
scrollHeight
} = currentScrollArea;
const canScroll = Boolean(scrollHeight > clientHeight);
const isBelowTopOfScroll = Boolean(scrollTop > 0);
const isAtBottomOfScroll = Boolean(scrollTop + clientHeight >= scrollHeight - LOW_RES_BUFFER);
setTopShadow(isBelowTopOfScroll);
setBottomShadow(!isAtBottomOfScroll);
if (canScroll && isAtBottomOfScroll && onScrolledToBottom) {
onScrolledToBottom();
}
});
}, [onScrolledToBottom]);
useComponentDidMount(() => {
handleScroll();
if (hint) {
requestAnimationFrame(() => performScrollHint(scrollArea.current));
}
});
useEffect(() => {
const currentScrollArea = scrollArea.current;
if (!currentScrollArea) {
return;
}
const handleResize = debounce(handleScroll, 50, {
trailing: true
});
stickyManager.current?.setContainer(currentScrollArea);
currentScrollArea.addEventListener('scroll', handleScroll);
globalThis.addEventListener('resize', handleResize);
return () => {
currentScrollArea.removeEventListener('scroll', handleScroll);
globalThis.removeEventListener('resize', handleResize);
};
}, [stickyManager, handleScroll]);
const finalClassName = classNames(className, styles.Scrollable, vertical && styles.vertical, horizontal && styles.horizontal, shadow && topShadow && styles.hasTopShadow, shadow && bottomShadow && styles.hasBottomShadow, scrollbarWidth && styles[variationName('scrollbarWidth', scrollbarWidth)], scrollbarGutter && styles[variationName('scrollbarGutter', scrollbarGutter.replace(' ', ''))]);
return /*#__PURE__*/React.createElement(ScrollableContext.Provider, {
value: scrollTo
}, /*#__PURE__*/React.createElement(StickyManagerContext.Provider, {
value: stickyManager.current
}, /*#__PURE__*/React.createElement("div", Object.assign({
className: finalClassName
}, scrollable.props, rest, {
ref: scrollArea
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
,
tabIndex: focusable ? 0 : undefined
}), children)));
});
ScrollableComponent.displayName = 'Scrollable';
function prefersReducedMotion() {
try {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
} catch (err) {
return false;
}
}
function performScrollHint(elem) {
if (!elem || prefersReducedMotion()) {
return;
}
const scrollableDistance = elem.scrollHeight - elem.clientHeight;
const distanceToPeek = Math.min(MAX_SCROLL_HINT_DISTANCE, scrollableDistance) - LOW_RES_BUFFER;
const goBackToTop = () => {
requestAnimationFrame(() => {
if (elem.scrollTop >= distanceToPeek) {
elem.removeEventListener('scroll', goBackToTop);
elem.scrollTo({
top: 0,
behavior: 'smooth'
});
}
});
};
elem.addEventListener('scroll', goBackToTop);
elem.scrollTo({
top: MAX_SCROLL_HINT_DISTANCE,
behavior: 'smooth'
});
}
const forNode = node => {
const closestElement = node.closest(scrollable.selector);
return closestElement instanceof HTMLElement ? closestElement : document;
};
// @ts-expect-error - expected functions/sub-components are assigned after declaration
const Scrollable = ScrollableComponent;
Scrollable.ScrollTo = ScrollTo;
Scrollable.forNode = forNode;
export { Scrollable };