Skip to content

Commit 16f34b1

Browse files
authored
[scroll area] Fix overflow edge rounding (#3888)
1 parent cdd9a74 commit 16f34b1

File tree

4 files changed

+113
-16
lines changed

4 files changed

+113
-16
lines changed

packages/react/src/scroll-area/root/ScrollAreaRoot.test.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,43 @@ describe('<ScrollArea.Root />', () => {
321321
expect(hScrollbar).not.to.have.attribute('data-overflow-x-end');
322322
});
323323

324+
it('treats near-edge scroll offsets as fully scrolled', async () => {
325+
await render(
326+
<ScrollArea.Root data-testid="root" style={{ width: VIEWPORT_SIZE, height: VIEWPORT_SIZE }}>
327+
<ScrollArea.Viewport data-testid="viewport" style={{ width: '100%', height: '100%' }}>
328+
<ScrollArea.Content data-testid="content">
329+
<div style={{ width: SCROLLABLE_CONTENT_SIZE, height: SCROLLABLE_CONTENT_SIZE }} />
330+
</ScrollArea.Content>
331+
</ScrollArea.Viewport>
332+
<ScrollArea.Scrollbar orientation="vertical" data-testid="scrollbar-vertical">
333+
<ScrollArea.Thumb />
334+
</ScrollArea.Scrollbar>
335+
<ScrollArea.Scrollbar orientation="horizontal" data-testid="scrollbar-horizontal">
336+
<ScrollArea.Thumb />
337+
</ScrollArea.Scrollbar>
338+
</ScrollArea.Root>,
339+
);
340+
341+
const root = screen.getByTestId('root');
342+
const viewport = screen.getByTestId('viewport');
343+
344+
const maxScrollTop = viewport.scrollHeight - viewport.clientHeight;
345+
const maxScrollLeft = viewport.scrollWidth - viewport.clientWidth;
346+
347+
fireEvent.scroll(viewport, {
348+
target: {
349+
scrollTop: maxScrollTop - 0.5,
350+
scrollLeft: maxScrollLeft - 0.5,
351+
},
352+
});
353+
await flushMicrotasks();
354+
355+
expect(root).to.have.attribute('data-overflow-y-start');
356+
expect(root).not.to.have.attribute('data-overflow-y-end');
357+
expect(root).to.have.attribute('data-overflow-x-start');
358+
expect(root).not.to.have.attribute('data-overflow-x-end');
359+
});
360+
324361
it('respects overflowEdgeThreshold and exposes scroll metrics', async () => {
325362
await render(
326363
<ScrollArea.Root
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { normalizeScrollOffset, SCROLL_EDGE_TOLERANCE_PX } from './scrollEdges';
3+
4+
describe('normalizeScrollOffset', () => {
5+
it('returns 0 when max is non-positive', () => {
6+
expect(normalizeScrollOffset(10, 0)).toBe(0);
7+
expect(normalizeScrollOffset(10, -5)).toBe(0);
8+
});
9+
10+
it('snaps to the start edge within the tolerance', () => {
11+
expect(normalizeScrollOffset(0.5, 10)).toBe(0);
12+
});
13+
14+
it('snaps to the end edge within the tolerance', () => {
15+
expect(normalizeScrollOffset(9.5, 10)).toBe(10);
16+
});
17+
18+
it('keeps values away from edges unchanged', () => {
19+
expect(normalizeScrollOffset(5, 10)).toBe(5);
20+
});
21+
22+
it('chooses the closest edge when tolerances overlap', () => {
23+
const max = SCROLL_EDGE_TOLERANCE_PX;
24+
25+
expect(normalizeScrollOffset(0, max)).toBe(0);
26+
expect(normalizeScrollOffset(max, max)).toBe(max);
27+
expect(normalizeScrollOffset(max * 0.4, max)).toBe(0);
28+
expect(normalizeScrollOffset(max * 0.6, max)).toBe(max);
29+
});
30+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { clamp } from '../../utils/clamp';
2+
3+
export const SCROLL_EDGE_TOLERANCE_PX = 1;
4+
5+
export function normalizeScrollOffset(value: number, max: number) {
6+
if (max <= 0) {
7+
return 0;
8+
}
9+
10+
const clamped = clamp(value, 0, max);
11+
const startDistance = clamped;
12+
const endDistance = max - clamped;
13+
const withinStartTolerance = startDistance <= SCROLL_EDGE_TOLERANCE_PX;
14+
const withinEndTolerance = endDistance <= SCROLL_EDGE_TOLERANCE_PX;
15+
16+
if (withinStartTolerance && withinEndTolerance) {
17+
return startDistance <= endDistance ? 0 : max;
18+
}
19+
20+
if (withinStartTolerance) {
21+
return 0;
22+
}
23+
24+
if (withinEndTolerance) {
25+
return max;
26+
}
27+
28+
return clamped;
29+
}

packages/react/src/scroll-area/viewport/ScrollAreaViewport.tsx

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { onVisible } from '../utils/onVisible';
1717
import { scrollAreaStateAttributesMapping } from '../root/stateAttributes';
1818
import type { ScrollAreaRoot } from '../root/ScrollAreaRoot';
1919
import { ScrollAreaViewportCssVars } from './ScrollAreaViewportCssVars';
20+
import { normalizeScrollOffset } from '../utils/scrollEdges';
2021

2122
// Module-level flag to ensure we only register the CSS properties once,
2223
// regardless of how many Scroll Area components are mounted.
@@ -137,15 +138,20 @@ export const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport(
137138
let scrollLeftFromStart = 0;
138139
let scrollLeftFromEnd = 0;
139140
if (!scrollbarXHidden) {
141+
let rawScrollLeftFromStart = 0;
140142
if (direction === 'rtl') {
141-
scrollLeftFromStart = clamp(-scrollLeft, 0, maxScrollLeft);
143+
rawScrollLeftFromStart = clamp(-scrollLeft, 0, maxScrollLeft);
142144
} else {
143-
scrollLeftFromStart = clamp(scrollLeft, 0, maxScrollLeft);
145+
rawScrollLeftFromStart = clamp(scrollLeft, 0, maxScrollLeft);
144146
}
147+
scrollLeftFromStart = normalizeScrollOffset(rawScrollLeftFromStart, maxScrollLeft);
145148
scrollLeftFromEnd = maxScrollLeft - scrollLeftFromStart;
146149
}
147150

148-
const scrollTopFromStart = !scrollbarYHidden ? clamp(scrollTop, 0, maxScrollTop) : 0;
151+
const rawScrollTopFromStart = !scrollbarYHidden ? clamp(scrollTop, 0, maxScrollTop) : 0;
152+
const scrollTopFromStart = !scrollbarYHidden
153+
? normalizeScrollOffset(rawScrollTopFromStart, maxScrollTop)
154+
: 0;
149155
const scrollTopFromEnd = !scrollbarYHidden ? maxScrollTop - scrollTopFromStart : 0;
150156
const nextWidth = scrollbarXHidden ? 0 : viewportWidth;
151157
const nextHeight = scrollbarYHidden ? 0 : viewportHeight;
@@ -223,16 +229,11 @@ export const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport(
223229
thumbXEl.style.transform = `translate3d(${thumbOffsetX}px,0,0)`;
224230
}
225231

226-
const clampedScrollLeftStart = clamp(scrollLeftFromStart, 0, maxScrollLeft);
227-
const clampedScrollLeftEnd = clamp(scrollLeftFromEnd, 0, maxScrollLeft);
228-
const clampedScrollTopStart = clamp(scrollTopFromStart, 0, maxScrollTop);
229-
const clampedScrollTopEnd = clamp(scrollTopFromEnd, 0, maxScrollTop);
230-
231232
const overflowMetricsPx: Array<[ScrollAreaViewportCssVars, number]> = [
232-
[ScrollAreaViewportCssVars.scrollAreaOverflowXStart, clampedScrollLeftStart],
233-
[ScrollAreaViewportCssVars.scrollAreaOverflowXEnd, clampedScrollLeftEnd],
234-
[ScrollAreaViewportCssVars.scrollAreaOverflowYStart, clampedScrollTopStart],
235-
[ScrollAreaViewportCssVars.scrollAreaOverflowYEnd, clampedScrollTopEnd],
233+
[ScrollAreaViewportCssVars.scrollAreaOverflowXStart, scrollLeftFromStart],
234+
[ScrollAreaViewportCssVars.scrollAreaOverflowXEnd, scrollLeftFromEnd],
235+
[ScrollAreaViewportCssVars.scrollAreaOverflowYStart, scrollTopFromStart],
236+
[ScrollAreaViewportCssVars.scrollAreaOverflowYEnd, scrollTopFromEnd],
236237
];
237238

238239
for (const [cssVar, value] of overflowMetricsPx) {
@@ -266,10 +267,10 @@ export const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport(
266267
});
267268

268269
const nextOverflowEdges = {
269-
xStart: !scrollbarXHidden && clampedScrollLeftStart > overflowEdgeThreshold.xStart,
270-
xEnd: !scrollbarXHidden && clampedScrollLeftEnd > overflowEdgeThreshold.xEnd,
271-
yStart: !scrollbarYHidden && clampedScrollTopStart > overflowEdgeThreshold.yStart,
272-
yEnd: !scrollbarYHidden && clampedScrollTopEnd > overflowEdgeThreshold.yEnd,
270+
xStart: !scrollbarXHidden && scrollLeftFromStart > overflowEdgeThreshold.xStart,
271+
xEnd: !scrollbarXHidden && scrollLeftFromEnd > overflowEdgeThreshold.xEnd,
272+
yStart: !scrollbarYHidden && scrollTopFromStart > overflowEdgeThreshold.yStart,
273+
yEnd: !scrollbarYHidden && scrollTopFromEnd > overflowEdgeThreshold.yEnd,
273274
};
274275

275276
setOverflowEdges((prev) => {

0 commit comments

Comments
 (0)