diff --git a/.changeset/fix-scrollarea-thumb-conflict.md b/.changeset/fix-scrollarea-thumb-conflict.md new file mode 100644 index 0000000000..d1aef93732 --- /dev/null +++ b/.changeset/fix-scrollarea-thumb-conflict.md @@ -0,0 +1,7 @@ +--- +'@radix-ui/react-scroll-area': patch +--- + +Fixed thumb conflict when scrolling content area on mobile + +When touching the viewport's content area and the user's finger moves to the thumb area, the thumb will no longer interfere with the ongoing content scroll interaction. diff --git a/packages/react/scroll-area/src/scroll-area.tsx b/packages/react/scroll-area/src/scroll-area.tsx index 2db5b971da..a970e0696d 100644 --- a/packages/react/scroll-area/src/scroll-area.tsx +++ b/packages/react/scroll-area/src/scroll-area.tsx @@ -143,6 +143,7 @@ const ScrollAreaViewport = React.forwardRef(null); const composedRefs = useComposedRefs(forwardedRef, ref, context.onViewportChange); + return ( <> {/* Hide scrollbars cross-browser and enable momentum scroll for touch devices */} @@ -288,63 +289,12 @@ const ScrollAreaScrollbarScroll = React.forwardRef< >((props: ScopedProps, forwardedRef) => { const { forceMount, ...scrollbarProps } = props; const context = useScrollAreaContext(SCROLLBAR_NAME, props.__scopeScrollArea); - const isHorizontal = props.orientation === 'horizontal'; - const debounceScrollEnd = useDebounceCallback(() => send('SCROLL_END'), 100); - const [state, send] = useStateMachine('hidden', { - hidden: { - SCROLL: 'scrolling', - }, - scrolling: { - SCROLL_END: 'idle', - POINTER_ENTER: 'interacting', - }, - interacting: { - SCROLL: 'interacting', - POINTER_LEAVE: 'idle', - }, - idle: { - HIDE: 'hidden', - SCROLL: 'scrolling', - POINTER_ENTER: 'interacting', - }, - }); - - React.useEffect(() => { - if (state === 'idle') { - const hideTimer = window.setTimeout(() => send('HIDE'), context.scrollHideDelay); - return () => window.clearTimeout(hideTimer); - } - }, [state, context.scrollHideDelay, send]); - - React.useEffect(() => { - const viewport = context.viewport; - const scrollDirection = isHorizontal ? 'scrollLeft' : 'scrollTop'; - - if (viewport) { - let prevScrollPos = viewport[scrollDirection]; - const handleScroll = () => { - const scrollPos = viewport[scrollDirection]; - const hasScrollInDirectionChanged = prevScrollPos !== scrollPos; - if (hasScrollInDirectionChanged) { - send('SCROLL'); - debounceScrollEnd(); - } - prevScrollPos = scrollPos; - }; - viewport.addEventListener('scroll', handleScroll); - return () => viewport.removeEventListener('scroll', handleScroll); - } - }, [context.viewport, isHorizontal, send, debounceScrollEnd]); + const scrollAreaRef = React.useRef(null); + const composeRefs = useComposedRefs(forwardedRef, (node) => (scrollAreaRef.current = node)); return ( - - send('POINTER_ENTER'))} - onPointerLeave={composeEventHandlers(props.onPointerLeave, () => send('POINTER_LEAVE'))} - /> + + ); }); @@ -358,147 +308,66 @@ const ScrollAreaScrollbarAuto = React.forwardRef< ScrollAreaScrollbarAutoElement, ScrollAreaScrollbarAutoProps >((props: ScopedProps, forwardedRef) => { - const context = useScrollAreaContext(SCROLLBAR_NAME, props.__scopeScrollArea); const { forceMount, ...scrollbarProps } = props; + const context = useScrollAreaContext(SCROLLBAR_NAME, props.__scopeScrollArea); + const scrollAreaRef = React.useRef(null); + const composeRefs = useComposedRefs(forwardedRef, (node) => (scrollAreaRef.current = node)); const [visible, setVisible] = React.useState(false); - const isHorizontal = props.orientation === 'horizontal'; - const handleResize = useDebounceCallback(() => { - if (context.viewport) { - const isOverflowX = context.viewport.offsetWidth < context.viewport.scrollWidth; - const isOverflowY = context.viewport.offsetHeight < context.viewport.scrollHeight; - setVisible(isHorizontal ? isOverflowX : isOverflowY); + const scrollstartTimerRef = React.useRef(0); + + React.useEffect(() => { + const scrollArea = scrollAreaRef.current; + if (!scrollArea) return; + + const handleScrollStart = () => { + clearTimeout(scrollstartTimerRef.current); + setVisible(true); + }; + + const handleScrollEnd = () => { + scrollstartTimerRef.current = window.setTimeout(() => setVisible(false), 1000); + }; + + const viewport = scrollArea.querySelector('[data-radix-scroll-area-viewport]'); + if (viewport) { + viewport.addEventListener('scrollstart', handleScrollStart as EventListener, { passive: true } as EventListenerOptions); + viewport.addEventListener('scrollend', handleScrollEnd as EventListener, { passive: true } as EventListenerOptions); } - }, 10); - useResizeObserver(context.viewport, handleResize); - useResizeObserver(context.content, handleResize); + return () => { + clearTimeout(scrollstartTimerRef.current); + if (viewport) { + viewport.removeEventListener('scrollstart', handleScrollStart as EventListener); + viewport.removeEventListener('scrollend', handleScrollEnd as EventListener); + } + }; + }, []); return ( - + ); }); +/* -----------------------------------------------------------------------------------------------*/ +/* SCROLLBAR Y */ /* -----------------------------------------------------------------------------------------------*/ -type ScrollAreaScrollbarVisibleElement = ScrollAreaScrollbarAxisElement; -interface ScrollAreaScrollbarVisibleProps - extends Omit { +type ScrollAreaScrollbarYElement = ScrollAreaScrollbarAxisElement; +interface ScrollAreaScrollbarYProps extends ScrollAreaScrollbarAxisProps { orientation?: 'horizontal' | 'vertical'; } -const ScrollAreaScrollbarVisible = React.forwardRef< - ScrollAreaScrollbarVisibleElement, - ScrollAreaScrollbarVisibleProps ->((props: ScopedProps, forwardedRef) => { - const { orientation = 'vertical', ...scrollbarProps } = props; - const context = useScrollAreaContext(SCROLLBAR_NAME, props.__scopeScrollArea); - const thumbRef = React.useRef(null); - const pointerOffsetRef = React.useRef(0); - const [sizes, setSizes] = React.useState({ - content: 0, - viewport: 0, - scrollbar: { size: 0, paddingStart: 0, paddingEnd: 0 }, - }); - const thumbRatio = getThumbRatio(sizes.viewport, sizes.content); - - type UncommonProps = 'onThumbPositionChange' | 'onDragScroll' | 'onWheelScroll'; - const commonProps: Omit = { - ...scrollbarProps, - sizes, - onSizesChange: setSizes, - hasThumb: Boolean(thumbRatio > 0 && thumbRatio < 1), - onThumbChange: (thumb) => (thumbRef.current = thumb), - onThumbPointerUp: () => (pointerOffsetRef.current = 0), - onThumbPointerDown: (pointerPos) => (pointerOffsetRef.current = pointerPos), - }; - - function getScrollPosition(pointerPos: number, dir?: Direction) { - return getScrollPositionFromPointer(pointerPos, pointerOffsetRef.current, sizes, dir); - } - - if (orientation === 'horizontal') { - return ( - { - if (context.viewport && thumbRef.current) { - const scrollPos = context.viewport.scrollLeft; - const offset = getThumbOffsetFromScroll(scrollPos, sizes, context.dir); - thumbRef.current.style.transform = `translate3d(${offset}px, 0, 0)`; - } - }} - onWheelScroll={(scrollPos) => { - if (context.viewport) context.viewport.scrollLeft = scrollPos; - }} - onDragScroll={(pointerPos) => { - if (context.viewport) { - context.viewport.scrollLeft = getScrollPosition(pointerPos, context.dir); - } - }} - /> - ); - } - - if (orientation === 'vertical') { - return ( - { - if (context.viewport && thumbRef.current) { - const scrollPos = context.viewport.scrollTop; - const offset = getThumbOffsetFromScroll(scrollPos, sizes); - thumbRef.current.style.transform = `translate3d(0, ${offset}px, 0)`; - } - }} - onWheelScroll={(scrollPos) => { - if (context.viewport) context.viewport.scrollTop = scrollPos; - }} - onDragScroll={(pointerPos) => { - if (context.viewport) context.viewport.scrollTop = getScrollPosition(pointerPos); - }} - /> - ); - } - - return null; -}); - -/* -----------------------------------------------------------------------------------------------*/ - -type ScrollAreaScrollbarAxisPrivateProps = { - hasThumb: boolean; - sizes: Sizes; - onSizesChange(sizes: Sizes): void; - onThumbChange(thumb: ScrollAreaThumbElement | null): void; - onThumbPointerDown(pointerPos: number): void; - onThumbPointerUp(): void; - onThumbPositionChange(): void; - onWheelScroll(scrollPos: number): void; - onDragScroll(pointerPos: number): void; -}; - -type ScrollAreaScrollbarAxisElement = ScrollAreaScrollbarImplElement; -interface ScrollAreaScrollbarAxisProps - extends Omit, - ScrollAreaScrollbarAxisPrivateProps {} - -const ScrollAreaScrollbarX = React.forwardRef< - ScrollAreaScrollbarAxisElement, - ScrollAreaScrollbarAxisProps ->((props: ScopedProps, forwardedRef) => { +const ScrollAreaScrollbarY = React.forwardRef< + ScrollAreaScrollbarYElement, + ScrollAreaScrollbarYProps +>((props: ScopedProps, forwardedRef) => { const { sizes, onSizesChange, ...scrollbarProps } = props; const context = useScrollAreaContext(SCROLLBAR_NAME, props.__scopeScrollArea); const [computedStyle, setComputedStyle] = React.useState(); const ref = React.useRef(null); - const composeRefs = useComposedRefs(forwardedRef, ref, context.onScrollbarXChange); + const composeRefs = useComposedRefs(forwardedRef, ref, context.onScrollbarYChange); React.useEffect(() => { if (ref.current) setComputedStyle(getComputedStyle(ref.current)); @@ -506,22 +375,23 @@ const ScrollAreaScrollbarX = React.forwardRef< return ( props.onThumbPointerDown(pointerPos.x)} - onDragScroll={(pointerPos) => props.onDragScroll(pointerPos.x)} + onThumbPointerDown={(pointerPos) => props.onThumbPointerDown(pointerPos.y)} + onDragScroll={(pointerPos) => props.onDragScroll(pointerPos.y)} onWheelScroll={(event, maxScrollPos) => { if (context.viewport) { - const scrollPos = context.viewport.scrollLeft + event.deltaX; + const scrollPos = context.viewport.scrollTop + event.deltaY; props.onWheelScroll(scrollPos); // prevent window scroll when wheeling on scrollbar if (isScrollingWithinScrollbarBounds(scrollPos, maxScrollPos)) { @@ -532,12 +402,12 @@ const ScrollAreaScrollbarX = React.forwardRef< onResize={() => { if (ref.current && context.viewport && computedStyle) { onSizesChange({ - content: context.viewport.scrollWidth, - viewport: context.viewport.offsetWidth, + content: context.viewport.scrollHeight, + viewport: context.viewport.offsetHeight, scrollbar: { - size: ref.current.clientWidth, - paddingStart: toInt(computedStyle.paddingLeft), - paddingEnd: toInt(computedStyle.paddingRight), + size: ref.current.clientHeight, + paddingStart: toInt(computedStyle.paddingTop), + paddingEnd: toInt(computedStyle.paddingBottom), }, }); } @@ -546,15 +416,22 @@ const ScrollAreaScrollbarX = React.forwardRef< ); }); -const ScrollAreaScrollbarY = React.forwardRef< - ScrollAreaScrollbarAxisElement, - ScrollAreaScrollbarAxisProps ->((props: ScopedProps, forwardedRef) => { +/* -----------------------------------------------------------------------------------------------*/ +/* SCROLLBAR X */ +/* -----------------------------------------------------------------------------------------------*/ + +type ScrollAreaScrollbarXElement = ScrollAreaScrollbarAxisElement; +interface ScrollAreaScrollbarXProps extends ScrollAreaScrollbarAxisProps {} + +const ScrollAreaScrollbarX = React.forwardRef< + ScrollAreaScrollbarXElement, + ScrollAreaScrollbarXProps +>((props: ScopedProps, forwardedRef) => { const { sizes, onSizesChange, ...scrollbarProps } = props; const context = useScrollAreaContext(SCROLLBAR_NAME, props.__scopeScrollArea); const [computedStyle, setComputedStyle] = React.useState(); const ref = React.useRef(null); - const composeRefs = useComposedRefs(forwardedRef, ref, context.onScrollbarYChange); + const composeRefs = useComposedRefs(forwardedRef, ref, context.onScrollbarXChange); React.useEffect(() => { if (ref.current) setComputedStyle(getComputedStyle(ref.current)); @@ -562,23 +439,22 @@ const ScrollAreaScrollbarY = React.forwardRef< return ( props.onThumbPointerDown(pointerPos.y)} - onDragScroll={(pointerPos) => props.onDragScroll(pointerPos.y)} + onThumbPointerDown={(pointerPos) => props.onThumbPointerDown(pointerPos.x)} + onDragScroll={(pointerPos) => props.onDragScroll(pointerPos.x)} onWheelScroll={(event, maxScrollPos) => { if (context.viewport) { - const scrollPos = context.viewport.scrollTop + event.deltaY; + const scrollPos = context.viewport.scrollLeft + event.deltaX; props.onWheelScroll(scrollPos); // prevent window scroll when wheeling on scrollbar if (isScrollingWithinScrollbarBounds(scrollPos, maxScrollPos)) { @@ -589,12 +465,12 @@ const ScrollAreaScrollbarY = React.forwardRef< onResize={() => { if (ref.current && context.viewport && computedStyle) { onSizesChange({ - content: context.viewport.scrollHeight, - viewport: context.viewport.offsetHeight, + content: context.viewport.scrollWidth, + viewport: context.viewport.offsetWidth, scrollbar: { - size: ref.current.clientHeight, - paddingStart: toInt(computedStyle.paddingTop), - paddingEnd: toInt(computedStyle.paddingBottom), + size: ref.current.clientWidth, + paddingStart: toInt(computedStyle.paddingLeft), + paddingEnd: toInt(computedStyle.paddingRight), }, }); } @@ -603,19 +479,28 @@ const ScrollAreaScrollbarY = React.forwardRef< ); }); +/* -----------------------------------------------------------------------------------------------*/ +/* SCROLLBAR IMPL */ /* -----------------------------------------------------------------------------------------------*/ -type ScrollbarContext = { - hasThumb: boolean; +type ScrollbarContextValue = { scrollbar: ScrollAreaScrollbarElement | null; - onThumbChange(thumb: ScrollAreaThumbElement | null): void; - onThumbPointerUp(): void; - onThumbPointerDown(pointerPos: { x: number; y: number }): void; - onThumbPositionChange(): void; + hasThumb: boolean; + scrollArea: ScrollAreaElement | null; + onThumbChange: ScrollbarContext['onThumbChange']; + onThumbPointerUp: ScrollbarContext['onThumbPointerUp']; + onThumbPointerDown: ScrollbarContext['onThumbPointerDown']; + onThumbPositionChange: ScrollbarContext['onThumbPositionChange']; }; -const [ScrollbarProvider, useScrollbarContext] = - createScrollAreaContext(SCROLLBAR_NAME); +const SCROLLBAR_CONTEXT_NAME = 'ScrollAreaScrollbarContext'; +const [createScrollbarContext, createScrollbarScope] = createContextScope(SCROLLBAR_CONTEXT_NAME, [ + createScrollAreaScope, +]); + +const [ScrollbarProvider, useScrollbarContext] = createScrollbarContext( + SCROLLBAR_CONTEXT_NAME, +); type ScrollAreaScrollbarImplElement = React.ComponentRef; type ScrollAreaScrollbarImplPrivateProps = { @@ -761,7 +646,7 @@ const ScrollAreaThumb = React.forwardRef; +type ScrollAreaThumbElement = React.ComponentRef; interface ScrollAreaThumbImplProps extends PrimitiveDivProps {} const ScrollAreaThumbImpl = React.forwardRef( @@ -816,6 +701,20 @@ const ScrollAreaThumbImpl = React.forwardRef { + // FIX for issue #896: If the user is touching the viewport (content area) + // and their finger moves to the thumb, prevent thumb interaction to avoid + // conflicting scroll behaviors. This is especially important on mobile where + // the thumb's hit area can be larger due to pseudo-elements. + const pointerId = event.pointerId; + const viewport = scrollAreaContext.viewport; + + if (viewport && viewport.hasPointerCapture(pointerId)) { + // User is already touching/scrolling the content area + // Prevent thumb from taking over the interaction + event.stopPropagation(); + return; + } + const thumb = event.target as HTMLElement; const thumbRect = thumb.getBoundingClientRect(); const x = event.clientX - thumbRect.left; @@ -859,76 +758,54 @@ const ScrollAreaCornerImpl = React.forwardRef< ScrollAreaCornerImplElement, ScrollAreaCornerImplProps >((props: ScopedProps, forwardedRef) => { - const { __scopeScrollArea, ...cornerProps } = props; - const context = useScrollAreaContext(CORNER_NAME, __scopeScrollArea); - const [width, setWidth] = React.useState(0); - const [height, setHeight] = React.useState(0); - const hasSize = Boolean(width && height); - - useResizeObserver(context.scrollbarX, () => { - const height = context.scrollbarX?.offsetHeight || 0; - context.onCornerHeightChange(height); - setHeight(height); - }); - - useResizeObserver(context.scrollbarY, () => { - const width = context.scrollbarY?.offsetWidth || 0; - context.onCornerWidthChange(width); - setWidth(width); - }); - - return hasSize ? ( + const context = useScrollAreaContext(CORNER_NAME, props.__scopeScrollArea); + return ( - ) : null; + ); }); +/* -----------------------------------------------------------------------------------------------*/ +/* UTILITIES */ /* -----------------------------------------------------------------------------------------------*/ -function toInt(value?: string) { - return value ? parseInt(value, 10) : 0; +function getThumbSize(sizes: Sizes) { + const { viewport, content, scrollbar } = sizes; + const scrollbarPadding = scrollbar.paddingStart + scrollbar.paddingEnd; + const scrollbarSize = scrollbar.size - scrollbarPadding; + let thumbSize; + // hide thumb if content fits in viewport + if (content <= viewport) return 0; + thumbSize = (viewport / content) * scrollbarSize; + return clamp(thumbSize, 8, scrollbarSize - 4); } -function getThumbRatio(viewportSize: number, contentSize: number) { - const ratio = viewportSize / contentSize; - return isNaN(ratio) ? 0 : ratio; +function getThumbRatio(viewport: number, content: number) { + const scrollbarRatio = viewport / content; + const thumbRatio = getThumbSize({ viewport, content, scrollbar: { size: 100, paddingStart: 0, paddingEnd: 0 } }) / 100; + return scrollbarRatio > 0 && scrollbarRatio < 1 ? scrollbarRatio : 0; } -function getThumbSize(sizes: Sizes) { - const ratio = getThumbRatio(sizes.viewport, sizes.content); +function getScrollPositionFromPointer(pointerPos: number, pointerOffset: number, sizes: Sizes, dir?: Direction) { + const thumbSize = getThumbSize(sizes); const scrollbarPadding = sizes.scrollbar.paddingStart + sizes.scrollbar.paddingEnd; - const thumbSize = (sizes.scrollbar.size - scrollbarPadding) * ratio; - // minimum of 18 matches macOS minimum - return Math.max(thumbSize, 18); -} - -function getScrollPositionFromPointer( - pointerPos: number, - pointerOffset: number, - sizes: Sizes, - dir: Direction = 'ltr', -) { - const thumbSizePx = getThumbSize(sizes); - const thumbCenter = thumbSizePx / 2; - const offset = pointerOffset || thumbCenter; - const thumbOffsetFromEnd = thumbSizePx - offset; - const minPointerPos = sizes.scrollbar.paddingStart + offset; - const maxPointerPos = sizes.scrollbar.size - sizes.scrollbar.paddingEnd - thumbOffsetFromEnd; + const scrollbar = sizes.scrollbar.size - scrollbarPadding; + const maxThumbPos = scrollbar - thumbSize; const maxScrollPos = sizes.content - sizes.viewport; - const scrollRange = dir === 'ltr' ? [0, maxScrollPos] : [maxScrollPos * -1, 0]; - const interpolate = linearScale([minPointerPos, maxPointerPos], scrollRange as [number, number]); - return interpolate(pointerPos); + const thumbPos = pointerPos - pointerOffset; + const normalizedThumbPos = clamp(thumbPos, 0, maxThumbPos); + return (normalizedThumbPos / maxThumbPos) * maxScrollPos; } function getThumbOffsetFromScroll(scrollPos: number, sizes: Sizes, dir: Direction = 'ltr') { @@ -1007,6 +884,10 @@ function useResizeObserver(element: HTMLElement | null, onResize: () => void) { }, [element, handleResize]); } +function toInt(value: string) { + return value ? parseInt(value, 10) : 0; +} + /* -----------------------------------------------------------------------------------------------*/ const Root = ScrollArea; @@ -1017,6 +898,7 @@ const Corner = ScrollAreaCorner; export { createScrollAreaScope, + createScrollbarScope, // ScrollArea, ScrollAreaViewport,