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.

252 lines (250 loc) 9.79 kB
'use client'; import * as React from 'react'; import { useStableCallback } from '@base-ui-components/utils/useStableCallback'; import { useTimeout } from '@base-ui-components/utils/useTimeout'; import { ScrollAreaRootContext } from "./ScrollAreaRootContext.js"; import { useRenderElement } from "../../utils/useRenderElement.js"; import { ScrollAreaRootCssVars } from "./ScrollAreaRootCssVars.js"; import { SCROLL_TIMEOUT } from "../constants.js"; import { getOffset } from "../utils/getOffset.js"; import { ScrollAreaScrollbarDataAttributes } from "../scrollbar/ScrollAreaScrollbarDataAttributes.js"; import { styleDisableScrollbar } from "../../utils/styles.js"; import { useBaseUiId } from "../../utils/useBaseUiId.js"; import { scrollAreaStateAttributesMapping } from "./stateAttributes.js"; import { contains } from "../../floating-ui-react/utils.js"; import { jsxs as _jsxs } from "react/jsx-runtime"; const DEFAULT_SIZE = { width: 0, height: 0 }; const DEFAULT_OVERFLOW_EDGES = { xStart: false, xEnd: false, yStart: false, yEnd: false }; /** * Groups all parts of the scroll area. * Renders a `<div>` element. * * Documentation: [Base UI Scroll Area](https://base-ui.com/react/components/scroll-area) */ export const ScrollAreaRoot = /*#__PURE__*/React.forwardRef(function ScrollAreaRoot(componentProps, forwardedRef) { const { render, className, overflowEdgeThreshold: overflowEdgeThresholdProp, ...elementProps } = componentProps; const [hovering, setHovering] = React.useState(false); const [scrollingX, setScrollingX] = React.useState(false); const [scrollingY, setScrollingY] = React.useState(false); const [cornerSize, setCornerSize] = React.useState(DEFAULT_SIZE); const [thumbSize, setThumbSize] = React.useState(DEFAULT_SIZE); const [touchModality, setTouchModality] = React.useState(false); const [overflowEdges, setOverflowEdges] = React.useState(DEFAULT_OVERFLOW_EDGES); const rootId = useBaseUiId(); 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 thumbDraggingRef = React.useRef(false); 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 scrollYTimeout = useTimeout(); const scrollXTimeout = useTimeout(); const scrollPositionRef = React.useRef({ x: 0, y: 0 }); const [hiddenState, setHiddenState] = React.useState({ scrollbarYHidden: false, scrollbarXHidden: false, cornerHidden: false }); const overflowEdgeThreshold = normalizeOverflowEdgeThreshold(overflowEdgeThresholdProp); const handleScroll = useStableCallback(scrollPosition => { const offsetX = scrollPosition.x - scrollPositionRef.current.x; const offsetY = scrollPosition.y - scrollPositionRef.current.y; scrollPositionRef.current = scrollPosition; if (offsetY !== 0) { setScrollingY(true); scrollYTimeout.start(SCROLL_TIMEOUT, () => { setScrollingY(false); }); } if (offsetX !== 0) { setScrollingX(true); scrollXTimeout.start(SCROLL_TIMEOUT, () => { setScrollingX(false); }); } }); const handlePointerDown = useStableCallback(event => { if (event.button !== 0) { return; } thumbDraggingRef.current = true; startYRef.current = event.clientY; startXRef.current = event.clientX; currentOrientationRef.current = event.currentTarget.getAttribute(ScrollAreaScrollbarDataAttributes.orientation); if (viewportRef.current) { startScrollTopRef.current = viewportRef.current.scrollTop; startScrollLeftRef.current = viewportRef.current.scrollLeft; } if (thumbYRef.current && currentOrientationRef.current === 'vertical') { thumbYRef.current.setPointerCapture(event.pointerId); } if (thumbXRef.current && currentOrientationRef.current === 'horizontal') { thumbXRef.current.setPointerCapture(event.pointerId); } }); const handlePointerMove = useStableCallback(event => { if (!thumbDraggingRef.current) { return; } const deltaY = event.clientY - startYRef.current; const deltaX = event.clientX - startXRef.current; if (viewportRef.current) { const scrollableContentHeight = viewportRef.current.scrollHeight; const viewportHeight = viewportRef.current.clientHeight; const scrollableContentWidth = viewportRef.current.scrollWidth; const viewportWidth = viewportRef.current.clientWidth; if (thumbYRef.current && scrollbarYRef.current && currentOrientationRef.current === 'vertical') { const scrollbarYOffset = getOffset(scrollbarYRef.current, 'padding', 'y'); const thumbYOffset = getOffset(thumbYRef.current, 'margin', 'y'); const thumbHeight = thumbYRef.current.offsetHeight; const maxThumbOffsetY = scrollbarYRef.current.offsetHeight - thumbHeight - scrollbarYOffset - thumbYOffset; const scrollRatioY = deltaY / maxThumbOffsetY; viewportRef.current.scrollTop = startScrollTopRef.current + scrollRatioY * (scrollableContentHeight - viewportHeight); event.preventDefault(); setScrollingY(true); scrollYTimeout.start(SCROLL_TIMEOUT, () => { setScrollingY(false); }); } if (thumbXRef.current && scrollbarXRef.current && currentOrientationRef.current === 'horizontal') { const scrollbarXOffset = getOffset(scrollbarXRef.current, 'padding', 'x'); const thumbXOffset = getOffset(thumbXRef.current, 'margin', 'x'); const thumbWidth = thumbXRef.current.offsetWidth; const maxThumbOffsetX = scrollbarXRef.current.offsetWidth - thumbWidth - scrollbarXOffset - thumbXOffset; const scrollRatioX = deltaX / maxThumbOffsetX; viewportRef.current.scrollLeft = startScrollLeftRef.current + scrollRatioX * (scrollableContentWidth - viewportWidth); event.preventDefault(); setScrollingX(true); scrollXTimeout.start(SCROLL_TIMEOUT, () => { setScrollingX(false); }); } } }); const handlePointerUp = useStableCallback(event => { thumbDraggingRef.current = false; if (thumbYRef.current && currentOrientationRef.current === 'vertical') { thumbYRef.current.releasePointerCapture(event.pointerId); } if (thumbXRef.current && currentOrientationRef.current === 'horizontal') { thumbXRef.current.releasePointerCapture(event.pointerId); } }); function handlePointerEnterOrMove(event) { const isTouch = event.pointerType === 'touch'; setTouchModality(isTouch); if (!isTouch) { const isTargetRootChild = contains(rootRef.current, event.target); setHovering(isTargetRootChild); } } const state = 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 props = { role: 'presentation', onPointerEnter: handlePointerEnterOrMove, onPointerMove: handlePointerEnterOrMove, onPointerDown({ pointerType }) { setTouchModality(pointerType === 'touch'); }, onPointerLeave() { setHovering(false); }, style: { position: 'relative', [ScrollAreaRootCssVars.scrollAreaCornerHeight]: `${cornerSize.height}px`, [ScrollAreaRootCssVars.scrollAreaCornerWidth]: `${cornerSize.width}px` } }; const element = useRenderElement('div', componentProps, { state, ref: [forwardedRef, rootRef], props: [props, elementProps], stateAttributesMapping: scrollAreaStateAttributesMapping }); const contextValue = React.useMemo(() => ({ handlePointerDown, handlePointerMove, handlePointerUp, handleScroll, cornerSize, setCornerSize, thumbSize, setThumbSize, touchModality, cornerRef, scrollingX, setScrollingX, scrollingY, setScrollingY, hovering, setHovering, viewportRef, rootRef, scrollbarYRef, scrollbarXRef, thumbYRef, thumbXRef, rootId, hiddenState, setHiddenState, overflowEdges, setOverflowEdges, viewportState: state, overflowEdgeThreshold }), [handlePointerDown, handlePointerMove, handlePointerUp, handleScroll, cornerSize, thumbSize, touchModality, cornerRef, scrollingX, setScrollingX, scrollingY, setScrollingY, hovering, setHovering, viewportRef, rootRef, scrollbarYRef, scrollbarXRef, thumbYRef, thumbXRef, rootId, hiddenState, overflowEdges, state, overflowEdgeThreshold]); return /*#__PURE__*/_jsxs(ScrollAreaRootContext.Provider, { value: contextValue, children: [styleDisableScrollbar.element, element] }); }); if (process.env.NODE_ENV !== "production") ScrollAreaRoot.displayName = "ScrollAreaRoot"; function normalizeOverflowEdgeThreshold(threshold) { if (typeof threshold === 'number') { const value = Math.max(0, threshold); return { xStart: value, xEnd: value, yStart: value, yEnd: value }; } return { xStart: Math.max(0, threshold?.xStart || 0), xEnd: Math.max(0, threshold?.xEnd || 0), yStart: Math.max(0, threshold?.yStart || 0), yEnd: Math.max(0, threshold?.yEnd || 0) }; }