-
Notifications
You must be signed in to change notification settings - Fork 1.6k
fix: properly scroll body if keyboard focusing a item with no other scroll parents #9780
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
82a863e
b4130a1
200a2c5
d5cff0c
3372f70
b58450c
9b50777
365ccb2
e0c7e08
7117d70
9596a38
3dc93af
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<typeof Button>; | ||
|
|
||
| export type ScrollIntoViewStory = StoryObj<typeof Button>; | ||
|
|
||
| export function ScrollIntoViewExample() { | ||
| let redSectionRef = useRef<HTMLDivElement>(null); | ||
| let yellowSectionRef = useRef<HTMLDivElement>(null); | ||
| let blueSectionRef = useRef<HTMLDivElement>(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<HTMLDivElement | null>, 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 ( | ||
| <div style={{display: 'flex', flexDirection: 'column', width: '100%'}}> | ||
| <div ref={redSectionRef} style={sectionStyle('red')}> | ||
| Test 1 | ||
| <div style={{display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8}}> | ||
| <Button onPress={() => triggerScroll(redSectionRef, 'start')}> | ||
| Scroll to Red (Start) | ||
| </Button> | ||
| <Button onPress={() => triggerScroll(yellowSectionRef, 'start')}> | ||
| Scroll to Yellow (Start) | ||
| </Button> | ||
| <Button onPress={() => triggerScroll(yellowSectionRef, 'end')}> | ||
| Scroll to Yellow (End) | ||
| </Button> | ||
| <Button onPress={() => triggerScroll(blueSectionRef, 'start')}> | ||
| Scroll to Blue (Start) | ||
| </Button> | ||
| <Button onPress={() => triggerScroll(blueSectionRef, 'end')}>Scroll to Blue (End)</Button> | ||
| </div> | ||
| </div> | ||
| <div style={sectionStyle('green')}>Test 2</div> | ||
| <div ref={yellowSectionRef} style={sectionStyle('yellow')}> | ||
| Test 3 | ||
| </div> | ||
| <div ref={blueSectionRef} style={sectionStyle('blue')}> | ||
| Test 4 | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export const RootScrollPlayground: ScrollIntoViewStory = { | ||
| render: () => <ScrollIntoViewExample /> | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
| } | ||
|
Comment on lines
+181
to
+183
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. mirrors the flow above, we need to first scroll the containing element into view if necessary, then re-scroll the target element into view to compensate. There is some logic in useSelectableCollection that revealed that this was a problem because we do two separate scroll in views (one via |
||
| } | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
old logic wasn't including the root element except if the initial node provided was the root.
isScrollableshould properly check if scrolling is being prevented on the root so we don't actually need thenode ! == rootcheck I believeThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Meh, our test suite was exactly calling with
rootas the container. Sorry about that.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
all good haha, I didn't catch this either on my original review. So many different scenarios to test too