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
1 change: 1 addition & 0 deletions packages/@react-aria/test-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/

export {triggerLongPress} from './utils';
export {TestContainer} from './testContainer';
export {installMouseEvent, installPointerEvent} from './testSetup';
export {pointerMap} from './userEventMaps';
export {User} from './user';
Expand Down
77 changes: 77 additions & 0 deletions packages/@react-aria/test-utils/src/testContainer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* 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.
*/

export class TestContainer {
private document: Document;
private window: Window & typeof globalThis;

constructor(type: string) {
let doctype = document.implementation.createDocumentType(type, '', '');

let iframe = document.createElement('iframe');
document.body.append(iframe);

this.document = iframe.contentDocument as typeof this.document;
this.window = iframe.contentWindow as typeof this.window;

this.document.replaceChildren();
this.document.append(doctype);
}

/**
* Returns the host node of the container.
*/
public getRootNode(): HTMLIFrameElement {
return this.window.frameElement as HTMLIFrameElement;
}

/**
* Returns the document of the container.
*/
public getDocument(): Document {
return this.document;
}

/**
* Returns the window of the container.
*/
public getWindow(): Window & typeof globalThis {
return this.window;
}

/**
* Clears all nodes in the container document.
*/
public reset(): void {
let doctype = this.document.doctype;

if (doctype != null) {
let serializer = new this.window.XMLSerializer();
let serialized = serializer.serializeToString(doctype);

this.document.open();
this.document.write(serialized);
this.document.close();
} else {
this.document.open();
this.document.write('');
this.document.close();
}
}

/**
* Removes the container from its owner document.
*/
public remove(): void {
this.window.frameElement?.remove();
}
}
1 change: 1 addition & 0 deletions packages/@react-types/shared/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ export * from './orientation';
export * from './locale';
export * from './key';
export * from './layout';
export * from './interactions';
25 changes: 25 additions & 0 deletions packages/@react-types/shared/src/interactions.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* 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 {BoundingNode} from './layout';

export type Modality = 'keyboard' | 'pointer' | 'virtual';

export type ScrollContainer = 'all' | 'nearest';
export type ScrollMode = 'always' | 'if-needed';

export interface ScrollOptions {
/** The animation behavior to use for the scroll. */
behavior?: ScrollBehavior;
/** The interaction modality to perform the scroll with. */
modality?: Modality;
}
5 changes: 5 additions & 0 deletions packages/@react-types/shared/src/layout.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
* governing permissions and limitations under the License.
*/

export type BoundingNode = Element | Document;

export type Axis = 'block' | 'inline';
export type Corner = 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';

