Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 113 additions & 33 deletions packages/react-aria/src/utils/scrollIntoView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Expand Down
61 changes: 60 additions & 1 deletion packages/react-aria/test/utils/scrollIntoView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
});
});
});