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.

197 lines (193 loc) 7.7 kB
'use client'; import * as React from 'react'; import { addEventListener } from '@base-ui/utils/addEventListener'; import { contains, getTarget } from "../../floating-ui-react/utils.mjs"; import { useScrollAreaRootContext } from "../root/ScrollAreaRootContext.mjs"; import { ScrollAreaScrollbarContext } from "./ScrollAreaScrollbarContext.mjs"; import { useRenderElement } from "../../internals/useRenderElement.mjs"; import { getOffset } from "../utils/getOffset.mjs"; import { useDirection } from "../../internals/direction-context/DirectionContext.mjs"; import { scrollAreaStateAttributesMapping } from "../root/stateAttributes.mjs"; import { jsx as _jsx } from "react/jsx-runtime"; /** * A vertical or horizontal scrollbar for the scroll area. * Renders a `<div>` element. * * Documentation: [Base UI Scroll Area](https://base-ui.com/react/components/scroll-area) */ export const ScrollAreaScrollbar = /*#__PURE__*/React.forwardRef(function ScrollAreaScrollbar(componentProps, forwardedRef) { const { render, className, orientation = 'vertical', keepMounted = false, style, ...elementProps } = componentProps; const { hovering, scrollingX, scrollingY, hiddenState, scrollbarYRef, scrollbarXRef, viewportRef, thumbYRef, thumbXRef, handlePointerDown, handlePointerUp, handleScroll, disableViewportSnap, rootId, thumbSize, hasMeasuredScrollbar, viewportState } = useScrollAreaRootContext(); const vertical = orientation === 'vertical'; const state = { ...viewportState, hovering, scrolling: vertical ? scrollingY : scrollingX, orientation }; const direction = useDirection(); const hideTrackUntilMeasured = !hasMeasuredScrollbar && !keepMounted; const isHidden = vertical ? hiddenState.y : hiddenState.x; const shouldRender = keepMounted || !isHidden; React.useEffect(() => { if (!shouldRender) { return undefined; } const viewportEl = viewportRef.current; const scrollbarEl = vertical ? scrollbarYRef.current : scrollbarXRef.current; if (!scrollbarEl) { return undefined; } function handleWheel(event) { if (!viewportEl || event.ctrlKey) { return; } const horizontal = !vertical; const scrollProperty = horizontal ? 'scrollLeft' : 'scrollTop'; const delta = horizontal ? event.deltaX : event.deltaY; if (delta === 0) { return; } const maxScroll = horizontal ? viewportEl.scrollWidth - viewportEl.clientWidth : viewportEl.scrollHeight - viewportEl.clientHeight; // RTL horizontal scrolling uses a negative `scrollLeft` range, from 0 to `-maxScroll`. const minScroll = horizontal && direction === 'rtl' ? -maxScroll : 0; const maxScrollValue = horizontal && direction === 'rtl' ? 0 : maxScroll; const scrollValue = viewportEl[scrollProperty]; // At an edge (or with no overflow), let the wheel event chain to the // parent/page instead of swallowing it via `preventDefault`. if (scrollValue <= minScroll && delta < 0 || scrollValue >= maxScrollValue && delta > 0) { return; } event.preventDefault(); viewportEl[scrollProperty] = Math.min(maxScrollValue, Math.max(minScroll, scrollValue + delta)); handleScroll({ x: viewportEl.scrollLeft, y: viewportEl.scrollTop }); } return addEventListener(scrollbarEl, 'wheel', handleWheel, { passive: false }); }, [direction, handleScroll, vertical, scrollbarXRef, scrollbarYRef, shouldRender, viewportRef]); const props = { ...(rootId && { 'data-id': `${rootId}-scrollbar` }), onPointerDown(event) { if (event.button !== 0) { return; } const target = getTarget(event.nativeEvent); const thumbEl = vertical ? thumbYRef.current : thumbXRef.current; // Ignore clicks on thumb, including cases where React retargets the // synthetic event to the track host across a shadow boundary. if (thumbEl && contains(thumbEl, target)) { return; } const viewportEl = viewportRef.current; if (!viewportEl) { return; } const scrollbarEl = vertical ? scrollbarYRef.current : scrollbarXRef.current; if (!thumbEl || !scrollbarEl) { return; } const axis = vertical ? 'y' : 'x'; const thumbOffset = getOffset(thumbEl, 'margin', axis); const scrollbarOffset = getOffset(scrollbarEl, 'padding', axis); const thumbSizePx = vertical ? thumbEl.offsetHeight : thumbEl.offsetWidth; const trackRect = scrollbarEl.getBoundingClientRect(); const clickPosition = vertical ? event.clientY - trackRect.top - thumbSizePx / 2 - scrollbarOffset + thumbOffset / 2 : event.clientX - trackRect.left - thumbSizePx / 2 - scrollbarOffset + thumbOffset / 2; const scrollableSize = vertical ? viewportEl.scrollHeight : viewportEl.scrollWidth; const viewportSize = vertical ? viewportEl.clientHeight : viewportEl.clientWidth; 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. if (maxThumbOffset <= 0) { return; } const scrollRatio = clickPosition / maxThumbOffset; const maxScrollDistance = scrollableSize - viewportSize; // Disable snapping before the jump-to-click assignment, or the // assigned position quantizes to the nearest snap point and the thumb // stays offset from the pointer for the whole drag. `handlePointerDown` // below re-runs this as a guarded no-op for the thumb-drag path. disableViewportSnap(); if (vertical) { viewportEl.scrollTop = scrollRatio * maxScrollDistance; } else if (direction === 'rtl') { viewportEl.scrollLeft = -(1 - scrollRatio) * maxScrollDistance; } else { viewportEl.scrollLeft = scrollRatio * maxScrollDistance; } handleScroll({ x: viewportEl.scrollLeft, y: viewportEl.scrollTop }); handlePointerDown(event); }, onPointerUp: handlePointerUp, // Mirror `onPointerUp` so a browser-cancelled gesture on the track (no thumb // child captures the pointer) still clears the drag state. onPointerCancel: handlePointerUp, style: { position: 'absolute', touchAction: 'none', WebkitUserSelect: 'none', userSelect: 'none', visibility: hideTrackUntilMeasured ? 'hidden' : undefined, ...(vertical ? { top: 0, bottom: 'var(--scroll-area-corner-height)', insetInlineEnd: 0, ['--scroll-area-thumb-height']: `${thumbSize.height}px` } : { insetInlineStart: 0, insetInlineEnd: 'var(--scroll-area-corner-width)', bottom: 0, ['--scroll-area-thumb-width']: `${thumbSize.width}px` }) } }; const element = useRenderElement('div', componentProps, { ref: [forwardedRef, vertical ? scrollbarYRef : scrollbarXRef], state, props: [props, elementProps], stateAttributesMapping: scrollAreaStateAttributesMapping }); if (!shouldRender) { return null; } return /*#__PURE__*/_jsx(ScrollAreaScrollbarContext.Provider, { value: orientation, children: element }); }); if (process.env.NODE_ENV !== "production") ScrollAreaScrollbar.displayName = "ScrollAreaScrollbar";