export interface Point {
x: number;
y: number;
Expand Down
8 changes: 8 additions & 0 deletions packages/react-aria/exports/private/utils/layoutHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export {
getVisualViewport,
getWritingElement,
getStylingElement,
getScrollingElement,
getOverflowingElement,
getContainingElement
} from '../../../src/utils/layoutHelpers';
9 changes: 8 additions & 1 deletion packages/react-aria/exports/private/utils/typeHelpers.ts
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
export {isWindow, isDocument, isElement, isShadowRoot} from '../../../src/utils/typeHelpers';
export {
isWindow,
isDocument,
isElement,
isHTMLElement,
isSVGElement,
isShadowRoot
} from '../../../src/utils/typeHelpers';
49 changes: 5 additions & 44 deletions packages/react-aria/src/overlays/calculatePosition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@

import {Axis, Placement, PlacementAxis, SizeAxis} from './useOverlayPosition';
import {clamp} from 'react-stately/private/utils/number';
import {getParentElement, nodeContains} from '../utils/shadowdom/DOMFunctions';
import {getContainingElement} from '../utils/layoutHelpers';
import {getOwnerDocument} from '../utils/domHelpers';
import {isWebKit} from '../utils/platform';
import {nodeContains} from '../utils/shadowdom/DOMFunctions';

interface Position {
top?: number;
Expand Down Expand Up @@ -805,47 +807,6 @@ function getPosition(
// this element will be positioned relative to.
// https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block
function getContainingBlock(node: HTMLElement): Element {
// The offsetParent of an element in most cases equals the containing block.
// https://w3c.github.io/csswg-drafts/cssom-view/#dom-htmlelement-offsetparent
let offsetParent = node.offsetParent;

// The offsetParent algorithm terminates at the document body,
// even if the body is not a containing block. Double check that
// and use the documentElement if so.
if (
offsetParent &&
offsetParent === document.body &&
window.getComputedStyle(offsetParent).position === 'static' &&
!isContainingBlock(offsetParent)
) {
offsetParent = document.documentElement;
}

// TODO(later): handle table elements?

// The offsetParent can be null if the element has position: fixed, or a few other cases.
// We have to walk up the tree manually in this case because fixed positioned elements
// are still positioned relative to their containing block, which is not always the viewport.
if (offsetParent == null) {
offsetParent = getParentElement(node);
while (offsetParent && !isContainingBlock(offsetParent)) {
offsetParent = getParentElement(offsetParent);
}
}

// Fall back to the viewport.
return offsetParent || document.documentElement;
}

// https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#identifying_the_containing_block
function isContainingBlock(node: Element): boolean {
let style = window.getComputedStyle(node);
return (
style.transform !== 'none' ||
/transform|perspective/.test(style.willChange) ||
style.filter !== 'none' ||
style.contain === 'paint' ||
('backdropFilter' in style && style.backdropFilter !== 'none') ||
('WebkitBackdropFilter' in style && style.WebkitBackdropFilter !== 'none')
);
let ownerDocument = getOwnerDocument(node);
return getContainingElement(node) ?? ownerDocument.documentElement;
}
138 changes: 138 additions & 0 deletions packages/react-aria/src/utils/getScrollOffset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/*
* 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 {Axis, BoundingNode} from '@react-types/shared';
import {getOverflowingElement, getScrollingElement, getWritingElement} from './layoutHelpers';
import {getOwnerDocument, getOwnerWindow} from './domHelpers';
import {isDocument} from './typeHelpers';
import {nodeContains} from './shadowdom/DOMFunctions';

export function getScrollLeft(node: BoundingNode): number {
return getScrollOffset(node, 'inline');
}

export function getScrollTop(node: BoundingNode): number {
return getScrollOffset(node, 'block');
}

export function getMaxScrollLeft(node: BoundingNode): number {
return getMaxScrollOffset(node, 'inline');
}

export function getMaxScrollTop(node: BoundingNode): number {
return getMaxScrollOffset(node, 'block');
}

export function getScrollLeftDirection(node: BoundingNode): 'ascending' | 'descending' {
return getScrollDirection(node, 'inline');
}

export function getScrollTopDirection(node: BoundingNode): 'ascending' | 'descending' {
return getScrollDirection(node, 'block');
}

function getScrollOffset(node: BoundingNode, axis: Axis): number {
let scrollingElement = getScrollingElement(node);

// TODO: Leave this to each callsite that needs it or force it here?
// https://issues.chromium.org/issues/40839168
// if (isWebKit() && !isIOS() && ownerWindow.devicePixelRatio !== 1) {
// top = Math.round(scrollOffsetBlock);
// left = Math.round(scrollOffsetInline);
// }

switch (axis) {
case 'block':
return scrollingElement.scrollTop;
case 'inline':
return scrollingElement.scrollLeft;
}
}

function getMaxScrollOffset(node: BoundingNode, axis: Axis): number {
let ownerWindow = getOwnerWindow(node);
let ownerDocument = getOwnerDocument(node);

let rootScrollingElement = getScrollingElement(ownerDocument);
let rootOverflowingElement = getOverflowingElement(ownerDocument);

// A node containing the root scrolling element shall assert as its document.
if (nodeContains(node, rootScrollingElement)) node = ownerDocument;

// Overflow on the body and root may be propagated to the viewport, so 'visible'
// becomes 'auto' and 'clip' turns into 'hidden'. If an element propagates
// its overflow, its own overflow is always a 'visible' used value, so bail out.
// https://drafts.csswg.org/css-overflow/#overflow-propagation
if (node === rootOverflowingElement) return 0;

let scrollingElement = getScrollingElement(node);
let overflowingElement = getOverflowingElement(node);

let style = ownerWindow.getComputedStyle(overflowingElement);
let [overflowX, overflowY = overflowX] = String(style.overflow).split(' ');

let isScrollableBlock = /(auto|scroll|hidden)/.test(overflowY + style.overflowY);
let isScrollableInline = /(auto|scroll|hidden)/.test(overflowX + style.overflowX);

// Bail if an element is not scrollable in the given axis.
if (axis === 'block' && !isScrollableBlock && !isDocument(node)) return 0;
if (axis === 'inline' && !isScrollableInline && !isDocument(node)) return 0;

// Otherwise, measure the scrollable range in the current direction.
let scrollSize = axis === 'block' ? scrollingElement.scrollHeight : scrollingElement.scrollWidth;
let clientSize = axis === 'block' ? scrollingElement.clientHeight : scrollingElement.clientWidth;

switch (getScrollDirection(node, axis)) {
case 'ascending':
return Math.max(0, scrollSize - clientSize);
case 'descending':
return Math.min(0, clientSize - scrollSize);
}
}

function getScrollDirection(node: BoundingNode, axis: Axis): 'ascending' | 'descending' {
let ownerWindow = getOwnerWindow(node);
let ownerDocument = getOwnerDocument(node);

let rootScrollingElement = getScrollingElement(ownerDocument);

// A node containing the root scrolling element shall assert as its document.
if (nodeContains(node, rootScrollingElement)) node = ownerDocument;

let writingElement = getWritingElement(node);
let style = ownerWindow.getComputedStyle(writingElement);

let isFlexDisplay = /flex/.test(style.display);
let isFlexReverseBlock = /column-reverse/.test(style.flexDirection);
let isFlexReverseInline = /row-reverse/.test(style.flexDirection);

// A viewport is never a flex container, so it only follows the writing direction
// and any reversed content falls outside of its scrollable overflow region.
// https://drafts.csswg.org/css-overflow/#scrollable-overflow-region
if (axis === 'block' && isFlexDisplay && isFlexReverseBlock) {
return isDocument(node) ? 'ascending' : 'descending';
}

if (axis === 'inline' && isFlexDisplay && isFlexReverseInline && isDocument(node)) {
return style.direction === 'rtl' ? 'descending' : 'ascending';
}

if (axis === 'inline' && isFlexDisplay && isFlexReverseInline) {
return style.direction === 'rtl' ? 'ascending' : 'descending';
}

if (axis === 'inline' && style.direction === 'rtl') {
return 'descending';
}

return 'ascending';
}
33 changes: 23 additions & 10 deletions packages/react-aria/src/utils/getScrollParent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,31 @@
* governing permissions and limitations under the License.
*/

import {getParentElement} from './shadowdom/DOMFunctions';
import {isScrollable} from './isScrollable';
import {genScrollParents, ScrollParentOptions} from './getScrollParents';
import {getOwnerDocument} from './domHelpers';
import {getScrollingElement} from './layoutHelpers';

export function getScrollParent(node: Element, checkForOverflow?: boolean): Element {
let scrollableNode: Element | null = node;
if (isScrollable(scrollableNode, checkForOverflow)) {
scrollableNode = getParentElement(scrollableNode);
/**
* Returns the nearest (scrollable) ancestor of a given element.
* https://drafts.csswg.org/cssom-view/#dom-htmlelement-scrollparent.
*/
export function getScrollParent(element: Element, options?: ScrollParentOptions): Element;
/** @deprecated Use 'getScrollParent(element, {scrollable: true})' instead. */
export function getScrollParent(element: Element, checkForOverflow?: boolean): Element;
export function getScrollParent(element: Element, options?: ScrollParentOptions | boolean) {
if (typeof options === 'undefined' || typeof options === 'boolean') {
return getScrollParent(element, {scrollable: options});
}

while (scrollableNode && !isScrollable(scrollableNode, checkForOverflow)) {
scrollableNode = getParentElement(scrollableNode);
}
let ownerDocument = getOwnerDocument(element);

let scrollGenerator = genScrollParents(element, {
container: 'nearest',
...options
});

let scrollParent = scrollGenerator.next();

return scrollableNode || document.scrollingElement || document.documentElement;
// TODO(later): Fallback is a bug, remove after auditing call sites.
Comment thread
nwidynski marked this conversation as resolved.
Resolved
return scrollParent.value ?? getScrollingElement(ownerDocument);
}
Loading
Loading