Skip to content

Commit

Permalink
[ScrollArea] Differentiate x/y orientation data-scrolling
Browse files Browse the repository at this point in the history
  • Loading branch information
atomiks committed Dec 20, 2024
1 parent 4941275 commit 2021879
Show file tree
Hide file tree
Showing 6 changed files with 101 additions and 33 deletions.
8 changes: 5 additions & 3 deletions packages/react/src/scroll-area/root/ScrollAreaRootContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ export interface ScrollAreaRootContext {
touchModality: boolean;
hovering: boolean;
setHovering: React.Dispatch<React.SetStateAction<boolean>>;
scrolling: boolean;
setScrolling: React.Dispatch<React.SetStateAction<boolean>>;
scrollingX: boolean;
setScrollingX: React.Dispatch<React.SetStateAction<boolean>>;
scrollingY: boolean;
setScrollingY: React.Dispatch<React.SetStateAction<boolean>>;
viewportRef: React.RefObject<HTMLDivElement | null>;
scrollbarYRef: React.RefObject<HTMLDivElement | null>;
thumbYRef: React.RefObject<HTMLDivElement | null>;
Expand All @@ -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;
Expand Down
68 changes: 48 additions & 20 deletions packages/react/src/scroll-area/root/useScrollAreaRoot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Size>({ width: 0, height: 0 });
const [thumbSize, setThumbSize] = React.useState<Size>({ width: 0, height: 0 });
const [touchModality, setTouchModality] = React.useState(false);
Expand All @@ -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,
Expand All @@ -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) => {
Expand Down Expand Up @@ -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);
}

Expand All @@ -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);
}
}
Expand Down Expand Up @@ -201,8 +224,10 @@ export function useScrollAreaRoot(params: useScrollAreaRoot.Parameters) {
setThumbSize,
touchModality,
cornerRef,
scrolling,
setScrolling,
scrollingX,
setScrollingX,
scrollingY,
setScrollingY,
hovering,
setHovering,
viewportRef,
Expand All @@ -224,7 +249,10 @@ export function useScrollAreaRoot(params: useScrollAreaRoot.Parameters) {
thumbSize,
touchModality,
cornerRef,
scrolling,
scrollingX,
setScrollingX,
scrollingY,
setScrollingY,
hovering,
setHovering,
viewportRef,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ describe('<ScrollArea.Scrollbar />', () => {
},
}));

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(
<ScrollArea.Root style={{ width: 200, height: 200 }}>
<ScrollArea.Viewport data-testid="viewport" style={{ width: '100%', height: '100%' }}>
Expand All @@ -32,19 +32,40 @@ describe('<ScrollArea.Scrollbar />', () => {

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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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({
Expand Down
10 changes: 8 additions & 2 deletions packages/react/src/scroll-area/thumb/ScrollAreaThumb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ const ScrollAreaThumb = React.forwardRef(function ScrollAreaThumb(
handlePointerDown,
handlePointerMove,
handlePointerUp,
setScrolling,
setScrollingX,
setScrollingY,
} = useScrollAreaRootContext();

const { orientation } = useScrollAreaScrollbarContext();
Expand All @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: (
<div
Expand Down

0 comments on commit 2021879

Please sign in to comment.