diff --git a/packages/react-aria/src/utils/scrollIntoView.ts b/packages/react-aria/src/utils/scrollIntoView.ts index ff975f6c7d0..fb694c14b64 100644 --- a/packages/react-aria/src/utils/scrollIntoView.ts +++ b/packages/react-aria/src/utils/scrollIntoView.ts @@ -144,43 +144,123 @@ export function scrollIntoViewport( opts: ScrollIntoViewportOpts = {} ): void { let {containingElement} = opts; - if (targetElement && targetElement.isConnected) { - let root = document.scrollingElement || document.documentElement; - let isScrollPrevented = window.getComputedStyle(root).overflow === 'hidden'; - if (!isScrollPrevented) { - let {left: originalLeft, top: originalTop} = targetElement.getBoundingClientRect(); - - // use scrollIntoView({block: 'nearest'}) instead of .focus to check if the element is fully in view or not since .focus() - // won't cause a scroll if the element is already focused and doesn't behave consistently when an element is partially out of view horizontally vs vertically - targetElement?.scrollIntoView?.({block: 'nearest'}); - let {left: newLeft, top: newTop} = targetElement.getBoundingClientRect(); - // Account for sub pixel differences from rounding - if (Math.abs(originalLeft - newLeft) > 1 || Math.abs(originalTop - newTop) > 1) { - containingElement?.scrollIntoView?.({block: 'center', inline: 'center'}); - targetElement.scrollIntoView?.({block: 'nearest'}); + if (!targetElement || !targetElement.isConnected) { + return; + } + + let root = document.scrollingElement || document.documentElement; + let isScrollPrevented = window.getComputedStyle(root).overflow === 'hidden'; + + if (!isScrollPrevented) { + let {left: originalLeft, top: originalTop} = targetElement.getBoundingClientRect(); + targetElement.scrollIntoView?.({block: 'nearest'}); + let {left: newLeft, top: newTop} = targetElement.getBoundingClientRect(); + + if (Math.abs(originalLeft - newLeft) > 1 || Math.abs(originalTop - newTop) > 1) { + let blockOption: ScrollLogicalPosition = 'center'; + let inlineOption: ScrollLogicalPosition = 'center'; + + if (containingElement) { + const containerRect = containingElement.getBoundingClientRect(); + + // 1. Inline check against global viewport windows + let isFullyVisible = + containerRect.top >= 0 && + containerRect.left >= 0 && + containerRect.bottom <= window.innerHeight && + containerRect.right <= window.innerWidth; + + // 2. Inline check against every single outer scroll parent layer + if (isFullyVisible) { + const structuralParents = getScrollParents(containingElement, true); + for (let parent of structuralParents) { + if ( + parent !== document.body && + parent !== document.documentElement && + parent !== document.scrollingElement + ) { + const pRect = (parent as Element).getBoundingClientRect(); + if ( + containerRect.top < pRect.top || + containerRect.left < pRect.left || + containerRect.bottom > pRect.bottom || + containerRect.right > pRect.right + ) { + isFullyVisible = false; + break; + } + } + } + } + + const isExceedingViewport = containerRect.height > window.innerHeight; + if (isFullyVisible || isExceedingViewport) { + blockOption = 'nearest'; + inlineOption = 'nearest'; + } } - } else { - let {left: originalLeft, top: originalTop} = targetElement.getBoundingClientRect(); - // If scrolling is prevented, we don't want to scroll the body since it might move the overlay partially offscreen and the user can't scroll it back into view. - let scrollParents = getScrollParents(targetElement, true); + containingElement?.scrollIntoView?.({block: blockOption, inline: inlineOption}); + targetElement.scrollIntoView?.({block: 'nearest'}); + } + } else { + let {left: originalLeft, top: originalTop} = targetElement.getBoundingClientRect(); + let scrollParents = getScrollParents(targetElement, true); + + for (let scrollParent of scrollParents) { + scrollIntoView(scrollParent as HTMLElement, targetElement as HTMLElement); + } + let {left: newLeft, top: newTop} = targetElement.getBoundingClientRect(); + + if (Math.abs(originalLeft - newLeft) > 1 || Math.abs(originalTop - newTop) > 1) { + scrollParents = containingElement ? getScrollParents(containingElement, true) : []; + for (let scrollParent of scrollParents) { - scrollIntoView(scrollParent as HTMLElement, targetElement as HTMLElement); - } - let {left: newLeft, top: newTop} = targetElement.getBoundingClientRect(); - // 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); + const parentEl = scrollParent as HTMLElement; + const containerEl = containingElement as HTMLElement; + const containerRect = containerEl.getBoundingClientRect(); + + // 1. Inline context check against parent boundaries + let isFullyVisible = + containerRect.top >= 0 && + containerRect.left >= 0 && + containerRect.bottom <= window.innerHeight && + containerRect.right <= window.innerWidth; + + if (isFullyVisible) { + const structuralParents = getScrollParents(containerEl, true); + for (let p of structuralParents) { + if ( + p !== document.body && + p !== document.documentElement && + p !== document.scrollingElement + ) { + const pRect = (p as Element).getBoundingClientRect(); + if ( + containerRect.top < pRect.top || + containerRect.left < pRect.left || + containerRect.bottom > pRect.bottom || + containerRect.right > pRect.right + ) { + isFullyVisible = false; + break; + } + } + } } + + const isLarge = containerRect.height > parentEl.clientHeight; + const useNearest = isFullyVisible || isLarge; + const targetBlock: ScrollLogicalPosition = useNearest ? 'nearest' : 'center'; + const targetInline: ScrollLogicalPosition = useNearest ? 'nearest' : 'center'; + + scrollIntoView(parentEl, containerEl, { + block: targetBlock, + inline: targetInline + }); + } + for (let scrollParent of getScrollParents(targetElement, true)) { + scrollIntoView(scrollParent as HTMLElement, targetElement as HTMLElement); } } } diff --git a/packages/react-aria/test/utils/scrollIntoView.test.ts b/packages/react-aria/test/utils/scrollIntoView.test.ts index c1f567fe839..e783a1503bd 100644 --- a/packages/react-aria/test/utils/scrollIntoView.test.ts +++ b/packages/react-aria/test/utils/scrollIntoView.test.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {scrollIntoView} from '../../src/utils/scrollIntoView'; +import {scrollIntoView, scrollIntoViewport} from '../../src/utils/scrollIntoView'; describe('scrollIntoView', () => { let target: HTMLElement; @@ -125,4 +125,63 @@ describe('scrollIntoView', () => { expect(scrollView.scrollTop).toBe(2600); }); }); + + // ========================================== + // NEW TEST BLOCK FOR TABLE SCROLLING + // ========================================== + describe('scrollIntoViewport', () => { + let containingElement: HTMLElement; + + beforeEach(() => { + containingElement = document.createElement('div'); + document.body.appendChild(containingElement); + containingElement.appendChild(target); + + // Mock target connections to look active in DOM + Object.defineProperty(target, 'isConnected', {get: () => true}); + }); + + it('should fall back to nearest block alignment if containingElement is larger than the viewport', () => { + const scrollIntoViewSpy = jest.fn(); + containingElement.scrollIntoView = scrollIntoViewSpy; + target.scrollIntoView = jest.fn(); + + // Mock window height + const originalInnerHeight = window.innerHeight; + window.innerHeight = 500; + + // Mock container element to be taller than the window viewport (e.g., 2000px) + jest.spyOn(containingElement, 'getBoundingClientRect').mockReturnValue({ + height: 2000, + top: 0, + bottom: 2000, + left: 0, + right: 1000, + width: 1000 + } as DOMRect); + + // Force a positional shift to trigger container alignment block + let getBoundingClientRectCallCount = 0; + jest.spyOn(target, 'getBoundingClientRect').mockImplementation(() => { + getBoundingClientRectCallCount++; + // Return changing coordinates to trick the layout difference checker + return { + top: getBoundingClientRectCallCount === 1 ? 100 : 200, + left: 0, + right: 100, + bottom: 200, + width: 100, + height: 100 + } as DOMRect; + }); + + scrollIntoViewport(target, {containingElement}); + + // Verification: Ensure it used 'nearest' rather than 'center' because it was too large + expect(scrollIntoViewSpy).toHaveBeenCalledWith({block: 'nearest', inline: 'nearest'}); + + // Clean up global mock + window.innerHeight = originalInnerHeight; + }); + }); });