UNPKG

@base-ui-components/react

Version:

Base UI is a library of headless ('unstyled') React components and low-level hooks. You gain complete control over your app's CSS and accessibility features.

340 lines (331 loc) 15.4 kB
"use strict"; 'use client'; var _interopRequireWildcard = require("@babel/runtime/helpers/interopRequireWildcard").default; Object.defineProperty(exports, "__esModule", { value: true }); exports.ScrollAreaViewport = void 0; var React = _interopRequireWildcard(require("react")); var ReactDOM = _interopRequireWildcard(require("react-dom")); var _useStableCallback = require("@base-ui-components/utils/useStableCallback"); var _useIsoLayoutEffect = require("@base-ui-components/utils/useIsoLayoutEffect"); var _detectBrowser = require("@base-ui-components/utils/detectBrowser"); var _useTimeout = require("@base-ui-components/utils/useTimeout"); var _ScrollAreaRootContext = require("../root/ScrollAreaRootContext"); var _ScrollAreaViewportContext = require("./ScrollAreaViewportContext"); var _useRenderElement = require("../../utils/useRenderElement"); var _DirectionContext = require("../../direction-provider/DirectionContext"); var _getOffset = require("../utils/getOffset"); var _constants = require("../constants"); var _clamp = require("../../utils/clamp"); var _styles = require("../../utils/styles"); var _onVisible = require("../utils/onVisible"); var _stateAttributes = require("../root/stateAttributes"); var _ScrollAreaViewportCssVars = require("./ScrollAreaViewportCssVars"); var _jsxRuntime = require("react/jsx-runtime"); // Module-level flag to ensure we only register the CSS properties once, // regardless of how many Scroll Area components are mounted. let scrollAreaOverflowVarsRegistered = false; /** * Removes inheritance of the scroll area overflow CSS variables, which * improves rendering performance in complex scroll areas with deep subtrees. * Instead, each child must manually opt-in to using these properties by * specifying `inherit`. * See https://motion.dev/blog/web-animation-performance-tier-list * under the "Improving CSS variable performance" section. */ function removeCSSVariableInheritance() { if (scrollAreaOverflowVarsRegistered || // When `inherits: false`, specifying `inherit` on child elements doesn't work // in Safari. To let CSS features work correctly, this optimization must be skipped. _detectBrowser.isWebKit) { return; } if (typeof CSS !== 'undefined' && 'registerProperty' in CSS) { [_ScrollAreaViewportCssVars.ScrollAreaViewportCssVars.scrollAreaOverflowXStart, _ScrollAreaViewportCssVars.ScrollAreaViewportCssVars.scrollAreaOverflowXEnd, _ScrollAreaViewportCssVars.ScrollAreaViewportCssVars.scrollAreaOverflowYStart, _ScrollAreaViewportCssVars.ScrollAreaViewportCssVars.scrollAreaOverflowYEnd].forEach(name => { try { CSS.registerProperty({ name, syntax: '<length>', inherits: false, initialValue: '0px' }); } catch { /* ignore already-registered */ } }); } scrollAreaOverflowVarsRegistered = true; } /** * The actual scrollable container of the scroll area. * Renders a `<div>` element. * * Documentation: [Base UI Scroll Area](https://base-ui.com/react/components/scroll-area) */ const ScrollAreaViewport = exports.ScrollAreaViewport = /*#__PURE__*/React.forwardRef(function ScrollAreaViewport(componentProps, forwardedRef) { const { render, className, ...elementProps } = componentProps; const { viewportRef, scrollbarYRef, scrollbarXRef, thumbYRef, thumbXRef, cornerRef, setCornerSize, setThumbSize, rootId, setHiddenState, hiddenState, handleScroll, setHovering, setOverflowEdges, overflowEdges, overflowEdgeThreshold } = (0, _ScrollAreaRootContext.useScrollAreaRootContext)(); const direction = (0, _DirectionContext.useDirection)(); const programmaticScrollRef = React.useRef(true); const scrollEndTimeout = (0, _useTimeout.useTimeout)(); const waitForAnimationsTimeout = (0, _useTimeout.useTimeout)(); function computeThumbPositionHandler() { const viewportEl = viewportRef.current; const scrollbarYEl = scrollbarYRef.current; const scrollbarXEl = scrollbarXRef.current; const thumbYEl = thumbYRef.current; const thumbXEl = thumbXRef.current; const cornerEl = cornerRef.current; if (!viewportEl) { return; } const scrollableContentHeight = viewportEl.scrollHeight; const scrollableContentWidth = viewportEl.scrollWidth; const viewportHeight = viewportEl.clientHeight; const viewportWidth = viewportEl.clientWidth; const scrollTop = viewportEl.scrollTop; const scrollLeft = viewportEl.scrollLeft; if (scrollableContentHeight === 0 || scrollableContentWidth === 0) { return; } const scrollbarYHidden = viewportHeight >= scrollableContentHeight; const scrollbarXHidden = viewportWidth >= scrollableContentWidth; const ratioX = viewportWidth / scrollableContentWidth; const ratioY = viewportHeight / scrollableContentHeight; const maxScrollLeft = Math.max(0, scrollableContentWidth - viewportWidth); const maxScrollTop = Math.max(0, scrollableContentHeight - viewportHeight); let scrollLeftFromStart = 0; let scrollLeftFromEnd = 0; if (!scrollbarXHidden) { if (direction === 'rtl') { scrollLeftFromStart = (0, _clamp.clamp)(-scrollLeft, 0, maxScrollLeft); } else { scrollLeftFromStart = (0, _clamp.clamp)(scrollLeft, 0, maxScrollLeft); } scrollLeftFromEnd = maxScrollLeft - scrollLeftFromStart; } const scrollTopFromStart = !scrollbarYHidden ? (0, _clamp.clamp)(scrollTop, 0, maxScrollTop) : 0; const scrollTopFromEnd = !scrollbarYHidden ? maxScrollTop - scrollTopFromStart : 0; const nextWidth = scrollbarXHidden ? 0 : viewportWidth; const nextHeight = scrollbarYHidden ? 0 : viewportHeight; const scrollbarXOffset = (0, _getOffset.getOffset)(scrollbarXEl, 'padding', 'x'); const scrollbarYOffset = (0, _getOffset.getOffset)(scrollbarYEl, 'padding', 'y'); const thumbXOffset = (0, _getOffset.getOffset)(thumbXEl, 'margin', 'x'); const thumbYOffset = (0, _getOffset.getOffset)(thumbYEl, 'margin', 'y'); const idealNextWidth = nextWidth - scrollbarXOffset - thumbXOffset; const idealNextHeight = nextHeight - scrollbarYOffset - thumbYOffset; const maxNextWidth = scrollbarXEl ? Math.min(scrollbarXEl.offsetWidth, idealNextWidth) : idealNextWidth; const maxNextHeight = scrollbarYEl ? Math.min(scrollbarYEl.offsetHeight, idealNextHeight) : idealNextHeight; const clampedNextWidth = Math.max(_constants.MIN_THUMB_SIZE, maxNextWidth * ratioX); const clampedNextHeight = Math.max(_constants.MIN_THUMB_SIZE, maxNextHeight * ratioY); setThumbSize(prevSize => { if (prevSize.height === clampedNextHeight && prevSize.width === clampedNextWidth) { return prevSize; } return { width: clampedNextWidth, height: clampedNextHeight }; }); // Handle Y (vertical) scroll if (scrollbarYEl && thumbYEl) { const maxThumbOffsetY = scrollbarYEl.offsetHeight - clampedNextHeight - scrollbarYOffset - thumbYOffset; const scrollRangeY = scrollableContentHeight - viewportHeight; const scrollRatioY = scrollRangeY === 0 ? 0 : scrollTop / scrollRangeY; // In Safari, don't allow it to go negative or too far as `scrollTop` considers the rubber // band effect. const thumbOffsetY = Math.min(maxThumbOffsetY, Math.max(0, scrollRatioY * maxThumbOffsetY)); thumbYEl.style.transform = `translate3d(0,${thumbOffsetY}px,0)`; } // Handle X (horizontal) scroll if (scrollbarXEl && thumbXEl) { const maxThumbOffsetX = scrollbarXEl.offsetWidth - clampedNextWidth - scrollbarXOffset - thumbXOffset; const scrollRangeX = scrollableContentWidth - viewportWidth; const scrollRatioX = scrollRangeX === 0 ? 0 : scrollLeft / scrollRangeX; // In Safari, don't allow it to go negative or too far as `scrollLeft` considers the rubber // band effect. const thumbOffsetX = direction === 'rtl' ? (0, _clamp.clamp)(scrollRatioX * maxThumbOffsetX, -maxThumbOffsetX, 0) : (0, _clamp.clamp)(scrollRatioX * maxThumbOffsetX, 0, maxThumbOffsetX); thumbXEl.style.transform = `translate3d(${thumbOffsetX}px,0,0)`; } const clampedScrollLeftStart = (0, _clamp.clamp)(scrollLeftFromStart, 0, maxScrollLeft); const clampedScrollLeftEnd = (0, _clamp.clamp)(scrollLeftFromEnd, 0, maxScrollLeft); const clampedScrollTopStart = (0, _clamp.clamp)(scrollTopFromStart, 0, maxScrollTop); const clampedScrollTopEnd = (0, _clamp.clamp)(scrollTopFromEnd, 0, maxScrollTop); const overflowMetricsPx = [[_ScrollAreaViewportCssVars.ScrollAreaViewportCssVars.scrollAreaOverflowXStart, clampedScrollLeftStart], [_ScrollAreaViewportCssVars.ScrollAreaViewportCssVars.scrollAreaOverflowXEnd, clampedScrollLeftEnd], [_ScrollAreaViewportCssVars.ScrollAreaViewportCssVars.scrollAreaOverflowYStart, clampedScrollTopStart], [_ScrollAreaViewportCssVars.ScrollAreaViewportCssVars.scrollAreaOverflowYEnd, clampedScrollTopEnd]]; for (const [cssVar, value] of overflowMetricsPx) { viewportEl.style.setProperty(cssVar, `${value}px`); } if (cornerEl) { if (scrollbarXHidden || scrollbarYHidden) { setCornerSize({ width: 0, height: 0 }); } else if (!scrollbarXHidden && !scrollbarYHidden) { const width = scrollbarYEl?.offsetWidth || 0; const height = scrollbarXEl?.offsetHeight || 0; setCornerSize({ width, height }); } } setHiddenState(prevState => { const cornerHidden = scrollbarYHidden || scrollbarXHidden; if (prevState.scrollbarYHidden === scrollbarYHidden && prevState.scrollbarXHidden === scrollbarXHidden && prevState.cornerHidden === cornerHidden) { return prevState; } return { scrollbarYHidden, scrollbarXHidden, cornerHidden }; }); const nextOverflowEdges = { xStart: !scrollbarXHidden && clampedScrollLeftStart > overflowEdgeThreshold.xStart, xEnd: !scrollbarXHidden && clampedScrollLeftEnd > overflowEdgeThreshold.xEnd, yStart: !scrollbarYHidden && clampedScrollTopStart > overflowEdgeThreshold.yStart, yEnd: !scrollbarYHidden && clampedScrollTopEnd > overflowEdgeThreshold.yEnd }; setOverflowEdges(prev => { if (prev.xStart === nextOverflowEdges.xStart && prev.xEnd === nextOverflowEdges.xEnd && prev.yStart === nextOverflowEdges.yStart && prev.yEnd === nextOverflowEdges.yEnd) { return prev; } return nextOverflowEdges; }); } const computeThumbPosition = (0, _useStableCallback.useStableCallback)(() => { ReactDOM.flushSync(computeThumbPositionHandler); }); (0, _useIsoLayoutEffect.useIsoLayoutEffect)(() => { if (!viewportRef.current) { return undefined; } removeCSSVariableInheritance(); const cleanup = (0, _onVisible.onVisible)(viewportRef.current, computeThumbPosition); return cleanup; }, [computeThumbPosition, viewportRef]); (0, _useIsoLayoutEffect.useIsoLayoutEffect)(() => { // Wait for scrollbar-related refs to be set queueMicrotask(computeThumbPosition); }, [computeThumbPosition, hiddenState, direction]); (0, _useIsoLayoutEffect.useIsoLayoutEffect)(() => { // `onMouseEnter` doesn't fire upon load, so we need to check if the viewport is already // being hovered. if (viewportRef.current?.matches(':hover')) { setHovering(true); } }, [viewportRef, setHovering]); React.useEffect(() => { const viewport = viewportRef.current; if (typeof ResizeObserver === 'undefined' || !viewport) { return undefined; } const ro = new ResizeObserver(computeThumbPosition); ro.observe(viewport); // If there are animations in the viewport, wait for them to finish and then recompute the thumb position. // This is necessary when the viewport contains a Dialog that is animating its popup on open // and the popup is using a transform for the animation, which affects the size of the viewport. // Without this, the thumb position will be incorrect until scrolling (i.e. if the scrollbar shows // on hover, the thumb has an incorrect size). // We assume the user is using `onOpenChangeComplete` to hide the scrollbar // until animations complete because otherwise the scrollbar would show the thumb resizing mid-animation. waitForAnimationsTimeout.start(0, () => { Promise.all(viewport.getAnimations({ subtree: true }).map(animation => animation.finished)).then(computeThumbPosition).catch(() => {}); }); return () => { ro.disconnect(); waitForAnimationsTimeout.clear(); }; }, [computeThumbPosition, viewportRef, waitForAnimationsTimeout]); function handleUserInteraction() { programmaticScrollRef.current = false; } const props = { role: 'presentation', ...(rootId && { 'data-id': `${rootId}-viewport` }), // https://accessibilityinsights.io/info-examples/web/scrollable-region-focusable/ ...((!hiddenState.scrollbarXHidden || !hiddenState.scrollbarYHidden) && { tabIndex: 0 }), className: _styles.styleDisableScrollbar.className, style: { overflow: 'scroll' }, onScroll() { if (!viewportRef.current) { return; } computeThumbPosition(); if (!programmaticScrollRef.current) { handleScroll({ x: viewportRef.current.scrollLeft, y: viewportRef.current.scrollTop }); } // Debounce the restoration of the programmatic flag so that it only // flips back to `true` once scrolling has come to a rest. This ensures // that momentum scrolling (where no further user-interaction events fire) // is still treated as user-driven. // 100 ms without scroll events ≈ scroll end // https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollend_event scrollEndTimeout.start(100, () => { programmaticScrollRef.current = true; }); }, onWheel: handleUserInteraction, onTouchMove: handleUserInteraction, onPointerMove: handleUserInteraction, onPointerEnter: handleUserInteraction, onKeyDown: handleUserInteraction }; const viewportState = React.useMemo(() => ({ hasOverflowX: !hiddenState.scrollbarXHidden, hasOverflowY: !hiddenState.scrollbarYHidden, overflowXStart: overflowEdges.xStart, overflowXEnd: overflowEdges.xEnd, overflowYStart: overflowEdges.yStart, overflowYEnd: overflowEdges.yEnd, cornerHidden: hiddenState.cornerHidden }), [hiddenState.scrollbarXHidden, hiddenState.scrollbarYHidden, hiddenState.cornerHidden, overflowEdges]); const element = (0, _useRenderElement.useRenderElement)('div', componentProps, { ref: [forwardedRef, viewportRef], state: viewportState, props: [props, elementProps], stateAttributesMapping: _stateAttributes.scrollAreaStateAttributesMapping }); const contextValue = React.useMemo(() => ({ computeThumbPosition }), [computeThumbPosition]); return /*#__PURE__*/(0, _jsxRuntime.jsx)(_ScrollAreaViewportContext.ScrollAreaViewportContext.Provider, { value: contextValue, children: element }); }); if (process.env.NODE_ENV !== "production") ScrollAreaViewport.displayName = "ScrollAreaViewport";