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..0333c1d93f9 --- /dev/null +++ b/packages/react-aria-components/stories/ScrollIntoView.stories.tsx @@ -0,0 +1,97 @@ +/* + * 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: () => +}; diff --git a/packages/react-aria/src/utils/getScrollParents.ts b/packages/react-aria/src/utils/getScrollParents.ts index 7266229339a..0b98228f817 100644 --- a/packages/react-aria/src/utils/getScrollParents.ts +++ b/packages/react-aria/src/utils/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/src/utils/scrollIntoView.ts b/packages/react-aria/src/utils/scrollIntoView.ts index 1796b50c19d..ff975f6c7d0 100644 --- a/packages/react-aria/src/utils/scrollIntoView.ts +++ b/packages/react-aria/src/utils/scrollIntoView.ts @@ -49,26 +49,27 @@ export function scrollIntoView( 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; 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 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 = 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 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 = 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 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; @@ -77,13 +78,16 @@ export function scrollIntoView( 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 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()) { @@ -167,12 +171,16 @@ export function scrollIntoViewport( // 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); + } } } } diff --git a/packages/react-aria/test/utils/getScrollParents.test.ts b/packages/react-aria/test/utils/getScrollParents.test.ts new file mode 100644 index 00000000000..78afc1f631e --- /dev/null +++ b/packages/react-aria/test/utils/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/utils/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); + }); +}); 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..c1f567fe839 --- /dev/null +++ b/packages/react-aria/test/utils/scrollIntoView.test.ts @@ -0,0 +1,128 @@ +/* + * 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); + }); + }); +});