UNPKG

@base-ui/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.

319 lines (316 loc) 12.8 kB
"use strict"; 'use client'; var _interopRequireWildcard = require("@babel/runtime/helpers/interopRequireWildcard").default; Object.defineProperty(exports, "__esModule", { value: true }); exports.ScrollAreaRoot = void 0; var React = _interopRequireWildcard(require("react")); var _useStableCallback = require("@base-ui/utils/useStableCallback"); var _useTimeout = require("@base-ui/utils/useTimeout"); var _ScrollAreaRootContext = require("./ScrollAreaRootContext"); var _useRenderElement = require("../../internals/useRenderElement"); var _constants = require("../constants"); var _getOffset = require("../utils/getOffset"); var _styles = require("../../utils/styles"); var _useBaseUiId = require("../../internals/useBaseUiId"); var _stateAttributes = require("./stateAttributes"); var _utils = require("../../floating-ui-react/utils"); var _CSPContext = require("../../internals/csp-context/CSPContext"); var _jsxRuntime = require("react/jsx-runtime"); const DEFAULT_COORDS = { x: 0, y: 0 }; const DEFAULT_SIZE = { width: 0, height: 0 }; const DEFAULT_OVERFLOW_EDGES = { xStart: false, xEnd: false, yStart: false, yEnd: false }; const DEFAULT_HIDDEN_STATE = { x: true, y: true, corner: true }; /** * Groups all parts of the scroll area. * Renders a `<div>` element. * * Documentation: [Base UI Scroll Area](https://base-ui.com/react/components/scroll-area) */ const ScrollAreaRoot = exports.ScrollAreaRoot = /*#__PURE__*/React.forwardRef(function ScrollAreaRoot(componentProps, forwardedRef) { const { render, className, overflowEdgeThreshold: overflowEdgeThresholdProp, style, ...elementProps } = componentProps; const { xStart, xEnd, yStart, yEnd } = normalizeOverflowEdgeThreshold(overflowEdgeThresholdProp); const rootId = (0, _useBaseUiId.useBaseUiId)(); const scrollYTimeout = (0, _useTimeout.useTimeout)(); const scrollXTimeout = (0, _useTimeout.useTimeout)(); const { nonce, disableStyleElements } = (0, _CSPContext.useCSPContext)(); const [hovering, setHovering] = React.useState(false); const [scrollingX, setScrollingX] = React.useState(false); const [scrollingY, setScrollingY] = React.useState(false); const [touchModality, setTouchModality] = React.useState(false); const [hasMeasuredScrollbar, setHasMeasuredScrollbar] = React.useState(false); const [cornerSize, setCornerSize] = React.useState(DEFAULT_SIZE); const [thumbSize, setThumbSize] = React.useState(DEFAULT_SIZE); const [overflowEdges, setOverflowEdges] = React.useState(DEFAULT_OVERFLOW_EDGES); const [hiddenState, setHiddenState] = React.useState(DEFAULT_HIDDEN_STATE); const rootRef = React.useRef(null); const viewportRef = React.useRef(null); const scrollbarYRef = React.useRef(null); const scrollbarXRef = React.useRef(null); const thumbYRef = React.useRef(null); const thumbXRef = React.useRef(null); const cornerRef = React.useRef(null); const activePointerIdRef = React.useRef(null); const startYRef = React.useRef(0); const startXRef = React.useRef(0); const startScrollTopRef = React.useRef(0); const startScrollLeftRef = React.useRef(0); const currentOrientationRef = React.useRef('vertical'); const scrollPositionRef = React.useRef(DEFAULT_COORDS); const savedSnapTypeRef = React.useRef(null); function startScrolling(vertical) { const setScrolling = vertical ? setScrollingY : setScrollingX; const timeout = vertical ? scrollYTimeout : scrollXTimeout; setScrolling(true); timeout.start(_constants.SCROLL_TIMEOUT, () => { setScrolling(false); }); } const handleScroll = (0, _useStableCallback.useStableCallback)(scrollPosition => { const offsetX = scrollPosition.x - scrollPositionRef.current.x; const offsetY = scrollPosition.y - scrollPositionRef.current.y; scrollPositionRef.current = scrollPosition; if (offsetY !== 0) { startScrolling(true); } if (offsetX !== 0) { startScrolling(false); } }); // CSS scroll snap forces every programmatic scroll to land on a snap // point, making thumb dragging jump between snap points. Native // scrollbars suppress snapping while dragging, so disable it until the // pointer is released; restoring the value re-snaps the viewport. The // save is guarded so a second pointer during an active drag can't // clobber the saved value with `none`. const disableViewportSnap = (0, _useStableCallback.useStableCallback)(() => { const viewportEl = viewportRef.current; if (viewportEl && savedSnapTypeRef.current === null) { savedSnapTypeRef.current = viewportEl.style.scrollSnapType; viewportEl.style.scrollSnapType = 'none'; } }); const handlePointerDown = (0, _useStableCallback.useStableCallback)(event => { if (event.button !== 0) { return; } if (activePointerIdRef.current !== null) { const activeThumb = currentOrientationRef.current === 'vertical' ? thumbYRef.current : thumbXRef.current; // A live drag holds capture for the active pointer — ignore other pointers. // No capture means the release went missing entirely (silent capture drop // with an id that never reappears, e.g. a lost touch contact), so let the // new pointer take over the latch instead of leaving dragging dead. if (activeThumb?.hasPointerCapture(activePointerIdRef.current)) { return; } } activePointerIdRef.current = event.pointerId; startYRef.current = event.clientY; startXRef.current = event.clientX; // Literal instead of `ScrollAreaScrollbarDataAttributes.orientation`: referencing an // enum member retains its whole object in the bundle, so the strings are inlined and // the enums kept for docs only. currentOrientationRef.current = event.currentTarget.getAttribute('data-orientation'); const viewportEl = viewportRef.current; if (viewportEl) { startScrollTopRef.current = viewportEl.scrollTop; startScrollLeftRef.current = viewportEl.scrollLeft; disableViewportSnap(); } const thumb = currentOrientationRef.current === 'vertical' ? thumbYRef.current : thumbXRef.current; thumb?.setPointerCapture(event.pointerId); }); const handlePointerUp = (0, _useStableCallback.useStableCallback)(event => { if (event.pointerId !== activePointerIdRef.current) { return; } activePointerIdRef.current = null; // Clear the drag's scrolling state immediately rather than waiting for the // `SCROLL_TIMEOUT` timer armed by the last drag move, so every release path // (real, `pointercancel`, or the missed-release fallback) behaves the same. (currentOrientationRef.current === 'vertical' ? setScrollingY : setScrollingX)(false); if (savedSnapTypeRef.current !== null) { if (viewportRef.current) { viewportRef.current.style.scrollSnapType = savedSnapTypeRef.current; } savedSnapTypeRef.current = null; } const thumb = currentOrientationRef.current === 'vertical' ? thumbYRef.current : thumbXRef.current; // `pointercancel` releases capture implicitly, so guard against releasing a // capture we no longer hold (which would throw). if (thumb?.hasPointerCapture(event.pointerId)) { thumb.releasePointerCapture(event.pointerId); } }); const handlePointerMove = (0, _useStableCallback.useStableCallback)(event => { if (event.pointerId !== activePointerIdRef.current) { return; } // The release can go missing entirely (e.g. the browser drops pointer // capture while the scrollbar is hidden mid-drag), leaving the drag // latched so a buttonless hover over the thumb scrolls the viewport. // Treat a move without the primary button held (`buttons` bit 1 unset) // as the missed release. if (event.buttons % 2 === 0) { handlePointerUp(event); return; } const viewportEl = viewportRef.current; if (!viewportEl) { return; } const vertical = currentOrientationRef.current === 'vertical'; const thumbEl = vertical ? thumbYRef.current : thumbXRef.current; const scrollbarEl = vertical ? scrollbarYRef.current : scrollbarXRef.current; if (!thumbEl || !scrollbarEl) { return; } const axis = vertical ? 'y' : 'x'; const scrollbarOffset = (0, _getOffset.getOffset)(scrollbarEl, 'padding', axis); const thumbOffset = (0, _getOffset.getOffset)(thumbEl, 'margin', axis); const thumbSizePx = vertical ? thumbEl.offsetHeight : thumbEl.offsetWidth; const trackSize = vertical ? scrollbarEl.offsetHeight : scrollbarEl.offsetWidth; const maxThumbOffset = trackSize - thumbSizePx - scrollbarOffset - thumbOffset; // A short or heavily padded track can drive `maxThumbOffset` to zero or // negative once the thumb hits its `MIN_THUMB_SIZE` floor. Dividing by it // would yield a non-finite (`Infinity`/`NaN`) or inverted scroll position. const delta = vertical ? event.clientY - startYRef.current : event.clientX - startXRef.current; const scrollRatio = maxThumbOffset <= 0 ? 0 : delta / maxThumbOffset; const scrollableSize = vertical ? viewportEl.scrollHeight : viewportEl.scrollWidth; const viewportSize = vertical ? viewportEl.clientHeight : viewportEl.clientWidth; const startScroll = vertical ? startScrollTopRef.current : startScrollLeftRef.current; const nextScroll = startScroll + scrollRatio * (scrollableSize - viewportSize); if (vertical) { viewportEl.scrollTop = nextScroll; } else { viewportEl.scrollLeft = nextScroll; } event.preventDefault(); startScrolling(vertical); }); function handleTouchModalityChange(event) { setTouchModality(event.pointerType === 'touch'); } function handlePointerEnterOrMove(event) { handleTouchModalityChange(event); if (event.pointerType !== 'touch') { const isTargetRootChild = (0, _utils.contains)(rootRef.current, event.target); setHovering(isTargetRootChild); } } const state = React.useMemo(() => ({ scrolling: scrollingX || scrollingY, hasOverflowX: !hiddenState.x, hasOverflowY: !hiddenState.y, overflowXStart: overflowEdges.xStart, overflowXEnd: overflowEdges.xEnd, overflowYStart: overflowEdges.yStart, overflowYEnd: overflowEdges.yEnd, cornerHidden: hiddenState.corner }), [scrollingX, scrollingY, hiddenState.x, hiddenState.y, hiddenState.corner, overflowEdges]); const props = { role: 'presentation', onPointerEnter: handlePointerEnterOrMove, onPointerMove: handlePointerEnterOrMove, onPointerDown: handleTouchModalityChange, onPointerLeave() { setHovering(false); }, style: { position: 'relative', ['--scroll-area-corner-height']: `${cornerSize.height}px`, ['--scroll-area-corner-width']: `${cornerSize.width}px` } }; const element = (0, _useRenderElement.useRenderElement)('div', componentProps, { state, ref: [forwardedRef, rootRef], props: [props, elementProps], stateAttributesMapping: _stateAttributes.scrollAreaStateAttributesMapping }); const contextValue = React.useMemo(() => ({ handlePointerDown, handlePointerMove, handlePointerUp, handleScroll, disableViewportSnap, cornerSize, setCornerSize, thumbSize, setThumbSize, hasMeasuredScrollbar, setHasMeasuredScrollbar, touchModality, cornerRef, scrollingX, scrollingY, hovering, setHovering, viewportRef, scrollbarYRef, scrollbarXRef, thumbYRef, thumbXRef, rootId, hiddenState, setHiddenState, overflowEdges, setOverflowEdges, viewportState: state, overflowEdgeThreshold: { xStart, xEnd, yStart, yEnd } }), [handlePointerDown, handlePointerMove, handlePointerUp, handleScroll, disableViewportSnap, cornerSize, thumbSize, hasMeasuredScrollbar, touchModality, scrollingX, scrollingY, hovering, rootId, hiddenState, overflowEdges, state, xStart, xEnd, yStart, yEnd]); return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_ScrollAreaRootContext.ScrollAreaRootContext.Provider, { value: contextValue, children: [!disableStyleElements && _styles.styleDisableScrollbar.getElement(nonce), element] }); }); if (process.env.NODE_ENV !== "production") ScrollAreaRoot.displayName = "ScrollAreaRoot"; function normalizeOverflowEdgeThreshold(threshold) { const thresholds = typeof threshold === 'number' ? { xStart: threshold, xEnd: threshold, yStart: threshold, yEnd: threshold } : threshold; return { xStart: Math.max(0, thresholds?.xStart || 0), xEnd: Math.max(0, thresholds?.xEnd || 0), yStart: Math.max(0, thresholds?.yStart || 0), yEnd: Math.max(0, thresholds?.yEnd || 0) }; }