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: () => +}; 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 149b255a11a..6bb64ed6d09 100644 --- a/packages/react-aria/src/utils/scrollIntoView.ts +++ b/packages/react-aria/src/utils/scrollIntoView.ts @@ -44,26 +44,27 @@ 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; 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; @@ -72,13 +73,13 @@ 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 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()) { @@ -157,9 +158,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); + } } } } 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..52a769d9a18 --- /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); + }); +});