From d63156935d957d0b4e35c6ac63cc24b8aa17f706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= <25958801+nwidynski@users.noreply.github.com> Date: Wed, 13 Aug 2025 20:10:26 +0200 Subject: [PATCH 1/4] fix: scrollIntoView should respect scroll-margin --- .../@react-aria/utils/src/scrollIntoView.ts | 67 +++++++++++++++---- 1 file changed, 54 insertions(+), 13 deletions(-) diff --git a/packages/@react-aria/utils/src/scrollIntoView.ts b/packages/@react-aria/utils/src/scrollIntoView.ts index 4022b9b27d3..dabbf2e5921 100644 --- a/packages/@react-aria/utils/src/scrollIntoView.ts +++ b/packages/@react-aria/utils/src/scrollIntoView.ts @@ -11,6 +11,7 @@ */ import {getScrollParents} from './getScrollParents'; +import {isChrome} from './platform'; interface ScrollIntoViewportOpts { /** The optional containing element of the target to be centered in the viewport. */ @@ -40,32 +41,71 @@ export function scrollIntoView(scrollView: HTMLElement, element: HTMLElement): v scrollPaddingLeft } = getComputedStyle(scrollView); + // Account for scroll margin of the element + let { + scrollMarginTop, + scrollMarginRight, + scrollMarginBottom, + scrollMarginLeft + } = getComputedStyle(element); + let borderAdjustedX = x + parseInt(borderLeftWidth, 10); let borderAdjustedY = y + parseInt(borderTopWidth, 10); // Ignore end/bottom border via clientHeight/Width instead of offsetHeight/Width let maxX = borderAdjustedX + scrollView.clientWidth; let maxY = borderAdjustedY + scrollView.clientHeight; - // Get scroll padding values as pixels - defaults to 0 if no scroll padding + // Get scroll padding / margin values as pixels - defaults to 0 if no scroll padding / margin // is used. let scrollPaddingTopNumber = parseInt(scrollPaddingTop, 10) || 0; let scrollPaddingBottomNumber = parseInt(scrollPaddingBottom, 10) || 0; let scrollPaddingRightNumber = parseInt(scrollPaddingRight, 10) || 0; let scrollPaddingLeftNumber = parseInt(scrollPaddingLeft, 10) || 0; + let scrollMarginTopNumber = parseInt(scrollMarginTop, 10) || 0; + let scrollMarginBottomNumber = parseInt(scrollMarginBottom, 10) || 0; + let scrollMarginRightNumber = parseInt(scrollMarginRight, 10) || 0; + let scrollMarginLeftNumber = parseInt(scrollMarginLeft, 10) || 0; + + let targetLeft = offsetX - scrollMarginLeftNumber; + let targetRight = offsetX + width + scrollMarginRightNumber; + let targetTop = offsetY - scrollMarginTopNumber; + let targetBottom = offsetY + height + scrollMarginBottomNumber; - if (offsetX <= x + scrollPaddingLeftNumber) { - x = offsetX - parseInt(borderLeftWidth, 10) - scrollPaddingLeftNumber; - } else if (offsetX + width > maxX - scrollPaddingRightNumber) { - x += offsetX + width - maxX + scrollPaddingRightNumber; + // convenience vars for padded viewport edges (content coords) + let viewportLeft = x + parseInt(borderLeftWidth, 10) + scrollPaddingLeftNumber; + let viewportRight = maxX - scrollPaddingRightNumber; + let viewportTop = y + parseInt(borderTopWidth, 10) + scrollPaddingTopNumber; + let viewportBottom = maxY - scrollPaddingBottomNumber; + + const spansBothX = + targetLeft <= viewportLeft && targetRight >= viewportRight; + + if (!spansBothX) { + if (targetLeft <= x + scrollPaddingLeftNumber) { + x = targetLeft - parseInt(borderLeftWidth, 10) - scrollPaddingLeftNumber; + } else if (targetRight > maxX - scrollPaddingRightNumber) { + x += targetRight - maxX + scrollPaddingRightNumber; + } } - if (offsetY <= borderAdjustedY + scrollPaddingTopNumber) { - y = offsetY - parseInt(borderTopWidth, 10) - scrollPaddingTopNumber; - } else if (offsetY + height > maxY - scrollPaddingBottomNumber) { - y += offsetY + height - maxY + scrollPaddingBottomNumber; + + const spansBothY = + targetTop <= viewportTop && targetBottom >= viewportBottom; + + if (!spansBothY) { + if (targetTop <= borderAdjustedY + scrollPaddingTopNumber) { + y = targetTop - parseInt(borderTopWidth, 10) - scrollPaddingTopNumber; + } else if (targetBottom > maxY - scrollPaddingBottomNumber) { + y += targetBottom - maxY + scrollPaddingBottomNumber; + } + } + + if (process.env.NODE_ENV === 'test') { + scrollView.scrollLeft = x; + scrollView.scrollTop = y; + return; } - scrollView.scrollLeft = x; - scrollView.scrollTop = y; + scrollView.scrollTo({left: x, top: y}); } /** @@ -101,8 +141,9 @@ export function scrollIntoViewport(targetElement: Element | null, opts?: ScrollI if (targetElement && document.contains(targetElement)) { let root = document.scrollingElement || document.documentElement; let isScrollPrevented = window.getComputedStyle(root).overflow === 'hidden'; - // 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 - if (!isScrollPrevented) { + // 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 + // Also ignore in chrome because of this bug: https://issues.chromium.org/issues/40074749 + if (!isScrollPrevented && !isChrome()) { let {left: originalLeft, top: originalTop} = targetElement.getBoundingClientRect(); // use scrollIntoView({block: 'nearest'}) instead of .focus to check if the element is fully in view or not since .focus() From 51297f973e8b651578a49837a40663e7bcb500fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= <25958801+nwidynski@users.noreply.github.com> Date: Wed, 13 Aug 2025 21:15:48 +0200 Subject: [PATCH 2/4] chore: formatting --- .../@react-aria/utils/src/scrollIntoView.ts | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/packages/@react-aria/utils/src/scrollIntoView.ts b/packages/@react-aria/utils/src/scrollIntoView.ts index dabbf2e5921..17b573ef8e6 100644 --- a/packages/@react-aria/utils/src/scrollIntoView.ts +++ b/packages/@react-aria/utils/src/scrollIntoView.ts @@ -71,16 +71,12 @@ export function scrollIntoView(scrollView: HTMLElement, element: HTMLElement): v let targetTop = offsetY - scrollMarginTopNumber; let targetBottom = offsetY + height + scrollMarginBottomNumber; - // convenience vars for padded viewport edges (content coords) - let viewportLeft = x + parseInt(borderLeftWidth, 10) + scrollPaddingLeftNumber; - let viewportRight = maxX - scrollPaddingRightNumber; - let viewportTop = y + parseInt(borderTopWidth, 10) + scrollPaddingTopNumber; - let viewportBottom = maxY - scrollPaddingBottomNumber; + let scrollPortLeft = x + parseInt(borderLeftWidth, 10) + scrollPaddingLeftNumber; + let scrollPortRight = maxX - scrollPaddingRightNumber; + let scrollPortTop = y + parseInt(borderTopWidth, 10) + scrollPaddingTopNumber; + let scrollPortBottom = maxY - scrollPaddingBottomNumber; - const spansBothX = - targetLeft <= viewportLeft && targetRight >= viewportRight; - - if (!spansBothX) { + if (targetLeft > scrollPortLeft || targetRight < scrollPortRight) { if (targetLeft <= x + scrollPaddingLeftNumber) { x = targetLeft - parseInt(borderLeftWidth, 10) - scrollPaddingLeftNumber; } else if (targetRight > maxX - scrollPaddingRightNumber) { @@ -88,10 +84,7 @@ export function scrollIntoView(scrollView: HTMLElement, element: HTMLElement): v } } - const spansBothY = - targetTop <= viewportTop && targetBottom >= viewportBottom; - - if (!spansBothY) { + if (targetTop > scrollPortTop || targetBottom < scrollPortBottom) { if (targetTop <= borderAdjustedY + scrollPaddingTopNumber) { y = targetTop - parseInt(borderTopWidth, 10) - scrollPaddingTopNumber; } else if (targetBottom > maxY - scrollPaddingBottomNumber) { From d0242ba5f32f759c28c251a7928b85623b8823bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Wed, 20 Aug 2025 11:11:58 +0200 Subject: [PATCH 3/4] feat: add scroll-margin story --- .../stories/ListBox.stories.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/react-aria-components/stories/ListBox.stories.tsx b/packages/react-aria-components/stories/ListBox.stories.tsx index d3ffaf1a281..31cfe3ef417 100644 --- a/packages/react-aria-components/stories/ListBox.stories.tsx +++ b/packages/react-aria-components/stories/ListBox.stories.tsx @@ -698,6 +698,18 @@ export const AsyncListBoxVirtualized: StoryFn = (args ); }; +export const ListBoxScrollMargin: ListBoxStory = (args) => { + let items: {id: number, name: string}[] = []; + for (let i = 0; i < 100; i++) { + items.push({id: i, name: `Item ${i}`}); + } + return ( + + {item => {item.name}} + + ); +}; + AsyncListBoxVirtualized.story = { args: { delay: 50 From ed29b7d02d01c88d50ddfa9f5454f43cb52767fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Wed, 20 Aug 2025 11:39:58 +0200 Subject: [PATCH 4/4] feat: add smooth scroll story --- .../stories/ListBox.stories.tsx | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/react-aria-components/stories/ListBox.stories.tsx b/packages/react-aria-components/stories/ListBox.stories.tsx index 31cfe3ef417..2dcc79e37e0 100644 --- a/packages/react-aria-components/stories/ListBox.stories.tsx +++ b/packages/react-aria-components/stories/ListBox.stories.tsx @@ -699,13 +699,41 @@ export const AsyncListBoxVirtualized: StoryFn = (args }; export const ListBoxScrollMargin: ListBoxStory = (args) => { + let items: {id: number, name: string, description: string}[] = []; + for (let i = 0; i < 100; i++) { + items.push({id: i, name: `Item ${i}`, description: `Description ${i}`}); + } + return ( + + {item => ( + + {item.name} + {item.description} + + )} + + ); +}; + +export const ListBoxSmoothScroll: ListBoxStory = (args) => { let items: {id: number, name: string}[] = []; for (let i = 0; i < 100; i++) { items.push({id: i, name: `Item ${i}`}); } return ( - - {item => {item.name}} + + {item => {item.name}} ); };