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.

335 lines (325 loc) 14.4 kB
'use client'; import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { useStableCallback } from '@base-ui-components/utils/useStableCallback'; import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect'; import { isWebKit } from '@base-ui-components/utils/detectBrowser'; import { useTimeout } from '@base-ui-components/utils/useTimeout'; import { useScrollAreaRootContext } from "../root/ScrollAreaRootContext.js"; import { ScrollAreaViewportContext } from "./ScrollAreaViewportContext.js"; import { useRenderElement } from "../../utils/useRenderElement.js"; import { useDirection } from "../../direction-provider/DirectionContext.js"; import { getOffset } from "../utils/getOffset.js"; import { MIN_THUMB_SIZE } from "../constants.js"; import { clamp } from "../../utils/clamp.js"; import { styleDisableScrollbar } from "../../utils/styles.js"; import { onVisible } from "../utils/onVisible.js"; import { scrollAreaStateAttributesMapping } from "../root/stateAttributes.js"; import { ScrollAreaViewportCssVars } from "./ScrollAreaViewportCssVars.js"; // Module-level flag to ensure we only register the CSS properties once, // regardless of how many Scroll Area components are mounted. import { jsx as _jsx } from "react/jsx-runtime"; 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. isWebKit) { return; } if (typeof CSS !== 'undefined' && 'registerProperty' in CSS) { [ScrollAreaViewportCssVars.scrollAreaOverflowXStart, ScrollAreaViewportCssVars.scrollAreaOverflowXEnd, ScrollAreaViewportCssVars.scrollAreaOverflowYStart, 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) */ export const 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 } = useScrollAreaRootContext(); const direction = useDirection(); const programmaticScrollRef = React.useRef(true); const scrollEndTimeout = useTimeout(); const waitForAnimationsTimeout = 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 = clamp(-scrollLeft, 0, maxScrollLeft); } else { scrollLeftFromStart = clamp(scrollLeft, 0, maxScrollLeft); } scrollLeftFromEnd = maxScrollLeft - scrollLeftFromStart; } const scrollTopFromStart = !scrollbarYHidden ? clamp(scrollTop, 0, maxScrollTop) : 0; const scrollTopFromEnd = !scrollbarYHidden ? maxScrollTop - scrollTopFromStart : 0; const nextWidth = scrollbarXHidden ? 0 : viewportWidth; const nextHeight = scrollbarYHidden ? 0 : viewportHeight; const scrollbarXOffset = getOffset(scrollbarXEl, 'padding', 'x'); const scrollbarYOffset = getOffset(scrollbarYEl, 'padding', 'y'); const thumbXOffset = getOffset(thumbXEl, 'margin', 'x'); const thumbYOffset = 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(MIN_THUMB_SIZE, maxNextWidth * ratioX); const clampedNextHeight = Math.max(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' ? clamp(scrollRatioX * maxThumbOffsetX, -maxThumbOffsetX, 0) : clamp(scrollRatioX * maxThumbOffsetX, 0, maxThumbOffsetX); thumbXEl.style.transform = `translate3d(${thumbOffsetX}px,0,0)`; } const clampedScrollLeftStart = clamp(scrollLeftFromStart, 0, maxScrollLeft); const clampedScrollLeftEnd = clamp(scrollLeftFromEnd, 0, maxScrollLeft); const clampedScrollTopStart = clamp(scrollTopFromStart, 0, maxScrollTop); const clampedScrollTopEnd = clamp(scrollTopFromEnd, 0, maxScrollTop); const overflowMetricsPx = [[ScrollAreaViewportCssVars.scrollAreaOverflowXStart, clampedScrollLeftStart], [ScrollAreaViewportCssVars.scrollAreaOverflowXEnd, clampedScrollLeftEnd], [ScrollAreaViewportCssVars.scrollAreaOverflowYStart, clampedScrollTopStart], [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 = useStableCallback(() => { ReactDOM.flushSync(computeThumbPositionHandler); }); useIsoLayoutEffect(() => { if (!viewportRef.current) { return undefined; } removeCSSVariableInheritance(); const cleanup = onVisible(viewportRef.current, computeThumbPosition); return cleanup; }, [computeThumbPosition, viewportRef]); useIsoLayoutEffect(() => { // Wait for scrollbar-related refs to be set queueMicrotask(computeThumbPosition); }, [computeThumbPosition, hiddenState, direction]); 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: 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 = useRenderElement('div', componentProps, { ref: [forwardedRef, viewportRef], state: viewportState, props: [props, elementProps], stateAttributesMapping: scrollAreaStateAttributesMapping }); const contextValue = React.useMemo(() => ({ computeThumbPosition }), [computeThumbPosition]); return /*#__PURE__*/_jsx(ScrollAreaViewportContext.Provider, { value: contextValue, children: element }); }); if (process.env.NODE_ENV !== "production") ScrollAreaViewport.displayName = "ScrollAreaViewport";