From 82a863e91b83aaf7efc9d0f9ac3d8e849e58dbf4 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 11 Mar 2026 13:26:39 -0700 Subject: [PATCH 1/6] fix: properly scroll body if keyboard focusing a item with no other scroll parents --- packages/@react-aria/utils/src/getScrollParents.ts | 7 +++++-- packages/@react-aria/utils/src/scrollIntoView.ts | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/@react-aria/utils/src/getScrollParents.ts b/packages/@react-aria/utils/src/getScrollParents.ts index 7266229339a..0b98228f817 100644 --- a/packages/@react-aria/utils/src/getScrollParents.ts +++ b/packages/@react-aria/utils/src/getScrollParents.ts @@ -16,12 +16,15 @@ export function getScrollParents(node: Element, checkForOverflow?: boolean): Ele let parentElements: Element[] = []; let root = document.scrollingElement || document.documentElement; - do { + while (node) { if (isScrollable(node, checkForOverflow)) { parentElements.push(node); } + if (node === root) { + break; + } node = node.parentElement as Element; - } while (node && node !== root); + } return parentElements; } diff --git a/packages/@react-aria/utils/src/scrollIntoView.ts b/packages/@react-aria/utils/src/scrollIntoView.ts index 2d69a34bc01..23de1cc82b9 100644 --- a/packages/@react-aria/utils/src/scrollIntoView.ts +++ b/packages/@react-aria/utils/src/scrollIntoView.ts @@ -73,7 +73,7 @@ export function scrollIntoView(scrollView: HTMLElement, element: HTMLElement, op let scrollBarOffsetX = scrollView === root ? 0 : borderLeftWidth + borderRightWidth; let scrollBarOffsetY = scrollView === root ? 0 : borderTopWidth + borderBottomWidth; let scrollBarWidth = scrollView.offsetWidth - scrollView.clientWidth - scrollBarOffsetX; - let scrollBarHeight = scrollView.offsetHeight - scrollView.clientHeight - scrollBarOffsetY; + let scrollBarHeight = scrollView === root ? 0 : scrollView.offsetHeight - scrollView.clientHeight - scrollBarOffsetY; let scrollPortTop = viewTop + borderTopWidth + scrollPaddingTop; let scrollPortBottom = viewBottom - borderBottomWidth - scrollPaddingBottom - scrollBarHeight; @@ -159,9 +159,13 @@ export function scrollIntoViewport(targetElement: Element | null, opts: ScrollIn // Account for sub pixel differences from rounding if ((Math.abs(originalLeft - newLeft) > 1) || (Math.abs(originalTop - newTop) > 1)) { scrollParents = containingElement ? getScrollParents(containingElement, true) : []; + // scroll containing element into view first, then rescroll target element into view like the non chrome flow above for (let scrollParent of scrollParents) { scrollIntoView(scrollParent as HTMLElement, containingElement as HTMLElement, {block: 'center', inline: 'center'}); } + for (let scrollParent of getScrollParents(targetElement, true)) { + scrollIntoView(scrollParent as HTMLElement, targetElement as HTMLElement); + } } } } From b4130a139676d495fff9d1b17dacc2068db9b7c0 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 13 Mar 2026 16:07:00 -0700 Subject: [PATCH 2/6] fix scrollbar width case and add basic tests --- .../@react-aria/utils/src/scrollIntoView.ts | 2 +- .../utils/test/getScrollParents.test.ts | 78 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 packages/@react-aria/utils/test/getScrollParents.test.ts diff --git a/packages/@react-aria/utils/src/scrollIntoView.ts b/packages/@react-aria/utils/src/scrollIntoView.ts index 23de1cc82b9..19da2420b94 100644 --- a/packages/@react-aria/utils/src/scrollIntoView.ts +++ b/packages/@react-aria/utils/src/scrollIntoView.ts @@ -72,7 +72,7 @@ export function scrollIntoView(scrollView: HTMLElement, element: HTMLElement, op let scrollBarOffsetX = scrollView === root ? 0 : borderLeftWidth + borderRightWidth; let scrollBarOffsetY = scrollView === root ? 0 : borderTopWidth + borderBottomWidth; - let scrollBarWidth = scrollView.offsetWidth - scrollView.clientWidth - scrollBarOffsetX; + let scrollBarWidth = scrollView === root ? 0 : scrollView.offsetWidth - scrollView.clientWidth - scrollBarOffsetX; let scrollBarHeight = scrollView === root ? 0 : scrollView.offsetHeight - scrollView.clientHeight - scrollBarOffsetY; let scrollPortTop = viewTop + borderTopWidth + scrollPaddingTop; diff --git a/packages/@react-aria/utils/test/getScrollParents.test.ts b/packages/@react-aria/utils/test/getScrollParents.test.ts new file mode 100644 index 00000000000..f76ce48f4a1 --- /dev/null +++ b/packages/@react-aria/utils/test/getScrollParents.test.ts @@ -0,0 +1,78 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {getScrollParents} from '../src/getScrollParents'; + +describe('getScrollParents', () => { + let root: Element; + + beforeEach(() => { + root = document.documentElement; + }); + + afterEach(() => { + document.body.innerHTML = ''; + jest.restoreAllMocks(); + }); + + it('includes root as a scroll parent for a node in the document', () => { + let div = document.createElement('div'); + document.body.appendChild(div); + + let parents = getScrollParents(div); + expect(parents).toContain(root); + }); + + it('does not include root when root has overflow: hidden', () => { + let div = document.createElement('div'); + document.body.appendChild(div); + + jest.spyOn(window, 'getComputedStyle').mockImplementation((el) => { + if (el === root) { + return {overflow: 'hidden'} as CSSStyleDeclaration; + } + return {overflow: 'visible'} as CSSStyleDeclaration; + }); + + let parents = getScrollParents(div); + expect(parents).not.toContain(root); + }); + + it('includes a scrollable intermediate parent', () => { + let scrollable = document.createElement('div'); + let child = document.createElement('div'); + document.body.appendChild(scrollable); + scrollable.appendChild(child); + + jest.spyOn(window, 'getComputedStyle').mockImplementation((el) => { + if (el === scrollable) { + return {overflow: 'auto'} as CSSStyleDeclaration; + } + return {overflow: 'visible'} as CSSStyleDeclaration; + }); + + let parents = getScrollParents(child); + expect(parents).toContain(scrollable); + expect(parents).toContain(root); + }); + + it('excludes non-scrollable ancestors', () => { + let plain = document.createElement('div'); + let child = document.createElement('div'); + document.body.appendChild(plain); + plain.appendChild(child); + + let parents = getScrollParents(child); + expect(parents).not.toContain(plain); + expect(parents).not.toContain(document.body); + }); +}); From 200a2c53646a3b2e5967b091be99cecc075373cb Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 13 Mar 2026 16:47:52 -0700 Subject: [PATCH 3/6] tentative fix to accomodate for borders on root when scrolling into view --- packages/@react-aria/utils/src/scrollIntoView.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/@react-aria/utils/src/scrollIntoView.ts b/packages/@react-aria/utils/src/scrollIntoView.ts index 19da2420b94..d19b77bf55e 100644 --- a/packages/@react-aria/utils/src/scrollIntoView.ts +++ b/packages/@react-aria/utils/src/scrollIntoView.ts @@ -44,6 +44,7 @@ export function scrollIntoView(scrollView: HTMLElement, element: HTMLElement, op let itemStyle = window.getComputedStyle(element); let viewStyle = window.getComputedStyle(scrollView); let root = document.scrollingElement || document.documentElement; + let isRoot = scrollView === root; let viewTop = scrollView === root ? 0 : view.top; let viewBottom = scrollView === root ? scrollView.clientHeight : view.bottom; @@ -75,10 +76,10 @@ export function scrollIntoView(scrollView: HTMLElement, element: HTMLElement, op let scrollBarWidth = scrollView === root ? 0 : scrollView.offsetWidth - scrollView.clientWidth - scrollBarOffsetX; let scrollBarHeight = scrollView === root ? 0 : scrollView.offsetHeight - scrollView.clientHeight - scrollBarOffsetY; - let scrollPortTop = viewTop + borderTopWidth + scrollPaddingTop; - let scrollPortBottom = viewBottom - borderBottomWidth - scrollPaddingBottom - scrollBarHeight; - let scrollPortLeft = viewLeft + borderLeftWidth + scrollPaddingLeft; - let scrollPortRight = viewRight - borderRightWidth - scrollPaddingRight; + let scrollPortTop = viewTop + (isRoot ? 0 : borderTopWidth) + scrollPaddingTop; + let scrollPortBottom = viewBottom - (isRoot ? 0 : borderBottomWidth) - scrollPaddingBottom - scrollBarHeight; + let scrollPortLeft = viewLeft + (isRoot ? 0 : borderLeftWidth) + scrollPaddingLeft; + let scrollPortRight = viewRight - (isRoot ? 0 : borderRightWidth) - scrollPaddingRight; // IOS always positions the scrollbar on the right ¯\_(ツ)_/¯ if (viewStyle.direction === 'rtl' && !isIOS()) { From 3372f70981b8fca7464c86318c063721a1686a93 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 6 Apr 2026 17:05:09 -0700 Subject: [PATCH 4/6] fix import --- packages/react-aria/test/utils/getScrollParents.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-aria/test/utils/getScrollParents.test.ts b/packages/react-aria/test/utils/getScrollParents.test.ts index f76ce48f4a1..52a769d9a18 100644 --- a/packages/react-aria/test/utils/getScrollParents.test.ts +++ b/packages/react-aria/test/utils/getScrollParents.test.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {getScrollParents} from '../src/getScrollParents'; +import {getScrollParents} from '../../src/utils/getScrollParents'; describe('getScrollParents', () => { let root: Element; From b58450cddd60e6f0550a5d5026c74f9fcefcae61 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 14 Apr 2026 16:44:45 -0700 Subject: [PATCH 5/6] use parseFloat --- .../react-aria/src/utils/scrollIntoView.ts | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/react-aria/src/utils/scrollIntoView.ts b/packages/react-aria/src/utils/scrollIntoView.ts index 125762e84a5..6bb64ed6d09 100644 --- a/packages/react-aria/src/utils/scrollIntoView.ts +++ b/packages/react-aria/src/utils/scrollIntoView.ts @@ -51,20 +51,20 @@ export function scrollIntoView(scrollView: HTMLElement, element: HTMLElement, op let viewLeft = scrollView === root ? 0 : view.left; let viewRight = scrollView === root ? scrollView.clientWidth : view.right; - let scrollMarginTop = parseInt(itemStyle.scrollMarginTop, 10) || 0; - let scrollMarginBottom = parseInt(itemStyle.scrollMarginBottom, 10) || 0; - let scrollMarginLeft = parseInt(itemStyle.scrollMarginLeft, 10) || 0; - let scrollMarginRight = parseInt(itemStyle.scrollMarginRight, 10) || 0; - - let scrollPaddingTop = parseInt(viewStyle.scrollPaddingTop, 10) || 0; - let scrollPaddingBottom = parseInt(viewStyle.scrollPaddingBottom, 10) || 0; - let scrollPaddingLeft = parseInt(viewStyle.scrollPaddingLeft, 10) || 0; - let scrollPaddingRight = parseInt(viewStyle.scrollPaddingRight, 10) || 0; - - let borderTopWidth = parseInt(viewStyle.borderTopWidth, 10) || 0; - let borderBottomWidth = parseInt(viewStyle.borderBottomWidth, 10) || 0; - let borderLeftWidth = parseInt(viewStyle.borderLeftWidth, 10) || 0; - let borderRightWidth = parseInt(viewStyle.borderRightWidth, 10) || 0; + let scrollMarginTop = parseFloat(itemStyle.scrollMarginTop) || 0; + let scrollMarginBottom = parseFloat(itemStyle.scrollMarginBottom) || 0; + let scrollMarginLeft = parseFloat(itemStyle.scrollMarginLeft) || 0; + let scrollMarginRight = parseFloat(itemStyle.scrollMarginRight) || 0; + + let scrollPaddingTop = parseFloat(viewStyle.scrollPaddingTop) || 0; + let scrollPaddingBottom = parseFloat(viewStyle.scrollPaddingBottom) || 0; + let scrollPaddingLeft = parseFloat(viewStyle.scrollPaddingLeft) || 0; + let scrollPaddingRight = parseFloat(viewStyle.scrollPaddingRight) || 0; + + let borderTopWidth = parseFloat(viewStyle.borderTopWidth) || 0; + let borderBottomWidth = parseFloat(viewStyle.borderBottomWidth) || 0; + let borderLeftWidth = parseFloat(viewStyle.borderLeftWidth) || 0; + let borderRightWidth = parseFloat(viewStyle.borderRightWidth) || 0; let scrollAreaTop = target.top - scrollMarginTop; let scrollAreaBottom = target.bottom + scrollMarginBottom; From 9b507773e9573e38afc3b0689000506f2c726d51 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 28 Apr 2026 17:17:11 -0700 Subject: [PATCH 6/6] add story to test --- .../s2/chromatic/ScrollIntoView.stories.tsx | 44 +++++++++ .../stories/ScrollIntoView.stories.tsx | 90 +++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 packages/@react-spectrum/s2/chromatic/ScrollIntoView.stories.tsx create mode 100644 packages/react-aria-components/stories/ScrollIntoView.stories.tsx diff --git a/packages/@react-spectrum/s2/chromatic/ScrollIntoView.stories.tsx b/packages/@react-spectrum/s2/chromatic/ScrollIntoView.stories.tsx new file mode 100644 index 00000000000..cb9cc44936d --- /dev/null +++ b/packages/@react-spectrum/s2/chromatic/ScrollIntoView.stories.tsx @@ -0,0 +1,44 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type {Meta, StoryObj} from '@storybook/react'; +import {ScrollIntoViewExample} from '../../../react-aria-components/stories/ScrollIntoView.stories'; +import {userEvent, within} from 'storybook/test'; + +const meta: Meta = { + component: ScrollIntoViewExample, + parameters: { + layout: 'fullscreen', + chromaticProvider: {colorSchemes: ['light'], backgrounds: ['base'], locales: ['en-US'], disableAnimations: true} + }, + title: 'S2 Chromatic/ScrollIntoView' +}; + +export default meta; + +type Story = StoryObj; + +export const YellowStart: Story = { + render: () => , + play: async ({canvasElement}) => { + let button = await within(canvasElement).findByRole('button', {name: 'Scroll to Yellow (Start)'}); + await userEvent.click(button); + } +}; + +export const YellowEnd: Story = { + render: () => , + play: async ({canvasElement}) => { + let button = await within(canvasElement).findByRole('button', {name: 'Scroll to Yellow (End)'}); + await userEvent.click(button); + } +}; diff --git a/packages/react-aria-components/stories/ScrollIntoView.stories.tsx b/packages/react-aria-components/stories/ScrollIntoView.stories.tsx new file mode 100644 index 00000000000..baa3d403d1f --- /dev/null +++ b/packages/react-aria-components/stories/ScrollIntoView.stories.tsx @@ -0,0 +1,90 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {Button} from '../src/Button'; +import {Meta, StoryObj} from '@storybook/react'; +import React, {useRef} from 'react'; +import {scrollIntoView} from 'react-aria/private/utils/scrollIntoView'; + +import {useLayoutEffect} from '@react-aria/utils'; +import './styles.css'; + +export default { + title: 'React Aria Components/ScrollIntoView', + component: Button, + parameters: { + layout: 'fullscreen', + description: { + data: + 'Reproduces window scrolling when the document root has a thick border (like a bare HTML page). Uses react-aria scrollIntoView with the document element as the scroll view.' + } + } +} as Meta; + +export type ScrollIntoViewStory = StoryObj; + +export function ScrollIntoViewExample() { + let redSectionRef = useRef(null); + let yellowSectionRef = useRef(null); + let blueSectionRef = useRef(null); + + useLayoutEffect(() => { + let html = document.documentElement; + let prevBorder = html.style.border; + let prevWidth = html.style.width; + html.style.border = '100px solid black'; + html.style.width = '1000px'; + return () => { + html.style.border = prevBorder; + html.style.width = prevWidth; + }; + }, []); + + let triggerScroll = (target: React.RefObject, align: 'start' | 'end') => { + let root = (document.scrollingElement || document.documentElement) as HTMLElement; + if (target.current) { + scrollIntoView(root, target.current, {block: align, inline: align}); + } + }; + + let sectionStyle = (color: string): React.CSSProperties => ({ + height: 1000, + backgroundColor: color, + width: '100%' + }); + + return ( +
+
+ Test 1 +
+ + + + + +
+
+
Test 2
+
+ Test 3 +
+
+ Test 4 +
+
+ ); +} + +export const RootScrollPlayground: ScrollIntoViewStory = { + render: () => +};