diff --git a/packages/react/src/scroll-area/root/ScrollAreaRootContext.ts b/packages/react/src/scroll-area/root/ScrollAreaRootContext.ts index f9d26a558f..2e7fb8f6ad 100644 --- a/packages/react/src/scroll-area/root/ScrollAreaRootContext.ts +++ b/packages/react/src/scroll-area/root/ScrollAreaRootContext.ts @@ -9,8 +9,10 @@ export interface ScrollAreaRootContext { touchModality: boolean; hovering: boolean; setHovering: React.Dispatch>; - scrolling: boolean; - setScrolling: React.Dispatch>; + scrollingX: boolean; + setScrollingX: React.Dispatch>; + scrollingY: boolean; + setScrollingY: React.Dispatch>; viewportRef: React.RefObject; scrollbarYRef: React.RefObject; thumbYRef: React.RefObject; @@ -20,7 +22,7 @@ export interface ScrollAreaRootContext { handlePointerDown: (event: React.PointerEvent) => void; handlePointerMove: (event: React.PointerEvent) => void; handlePointerUp: (event: React.PointerEvent) => void; - handleScroll: () => void; + handleScroll: (scrollPosition: { x: number; y: number }) => void; rootId: string | undefined; hiddenState: { scrollbarYHidden: boolean; diff --git a/packages/react/src/scroll-area/root/useScrollAreaRoot.ts b/packages/react/src/scroll-area/root/useScrollAreaRoot.ts index e1edda89b3..05b818fdb8 100644 --- a/packages/react/src/scroll-area/root/useScrollAreaRoot.ts +++ b/packages/react/src/scroll-area/root/useScrollAreaRoot.ts @@ -16,7 +16,8 @@ export function useScrollAreaRoot(params: useScrollAreaRoot.Parameters) { const { dir: dirParam } = params; const [hovering, setHovering] = React.useState(false); - const [scrolling, setScrolling] = React.useState(false); + const [scrollingX, setScrollingX] = React.useState(false); + const [scrollingY, setScrollingY] = React.useState(false); const [cornerSize, setCornerSize] = React.useState({ width: 0, height: 0 }); const [thumbSize, setThumbSize] = React.useState({ width: 0, height: 0 }); const [touchModality, setTouchModality] = React.useState(false); @@ -36,7 +37,9 @@ export function useScrollAreaRoot(params: useScrollAreaRoot.Parameters) { const startScrollTopRef = React.useRef(0); const startScrollLeftRef = React.useRef(0); const currentOrientationRef = React.useRef<'vertical' | 'horizontal'>('vertical'); - const timeoutRef = React.useRef(-1); + const scrollYTimeoutRef = React.useRef(-1); + const scrollXTimeoutRef = React.useRef(-1); + const scrollPositionRef = React.useRef({ x: 0, y: 0 }); const [hiddenState, setHiddenState] = React.useState({ scrollbarYHidden: false, @@ -55,17 +58,33 @@ export function useScrollAreaRoot(params: useScrollAreaRoot.Parameters) { React.useEffect(() => { return () => { - window.clearTimeout(timeoutRef.current); + window.clearTimeout(scrollYTimeoutRef.current); + window.clearTimeout(scrollXTimeoutRef.current); }; }, []); - const handleScroll = useEventCallback(() => { - setScrolling(true); + const handleScroll = useEventCallback((scrollPosition: { x: number; y: number }) => { + const offsetX = scrollPosition.x - scrollPositionRef.current.x; + const offsetY = scrollPosition.y - scrollPositionRef.current.y; + scrollPositionRef.current = scrollPosition; - window.clearTimeout(timeoutRef.current); - timeoutRef.current = window.setTimeout(() => { - setScrolling(false); - }, SCROLL_TIMEOUT); + if (offsetY !== 0) { + setScrollingY(true); + + window.clearTimeout(scrollYTimeoutRef.current); + scrollYTimeoutRef.current = window.setTimeout(() => { + setScrollingY(false); + }, SCROLL_TIMEOUT); + } + + if (offsetX !== 0) { + setScrollingX(true); + + window.clearTimeout(scrollXTimeoutRef.current); + scrollXTimeoutRef.current = window.setTimeout(() => { + setScrollingX(false); + }, SCROLL_TIMEOUT); + } }); const handlePointerDown = useEventCallback((event: React.PointerEvent) => { @@ -116,10 +135,12 @@ export function useScrollAreaRoot(params: useScrollAreaRoot.Parameters) { viewportRef.current.scrollTop = startScrollTopRef.current + scrollRatioY * (scrollableContentHeight - viewportHeight); event.preventDefault(); - setScrolling(true); - window.clearTimeout(timeoutRef.current); - timeoutRef.current = window.setTimeout(() => { - setScrolling(false); + + setScrollingY(true); + + window.clearTimeout(scrollYTimeoutRef.current); + scrollYTimeoutRef.current = window.setTimeout(() => { + setScrollingY(false); }, SCROLL_TIMEOUT); } @@ -137,10 +158,12 @@ export function useScrollAreaRoot(params: useScrollAreaRoot.Parameters) { viewportRef.current.scrollLeft = startScrollLeftRef.current + scrollRatioX * (scrollableContentWidth - viewportWidth); event.preventDefault(); - setScrolling(true); - window.clearTimeout(timeoutRef.current); - timeoutRef.current = window.setTimeout(() => { - setScrolling(false); + + setScrollingX(true); + + window.clearTimeout(scrollXTimeoutRef.current); + scrollXTimeoutRef.current = window.setTimeout(() => { + setScrollingX(false); }, SCROLL_TIMEOUT); } } @@ -201,8 +224,10 @@ export function useScrollAreaRoot(params: useScrollAreaRoot.Parameters) { setThumbSize, touchModality, cornerRef, - scrolling, - setScrolling, + scrollingX, + setScrollingX, + scrollingY, + setScrollingY, hovering, setHovering, viewportRef, @@ -224,7 +249,10 @@ export function useScrollAreaRoot(params: useScrollAreaRoot.Parameters) { thumbSize, touchModality, cornerRef, - scrolling, + scrollingX, + setScrollingX, + scrollingY, + setScrollingY, hovering, setHovering, viewportRef, diff --git a/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbar.test.tsx b/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbar.test.tsx index fde46c87ff..03a497a1dc 100644 --- a/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbar.test.tsx +++ b/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbar.test.tsx @@ -18,7 +18,7 @@ describe('', () => { }, })); - it('adds [data-scrolling] attribute when viewport is scrolled', async () => { + it('adds [data-scrolling] attribute when viewport is scrolled in the correct direction', async () => { await render( @@ -32,19 +32,40 @@ describe('', () => { const verticalScrollbar = screen.getByTestId('vertical'); const horizontalScrollbar = screen.getByTestId('horizontal'); + const viewport = screen.getByTestId('viewport'); expect(verticalScrollbar).not.to.have.attribute('data-scrolling'); expect(horizontalScrollbar).not.to.have.attribute('data-scrolling'); - fireEvent.scroll(screen.getByTestId('viewport')); + fireEvent.scroll(viewport, { + target: { + scrollTop: 1, + }, + }); expect(verticalScrollbar).to.have.attribute('data-scrolling', ''); - expect(horizontalScrollbar).to.have.attribute('data-scrolling', ''); + expect(horizontalScrollbar).not.to.have.attribute('data-scrolling', ''); clock.tick(SCROLL_TIMEOUT - 1); expect(verticalScrollbar).to.have.attribute('data-scrolling', ''); - expect(horizontalScrollbar).to.have.attribute('data-scrolling', ''); + expect(horizontalScrollbar).not.to.have.attribute('data-scrolling', ''); + + fireEvent.scroll(viewport, { + target: { + scrollLeft: 1, + }, + }); + + clock.tick(1); // vertical just finished + + expect(verticalScrollbar).not.to.have.attribute('data-scrolling'); + expect(horizontalScrollbar).to.have.attribute('data-scrolling'); + + clock.tick(SCROLL_TIMEOUT - 2); // already ticked 1ms above + + expect(verticalScrollbar).not.to.have.attribute('data-scrolling'); + expect(horizontalScrollbar).to.have.attribute('data-scrolling'); clock.tick(1); diff --git a/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbar.tsx b/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbar.tsx index cc217e55fd..0f2d67131f 100644 --- a/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbar.tsx +++ b/packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbar.tsx @@ -20,7 +20,7 @@ const ScrollAreaScrollbar = React.forwardRef(function ScrollAreaScrollbar( ) { const { render, className, orientation = 'vertical', keepMounted = false, ...otherProps } = props; - const { hovering, scrolling, hiddenState, scrollbarYRef, scrollbarXRef } = + const { hovering, scrollingX, scrollingY, hiddenState, scrollbarYRef, scrollbarXRef } = useScrollAreaRootContext(); const mergedRef = useForkRef( @@ -31,10 +31,13 @@ const ScrollAreaScrollbar = React.forwardRef(function ScrollAreaScrollbar( const state: ScrollAreaScrollbar.State = React.useMemo( () => ({ hovering, - scrolling, + scrolling: { + horizontal: scrollingX, + vertical: scrollingY, + }[orientation], orientation, }), - [hovering, scrolling, orientation], + [hovering, scrollingX, scrollingY, orientation], ); const { getScrollbarProps } = useScrollAreaScrollbar({ diff --git a/packages/react/src/scroll-area/thumb/ScrollAreaThumb.tsx b/packages/react/src/scroll-area/thumb/ScrollAreaThumb.tsx index c87d8189cc..ad7c933faa 100644 --- a/packages/react/src/scroll-area/thumb/ScrollAreaThumb.tsx +++ b/packages/react/src/scroll-area/thumb/ScrollAreaThumb.tsx @@ -27,7 +27,8 @@ const ScrollAreaThumb = React.forwardRef(function ScrollAreaThumb( handlePointerDown, handlePointerMove, handlePointerUp, - setScrolling, + setScrollingX, + setScrollingY, } = useScrollAreaRootContext(); const { orientation } = useScrollAreaScrollbarContext(); @@ -45,7 +46,12 @@ const ScrollAreaThumb = React.forwardRef(function ScrollAreaThumb( onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp(event) { - setScrolling(false); + if (orientation === 'vertical') { + setScrollingY(false); + } + if (orientation === 'horizontal') { + setScrollingX(false); + } handlePointerUp(event); }, style: { diff --git a/packages/react/src/scroll-area/viewport/useScrollAreaViewport.tsx b/packages/react/src/scroll-area/viewport/useScrollAreaViewport.tsx index ed3f242e12..8d8e5be7f8 100644 --- a/packages/react/src/scroll-area/viewport/useScrollAreaViewport.tsx +++ b/packages/react/src/scroll-area/viewport/useScrollAreaViewport.tsx @@ -184,8 +184,16 @@ export function useScrollAreaViewport(params: useScrollAreaViewport.Parameters) overflow: 'scroll', }, onScroll() { + if (!viewportRef.current) { + return; + } + computeThumb(); - handleScroll(); + + handleScroll({ + x: viewportRef.current.scrollLeft, + y: viewportRef.current.scrollTop, + }); }, children: (