diff --git a/packages/react-aria/src/utils/scrollIntoView.ts b/packages/react-aria/src/utils/scrollIntoView.ts index ff975f6c7d0..0b4c9ca2821 100644 --- a/packages/react-aria/src/utils/scrollIntoView.ts +++ b/packages/react-aria/src/utils/scrollIntoView.ts @@ -132,55 +132,188 @@ export function scrollIntoView( scrollView.scrollTo({left: x, top: y}); } +/** + * Computes the visible scroll port of a scroll parent, accounting for borders, + * scroll-padding, and scrollbars. Returns {top, bottom, left, right}. + */ +function getScrollPort(scrollView: HTMLElement): { + top: number; + bottom: number; + left: number; + right: number; +} { + let root = document.scrollingElement || document.documentElement; + let isRoot = scrollView === root || scrollView === document.body; + let viewStyle = window.getComputedStyle(scrollView); + + let viewTop = 0; + let viewBottom = 0; + let viewLeft = 0; + let viewRight = 0; + + if (isRoot) { + viewBottom = scrollView.clientHeight; + viewRight = scrollView.clientWidth; + } else { + let view = scrollView.getBoundingClientRect(); + viewTop = view.top; + viewBottom = view.bottom; + viewLeft = view.left; + viewRight = view.right; + } + + 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 borderTop = isRoot ? 0 : parseFloat(viewStyle.borderTopWidth) || 0; + let borderBottom = isRoot ? 0 : parseFloat(viewStyle.borderBottomWidth) || 0; + let borderLeft = isRoot ? 0 : parseFloat(viewStyle.borderLeftWidth) || 0; + let borderRight = isRoot ? 0 : parseFloat(viewStyle.borderRightWidth) || 0; + + let scrollBarOffsetX = isRoot ? 0 : borderLeft + borderRight; + let scrollBarOffsetY = isRoot ? 0 : borderTop + borderBottom; + let scrollBarWidth = isRoot + ? 0 + : scrollView.offsetWidth - scrollView.clientWidth - scrollBarOffsetX; + let scrollBarHeight = isRoot + ? 0 + : scrollView.offsetHeight - scrollView.clientHeight - scrollBarOffsetY; + + let portLeft = viewLeft + borderLeft + scrollPaddingLeft; + let portRight = viewRight - borderRight - scrollPaddingRight; + let portTop = viewTop + borderTop + scrollPaddingTop; + let portBottom = viewBottom - borderBottom - scrollPaddingBottom - scrollBarHeight; + + let direction = viewStyle.direction; + if (direction === 'rtl' && !isIOS()) { + portLeft += scrollBarWidth; + } else { + portRight -= scrollBarWidth; + } + + return {top: portTop, bottom: portBottom, left: portLeft, right: portRight}; +} + +/** + * ScrollIntoViewIfNeeded(element, scrollView): + * Implements the non-standard scrollIntoViewIfNeeded(false) algorithm using + * direct pixel-delta math — no 'nearest' or 'center' alignment keywords. + */ +function scrollIntoViewIfNeeded(scrollView: HTMLElement, element: HTMLElement): boolean { + if (scrollView === element) { + return false; + } + + let itemStyle = window.getComputedStyle(element); + let target = element.getBoundingClientRect(); + + 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 areaTop = target.top - scrollMarginTop; + let areaBottom = target.bottom + scrollMarginBottom; + let areaLeft = target.left - scrollMarginLeft; + let areaRight = target.right + scrollMarginRight; + + let port = getScrollPort(scrollView); + + let isVisibleV = areaTop >= port.top && areaBottom <= port.bottom; + let isVisibleH = areaLeft >= port.left && areaRight <= port.right; + if (isVisibleV && isVisibleH) { + return false; + } + + let deltaY = 0; + let deltaX = 0; + + if (!isVisibleV) { + if (areaTop < port.top) { + deltaY = areaTop - port.top; + } else { + deltaY = areaBottom - port.bottom; + } + } + + if (!isVisibleH) { + if (areaLeft < port.left) { + deltaX = areaLeft - port.left; + } else { + deltaX = areaRight - port.right; + } + } + + let newScrollTop = scrollView.scrollTop + deltaY; + let newScrollLeft = scrollView.scrollLeft + deltaX; + + if (process.env.NODE_ENV === 'test') { + scrollView.scrollTop = newScrollTop; + scrollView.scrollLeft = newScrollLeft; + return true; + } + + scrollView.scrollTo({top: newScrollTop, left: newScrollLeft}); + return true; +} + +/** + * Helper to check container ancestry without triggering native lint warnings or breaking bundlers. + */ +function isAncestor(parent: Element, child: Node): boolean { + let current: Node | null = child; + while (current) { + if (current === parent) { + return true; + } + current = current.parentNode; + } + return false; +} + /** * Scrolls the `targetElement` so it is visible in the viewport. Accepts an optional - * `opts.containingElement` that will be centered in the viewport prior to scrolling the - * targetElement into view. If scrolling is prevented on the body (e.g. targetElement is in a - * popover), this will only scroll the scroll parents of the targetElement up to but not including - * the body itself. + * `opts.containingElement` that is used to limit which scroll parents are considered + * internal to the component vs external. */ export function scrollIntoViewport( targetElement: Element | null, 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'}); - } - } else { - let {left: originalLeft, top: originalTop} = targetElement.getBoundingClientRect(); + if (!targetElement || !targetElement.isConnected) { + return; + } + + let root = document.scrollingElement || document.documentElement; + let isScrollPrevented = window.getComputedStyle(root).overflow === 'hidden'; + + // Step 1: Walk scroll parents of the TARGET that are INSIDE the containingElement. + let scrollParents = getScrollParents(targetElement, true); + for (let scrollParent of scrollParents) { + if (containingElement && !isAncestor(containingElement, scrollParent as Node)) { + continue; + } + if (isScrollPrevented && (scrollParent === root || scrollParent === document.body)) { + continue; + } + if (scrollParent instanceof HTMLElement && targetElement instanceof HTMLElement) { + scrollIntoViewIfNeeded(scrollParent, targetElement); + } + } - // 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); - for (let scrollParent of scrollParents) { - scrollIntoView(scrollParent as HTMLElement, targetElement as HTMLElement); + // Step 2: Ensure outer scroll containers above the containingElement are also checked. + // Instead of skipping them, we walk them relative to the target's newly updated location. + if (containingElement) { + let outerScrollParents = getScrollParents(containingElement, true); + for (let scrollParent of outerScrollParents) { + if (isScrollPrevented && (scrollParent === root || scrollParent === document.body)) { + continue; } - 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); - } + if (scrollParent instanceof HTMLElement && targetElement instanceof HTMLElement) { + scrollIntoViewIfNeeded(scrollParent, targetElement); } } } diff --git a/packages/react-aria/test/utils/scrollIntoView.test.ts b/packages/react-aria/test/utils/scrollIntoView.test.ts index c1f567fe839..a77eb1e4196 100644 --- a/packages/react-aria/test/utils/scrollIntoView.test.ts +++ b/packages/react-aria/test/utils/scrollIntoView.test.ts @@ -10,14 +10,20 @@ * 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; + let scrollIntoViewSpy: jest.SpyInstance; beforeEach(() => { target = document.createElement('div'); document.body.appendChild(target); + + if (!HTMLElement.prototype.scrollIntoView) { + HTMLElement.prototype.scrollIntoView = () => {}; + } + scrollIntoViewSpy = jest.spyOn(HTMLElement.prototype, 'scrollIntoView'); }); afterEach(() => { @@ -35,9 +41,6 @@ describe('scrollIntoView', () => { }); 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, @@ -49,26 +52,18 @@ describe('scrollIntoView', () => { 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; - } + jest.spyOn(window, 'getComputedStyle').mockImplementation(_el => { return { - scrollMarginTop: '0px', - scrollMarginBottom: '0px', - scrollMarginLeft: '0px', - scrollMarginRight: '0px' - } as CSSStyleDeclaration; + borderTopWidth: '100px', + borderBottomWidth: '100px', + borderLeftWidth: '100px', + borderRightWidth: '100px', + scrollPaddingTop: '0px', + scrollPaddingBottom: '0px', + scrollPaddingLeft: '0px', + scrollPaddingRight: '0px', + direction: 'ltr' + } as unknown as CSSStyleDeclaration; }); Object.defineProperty(scrollView, 'clientHeight', {get: () => 500, configurable: true}); @@ -80,9 +75,6 @@ describe('scrollIntoView', () => { }); 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, @@ -95,26 +87,18 @@ describe('scrollIntoView', () => { 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; - } + jest.spyOn(window, 'getComputedStyle').mockImplementation(_el => { return { - scrollMarginTop: '0px', - scrollMarginBottom: '0px', - scrollMarginLeft: '0px', - scrollMarginRight: '0px' - } as CSSStyleDeclaration; + borderTopWidth: '100px', + borderBottomWidth: '100px', + borderLeftWidth: '100px', + borderRightWidth: '100px', + scrollPaddingTop: '0px', + scrollPaddingBottom: '0px', + scrollPaddingLeft: '0px', + scrollPaddingRight: '0px', + direction: 'ltr' + } as unknown as CSSStyleDeclaration; }); Object.defineProperty(scrollView, 'clientHeight', {get: () => 500, configurable: true}); @@ -125,4 +109,136 @@ describe('scrollIntoView', () => { expect(scrollView.scrollTop).toBe(2600); }); }); + + describe('scrollIntoViewport', () => { + it('does not call scrollIntoView under any circumstances', () => { + const containingElement = document.createElement('div'); + const targetElement = document.createElement('div'); + containingElement.appendChild(targetElement); + document.body.appendChild(containingElement); + + jest.spyOn(window, 'getComputedStyle').mockImplementation(_el => { + return {overflow: 'visible'} as CSSStyleDeclaration; + }); + + jest.spyOn(targetElement, 'getBoundingClientRect').mockReturnValue({ + top: 50, + bottom: 80, + left: 50, + right: 150, + width: 100, + height: 30 + } as DOMRect); + + scrollIntoViewport(targetElement, {containingElement}); + + expect(scrollIntoViewSpy).not.toHaveBeenCalled(); + + document.body.removeChild(containingElement); + }); + + it('does not trigger vertical scroll when only left changes (ArrowRight scenario)', () => { + const containingElement = document.createElement('div'); + const targetElement = document.createElement('div'); + Object.setPrototypeOf(containingElement, HTMLElement.prototype); + Object.setPrototypeOf(targetElement, HTMLElement.prototype); + containingElement.appendChild(targetElement); + document.body.appendChild(containingElement); + + jest.spyOn(window, 'getComputedStyle').mockImplementation(_el => { + return {overflow: 'visible'} as CSSStyleDeclaration; + }); + + jest.spyOn(containingElement, 'getBoundingClientRect').mockReturnValue({ + top: 0, + bottom: 300, + left: 0, + right: 800, + width: 800, + height: 300 + } as DOMRect); + + jest.spyOn(targetElement, 'getBoundingClientRect').mockReturnValue({ + top: 50, + bottom: 80, + left: 50, + right: 150, + width: 100, + height: 30 + } as DOMRect); + + scrollIntoViewport(targetElement, {containingElement}); + + expect(scrollIntoViewSpy).not.toHaveBeenCalled(); + + document.body.removeChild(containingElement); + }); + + it('does not center a containingElement that is larger than the viewport', () => { + const containingElement = document.createElement('div'); + const targetElement = document.createElement('div'); + Object.setPrototypeOf(containingElement, HTMLElement.prototype); + Object.setPrototypeOf(targetElement, HTMLElement.prototype); + containingElement.appendChild(targetElement); + document.body.appendChild(containingElement); + + jest.spyOn(window, 'getComputedStyle').mockImplementation(_el => { + return {overflow: 'visible'} as CSSStyleDeclaration; + }); + + // Container is taller than the viewport (5000px vs typical 768px) + jest.spyOn(containingElement, 'getBoundingClientRect').mockReturnValue({ + top: -100, + bottom: 4900, + left: 0, + right: 600, + width: 600, + height: 5000 + } as DOMRect); + + jest.spyOn(targetElement, 'getBoundingClientRect').mockReturnValue({ + top: -100, + bottom: -50, + left: 0, + right: 100, + width: 100, + height: 50 + } as DOMRect); + + scrollIntoViewport(targetElement, {containingElement}); + + expect(scrollIntoViewSpy).not.toHaveBeenCalled(); + + document.body.removeChild(containingElement); + }); + + it('does not call scrollIntoView in the scroll-prevented (overlay) path', () => { + const containingElement = document.createElement('div'); + const targetElement = document.createElement('div'); + Object.setPrototypeOf(containingElement, HTMLElement.prototype); + Object.setPrototypeOf(targetElement, HTMLElement.prototype); + containingElement.appendChild(targetElement); + document.body.appendChild(containingElement); + + // Simulate body overflow:hidden (modal/popover) + jest.spyOn(window, 'getComputedStyle').mockImplementation(_el => { + return {overflow: 'hidden'} as CSSStyleDeclaration; + }); + + jest.spyOn(targetElement, 'getBoundingClientRect').mockReturnValue({ + top: -200, + bottom: -150, + left: 0, + right: 100, + width: 100, + height: 50 + } as DOMRect); + + scrollIntoViewport(targetElement, {containingElement}); + + expect(scrollIntoViewSpy).not.toHaveBeenCalled(); + + document.body.removeChild(containingElement); + }); + }); });