From 82a863e91b83aaf7efc9d0f9ac3d8e849e58dbf4 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 11 Mar 2026 13:26:39 -0700 Subject: [PATCH 1/9] 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/9] 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/9] 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/9] 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/9] 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/9] 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: () => +}; From e0c7e08e993bdffcfa6d4e94982d6735547a1ef2 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 30 Apr 2026 14:13:02 -0700 Subject: [PATCH 7/9] try to limit viewport of chromatic story so it actually scrolls --- .../s2/chromatic/ScrollIntoView.stories.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/@react-spectrum/s2/chromatic/ScrollIntoView.stories.tsx b/packages/@react-spectrum/s2/chromatic/ScrollIntoView.stories.tsx index cb9cc44936d..b8cd0ba8c71 100644 --- a/packages/@react-spectrum/s2/chromatic/ScrollIntoView.stories.tsx +++ b/packages/@react-spectrum/s2/chromatic/ScrollIntoView.stories.tsx @@ -18,7 +18,18 @@ const meta: Meta = { component: ScrollIntoViewExample, parameters: { layout: 'fullscreen', - chromaticProvider: {colorSchemes: ['light'], backgrounds: ['base'], locales: ['en-US'], disableAnimations: true} + chromaticProvider: {colorSchemes: ['light'], backgrounds: ['base'], locales: ['en-US'], disableAnimations: true}, + viewport: { + options: { + scrollTest: { + name: 'Scroll Test', + styles: {width: '800px', height: '600px'} + } + } + } + }, + globals: { + viewport: {value: 'scrollTest'} }, title: 'S2 Chromatic/ScrollIntoView' }; From 7117d7055e77f5da62a8b1d2fa3e0affd8dd757a Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 30 Apr 2026 15:10:39 -0700 Subject: [PATCH 8/9] get rid of chromatic story since it wasnt scrolling, replace with unit test --- .../s2/chromatic/ScrollIntoView.stories.tsx | 55 ---------- .../test/utils/scrollIntoView.test.ts | 103 ++++++++++++++++++ 2 files changed, 103 insertions(+), 55 deletions(-) delete mode 100644 packages/@react-spectrum/s2/chromatic/ScrollIntoView.stories.tsx create mode 100644 packages/react-aria/test/utils/scrollIntoView.test.ts diff --git a/packages/@react-spectrum/s2/chromatic/ScrollIntoView.stories.tsx b/packages/@react-spectrum/s2/chromatic/ScrollIntoView.stories.tsx deleted file mode 100644 index b8cd0ba8c71..00000000000 --- a/packages/@react-spectrum/s2/chromatic/ScrollIntoView.stories.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/* - * 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}, - viewport: { - options: { - scrollTest: { - name: 'Scroll Test', - styles: {width: '800px', height: '600px'} - } - } - } - }, - globals: { - viewport: {value: 'scrollTest'} - }, - 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/test/utils/scrollIntoView.test.ts b/packages/react-aria/test/utils/scrollIntoView.test.ts new file mode 100644 index 00000000000..dfd244cf4d7 --- /dev/null +++ b/packages/react-aria/test/utils/scrollIntoView.test.ts @@ -0,0 +1,103 @@ +/* + * 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 {scrollIntoView} from '../../src/utils/scrollIntoView'; + +describe('scrollIntoView', () => { + let target: HTMLElement; + + beforeEach(() => { + target = document.createElement('div'); + document.body.appendChild(target); + }); + + afterEach(() => { + document.body.innerHTML = ''; + jest.restoreAllMocks(); + }); + + describe('document root scrolling', () => { + let scrollView: HTMLElement; + + beforeEach(() => { + scrollView = (document.scrollingElement as HTMLElement) || document.documentElement; + scrollView.scrollTop = 0; + scrollView.scrollLeft = 0; + }); + + it('excludes root border from scroll port when scrolling to start', () => { + // the config here is a window of 500 x 500 with a border of 100 + // the target top is at 100, 2100 aka border left of scrolling body, border top + 2000 + // scrollIntoView of block start + inline start should bring us to 100, 2100 + jest.spyOn(target, 'getBoundingClientRect').mockReturnValue({ + top: 2100, bottom: 3100, left: 100, right: 1100, + width: 1000, height: 1000, x: 100, y: 2100 + } as DOMRect); + + jest.spyOn(window, 'getComputedStyle').mockImplementation((el) => { + if (el === scrollView) { + return { + borderTopWidth: '100px', borderBottomWidth: '100px', + borderLeftWidth: '100px', borderRightWidth: '100px', + scrollPaddingTop: '0px', scrollPaddingBottom: '0px', + scrollPaddingLeft: '0px', scrollPaddingRight: '0px', + direction: 'ltr' + } as CSSStyleDeclaration; + } + return { + scrollMarginTop: '0px', scrollMarginBottom: '0px', + scrollMarginLeft: '0px', scrollMarginRight: '0px' + } as CSSStyleDeclaration; + }); + + Object.defineProperty(scrollView, 'clientHeight', {get: () => 500, configurable: true}); + Object.defineProperty(scrollView, 'clientWidth', {get: () => 500, configurable: true}); + + scrollIntoView(scrollView, target, {block: 'start', inline: 'start'}); + expect(scrollView.scrollLeft).toBe(100); + expect(scrollView.scrollTop).toBe(2100); + }); + + it('excludes root border from scroll port when scrolling to end', () => { + // the config here is a window of 500 x 500 with a border of 100 + // the target top is at 100, 2100 aka border left of scrolling body, border top + 2000 + // scrollIntoView of block end + inline end should bring us to 600, 2600 + jest.spyOn(target, 'getBoundingClientRect').mockReturnValue({ + top: 2100, bottom: 3100, left: 100, right: 1100, + width: 1000, height: 1000, x: 100, y: 2100, toJSON: () => {} + } as DOMRect); + + jest.spyOn(window, 'getComputedStyle').mockImplementation((el) => { + if (el === scrollView) { + return { + borderTopWidth: '100px', borderBottomWidth: '100px', + borderLeftWidth: '100px', borderRightWidth: '100px', + scrollPaddingTop: '0px', scrollPaddingBottom: '0px', + scrollPaddingLeft: '0px', scrollPaddingRight: '0px', + direction: 'ltr' + } as CSSStyleDeclaration; + } + return { + scrollMarginTop: '0px', scrollMarginBottom: '0px', + scrollMarginLeft: '0px', scrollMarginRight: '0px' + } as CSSStyleDeclaration; + }); + + Object.defineProperty(scrollView, 'clientHeight', {get: () => 500, configurable: true}); + Object.defineProperty(scrollView, 'clientWidth', {get: () => 500, configurable: true}); + + scrollIntoView(scrollView, target, {block: 'end', inline: 'end'}); + expect(scrollView.scrollLeft).toBe(600); + expect(scrollView.scrollTop).toBe(2600); + }); + }); +}); From 3dc93af16a567058fd28edf2ab2766a280ddefac Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 12 May 2026 16:08:00 -0700 Subject: [PATCH 9/9] fix formatting --- .../stories/ScrollIntoView.stories.tsx | 19 ++++-- .../react-aria/src/utils/scrollIntoView.ts | 9 ++- .../test/utils/getScrollParents.test.ts | 4 +- .../test/utils/scrollIntoView.test.ts | 61 +++++++++++++------ 4 files changed, 64 insertions(+), 29 deletions(-) diff --git a/packages/react-aria-components/stories/ScrollIntoView.stories.tsx b/packages/react-aria-components/stories/ScrollIntoView.stories.tsx index baa3d403d1f..0333c1d93f9 100644 --- a/packages/react-aria-components/stories/ScrollIntoView.stories.tsx +++ b/packages/react-aria-components/stories/ScrollIntoView.stories.tsx @@ -24,8 +24,7 @@ export default { 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.' + 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; @@ -67,10 +66,18 @@ export function ScrollIntoViewExample() {
Test 1
- - - - + + + +
diff --git a/packages/react-aria/src/utils/scrollIntoView.ts b/packages/react-aria/src/utils/scrollIntoView.ts index 76d77aeb333..ff975f6c7d0 100644 --- a/packages/react-aria/src/utils/scrollIntoView.ts +++ b/packages/react-aria/src/utils/scrollIntoView.ts @@ -78,11 +78,14 @@ export function scrollIntoView( let scrollBarOffsetX = scrollView === root ? 0 : borderLeftWidth + borderRightWidth; let scrollBarOffsetY = scrollView === root ? 0 : borderTopWidth + borderBottomWidth; - let scrollBarWidth = scrollView === root ? 0 : scrollView.offsetWidth - scrollView.clientWidth - scrollBarOffsetX; - let scrollBarHeight = scrollView === root ? 0 : scrollView.offsetHeight - scrollView.clientHeight - scrollBarOffsetY; + let scrollBarWidth = + scrollView === root ? 0 : scrollView.offsetWidth - scrollView.clientWidth - scrollBarOffsetX; + let scrollBarHeight = + scrollView === root ? 0 : scrollView.offsetHeight - scrollView.clientHeight - scrollBarOffsetY; let scrollPortTop = viewTop + (isRoot ? 0 : borderTopWidth) + scrollPaddingTop; - let scrollPortBottom = viewBottom - (isRoot ? 0 : borderBottomWidth) - scrollPaddingBottom - scrollBarHeight; + let scrollPortBottom = + viewBottom - (isRoot ? 0 : borderBottomWidth) - scrollPaddingBottom - scrollBarHeight; let scrollPortLeft = viewLeft + (isRoot ? 0 : borderLeftWidth) + scrollPaddingLeft; let scrollPortRight = viewRight - (isRoot ? 0 : borderRightWidth) - scrollPaddingRight; diff --git a/packages/react-aria/test/utils/getScrollParents.test.ts b/packages/react-aria/test/utils/getScrollParents.test.ts index 52a769d9a18..78afc1f631e 100644 --- a/packages/react-aria/test/utils/getScrollParents.test.ts +++ b/packages/react-aria/test/utils/getScrollParents.test.ts @@ -36,7 +36,7 @@ describe('getScrollParents', () => { let div = document.createElement('div'); document.body.appendChild(div); - jest.spyOn(window, 'getComputedStyle').mockImplementation((el) => { + jest.spyOn(window, 'getComputedStyle').mockImplementation(el => { if (el === root) { return {overflow: 'hidden'} as CSSStyleDeclaration; } @@ -53,7 +53,7 @@ describe('getScrollParents', () => { document.body.appendChild(scrollable); scrollable.appendChild(child); - jest.spyOn(window, 'getComputedStyle').mockImplementation((el) => { + jest.spyOn(window, 'getComputedStyle').mockImplementation(el => { if (el === scrollable) { return {overflow: 'auto'} as CSSStyleDeclaration; } diff --git a/packages/react-aria/test/utils/scrollIntoView.test.ts b/packages/react-aria/test/utils/scrollIntoView.test.ts index dfd244cf4d7..c1f567fe839 100644 --- a/packages/react-aria/test/utils/scrollIntoView.test.ts +++ b/packages/react-aria/test/utils/scrollIntoView.test.ts @@ -39,23 +39,35 @@ describe('scrollIntoView', () => { // the target top is at 100, 2100 aka border left of scrolling body, border top + 2000 // scrollIntoView of block start + inline start should bring us to 100, 2100 jest.spyOn(target, 'getBoundingClientRect').mockReturnValue({ - top: 2100, bottom: 3100, left: 100, right: 1100, - width: 1000, height: 1000, x: 100, y: 2100 + top: 2100, + bottom: 3100, + left: 100, + right: 1100, + width: 1000, + height: 1000, + x: 100, + y: 2100 } as DOMRect); - jest.spyOn(window, 'getComputedStyle').mockImplementation((el) => { + jest.spyOn(window, 'getComputedStyle').mockImplementation(el => { if (el === scrollView) { return { - borderTopWidth: '100px', borderBottomWidth: '100px', - borderLeftWidth: '100px', borderRightWidth: '100px', - scrollPaddingTop: '0px', scrollPaddingBottom: '0px', - scrollPaddingLeft: '0px', scrollPaddingRight: '0px', + borderTopWidth: '100px', + borderBottomWidth: '100px', + borderLeftWidth: '100px', + borderRightWidth: '100px', + scrollPaddingTop: '0px', + scrollPaddingBottom: '0px', + scrollPaddingLeft: '0px', + scrollPaddingRight: '0px', direction: 'ltr' } as CSSStyleDeclaration; } return { - scrollMarginTop: '0px', scrollMarginBottom: '0px', - scrollMarginLeft: '0px', scrollMarginRight: '0px' + scrollMarginTop: '0px', + scrollMarginBottom: '0px', + scrollMarginLeft: '0px', + scrollMarginRight: '0px' } as CSSStyleDeclaration; }); @@ -72,23 +84,36 @@ describe('scrollIntoView', () => { // the target top is at 100, 2100 aka border left of scrolling body, border top + 2000 // scrollIntoView of block end + inline end should bring us to 600, 2600 jest.spyOn(target, 'getBoundingClientRect').mockReturnValue({ - top: 2100, bottom: 3100, left: 100, right: 1100, - width: 1000, height: 1000, x: 100, y: 2100, toJSON: () => {} + top: 2100, + bottom: 3100, + left: 100, + right: 1100, + width: 1000, + height: 1000, + x: 100, + y: 2100, + toJSON: () => {} } as DOMRect); - jest.spyOn(window, 'getComputedStyle').mockImplementation((el) => { + jest.spyOn(window, 'getComputedStyle').mockImplementation(el => { if (el === scrollView) { return { - borderTopWidth: '100px', borderBottomWidth: '100px', - borderLeftWidth: '100px', borderRightWidth: '100px', - scrollPaddingTop: '0px', scrollPaddingBottom: '0px', - scrollPaddingLeft: '0px', scrollPaddingRight: '0px', + borderTopWidth: '100px', + borderBottomWidth: '100px', + borderLeftWidth: '100px', + borderRightWidth: '100px', + scrollPaddingTop: '0px', + scrollPaddingBottom: '0px', + scrollPaddingLeft: '0px', + scrollPaddingRight: '0px', direction: 'ltr' } as CSSStyleDeclaration; } return { - scrollMarginTop: '0px', scrollMarginBottom: '0px', - scrollMarginLeft: '0px', scrollMarginRight: '0px' + scrollMarginTop: '0px', + scrollMarginBottom: '0px', + scrollMarginLeft: '0px', + scrollMarginRight: '0px' } as CSSStyleDeclaration; });