@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
JavaScript
"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";