Skip to content

Commit 591d1f8

Browse files
fix: scrollIntoView should respect scroll-margin (#8715)
* fix: scrollIntoView should respect scroll-margin * chore: formatting * feat: add scroll-margin story * feat: add smooth scroll story --------- Co-authored-by: Robert Snow <rsnow@adobe.com>
1 parent 778bf5f commit 591d1f8

File tree

2 files changed

+87
-13
lines changed

2 files changed

+87
-13
lines changed

packages/@react-aria/utils/src/scrollIntoView.ts

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
*/
1212

1313
import {getScrollParents} from './getScrollParents';
14+
import {isChrome} from './platform';
1415

1516
interface ScrollIntoViewportOpts {
1617
/** The optional containing element of the target to be centered in the viewport. */
@@ -40,32 +41,64 @@ export function scrollIntoView(scrollView: HTMLElement, element: HTMLElement): v
4041
scrollPaddingLeft
4142
} = getComputedStyle(scrollView);
4243

44+
// Account for scroll margin of the element
45+
let {
46+
scrollMarginTop,
47+
scrollMarginRight,
48+
scrollMarginBottom,
49+
scrollMarginLeft
50+
} = getComputedStyle(element);
51+
4352
let borderAdjustedX = x + parseInt(borderLeftWidth, 10);
4453
let borderAdjustedY = y + parseInt(borderTopWidth, 10);
4554
// Ignore end/bottom border via clientHeight/Width instead of offsetHeight/Width
4655
let maxX = borderAdjustedX + scrollView.clientWidth;
4756
let maxY = borderAdjustedY + scrollView.clientHeight;
4857

49-
// Get scroll padding values as pixels - defaults to 0 if no scroll padding
58+
// Get scroll padding / margin values as pixels - defaults to 0 if no scroll padding / margin
5059
// is used.
5160
let scrollPaddingTopNumber = parseInt(scrollPaddingTop, 10) || 0;
5261
let scrollPaddingBottomNumber = parseInt(scrollPaddingBottom, 10) || 0;
5362
let scrollPaddingRightNumber = parseInt(scrollPaddingRight, 10) || 0;
5463
let scrollPaddingLeftNumber = parseInt(scrollPaddingLeft, 10) || 0;
64+
let scrollMarginTopNumber = parseInt(scrollMarginTop, 10) || 0;
65+
let scrollMarginBottomNumber = parseInt(scrollMarginBottom, 10) || 0;
66+
let scrollMarginRightNumber = parseInt(scrollMarginRight, 10) || 0;
67+
let scrollMarginLeftNumber = parseInt(scrollMarginLeft, 10) || 0;
68+
69+
let targetLeft = offsetX - scrollMarginLeftNumber;
70+
let targetRight = offsetX + width + scrollMarginRightNumber;
71+
let targetTop = offsetY - scrollMarginTopNumber;
72+
let targetBottom = offsetY + height + scrollMarginBottomNumber;
5573

56-
if (offsetX <= x + scrollPaddingLeftNumber) {
57-
x = offsetX - parseInt(borderLeftWidth, 10) - scrollPaddingLeftNumber;
58-
} else if (offsetX + width > maxX - scrollPaddingRightNumber) {
59-
x += offsetX + width - maxX + scrollPaddingRightNumber;
74+
let scrollPortLeft = x + parseInt(borderLeftWidth, 10) + scrollPaddingLeftNumber;
75+
let scrollPortRight = maxX - scrollPaddingRightNumber;
76+
let scrollPortTop = y + parseInt(borderTopWidth, 10) + scrollPaddingTopNumber;
77+
let scrollPortBottom = maxY - scrollPaddingBottomNumber;
78+
79+
if (targetLeft > scrollPortLeft || targetRight < scrollPortRight) {
80+
if (targetLeft <= x + scrollPaddingLeftNumber) {
81+
x = targetLeft - parseInt(borderLeftWidth, 10) - scrollPaddingLeftNumber;
82+
} else if (targetRight > maxX - scrollPaddingRightNumber) {
83+
x += targetRight - maxX + scrollPaddingRightNumber;
84+
}
6085
}
61-
if (offsetY <= borderAdjustedY + scrollPaddingTopNumber) {
62-
y = offsetY - parseInt(borderTopWidth, 10) - scrollPaddingTopNumber;
63-
} else if (offsetY + height > maxY - scrollPaddingBottomNumber) {
64-
y += offsetY + height - maxY + scrollPaddingBottomNumber;
86+
87+
if (targetTop > scrollPortTop || targetBottom < scrollPortBottom) {
88+
if (targetTop <= borderAdjustedY + scrollPaddingTopNumber) {
89+
y = targetTop - parseInt(borderTopWidth, 10) - scrollPaddingTopNumber;
90+
} else if (targetBottom > maxY - scrollPaddingBottomNumber) {
91+
y += targetBottom - maxY + scrollPaddingBottomNumber;
92+
}
93+
}
94+
95+
if (process.env.NODE_ENV === 'test') {
96+
scrollView.scrollLeft = x;
97+
scrollView.scrollTop = y;
98+
return;
6599
}
66100

67-
scrollView.scrollLeft = x;
68-
scrollView.scrollTop = y;
101+
scrollView.scrollTo({left: x, top: y});
69102
}
70103

71104
/**
@@ -101,8 +134,9 @@ export function scrollIntoViewport(targetElement: Element | null, opts?: ScrollI
101134
if (targetElement && document.contains(targetElement)) {
102135
let root = document.scrollingElement || document.documentElement;
103136
let isScrollPrevented = window.getComputedStyle(root).overflow === 'hidden';
104-
// If scrolling is not currently prevented then we aren’t in a overlay nor is a overlay open, just use element.scrollIntoView to bring the element into view
105-
if (!isScrollPrevented) {
137+
// If scrolling is not currently prevented then we aren't in a overlay nor is a overlay open, just use element.scrollIntoView to bring the element into view
138+
// Also ignore in chrome because of this bug: https://issues.chromium.org/issues/40074749
139+
if (!isScrollPrevented && !isChrome()) {
106140
let {left: originalLeft, top: originalTop} = targetElement.getBoundingClientRect();
107141

108142
// use scrollIntoView({block: 'nearest'}) instead of .focus to check if the element is fully in view or not since .focus()

packages/react-aria-components/stories/ListBox.stories.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,46 @@ export const AsyncListBoxVirtualized: StoryFn<typeof AsyncListBoxRender> = (args
743743
);
744744
};
745745

746+
export const ListBoxScrollMargin: ListBoxStory = (args) => {
747+
let items: {id: number, name: string, description: string}[] = [];
748+
for (let i = 0; i < 100; i++) {
749+
items.push({id: i, name: `Item ${i}`, description: `Description ${i}`});
750+
}
751+
return (
752+
<ListBox
753+
className={styles.menu}
754+
{...args}
755+
aria-label="test listbox"
756+
style={{height: 200, width: 100, overflow: 'scroll'}}
757+
items={items}>
758+
{item => (
759+
<MyListBoxItem style={{scrollMargin: 10, width: 150, display: 'flex', padding: '2px 20px', justifyContent: 'space-between'}}>
760+
<span>{item.name}</span>
761+
<span>{item.description}</span>
762+
</MyListBoxItem>
763+
)}
764+
</ListBox>
765+
);
766+
};
767+
768+
export const ListBoxSmoothScroll: ListBoxStory = (args) => {
769+
let items: {id: number, name: string}[] = [];
770+
for (let i = 0; i < 100; i++) {
771+
items.push({id: i, name: `Item ${i}`});
772+
}
773+
return (
774+
<ListBox
775+
className={styles.menu}
776+
{...args}
777+
aria-label="test listbox"
778+
style={{height: 200, width: 200, overflow: 'scroll', display: 'grid', gridTemplateColumns: 'repeat(4, 80px)', scrollBehavior: 'smooth'}}
779+
items={items}
780+
layout="grid">
781+
{item => <MyListBoxItem style={{minHeight: 32}}>{item.name}</MyListBoxItem>}
782+
</ListBox>
783+
);
784+
};
785+
746786
AsyncListBoxVirtualized.story = {
747787
args: {
748788
delay: 50

0 commit comments

Comments
 (0)