From 999747865d24925f99af40d2a07dd749e0cb36cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Thu, 3 Sep 2026 00:01:01 +0200 Subject: [PATCH 1/6] fix: scrollable flat tree --- .../@react-types/shared/src/collections.d.ts | 15 +- packages/@react-types/shared/src/index.d.ts | 2 + .../@react-types/shared/src/interactions.d.ts | 25 +++ packages/@react-types/shared/src/layout.d.ts | 33 ++++ .../exports/private/utils/domHelpers.ts | 9 +- .../exports/private/utils/layoutHelpers.ts | 8 + .../exports/private/utils/typeHelpers.ts | 8 + packages/react-aria/src/interactions/utils.ts | 3 +- .../src/overlays/ariaHideOutside.ts | 4 +- .../src/overlays/calculatePosition.ts | 47 +---- packages/react-aria/src/utils/domHelpers.ts | 39 +--- .../react-aria/src/utils/getScrollOffset.ts | 112 ++++++++++++ .../react-aria/src/utils/getScrollParent.ts | 28 +-- .../react-aria/src/utils/getScrollParents.ts | 125 +++++++++++-- .../react-aria/src/utils/isContainingBlock.ts | 29 +++ packages/react-aria/src/utils/isScrollable.ts | 99 +++++++++-- .../react-aria/src/utils/layoutHelpers.ts | 166 ++++++++++++++++++ .../src/utils/shadowdom/DOMFunctions.ts | 33 +++- packages/react-aria/src/utils/typeHelpers.ts | 74 ++++++++ .../test/utils/getScrollParents.test.ts | 8 + 20 files changed, 728 insertions(+), 139 deletions(-) create mode 100644 packages/@react-types/shared/src/interactions.d.ts create mode 100644 packages/@react-types/shared/src/layout.d.ts create mode 100644 packages/react-aria/exports/private/utils/layoutHelpers.ts create mode 100644 packages/react-aria/exports/private/utils/typeHelpers.ts create mode 100644 packages/react-aria/src/utils/getScrollOffset.ts create mode 100644 packages/react-aria/src/utils/isContainingBlock.ts create mode 100644 packages/react-aria/src/utils/layoutHelpers.ts create mode 100644 packages/react-aria/src/utils/typeHelpers.ts diff --git a/packages/@react-types/shared/src/collections.d.ts b/packages/@react-types/shared/src/collections.d.ts index 69a53116c3a..60c98d76038 100644 --- a/packages/@react-types/shared/src/collections.d.ts +++ b/packages/@react-types/shared/src/collections.d.ts @@ -10,9 +10,10 @@ * governing permissions and limitations under the License. */ -import {Key} from '@react-types/shared'; +import {Key} from './key'; import {LinkDOMProps} from './dom'; import {ReactElement, ReactNode} from 'react'; +import {Rect, Size} from './layout'; export interface ItemProps extends LinkDOMProps { /** Rendered contents of the item or child items. */ @@ -132,18 +133,6 @@ export interface KeyboardDelegate { getKeyForSearch?(search: string, fromKey?: Key | null): Key | null; } -export interface Rect { - x: number; - y: number; - width: number; - height: number; -} - -export interface Size { - width: number; - height: number; -} - /** A LayoutDelegate provides layout information for collection items. */ export interface LayoutDelegate { /** Returns a rectangle for the item with the given key. */ diff --git a/packages/@react-types/shared/src/index.d.ts b/packages/@react-types/shared/src/index.d.ts index a03f171f718..9276726a2aa 100644 --- a/packages/@react-types/shared/src/index.d.ts +++ b/packages/@react-types/shared/src/index.d.ts @@ -24,3 +24,5 @@ export * from './labelable'; export * from './orientation'; export * from './locale'; export * from './key'; +export * from './layout'; +export * from './interactions'; diff --git a/packages/@react-types/shared/src/interactions.d.ts b/packages/@react-types/shared/src/interactions.d.ts new file mode 100644 index 00000000000..90ef8966fcc --- /dev/null +++ b/packages/@react-types/shared/src/interactions.d.ts @@ -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; +} diff --git a/packages/@react-types/shared/src/layout.d.ts b/packages/@react-types/shared/src/layout.d.ts new file mode 100644 index 00000000000..cc9f4599eff --- /dev/null +++ b/packages/@react-types/shared/src/layout.d.ts @@ -0,0 +1,33 @@ +/* + * 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 type BoundingNode = Element | Document; + +export type Axis = 'block' | 'inline'; +export type Corner = 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight'; + +export interface Point { + x: number; + y: number; +} + +export interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +export interface Size { + width: number; + height: number; +} diff --git a/packages/react-aria/exports/private/utils/domHelpers.ts b/packages/react-aria/exports/private/utils/domHelpers.ts index 156b11a67a4..9c8e160b569 100644 --- a/packages/react-aria/exports/private/utils/domHelpers.ts +++ b/packages/react-aria/exports/private/utils/domHelpers.ts @@ -1,7 +1,2 @@ -export { - addEvent, - getOwnerDocument, - getOwnerWindow, - isDocument, - isShadowRoot -} from '../../../src/utils/domHelpers'; +export {addEvent, getOwnerDocument, getOwnerWindow} from '../../../src/utils/domHelpers'; +export {isDocument, isShadowRoot} from '../../../src/utils/typeHelpers'; diff --git a/packages/react-aria/exports/private/utils/layoutHelpers.ts b/packages/react-aria/exports/private/utils/layoutHelpers.ts new file mode 100644 index 00000000000..6ceaf384d8f --- /dev/null +++ b/packages/react-aria/exports/private/utils/layoutHelpers.ts @@ -0,0 +1,8 @@ +export { + getVisualViewport, + getWritingElement, + getStylingElement, + getScrollingElement, + getOverflowingElement, + getContainingElement +} from '../../../src/utils/layoutHelpers'; diff --git a/packages/react-aria/exports/private/utils/typeHelpers.ts b/packages/react-aria/exports/private/utils/typeHelpers.ts new file mode 100644 index 00000000000..1ef1df84c7e --- /dev/null +++ b/packages/react-aria/exports/private/utils/typeHelpers.ts @@ -0,0 +1,8 @@ +export { + isWindow, + isDocument, + isElement, + isHTMLElement, + isSVGElement, + isShadowRoot +} from '../../../src/utils/typeHelpers'; diff --git a/packages/react-aria/src/interactions/utils.ts b/packages/react-aria/src/interactions/utils.ts index 21878b6833a..83ac3f772bf 100644 --- a/packages/react-aria/src/interactions/utils.ts +++ b/packages/react-aria/src/interactions/utils.ts @@ -13,8 +13,9 @@ import {FocusableElement} from '@react-types/shared'; import {focusWithoutScrolling} from '../utils/focusWithoutScrolling'; import {getActiveElement, getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions'; -import {getOwnerWindow, isShadowRoot} from '../utils/domHelpers'; +import {getOwnerWindow} from '../utils/domHelpers'; import {isFocusable} from '../utils/isFocusable'; +import {isShadowRoot} from '../utils/typeHelpers'; import {FocusEvent as ReactFocusEvent, SyntheticEvent, useCallback, useRef} from 'react'; import {useLayoutEffect} from '../utils/useLayoutEffect'; diff --git a/packages/react-aria/src/overlays/ariaHideOutside.ts b/packages/react-aria/src/overlays/ariaHideOutside.ts index 66cc828ad2c..0989a86c953 100644 --- a/packages/react-aria/src/overlays/ariaHideOutside.ts +++ b/packages/react-aria/src/overlays/ariaHideOutside.ts @@ -11,8 +11,8 @@ */ import {createShadowTreeWalker} from '../utils/shadowdom/ShadowTreeWalker'; - -import {getOwnerDocument, getOwnerWindow, isShadowRoot} from '../utils/domHelpers'; +import {getOwnerDocument, getOwnerWindow} from '../utils/domHelpers'; +import {isShadowRoot} from '../utils/typeHelpers'; import {nodeContains} from '../utils/shadowdom/DOMFunctions'; import {shadowDOM} from 'react-stately/private/flags/flags'; diff --git a/packages/react-aria/src/overlays/calculatePosition.ts b/packages/react-aria/src/overlays/calculatePosition.ts index 65dc7ee2cc0..bf6cea3335d 100644 --- a/packages/react-aria/src/overlays/calculatePosition.ts +++ b/packages/react-aria/src/overlays/calculatePosition.ts @@ -12,6 +12,8 @@ import {Axis, Placement, PlacementAxis, SizeAxis} from './useOverlayPosition'; import {clamp} from 'react-stately/private/utils/number'; +import {getContainingElement} from '../utils/layoutHelpers'; +import {getOwnerDocument} from '../utils/domHelpers'; import {isWebKit} from '../utils/platform'; import {nodeContains} from '../utils/shadowdom/DOMFunctions'; @@ -804,47 +806,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 = node.parentElement; - while (offsetParent && !isContainingBlock(offsetParent)) { - offsetParent = offsetParent.parentElement; - } - } - - // 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; } diff --git a/packages/react-aria/src/utils/domHelpers.ts b/packages/react-aria/src/utils/domHelpers.ts index a1f7f8d7701..8922c4ee7af 100644 --- a/packages/react-aria/src/utils/domHelpers.ts +++ b/packages/react-aria/src/utils/domHelpers.ts @@ -11,6 +11,7 @@ */ import type {EventMapType} from '@react-types/shared'; +import {isDocument, isWindow} from './typeHelpers'; export const getOwnerDocument = (target?: EventTarget | null): Document => { if (isWindow(target)) return target.document; @@ -28,44 +29,6 @@ export const getOwnerWindow = (target?: EventTarget | null): Window & typeof glo return ownerDocument?.defaultView ?? (typeof window !== 'undefined' ? window : undefined); }; -/** - * Type guard that checks if a value is a Node. Verifies the presence and type of the nodeType - * property. - */ -export function isNode(value: unknown): value is Node { - return ( - value !== null && - typeof value === 'object' && - 'nodeType' in value && - typeof value.nodeType === 'number' - ); -} - -/** - * Type guard that checks if a value is a Window. Uses window self reference checks to - * distinguish Window from other values. - */ -function isWindow(value: unknown): value is Window & typeof globalThis { - return typeof value === 'object' && value != null && 'window' in value && value.window === value; -} - -/** - * Type guard that checks if a value is a Document. Uses nodeType and host property checks to - * distinguish Document from other values. - */ -export function isDocument(value: unknown): value is Document { - return isNode(value) && value.nodeType === 9; -} - -/** - * Type guard that checks if a value is a ShadowRoot. Uses nodeType and host property checks to - * distinguish ShadowRoot from other values. - */ -export function isShadowRoot(value: unknown): value is ShadowRoot { - // 11 = DOCUMENT_FRAGMENT_NODE - return isNode(value) && value.nodeType === 11 && 'host' in value; -} - /** * Attaches an event listener on target(s) and returns a cleanup function. */ diff --git a/packages/react-aria/src/utils/getScrollOffset.ts b/packages/react-aria/src/utils/getScrollOffset.ts new file mode 100644 index 00000000000..c49241bc4f4 --- /dev/null +++ b/packages/react-aria/src/utils/getScrollOffset.ts @@ -0,0 +1,112 @@ +/* + * 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 {getOwnerDocument, getOwnerWindow} from './domHelpers'; +import {getScrollingElement, getWritingElement} from './layoutHelpers'; +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 ownerDocument = getOwnerDocument(node); + + let scrollingElement = getScrollingElement(node); + let rootScrollingElement = getScrollingElement(ownerDocument); + + // A node containing the root scrolling element shall assert as its document. + let client = nodeContains(node, rootScrollingElement) + ? ownerDocument.documentElement + : (node as Element); + + let scrollSize = axis === 'block' ? scrollingElement.scrollHeight : scrollingElement.scrollWidth; + let clientSize = axis === 'block' ? client.clientHeight : client.clientWidth; + + switch (getScrollDirection(node, axis)) { + case 'ascending': + return Math.max(0, scrollSize - clientSize); + case 'descending': + return Math.max(0, scrollSize - clientSize) * -1; + } +} + +function getScrollDirection(node: BoundingNode, axis: Axis): 'ascending' | 'descending' { + let ownerWindow = getOwnerWindow(node); + let ownerDocument = getOwnerDocument(node); + + let scrollingElement = getScrollingElement(node); + let rootScrollingElement = getScrollingElement(ownerDocument); + + // A node containing the root scrolling element shall assert as its document. + let style = nodeContains(node, rootScrollingElement) + ? ownerWindow.getComputedStyle(getWritingElement(ownerDocument)) + : ownerWindow.getComputedStyle(getWritingElement(node)); + + let isFlexDisplay = /flex/.test(style.display); + let isFlexReverseBlock = /column-reverse/.test(style.flexDirection); + let isFlexReverseInline = /row-reverse/.test(style.flexDirection); + + // https://bugs.webkit.org/show_bug.cgi?id=313748 + if (axis === 'block' && isFlexDisplay && isFlexReverseBlock) { + return scrollingElement === rootScrollingElement ? 'ascending' : 'descending'; + } + + if (axis === 'inline' && isFlexDisplay && isFlexReverseInline) { + return style.direction === 'rtl' ? 'ascending' : 'descending'; + } + + if (axis === 'inline' && style.direction === 'rtl') { + return 'descending'; + } + + return 'ascending'; +} diff --git a/packages/react-aria/src/utils/getScrollParent.ts b/packages/react-aria/src/utils/getScrollParent.ts index be231aabae7..4ca997029cc 100644 --- a/packages/react-aria/src/utils/getScrollParent.ts +++ b/packages/react-aria/src/utils/getScrollParent.ts @@ -10,17 +10,25 @@ * governing permissions and limitations under the License. */ -import {isScrollable} from './isScrollable'; +import {genScrollParents} 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 = scrollableNode.parentElement; - } +/** + * Returns the (scrollable) parent container for a given scroll alignment query. + * + * @deprecated Use 'getScrollTarget(element.parentElement)' instead. + */ +export function getScrollParent(element: Element, checkForOverflow?: boolean): Element { + let ownerDocument = getOwnerDocument(element); + + let generator = genScrollParents(element, { + scrollable: checkForOverflow, + container: 'nearest' + }); - while (scrollableNode && !isScrollable(scrollableNode, checkForOverflow)) { - scrollableNode = scrollableNode.parentElement; - } + let cursor = generator.next(); - return scrollableNode || document.scrollingElement || document.documentElement; + // Fallback is a bug, but is kept for backwards compatibility. + return cursor.value ?? getScrollingElement(ownerDocument); } diff --git a/packages/react-aria/src/utils/getScrollParents.ts b/packages/react-aria/src/utils/getScrollParents.ts index 0b98228f817..ab087c329e0 100644 --- a/packages/react-aria/src/utils/getScrollParents.ts +++ b/packages/react-aria/src/utils/getScrollParents.ts @@ -10,21 +10,122 @@ * governing permissions and limitations under the License. */ -import {isScrollable} from './isScrollable'; +import {getContainingElement, getScrollingElement} from './layoutHelpers'; +import {getOwnerDocument, getOwnerWindow} from '../utils/domHelpers'; +import {getParentNode, nodeContains} from './shadowdom/DOMFunctions'; +import {isDocument, isElement, isNode, isShadowRoot} from './typeHelpers'; +import {isScrollable, ScrollableOptions} from './isScrollable'; +import {ScrollContainer} from '@react-types/shared'; +import {shadowDOM} from 'react-stately/private/flags/flags'; -export function getScrollParents(node: Element, checkForOverflow?: boolean): Element[] { - let parentElements: Element[] = []; - let root = document.scrollingElement || document.documentElement; +export interface ScrollParentOptions extends ScrollableOptions { + /** The ancestor container to stop traversal at. */ + container?: Element | Document | ScrollContainer | null; +} - while (node) { - if (isScrollable(node, checkForOverflow)) { - parentElements.push(node); - } - if (node === root) { - break; +export interface ScrollTargetOptions extends ScrollableOptions { + /** The ancestor container to stop traversal at. */ + container?: Element | Document; +} + +/** + * Returns the (scrollable) ancestor for a given scroll alignment query. + * + * @deprecated Use 'Array.from(genScrollParents(element))' instead. + */ +export function getScrollParents(element: Element, checkForOverflow?: boolean): Element[] { + let scrollGenerator = genScrollParents(element, { + scrollable: checkForOverflow, + container: 'all' + }); + + return Array.from(scrollGenerator); +} + +/** + * Returns the nearest container-bound (scrollable) ancestor of an event target. + * This effectively translates to the element affected by a touch gesture. + */ +export function getScrollTarget( + target: EventTarget, + options: ScrollTargetOptions = {} +): Element | null { + let ownerDocument = getOwnerDocument(target); + + // A scrollable document returns its scrolling element. + if (isDocument(target) && isScrollable(ownerDocument, options)) { + return getScrollingElement(ownerDocument); + } + + if (isDocument(target) || !isElement(target)) { + return null; + } + + // Similarly, a scrollable element returns itself. + if (isScrollable(target, options)) { + return target; + } + + let scrollGenerator = genScrollParents(target, { + container: 'nearest', + ...options + }); + + let scrollParent = scrollGenerator.next(); + + return scrollParent.value; +} + +/** + * Returns the container-bound (scrollable) ancestor of an element. Yields intermediary + * ancestors as nodes of a scrollable flat-tree that composes the element. + */ +export function* genScrollParents( + element: Element, + options: ScrollParentOptions = {} +): Generator { + let {container = 'all'} = options; + + let node: Node | null = null; + let cursor: Element | null = null; + + let ownerWindow = getOwnerWindow(element); + let ownerDocument = getOwnerDocument(element); + + let rootScrollingElement = getScrollingElement(ownerDocument); + + while ((node = getParentNode(element))) { + let style = ownerWindow.getComputedStyle(element); + + // A positioned node may only scroll with its containing element. + if (/(absolute|fixed)/.test(style.position)) node = getContainingElement(element); + + // A shadow root cant be a scroll parent so skip to its host. + if (isShadowRoot(node) && shadowDOM()) node = node.host; + else if (isShadowRoot(node)) return null; + + // A fixed element without a containing block has no scroll parent. + if (node == null && style.position === 'fixed') return null; + + // Bail if we traverse past a (custom) boundary (inclusive). + if (isNode(container) && !nodeContains(container, node)) return cursor; + else if (!isElement(node)) break; + + // A node containing the root scrolling element is special cased below. + if (nodeContains(node, rootScrollingElement)) break; + + // Otherwise, yield if the node is scrollable. + if (isScrollable(node, options)) { + yield (cursor = node); + if (container === 'nearest') return cursor; } - node = node.parentElement as Element; + + element = node; + } + + if (isScrollable(rootScrollingElement, options)) { + yield (cursor = rootScrollingElement); } - return parentElements; + return cursor; } diff --git a/packages/react-aria/src/utils/isContainingBlock.ts b/packages/react-aria/src/utils/isContainingBlock.ts new file mode 100644 index 00000000000..02ec9452cae --- /dev/null +++ b/packages/react-aria/src/utils/isContainingBlock.ts @@ -0,0 +1,29 @@ +/* + * 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 {getOwnerWindow} from './domHelpers'; + +// https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#identifying_the_containing_block +export function isContainingBlock(element: Element): boolean { + let ownerWindow = getOwnerWindow(element); + let style = ownerWindow.getComputedStyle(element); + + return ( + style.transform !== 'none' || + style.perspective !== 'none' || + style.filter !== 'none' || + /(transform|perspective|filter)/.test(style.willChange) || + /(layout|paint|strict|content)/.test(style.contain) || + ('backdropFilter' in style && style.backdropFilter !== 'none') || + ('WebkitBackdropFilter' in style && style.WebkitBackdropFilter !== 'none') + ); +} diff --git a/packages/react-aria/src/utils/isScrollable.ts b/packages/react-aria/src/utils/isScrollable.ts index 352a5780399..5819b98b220 100644 --- a/packages/react-aria/src/utils/isScrollable.ts +++ b/packages/react-aria/src/utils/isScrollable.ts @@ -10,22 +10,97 @@ * governing permissions and limitations under the License. */ -export function isScrollable(node: Element | null, checkForOverflow?: boolean): boolean { - if (!node) { - return false; +import {Axis, BoundingNode, ScrollOptions} from '@react-types/shared'; +import {getMaxScrollLeft, getMaxScrollTop} from './getScrollOffset'; +import {getOverflowingElement, getScrollingElement, getStylingElement} from './layoutHelpers'; +import {getOwnerDocument, getOwnerWindow} from './domHelpers'; +import {isDocument} from './typeHelpers'; +import {nodeContains} from './shadowdom/DOMFunctions'; + +export interface ScrollableOptions extends Omit { + /** Whether the container must overflow. */ + scrollable?: boolean; + /** Whether the container must be snap-enabled. */ + snappable?: boolean; + /** A logical axis to restrict the scroll to. */ + axis?: Axis; +} + +/** + * Checks whether a container is potentially scroll(snap)able using modality. + */ +// TODO: Revisit https://github.com/adobe/react-spectrum/pull/5513#issuecomment-1847614274 +export function isScrollable(node: BoundingNode, options?: ScrollableOptions): boolean; +/** @deprecated Use 'isScrollable(element, {scrollable: true})' instead. */ +export function isScrollable(node: BoundingNode, checkForOverflow?: boolean): boolean; +export function isScrollable(node: BoundingNode, options?: ScrollableOptions | boolean) { + if (typeof options === 'undefined' || typeof options === 'boolean') { + return isScrollable(node, {scrollable: options}); } - let style = window.getComputedStyle(node); - let root = document.scrollingElement || document.documentElement; - let isScrollable = /(auto|scroll)/.test(style.overflow + style.overflowX + style.overflowY); - // Root element has `visible` overflow by default, but is scrollable nonetheless. - if (node === root && style.overflow !== 'hidden') { - isScrollable = true; + let {scrollable = false, snappable = false, modality = 'pointer', axis = 'both'} = options; + + // A snap always originates from the user agent so force 'virtual' modality. + modality = snappable ? 'virtual' : modality; + + 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 false; + + let stylingElement = getStylingElement(node); + let overflowingElement = getOverflowingElement(node); + + let style = ownerWindow.getComputedStyle(stylingElement); + let overflowStyle = ownerWindow.getComputedStyle(overflowingElement); + + let [snapType] = String(style.scrollSnapType).split(' '); + let [overflowX, overflowY = overflowX] = String(overflowStyle.overflow).split(' '); + + let isScrollableBlock = /(auto|scroll)/.test(overflowY + overflowStyle.overflowY); + let isScrollableInline = /(auto|scroll)/.test(overflowX + overflowStyle.overflowX); + + if (isDocument(node)) { + isScrollableBlock ||= /(visible)/.test(overflowY + overflowStyle.overflowY); + isScrollableInline ||= /(visible)/.test(overflowX + overflowStyle.overflowX); + } + + if (modality !== 'pointer' && isDocument(node)) { + isScrollableBlock ||= /(clip)/.test(overflowY + overflowStyle.overflowY); + isScrollableInline ||= /(clip)/.test(overflowX + overflowStyle.overflowX); } - if (isScrollable && checkForOverflow) { - isScrollable = node.scrollHeight !== node.clientHeight || node.scrollWidth !== node.clientWidth; + if (modality !== 'pointer') { + isScrollableBlock ||= /(hidden)/.test(overflowY + overflowStyle.overflowY); + isScrollableInline ||= /(hidden)/.test(overflowX + overflowStyle.overflowX); } - return isScrollable; + if (snappable) { + isScrollableBlock &&= /(both|block|y)/.test(snapType); + isScrollableInline &&= /(both|inline|x)/.test(snapType); + } + + if (scrollable) { + isScrollableBlock &&= getMaxScrollTop(node) !== 0; + isScrollableInline &&= getMaxScrollLeft(node) !== 0; + } + + switch (axis) { + case 'block': + return isScrollableBlock; + case 'inline': + return isScrollableInline; + default: + return isScrollableBlock || isScrollableInline; + } } diff --git a/packages/react-aria/src/utils/layoutHelpers.ts b/packages/react-aria/src/utils/layoutHelpers.ts new file mode 100644 index 00000000000..3577793f223 --- /dev/null +++ b/packages/react-aria/src/utils/layoutHelpers.ts @@ -0,0 +1,166 @@ +/* + * 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 '@react-types/shared'; +import {getOwnerDocument, getOwnerWindow} from './domHelpers'; +import {getParentNode, nodeContains} from './shadowdom/DOMFunctions'; +import {isContainingBlock} from './isContainingBlock'; +import {isDocument, isElement, isHTMLElement} from './typeHelpers'; + +/** + * Returns the visual viewport of a document. This is the visible viewport intersection. + * https://www.w3.org/TR/css-viewport/#visual-viewport. + */ +export function getVisualViewport(node: BoundingNode): VisualViewport | null { + let ownerWindow = getOwnerWindow(node); + + return ownerWindow.visualViewport ?? null; +} + +/** + * Returns the styling element of a bounding node. This is typically the node itself. + * https://www.w3.org/TR/2000/CR-SVG-20001102/styling.html. + */ +export function getStylingElement(node: BoundingNode): Element { + let ownerDocument = getOwnerDocument(node); + + if (isDocument(node)) { + return ownerDocument.documentElement; + } else { + return node; + } +} + +/** + * Returns the scrolling element of a bounding node. This is typically the node itself. + * https://www.w3.org/TR/cssom-view/#dom-document-scrollingelement. + */ +export function getScrollingElement(node: BoundingNode): Element { + let ownerDocument = getOwnerDocument(node); + + // A node containing the root scrolling element shall assert as its document. + if (nodeContains(node, ownerDocument.scrollingElement)) node = ownerDocument; + + // Ignore a potentially scrollable body in a quirks mode document for convenience, + // since its unlikely to occur inside of a React application anyways. + if (isDocument(node) && isHTMLElement(ownerDocument.scrollingElement)) { + return ownerDocument.scrollingElement; + } else if (isDocument(node)) { + return ownerDocument.documentElement; + } else { + return node; + } +} + +/** + * Returns the flow propagating element of a document. This is typically the body element. + * https://www.w3.org/TR/css-writing-modes/#principal-flow. + */ +export function getWritingElement(node: BoundingNode): Element { + let ownerWindow = getOwnerWindow(node); + let ownerDocument = getOwnerDocument(node); + + // A node containing the body element shall assert as its document. + if (nodeContains(node, ownerDocument.body)) node = ownerDocument; + + if (isDocument(node) && ownerDocument.body == null) { + return ownerDocument.documentElement; + } else if (!isDocument(node)) { + return node; + } + + let bodyStyle = ownerWindow.getComputedStyle(ownerDocument.body); + + if (bodyStyle.display === 'none') { + return ownerDocument.documentElement; + } else { + return ownerDocument.body; + } +} + +/** + * Returns overflow propagating element of a document. This is typically the body element. + * https://www.w3.org/TR/css-overflow-3/#overflow-propagation. + */ +export function getOverflowingElement(node: BoundingNode): Element { + let ownerWindow = getOwnerWindow(node); + let ownerDocument = getOwnerDocument(node); + + // A node containing the body element shall assert as its document. + if (nodeContains(node, ownerDocument.body)) node = ownerDocument; + + if (isDocument(node) && ownerDocument.body == null) { + return ownerDocument.documentElement; + } else if (!isDocument(node)) { + return node; + } + + let rootStyle = ownerWindow.getComputedStyle(ownerDocument.documentElement); + let bodyStyle = ownerWindow.getComputedStyle(ownerDocument.body); + + let [overflowX, overflowY = overflowX] = String(rootStyle.overflow).split(' '); + let isRootVisibleBlock = /(visible)/.test(overflowY + rootStyle.overflowY); + let isRootVisibleInline = /(visible)/.test(overflowX + rootStyle.overflowX); + let isBodyHidden = /(none)/.test(rootStyle.display + bodyStyle.display); + + if (!isRootVisibleBlock || !isRootVisibleInline || isBodyHidden) { + return ownerDocument.documentElement; + } else { + return ownerDocument.body; + } +} + +/** + * Returns the containing block of a bounding node. This is typically the offset parent. + * https://www.w3.org/TR/css-display-4/#containing-block. + */ +export function getContainingElement(node: BoundingNode): Element | null { + let ownerWindow = getOwnerWindow(node); + let ownerDocument = getOwnerDocument(node); + + // A node containing the body element shall return the initial containing block. + if (nodeContains(node, ownerDocument.body)) { + return ownerDocument.documentElement; + } + + // 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 = isHTMLElement(node) ? node.offsetParent : null; + + // The offsetParent algorithm terminates at the document body, even if the + // body is not a containing block — fall through to the root element then. + if (offsetParent === ownerDocument.body) { + let style = ownerWindow.getComputedStyle(offsetParent); + + if (style.position === 'static' && !isContainingBlock(offsetParent)) { + offsetParent = ownerDocument.documentElement; + } + } + + // TODO(later): handle table elements? + // TODO(later): handle anchor positioning? + + // The offsetParent is null for 'position: fixed', among a few other cases. + // Fixed positioned elements are still positioned relative to their + // containing block, which is not always the viewport — walk the flat tree. + let currentNode: Node | null = offsetParent == null ? node : null; + + while (currentNode != null) { + currentNode = getParentNode(currentNode); + + if (isElement(currentNode) && isContainingBlock(currentNode)) { + return currentNode; + } + } + + return offsetParent; +} diff --git a/packages/react-aria/src/utils/shadowdom/DOMFunctions.ts b/packages/react-aria/src/utils/shadowdom/DOMFunctions.ts index 5190bfd103c..9243ff44964 100644 --- a/packages/react-aria/src/utils/shadowdom/DOMFunctions.ts +++ b/packages/react-aria/src/utils/shadowdom/DOMFunctions.ts @@ -1,10 +1,41 @@ // Source: https://github.com/microsoft/tabster/blob/a89fc5d7e332d48f68d03b1ca6e344489d1c3898/src/Shadowdomize/DOMFunctions.ts#L16 /* eslint-disable rsp-rules/no-non-shadow-contains, rsp-rules/safe-event-target */ -import {getOwnerWindow, isShadowRoot} from '../domHelpers'; +import {getOwnerWindow} from '../domHelpers'; +import {isShadowRoot} from '../typeHelpers'; import {shadowDOM} from 'react-stately/private/flags/flags'; import type {SyntheticEvent} from 'react'; +/** + * ShadowDOM safe version of Node.parentNode. + */ +export function getParentNode(node: Node | Element | null | undefined): Node | null { + let currentNode: HTMLElement | Node | null | undefined = node; + + if (!shadowDOM()) { + return currentNode?.parentNode ?? null; + } + + if (!currentNode) { + return null; + } + + if ( + typeof (currentNode as HTMLSlotElement).assignedElements !== 'function' && + (currentNode as HTMLSlotElement).assignedSlot?.parentNode + ) { + // Element is slotted + currentNode = (currentNode as HTMLSlotElement).assignedSlot!.parentNode; + } else if (isShadowRoot(currentNode)) { + // Element is in shadow root + currentNode = currentNode.host; + } else { + currentNode = currentNode.parentNode; + } + + return currentNode; +} + /** * ShadowDOM safe version of Node.contains. */ diff --git a/packages/react-aria/src/utils/typeHelpers.ts b/packages/react-aria/src/utils/typeHelpers.ts new file mode 100644 index 00000000000..e4f051cd3d0 --- /dev/null +++ b/packages/react-aria/src/utils/typeHelpers.ts @@ -0,0 +1,74 @@ +/* + * 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. + */ + +/** + * Type guard that checks if a value is a Node. Verifies the presence and type of the nodeType + * property. + */ +export function isNode(value: unknown): value is Node { + return ( + value !== null && + typeof value === 'object' && + 'nodeType' in value && + typeof value.nodeType === 'number' + ); +} + +/** + * Type guard that checks if a value is a Window. Uses window self reference checks to + * distinguish Window from other values. + */ +export function isWindow(value: unknown): value is Window & typeof globalThis { + return typeof value === 'object' && value != null && 'window' in value && value.window === value; +} + +/** + * Type guard that checks if a value is a Document. Uses nodeType and host property checks to + * distinguish Document from other values. + */ +export function isDocument(value: unknown): value is Document { + return isNode(value) && value.nodeType === 9; +} + +/** + * Type guard that checks if a value is a ShadowRoot. Uses nodeType and host property checks to + * distinguish ShadowRoot from other values. + */ +export function isShadowRoot(value: unknown): value is ShadowRoot { + // 11 = DOCUMENT_FRAGMENT_NODE + return isNode(value) && value.nodeType === 11 && 'host' in value; +} + +/* + * Type guard that checks if a value is an Element. Uses nodeType and host property checks to + * distinguish Element from other values. + */ +export function isElement(value: unknown): value is Element { + // 1 = ELEMENT_NODE + return isNode(value) && value.nodeType === 1; +} + +/** + * Type guard that checks if a value is an HTMLElement. Uses nodeType, host property and + * namespace checks to distinguish HTMLElement from other values. + */ +export function isHTMLElement(value: unknown): value is HTMLElement { + return isElement(value) && value.namespaceURI === 'http://www.w3.org/1999/xhtml'; +} + +/** + * Type guard that checks if a value is an SVGElement. Uses nodeType, host property and + * namespace checks to distinguish SVGElement from other values. + */ +export function isSVGElement(value: unknown): value is SVGElement { + return isElement(value) && value.namespaceURI === 'http://www.w3.org/2000/svg'; +} diff --git a/packages/react-aria/test/utils/getScrollParents.test.ts b/packages/react-aria/test/utils/getScrollParents.test.ts index 78afc1f631e..ecc39f481cf 100644 --- a/packages/react-aria/test/utils/getScrollParents.test.ts +++ b/packages/react-aria/test/utils/getScrollParents.test.ts @@ -28,6 +28,10 @@ describe('getScrollParents', () => { let div = document.createElement('div'); document.body.appendChild(div); + jest.spyOn(window, 'getComputedStyle').mockImplementation(() => { + return {overflow: 'visible'} as CSSStyleDeclaration; + }); + let parents = getScrollParents(div); expect(parents).toContain(root); }); @@ -71,6 +75,10 @@ describe('getScrollParents', () => { document.body.appendChild(plain); plain.appendChild(child); + jest.spyOn(window, 'getComputedStyle').mockImplementation(() => { + return {overflow: 'visible'} as CSSStyleDeclaration; + }); + let parents = getScrollParents(child); expect(parents).not.toContain(plain); expect(parents).not.toContain(document.body); From 6bb0942a7f079d1adfb3dab44104b0d8833f43d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= <25958801+nwidynski@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:34:47 +0200 Subject: [PATCH 2/6] fix: jsdocs for scroll parent --- packages/react-aria/src/utils/getScrollParent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-aria/src/utils/getScrollParent.ts b/packages/react-aria/src/utils/getScrollParent.ts index 4ca997029cc..1a26b3c9ddb 100644 --- a/packages/react-aria/src/utils/getScrollParent.ts +++ b/packages/react-aria/src/utils/getScrollParent.ts @@ -15,7 +15,7 @@ import {getOwnerDocument} from './domHelpers'; import {getScrollingElement} from './layoutHelpers'; /** - * Returns the (scrollable) parent container for a given scroll alignment query. + * Returns the nearest (scrollable) ancestor of a given element. * * @deprecated Use 'getScrollTarget(element.parentElement)' instead. */ From ad3fc7598e0fff2755bf893728c1c1a1fe8ffa45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Sun, 20 Sep 2026 22:47:42 +0200 Subject: [PATCH 3/6] feat: browser test suite --- packages/@react-aria/test-utils/src/index.ts | 1 + .../test-utils/src/testContainer.ts | 77 + .../react-aria/src/utils/getScrollOffset.ts | 52 +- .../react-aria/src/utils/getScrollParent.ts | 26 +- .../react-aria/src/utils/getScrollParents.ts | 60 +- .../react-aria/src/utils/isContainingBlock.ts | 28 +- .../react-aria/src/utils/layoutHelpers.ts | 25 +- .../utils/getScrollOffset.browser.test.tsx | 1053 +++++++++ .../utils/getScrollParents.browser.test.tsx | 1879 +++++++++++++++++ .../test/utils/isScrollable.browser.test.tsx | 1500 +++++++++++++ vitest.browser.config.ts | 16 +- 11 files changed, 4627 insertions(+), 90 deletions(-) create mode 100644 packages/@react-aria/test-utils/src/testContainer.ts create mode 100644 packages/react-aria/test/utils/getScrollOffset.browser.test.tsx create mode 100644 packages/react-aria/test/utils/getScrollParents.browser.test.tsx create mode 100644 packages/react-aria/test/utils/isScrollable.browser.test.tsx diff --git a/packages/@react-aria/test-utils/src/index.ts b/packages/@react-aria/test-utils/src/index.ts index 38c65998b4e..bd90851dfe2 100644 --- a/packages/@react-aria/test-utils/src/index.ts +++ b/packages/@react-aria/test-utils/src/index.ts @@ -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'; diff --git a/packages/@react-aria/test-utils/src/testContainer.ts b/packages/@react-aria/test-utils/src/testContainer.ts new file mode 100644 index 00000000000..61ae1e67e62 --- /dev/null +++ b/packages/@react-aria/test-utils/src/testContainer.ts @@ -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(); + } +} diff --git a/packages/react-aria/src/utils/getScrollOffset.ts b/packages/react-aria/src/utils/getScrollOffset.ts index c49241bc4f4..26c362c319f 100644 --- a/packages/react-aria/src/utils/getScrollOffset.ts +++ b/packages/react-aria/src/utils/getScrollOffset.ts @@ -11,8 +11,9 @@ */ import {Axis, BoundingNode} from '@react-types/shared'; +import {getOverflowingElement, getScrollingElement, getWritingElement} from './layoutHelpers'; import {getOwnerDocument, getOwnerWindow} from './domHelpers'; -import {getScrollingElement, getWritingElement} from './layoutHelpers'; +import {isDocument} from './typeHelpers'; import {nodeContains} from './shadowdom/DOMFunctions'; export function getScrollLeft(node: BoundingNode): number { @@ -58,24 +59,43 @@ function getScrollOffset(node: BoundingNode, axis: Axis): number { } function getMaxScrollOffset(node: BoundingNode, axis: Axis): number { + let ownerWindow = getOwnerWindow(node); let ownerDocument = getOwnerDocument(node); - let scrollingElement = getScrollingElement(node); let rootScrollingElement = getScrollingElement(ownerDocument); + let rootOverflowingElement = getOverflowingElement(ownerDocument); // A node containing the root scrolling element shall assert as its document. - let client = nodeContains(node, rootScrollingElement) - ? ownerDocument.documentElement - : (node as Element); + 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' ? client.clientHeight : client.clientWidth; + let clientSize = axis === 'block' ? scrollingElement.clientHeight : scrollingElement.clientWidth; switch (getScrollDirection(node, axis)) { case 'ascending': return Math.max(0, scrollSize - clientSize); case 'descending': - return Math.max(0, scrollSize - clientSize) * -1; + return Math.min(0, clientSize - scrollSize); } } @@ -83,21 +103,27 @@ function getScrollDirection(node: BoundingNode, axis: Axis): 'ascending' | 'desc let ownerWindow = getOwnerWindow(node); let ownerDocument = getOwnerDocument(node); - let scrollingElement = getScrollingElement(node); let rootScrollingElement = getScrollingElement(ownerDocument); // A node containing the root scrolling element shall assert as its document. - let style = nodeContains(node, rootScrollingElement) - ? ownerWindow.getComputedStyle(getWritingElement(ownerDocument)) - : ownerWindow.getComputedStyle(getWritingElement(node)); + 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); - // https://bugs.webkit.org/show_bug.cgi?id=313748 + // 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 scrollingElement === rootScrollingElement ? 'ascending' : 'descending'; + return isDocument(node) ? 'ascending' : 'descending'; + } + + if (axis === 'inline' && isFlexDisplay && isFlexReverseInline && isDocument(node)) { + return style.direction === 'rtl' ? 'descending' : 'ascending'; } if (axis === 'inline' && isFlexDisplay && isFlexReverseInline) { diff --git a/packages/react-aria/src/utils/getScrollParent.ts b/packages/react-aria/src/utils/getScrollParent.ts index 1a26b3c9ddb..0c737857455 100644 --- a/packages/react-aria/src/utils/getScrollParent.ts +++ b/packages/react-aria/src/utils/getScrollParent.ts @@ -10,25 +10,31 @@ * governing permissions and limitations under the License. */ -import {genScrollParents} from './getScrollParents'; +import {genScrollParents, ScrollParentOptions} from './getScrollParents'; import {getOwnerDocument} from './domHelpers'; import {getScrollingElement} from './layoutHelpers'; /** * Returns the nearest (scrollable) ancestor of a given element. - * - * @deprecated Use 'getScrollTarget(element.parentElement)' instead. + * https://drafts.csswg.org/cssom-view/#dom-htmlelement-scrollparent. */ -export function getScrollParent(element: Element, checkForOverflow?: boolean): Element { +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}); + } + let ownerDocument = getOwnerDocument(element); - let generator = genScrollParents(element, { - scrollable: checkForOverflow, - container: 'nearest' + let scrollGenerator = genScrollParents(element, { + container: 'nearest', + ...options }); - let cursor = generator.next(); + let scrollParent = scrollGenerator.next(); - // Fallback is a bug, but is kept for backwards compatibility. - return cursor.value ?? getScrollingElement(ownerDocument); + // TODO(later): Fallback is a bug, remove after auditing call sites. + return scrollParent.value ?? getScrollingElement(ownerDocument); } diff --git a/packages/react-aria/src/utils/getScrollParents.ts b/packages/react-aria/src/utils/getScrollParents.ts index ab087c329e0..cb8ac6c8365 100644 --- a/packages/react-aria/src/utils/getScrollParents.ts +++ b/packages/react-aria/src/utils/getScrollParents.ts @@ -13,67 +13,39 @@ import {getContainingElement, getScrollingElement} from './layoutHelpers'; import {getOwnerDocument, getOwnerWindow} from '../utils/domHelpers'; import {getParentNode, nodeContains} from './shadowdom/DOMFunctions'; -import {isDocument, isElement, isNode, isShadowRoot} from './typeHelpers'; +import {isElement, isNode, isShadowRoot} from './typeHelpers'; import {isScrollable, ScrollableOptions} from './isScrollable'; import {ScrollContainer} from '@react-types/shared'; import {shadowDOM} from 'react-stately/private/flags/flags'; -export interface ScrollParentOptions extends ScrollableOptions { +export interface ScrollGeneratorOptions extends ScrollableOptions { /** The ancestor container to stop traversal at. */ container?: Element | Document | ScrollContainer | null; } -export interface ScrollTargetOptions extends ScrollableOptions { +export interface ScrollParentOptions extends ScrollableOptions { /** The ancestor container to stop traversal at. */ - container?: Element | Document; -} - -/** - * Returns the (scrollable) ancestor for a given scroll alignment query. - * - * @deprecated Use 'Array.from(genScrollParents(element))' instead. - */ -export function getScrollParents(element: Element, checkForOverflow?: boolean): Element[] { - let scrollGenerator = genScrollParents(element, { - scrollable: checkForOverflow, - container: 'all' - }); - - return Array.from(scrollGenerator); + container?: Element | Document | null; } /** - * Returns the nearest container-bound (scrollable) ancestor of an event target. - * This effectively translates to the element affected by a touch gesture. + * Returns all (scrollable) ancestors of a given element. + * https://drafts.csswg.org/cssom-view/#dom-htmlelement-scrollparent. */ -export function getScrollTarget( - target: EventTarget, - options: ScrollTargetOptions = {} -): Element | null { - let ownerDocument = getOwnerDocument(target); - - // A scrollable document returns its scrolling element. - if (isDocument(target) && isScrollable(ownerDocument, options)) { - return getScrollingElement(ownerDocument); +export function getScrollParents(element: Element, options?: ScrollParentOptions): Element[]; +/** @deprecated Use 'getScrollParents(element, {scrollable: true})' instead. */ +export function getScrollParents(element: Element, checkForOverflow?: boolean): Element[]; +export function getScrollParents(element: Element, options?: ScrollParentOptions | boolean) { + if (typeof options === 'undefined' || typeof options === 'boolean') { + return getScrollParents(element, {scrollable: options}); } - if (isDocument(target) || !isElement(target)) { - return null; - } - - // Similarly, a scrollable element returns itself. - if (isScrollable(target, options)) { - return target; - } - - let scrollGenerator = genScrollParents(target, { - container: 'nearest', + let scrollGenerator = genScrollParents(element, { + container: 'all', ...options }); - let scrollParent = scrollGenerator.next(); - - return scrollParent.value; + return Array.from(scrollGenerator); } /** @@ -82,7 +54,7 @@ export function getScrollTarget( */ export function* genScrollParents( element: Element, - options: ScrollParentOptions = {} + options: ScrollGeneratorOptions = {} ): Generator { let {container = 'all'} = options; diff --git a/packages/react-aria/src/utils/isContainingBlock.ts b/packages/react-aria/src/utils/isContainingBlock.ts index 02ec9452cae..49b98b21e09 100644 --- a/packages/react-aria/src/utils/isContainingBlock.ts +++ b/packages/react-aria/src/utils/isContainingBlock.ts @@ -12,18 +12,24 @@ import {getOwnerWindow} from './domHelpers'; -// https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#identifying_the_containing_block -export function isContainingBlock(element: Element): boolean { - let ownerWindow = getOwnerWindow(element); - let style = ownerWindow.getComputedStyle(element); +/** + * Returns whether a container establishes a containing block (of an element). + * https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#identifying_the_containing_block. + */ +export function isContainingBlock(container: Element, element?: Element): boolean { + let ownerWindow = getOwnerWindow(container); + + let containerStyle = ownerWindow.getComputedStyle(container); + let contentStyle = element ? ownerWindow.getComputedStyle(element) : null; return ( - style.transform !== 'none' || - style.perspective !== 'none' || - style.filter !== 'none' || - /(transform|perspective|filter)/.test(style.willChange) || - /(layout|paint|strict|content)/.test(style.contain) || - ('backdropFilter' in style && style.backdropFilter !== 'none') || - ('WebkitBackdropFilter' in style && style.WebkitBackdropFilter !== 'none') + containerStyle.transform !== 'none' || + containerStyle.perspective !== 'none' || + containerStyle.filter !== 'none' || + /(transform|perspective|filter)/.test(containerStyle.willChange) || + /(layout|paint|strict|content)/.test(containerStyle.contain) || + ('backdropFilter' in containerStyle && containerStyle.backdropFilter !== 'none') || + ('WebkitBackdropFilter' in containerStyle && containerStyle.WebkitBackdropFilter !== 'none') || + (contentStyle?.position === 'absolute' && containerStyle.position !== 'static') ); } diff --git a/packages/react-aria/src/utils/layoutHelpers.ts b/packages/react-aria/src/utils/layoutHelpers.ts index 3577793f223..90a7e60874e 100644 --- a/packages/react-aria/src/utils/layoutHelpers.ts +++ b/packages/react-aria/src/utils/layoutHelpers.ts @@ -95,8 +95,8 @@ export function getOverflowingElement(node: BoundingNode): Element { let ownerWindow = getOwnerWindow(node); let ownerDocument = getOwnerDocument(node); - // A node containing the body element shall assert as its document. - if (nodeContains(node, ownerDocument.body)) node = ownerDocument; + // A node containing the root element shall assert as its document. + if (nodeContains(node, ownerDocument.documentElement)) node = ownerDocument; if (isDocument(node) && ownerDocument.body == null) { return ownerDocument.documentElement; @@ -128,10 +128,18 @@ export function getContainingElement(node: BoundingNode): Element | null { let ownerDocument = getOwnerDocument(node); // A node containing the body element shall return the initial containing block. - if (nodeContains(node, ownerDocument.body)) { + if (!isElement(node) || nodeContains(node, ownerDocument.body)) { return ownerDocument.documentElement; } + // A top layer element is positioned relative to the initial containing block + // regardless of its ancestors, so it has no offset parent. + try { + if (node.matches(':popover-open, :modal')) return null; + } catch { + /** */ + } + // 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 = isHTMLElement(node) ? node.offsetParent : null; @@ -141,7 +149,7 @@ export function getContainingElement(node: BoundingNode): Element | null { if (offsetParent === ownerDocument.body) { let style = ownerWindow.getComputedStyle(offsetParent); - if (style.position === 'static' && !isContainingBlock(offsetParent)) { + if (style.position === 'static' && !isContainingBlock(offsetParent, node)) { offsetParent = ownerDocument.documentElement; } } @@ -152,12 +160,11 @@ export function getContainingElement(node: BoundingNode): Element | null { // The offsetParent is null for 'position: fixed', among a few other cases. // Fixed positioned elements are still positioned relative to their // containing block, which is not always the viewport — walk the flat tree. - let currentNode: Node | null = offsetParent == null ? node : null; - - while (currentNode != null) { - currentNode = getParentNode(currentNode); + // Also walk below a known offsetParent, as it is retargeted past shadow trees. + let currentNode: Node | null = node; - if (isElement(currentNode) && isContainingBlock(currentNode)) { + while ((currentNode = getParentNode(currentNode)) != null && currentNode !== offsetParent) { + if (isElement(currentNode) && isContainingBlock(currentNode, node)) { return currentNode; } } diff --git a/packages/react-aria/test/utils/getScrollOffset.browser.test.tsx b/packages/react-aria/test/utils/getScrollOffset.browser.test.tsx new file mode 100644 index 00000000000..7d5e00c9d19 --- /dev/null +++ b/packages/react-aria/test/utils/getScrollOffset.browser.test.tsx @@ -0,0 +1,1053 @@ +/* + * 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, Key} from '@react-types/shared'; +import {createPortal} from 'react-dom'; +import {describe, expect, it} from 'vitest'; +import { + getMaxScrollLeft, + getMaxScrollTop, + getScrollLeft, + getScrollLeftDirection, + getScrollTop, + getScrollTopDirection +} from '../../src/utils/getScrollOffset'; +import React, {createRef, forwardRef, useImperativeHandle, useMemo} from 'react'; +import {render} from 'vitest-browser-react'; +import {TestContainer} from '@react-aria/test-utils'; +import {useId} from '../../src/utils/useId'; +import {useLayoutEffect} from '../../src/utils/useLayoutEffect'; + +export const STYLESHEET = Object.freeze(` + .container { width: 100px; height: 100px; } + .content { width: 100%; height: 100%; min-width: 1px; min-height: 1px; flex: none; } + + .scroll-y { height: 300vh; } + .scroll-x { width: 300vw; } + + .overflow-auto { overflow: auto; } + .overflow-hidden { overflow: hidden; } + + .flex-column-reverse { display: flex; flex-direction: column-reverse; } + .flex-row-reverse { display: flex; flex-direction: row-reverse; } +`); + +interface DocumentProps extends React.PropsWithChildren { + doctype: string; + width?: number; + height?: number; +} + +class TestBench extends TestContainer { + constructor(type: string, key: Key) { + super(type); + + let window = this.getWindow(); + let iframe = this.getRootNode(); + let document = this.getDocument(); + + iframe.dataset.testId = String(key); + + let sheet = new window.CSSStyleSheet(); + sheet.replaceSync(STYLESHEET.trim()); + + document.adoptedStyleSheets.push(sheet); + + this.reset(); + } + + public override reset(): void { + let document = this.getDocument(); + + super.reset(); + + if (parseInt(React.version, 10) < 19) { + document.documentElement.remove(); + } + } +} + +const Document = forwardRef((props, ref) => { + let {width = 300, height = 300} = props; + + let id = useId(); + + let bench = useMemo(() => { + return new TestBench(props.doctype, id); + }, [props.doctype, id]); + + useImperativeHandle(ref, () => { + return bench.getDocument(); + }, [bench]); + + useLayoutEffect(() => { + let iframe = bench.getRootNode(); + iframe.width = String(width); + iframe.height = String(height); + + return () => bench.remove(); + }, [width, height, bench]); + + // @ts-expect-error + return createPortal(props.children, bench.getDocument()); +}); + +function getNativeScrollOffset(element: HTMLElement, axis: Axis): number { + let scrollingElement = element.contains(element.ownerDocument.scrollingElement) + ? element.ownerDocument.scrollingElement! + : element; + + switch (axis) { + case 'block': + return scrollingElement.scrollTop; + case 'inline': + return scrollingElement.scrollLeft; + } +} + +function getNativeMaxScrollOffset(element: HTMLElement, axis: Axis): number { + let scrollingElement = element.contains(element.ownerDocument.scrollingElement) + ? element.ownerDocument.scrollingElement! + : element; + + try { + let key = axis === 'block' ? 'top' : 'left'; + + scrollingElement.scrollTo({[key]: Number.MAX_SAFE_INTEGER}); + let max = getNativeScrollOffset(element, axis); + + scrollingElement.scrollTo({[key]: Number.MIN_SAFE_INTEGER}); + let min = getNativeScrollOffset(element, axis); + + return max !== 0 ? max : min; + } finally { + scrollingElement.scroll(0, 0); + } +} + +describe('getScrollTop', () => { + it('should return the block scroll offset', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = getNativeScrollOffset(ref.current!, 'block'); + let actual = getScrollTop(ref.current!); + + expect(actual).toBe(0); + expect(actual).toBe(expected); + + ref.current?.scroll(0, 1); + + expected = getNativeScrollOffset(ref.current!, 'block'); + actual = getScrollTop(ref.current!); + + expect(actual).toBe(1); + expect(actual).toBe(expected); + }); + + it('should return the block reverse scroll offset', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = getNativeScrollOffset(ref.current!, 'block'); + let actual = getScrollTop(ref.current!); + + expect(actual).toBe(0); + expect(actual).toBe(expected); + + ref.current?.scroll(0, -1); + + expected = getNativeScrollOffset(ref.current!, 'block'); + actual = getScrollTop(ref.current!); + + expect(actual).toBe(-1); + expect(actual).toBe(expected); + }); + + it('should return the block root scroll offset', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = getNativeScrollOffset(ref.current!, 'block'); + let actual = getScrollTop(ref.current!); + + expect(actual).toBe(0); + expect(actual).toBe(expected); + + ref.current?.ownerDocument.defaultView?.scroll(0, 1); + + expected = getNativeScrollOffset(ref.current!, 'block'); + actual = getScrollTop(ref.current!); + + expect(actual).toBe(1); + expect(actual).toBe(expected); + }); + + it('supports document nodes', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = getNativeScrollOffset(ref.current!.documentElement, 'block'); + let actual = getScrollTop(ref.current!); + + expect(actual).toBe(0); + expect(actual).toBe(expected); + + ref.current?.defaultView?.scroll(0, 1); + + expected = getNativeScrollOffset(ref.current!.documentElement, 'block'); + actual = getScrollTop(ref.current!); + + expect(actual).toBe(1); + expect(actual).toBe(expected); + }); + + it('supports quirks mode documents', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let bodyElement = ref.current?.ownerDocument.body; + + expect(scrollingElement).toBe(bodyElement); + + let expected = getNativeScrollOffset(ref.current!, 'block'); + let actual = getScrollTop(ref.current!); + + expect(actual).toBe(0); + expect(actual).toBe(expected); + + ref.current?.ownerDocument.defaultView?.scroll(0, 1); + + expected = getNativeScrollOffset(ref.current!, 'block'); + actual = getScrollTop(ref.current!); + + expect(actual).toBe(1); + expect(actual).toBe(expected); + }); +}); + +describe('getScrollLeft', () => { + it('should return the inline scroll offset', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = getNativeScrollOffset(ref.current!, 'inline'); + let actual = getScrollLeft(ref.current!); + + expect(actual).toBe(0); + expect(actual).toBe(expected); + + ref.current?.scroll(1, 0); + + expected = getNativeScrollOffset(ref.current!, 'inline'); + actual = getScrollLeft(ref.current!); + + expect(actual).toBe(1); + expect(actual).toBe(expected); + }); + + it('should return the inline reverse scroll offset', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = getNativeScrollOffset(ref.current!, 'inline'); + let actual = getScrollLeft(ref.current!); + + expect(actual).toBe(0); + expect(actual).toBe(expected); + + ref.current?.scroll(-1, 0); + + expected = getNativeScrollOffset(ref.current!, 'inline'); + actual = getScrollLeft(ref.current!); + + expect(actual).toBe(-1); + expect(actual).toBe(expected); + }); + + it('should return the inline root scroll offset', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = getNativeScrollOffset(ref.current!, 'inline'); + let actual = getScrollLeft(ref.current!); + + expect(actual).toBe(0); + expect(actual).toBe(expected); + + ref.current?.ownerDocument.defaultView?.scroll(1, 0); + + expected = getNativeScrollOffset(ref.current!, 'inline'); + actual = getScrollLeft(ref.current!); + + expect(actual).toBe(1); + expect(actual).toBe(expected); + }); + + it('supports document nodes', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = getNativeScrollOffset(ref.current!.documentElement, 'inline'); + let actual = getScrollLeft(ref.current!); + + expect(actual).toBe(0); + expect(actual).toBe(expected); + + ref.current?.defaultView?.scroll(1, 0); + + expected = getNativeScrollOffset(ref.current!.documentElement, 'inline'); + actual = getScrollLeft(ref.current!); + + expect(actual).toBe(1); + expect(actual).toBe(expected); + }); + + it('supports quirks mode documents', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let bodyElement = ref.current?.ownerDocument.body; + + expect(scrollingElement).toBe(bodyElement); + + let expected = getNativeScrollOffset(ref.current!, 'inline'); + let actual = getScrollLeft(ref.current!); + + expect(actual).toBe(0); + expect(actual).toBe(expected); + + ref.current?.ownerDocument.defaultView?.scroll(1, 0); + + expected = getNativeScrollOffset(ref.current!, 'inline'); + actual = getScrollLeft(ref.current!); + + expect(actual).toBe(1); + expect(actual).toBe(expected); + }); +}); + +describe('getMaxScrollTop', () => { + it('should return the max block scroll offset', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!, 'block'); + let actual = getMaxScrollTop(ref.current!); + + expect(actual).toBeGreaterThan(0); + expect(actual).toBe(expected); + }); + + it('should return zero for non-scrollable', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!, 'block'); + let actual = getMaxScrollTop(ref.current!); + + expect(actual).toBe(0); + expect(actual).toBe(expected); + }); + + it('should return the max block reverse scroll offset', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!, 'block'); + let actual = getMaxScrollTop(ref.current!); + + expect(actual).toBeLessThan(0); + expect(actual).toBe(expected); + }); + + it('should return the max block root scroll offset', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!, 'block'); + let actual = getMaxScrollTop(ref.current!); + + expect(actual).toBeGreaterThan(0); + expect(actual).toBe(expected); + }); + + it('should return the max block reverse root scroll offset', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!, 'block'); + let actual = getMaxScrollTop(ref.current!); + + expect(actual).toBeGreaterThan(0); + expect(actual).toBe(expected); + }); + + it('should return zero when propagating overflow', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!, 'block'); + let actual = getMaxScrollTop(ref.current!); + + expect(actual).toBe(0); + expect(actual).toBe(expected); + }); + + it('supports document nodes', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!.documentElement, 'block'); + let actual = getMaxScrollTop(ref.current!); + + expect(actual).toBeGreaterThan(0); + expect(actual).toBe(expected); + }); + + it('supports quirks mode documents', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!, 'block'); + let actual = getMaxScrollTop(ref.current!); + + expect(actual).toBeGreaterThan(0); + expect(actual).toBe(expected); + }); +}); + +describe('getMaxScrollLeft', () => { + it('should return the max inline scroll offset', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!, 'inline'); + let actual = getMaxScrollLeft(ref.current!); + + expect(actual).toBeGreaterThan(0); + expect(actual).toBe(expected); + }); + + it('should return zero for non-scrollable', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!, 'inline'); + let actual = getMaxScrollLeft(ref.current!); + + expect(actual).toBe(0); + expect(actual).toBe(expected); + }); + + it('should return the max inline reverse scroll offset', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!, 'inline'); + let actual = getMaxScrollLeft(ref.current!); + + expect(actual).toBeLessThan(0); + expect(actual).toBe(expected); + }); + + it('should return the max inline rtl scroll offset', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!, 'inline'); + let actual = getMaxScrollLeft(ref.current!); + + expect(actual).toBeLessThan(0); + expect(actual).toBe(expected); + }); + + it('should return the max inline reverse rtl scroll offset', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!, 'inline'); + let actual = getMaxScrollLeft(ref.current!); + + expect(actual).toBeGreaterThan(0); + expect(actual).toBe(expected); + }); + + it('should return the max inline root scroll offset', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!, 'inline'); + let actual = getMaxScrollLeft(ref.current!); + + expect(actual).toBeGreaterThan(0); + expect(actual).toBe(expected); + }); + + it('should return the max inline rtl root scroll offset', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!, 'inline'); + let actual = getMaxScrollLeft(ref.current!); + + expect(actual).toBeLessThan(0); + expect(actual).toBe(expected); + }); + + it('should return zero when propagating overflow', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!, 'inline'); + let actual = getMaxScrollLeft(ref.current!); + + expect(actual).toBe(0); + expect(actual).toBe(expected); + }); + + it('supports document nodes', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!.documentElement, 'inline'); + let actual = getMaxScrollLeft(ref.current!); + + expect(actual).toBeGreaterThan(0); + expect(actual).toBe(expected); + }); + + it('supports quirks mode documents', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!, 'inline'); + let actual = getMaxScrollLeft(ref.current!); + + expect(actual).toBeGreaterThan(0); + expect(actual).toBe(expected); + }); +}); + +describe('getScrollTopDirection', () => { + it('should return ascending for normal scroll direction', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!, 'block'); + let actual = getScrollTopDirection(ref.current!); + + expect(actual).toBe('ascending'); + expect(actual).toBe(expected > 0 ? 'ascending' : 'descending'); + }); + + it('should return descending for reverse scroll direction', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!, 'block'); + let actual = getScrollTopDirection(ref.current!); + + expect(actual).toBe('descending'); + expect(actual).toBe(expected > 0 ? 'ascending' : 'descending'); + }); + + it('should return ascending for root scroll direction', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!, 'block'); + let actual = getScrollTopDirection(ref.current!); + + expect(actual).toBe('ascending'); + expect(actual).toBe(expected > 0 ? 'ascending' : 'descending'); + }); + + it('should return ascending for reverse root scroll direction', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!, 'block'); + let actual = getScrollTopDirection(ref.current!); + + expect(actual).toBe('ascending'); + expect(actual).toBe(expected > 0 ? 'ascending' : 'descending'); + }); +}); + +describe('getScrollLeftDirection', () => { + it('should return ascending for normal scroll direction', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!, 'inline'); + let actual = getScrollLeftDirection(ref.current!); + + expect(actual).toBe('ascending'); + expect(actual).toBe(expected > 0 ? 'ascending' : 'descending'); + }); + + it('should return descending for reverse scroll direction', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!, 'inline'); + let actual = getScrollLeftDirection(ref.current!); + + expect(actual).toBe('descending'); + expect(actual).toBe(expected > 0 ? 'ascending' : 'descending'); + }); + + it('should return descending for rtl scroll direction', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!, 'inline'); + let actual = getScrollLeftDirection(ref.current!); + + expect(actual).toBe('descending'); + expect(actual).toBe(expected > 0 ? 'ascending' : 'descending'); + }); + + it('should return ascending for reverse rtl scroll direction', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!, 'inline'); + let actual = getScrollLeftDirection(ref.current!); + + expect(actual).toBe('ascending'); + expect(actual).toBe(expected > 0 ? 'ascending' : 'descending'); + }); + + it('should return ascending for reverse root scroll direction', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!, 'inline'); + let actual = getScrollLeftDirection(ref.current!); + + expect(actual).toBe('ascending'); + expect(expected).toBe(0); + }); + + it('should return descending for rtl root scroll direction', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!, 'inline'); + let actual = getScrollLeftDirection(ref.current!); + + expect(actual).toBe('descending'); + expect(actual).toBe(expected > 0 ? 'ascending' : 'descending'); + }); + + it('should return descending for reverse rtl root scroll direction', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = getNativeMaxScrollOffset(ref.current!, 'inline'); + let actual = getScrollLeftDirection(ref.current!); + + expect(actual).toBe('descending'); + expect(expected).toBe(0); + }); +}); diff --git a/packages/react-aria/test/utils/getScrollParents.browser.test.tsx b/packages/react-aria/test/utils/getScrollParents.browser.test.tsx new file mode 100644 index 00000000000..44c45b3b910 --- /dev/null +++ b/packages/react-aria/test/utils/getScrollParents.browser.test.tsx @@ -0,0 +1,1879 @@ +/* + * 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 {createPortal} from 'react-dom'; +import {describe, expect, it, vi} from 'vitest'; +import {getScrollParent} from '../../src/utils/getScrollParent'; +import {getScrollParents} from '../../src/utils/getScrollParents'; +import {isScrollable} from '../../src/utils/isScrollable'; +import {Key} from '@react-types/shared'; +import {mergeRefs} from '../../src/utils/mergeRefs'; +import React, { + createContext, + createRef, + forwardRef, + useImperativeHandle, + useMemo, + useState +} from 'react'; +import {render} from 'vitest-browser-react'; +import {shadowDOM} from 'react-stately/private/flags/flags'; +import {TestContainer} from '@react-aria/test-utils'; +import {useId} from '../../src/utils/useId'; +import {useLayoutEffect} from '../../src/utils/useLayoutEffect'; +import {useObjectRef} from '../../src/utils/useObjectRef'; + +vi.mock('react-stately/private/flags/flags', () => ({ + shadowDOM: vi.fn(() => false), + enableShadowDOM: vi.fn(), + tableNestedRows: vi.fn(() => false), + enableTableNestedRows: vi.fn() +})); + +export const STYLESHEET = Object.freeze(` + .container { width: 100px; height: 100px; } + .content { width: 100%; height: 100%; min-width: 1px; min-height: 1px; flex: none; } + + .scroll-y { height: 300vh; } + .scroll-x { width: 300vw; } + + .overflow-auto { overflow: auto; } + .overflow-hidden { overflow: hidden; } + + .overflow-x-auto { overflow-x: auto; } + .overflow-x-hidden { overflow-x: hidden; } + .overflow-y-auto { overflow-y: auto; } + .overflow-y-hidden { overflow-y: hidden; } + + .snap-block { scroll-snap-type: block mandatory; } + .snap-inline { scroll-snap-type: inline mandatory; } + + .position-relative { position: relative; } + .position-absolute { position: absolute; } + .position-fixed { position: fixed; } + + .transform { transform: translateZ(0); } +`); + +interface ShadowRootProps extends React.PropsWithChildren { + mode: ShadowRootMode; +} + +interface DocumentProps extends React.PropsWithChildren { + doctype: string; + width?: number; + height?: number; +} + +class TestBench extends TestContainer { + constructor(type: string, key: Key) { + super(type); + + let window = this.getWindow(); + let iframe = this.getRootNode(); + let document = this.getDocument(); + + iframe.dataset.testId = String(key); + + let sheet = new window.CSSStyleSheet(); + sheet.replaceSync(STYLESHEET.trim()); + + document.adoptedStyleSheets.push(sheet); + + this.reset(); + } + + public override reset(): void { + let document = this.getDocument(); + + super.reset(); + + if (parseInt(React.version, 10) < 19) { + document.documentElement.remove(); + } + } +} + +const ShadowRootContext = createContext(null); + +const ShadowRoot = forwardRef((props, forwardedRef) => { + let ref = useObjectRef(forwardedRef); + let [root, setRoot] = useState(null); + + useLayoutEffect(() => { + let host = ref.current!; + + let root = host.attachShadow({mode: props.mode}); + let styles = host.ownerDocument.adoptedStyleSheets; + + root.adoptedStyleSheets.push(...styles); + setRoot(root); + }, [ref, props.mode]); + + return ( +
+ +
+ ); +}); + +const Shadow = forwardRef(props => { + let ctx = React.useContext(ShadowRootContext); + + if (ctx == null) return null; + + return createPortal(props.children, ctx); +}); + +const Document = forwardRef((props, ref) => { + let {width = 300, height = 300} = props; + + let id = useId(); + + let bench = useMemo(() => { + return new TestBench(props.doctype, id); + }, [props.doctype, id]); + + useImperativeHandle(ref, () => { + return bench.getDocument(); + }, [bench]); + + useLayoutEffect(() => { + let iframe = bench.getRootNode(); + iframe.width = String(width); + iframe.height = String(height); + + return () => bench.remove(); + }, [width, height, bench]); + + // @ts-expect-error + return createPortal(props.children, bench.getDocument()); +}); + +function supportsModernSignature(): boolean { + try { + // @ts-ignore + isScrollable(document); + return true; + } catch { + return false; + } +} + +function getNativeScrollParents(element: Element): Element[] { + let scrollingElement = element.contains(element.ownerDocument.scrollingElement) + ? element.ownerDocument.scrollingElement! + : element; + + let scrollParents: Element[] = []; + let cursor: Element | null = scrollingElement; + + element.scrollIntoView({block: 'end', inline: 'end', behavior: 'instant'}); + + while (cursor != null) { + if (cursor.scrollTop !== 0 || cursor.scrollLeft !== 0) { + scrollParents.push(cursor); + } + + cursor.scrollTo(0, 0); + + let root = cursor.getRootNode() as ShadowRoot | Document; + cursor = cursor.assignedSlot ?? cursor.parentElement ?? ('host' in root ? root.host : null); + } + + return scrollParents; +} + +describe('getScrollParent (legacy)', () => { + describe('supports querying the nearest scrollable ancestor', () => { + it('should return the nearest scrollable ancestor', async () => { + let ref = createRef(); + let scrollRef = createRef(); + + await render( + + + +
+
+
+
+ + + + ); + + let [expected] = getNativeScrollParents(ref.current!); + let actual = getScrollParent(ref.current!); + + expect(actual).toBe(scrollRef.current); + expect(actual).toBe(expected); + }); + + it('should not return a scrollable element itself', async () => { + let ref = createRef(); + let scrollRef = createRef(); + + await render( + + + +
+
+
+
+
+
+ + + + ); + + let [expected] = getNativeScrollParents(ref.current!); + let actual = getScrollParent(ref.current!); + + expect(actual).toBe(scrollRef.current); + expect(actual).toBe(expected); + }); + + it('should skip past a non-containing scrollable ancestor', async () => { + let ref = createRef(); + let scrollRef = createRef(); + + await render( + + + +
+
+
+
+
+
+
+ + + + ); + + let [expected] = getNativeScrollParents(ref.current!); + let actual = getScrollParent(ref.current!); + + expect(actual).toBe(scrollRef.current); + expect(actual).toBe(expected); + }); + + it('should skip past a non-containing scrollable ancestor to the root', async () => { + let ref = createRef(); + let scrollRef = createRef(); + + await render( + + + +
+
+
+
+
+ + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let rootElement = ref.current?.ownerDocument.documentElement; + + expect(scrollingElement).toBe(rootElement); + expect(scrollRef.current).toBe(rootElement); + + let [expected] = getNativeScrollParents(ref.current!); + let actual = getScrollParent(ref.current!); + + expect(actual).toBe(scrollRef.current); + expect(actual).toBe(expected); + }); + + it('should skip past a transformed containing block', async () => { + let ref = createRef(); + let scrollRef = createRef(); + + await render( + + + +
+
+
+
+
+
+ + + + ); + + let [expected] = getNativeScrollParents(ref.current!); + let actual = getScrollParent(ref.current!); + + expect(actual).toBe(scrollRef.current); + expect(actual).toBe(expected); + }); + + it('should return null for a fixed element', async () => { + let ref = createRef(); + let scrollRef = createRef(); + + await render( + + + +
+
+
+
+ + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let rootElement = ref.current?.ownerDocument.documentElement; + + expect(scrollingElement).toBe(rootElement); + expect(scrollRef.current).toBe(rootElement); + + let [expected] = getNativeScrollParents(ref.current!); + let actual = getScrollParent(ref.current!); + + // TODO(later): Revisit after fallback fix + expect(expected).toBe(undefined); + expect(actual).toBe(scrollRef.current); + expect(actual).not.toBe(expected); + }); + + it('should return null without a scrollable root', async () => { + let ref = createRef(); + let scrollRef = createRef(); + + await render( + + + +
+ + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let rootElement = ref.current?.ownerDocument.documentElement; + + expect(scrollingElement).toBe(rootElement); + expect(scrollRef.current).toBe(rootElement); + + let [expected] = getNativeScrollParents(ref.current!); + let actual = getScrollParent(ref.current!); + + // TODO(later): Revisit after fallback fix. + expect(expected).toBe(undefined); + expect(actual).toBe(scrollRef.current); + expect(actual).not.toBe(expected); + }); + + it('should return null for a top layer element', async () => { + let ref = createRef(); + let scrollRef = createRef(); + + await render( + + + +
+
+
+
+ + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let rootElement = ref.current?.ownerDocument.documentElement; + + expect(scrollingElement).toBe(rootElement); + expect(scrollRef.current).toBe(rootElement); + + ref.current!.showPopover(); + + let [expected] = getNativeScrollParents(ref.current!); + let actual = getScrollParent(ref.current!); + + // TODO(later): Revisit after fallback fix. + expect(expected).toBe(undefined); + expect(actual).toBe(scrollRef.current); + expect(actual).not.toBe(expected); + }); + + it('should return the root scrolling element for a detached element', async () => { + let ref = createRef(); + let scrollRef = createRef(); + + await render( + + + +
+
+ + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let rootElement = ref.current?.ownerDocument.documentElement; + + expect(scrollingElement).toBe(rootElement); + expect(scrollRef.current).toBe(rootElement); + + let element = ref.current!.ownerDocument.createElement('div'); + + let [expected] = getNativeScrollParents(element); + let actual = getScrollParent(element); + + expect(expected).toBe(undefined); + expect(actual).toBe(scrollRef.current); + }); + + it('should return the root scrolling element under a scrollable root', async () => { + let ref = createRef(); + let scrollRef = createRef(); + + await render( + + + +
+
+ + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let rootElement = ref.current?.ownerDocument.documentElement; + + expect(scrollingElement).toBe(rootElement); + expect(scrollRef.current).toBe(rootElement); + + let [expected] = getNativeScrollParents(ref.current!); + let actual = getScrollParent(ref.current!); + + expect(actual).toBe(scrollRef.current); + expect(actual).toBe(expected); + }); + + it('should return the root scrolling element for the body', async () => { + let ref = createRef(); + let scrollRef = createRef(); + + await render( + + + +
+ + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let rootElement = ref.current?.ownerDocument.documentElement; + + expect(scrollingElement).toBe(rootElement); + expect(scrollRef.current).toBe(rootElement); + + let [expected] = getNativeScrollParents(ref.current!); + let actual = getScrollParent(ref.current!); + + expect(actual).toBe(scrollRef.current); + expect(actual).toBe(expected); + }); + + it('should return the root scrolling element for itself', async () => { + let ref = createRef(); + let scrollRef = createRef(); + let mergedRef = mergeRefs(ref, scrollRef); + + await render( + + + +
+ + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let rootElement = ref.current?.ownerDocument.documentElement; + + expect(scrollingElement).toBe(rootElement); + expect(scrollRef.current).toBe(rootElement); + + let [expected] = getNativeScrollParents(ref.current!); + let actual = getScrollParent(ref.current!); + + expect(actual).toBe(scrollRef.current); + expect(actual).toBe(expected); + }); + }); + + describe('supports checking for actual overflow', () => { + it('should skip past a non-scrollable ancestor', async () => { + let ref = createRef(); + let scrollRef = createRef(); + let containerRef = createRef(); + + await render( + + + +
+
+
+
+
+
+ + + + ); + + let [expected] = getNativeScrollParents(ref.current!); + let actualPotential = getScrollParent(ref.current!); + let actual = getScrollParent(ref.current!, true); + + expect(actualPotential).toBe(containerRef.current); + expect(actual).toBe(scrollRef.current); + expect(actual).toBe(expected); + }); + }); + + describe('supports quirks mode documents', () => { + it('should return the root scrolling element under a scrollable root', async () => { + let ref = createRef(); + let scrollRef = createRef(); + + await render( + + + +
+
+ + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let bodyElement = ref.current?.ownerDocument.body; + + expect(scrollingElement).toBe(bodyElement); + expect(scrollRef.current).toBe(bodyElement); + + let [expected] = getNativeScrollParents(ref.current!); + let actual = getScrollParent(ref.current!); + + expect(actual).toBe(scrollRef.current); + expect(actual).toBe(expected); + }); + + it('should return the root scrolling element for the root', async () => { + let ref = createRef(); + let scrollRef = createRef(); + + await render( + + + +
+ + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let bodyElement = ref.current?.ownerDocument.body; + + expect(scrollingElement).toBe(bodyElement); + expect(scrollRef.current).toBe(bodyElement); + + let [expected] = getNativeScrollParents(ref.current!); + let actual = getScrollParent(ref.current!); + + expect(actual).toBe(scrollRef.current); + expect(actual).toBe(expected); + }); + }); + + describe('supports shadow DOM (disabled)', () => { + beforeAll(() => vi.mocked(shadowDOM).mockReturnValue(false)); + + it('should return the nearest scrollable ancestor', async () => { + let ref = createRef(); + let scrollRef = createRef(); + + await render( + + + + + +
+
+
+
+ + + + + + ); + + let [expected] = getNativeScrollParents(ref.current!); + let actual = getScrollParent(ref.current!); + + expect(actual).toBe(scrollRef.current); + expect(actual).toBe(expected); + }); + + it('should not skip past a shadow boundary', async () => { + let ref = createRef(); + let scrollRef = createRef(); + let containerRef = createRef(); + + await render( + + + +
+ + +
+
+ + +
+ + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let rootElement = ref.current?.ownerDocument.documentElement; + + expect(scrollingElement).toBe(rootElement); + expect(scrollRef.current).toBe(rootElement); + + let [expected] = getNativeScrollParents(ref.current!); + let actual = getScrollParent(ref.current!); + + // TODO(later): Revisit after fallback fix. + expect(expected).toBe(containerRef.current); + expect(actual).toBe(scrollRef.current); + expect(actual).not.toBe(expected); + }); + + it('should not skip past an assigned shadow slot', async () => { + let ref = createRef(); + let scrollRef = createRef(); + let containerRef = createRef(); + + await render( + + + + + +
+
+ +
+ +
+ + + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let rootElement = ref.current?.ownerDocument.documentElement; + + expect(scrollingElement).toBe(rootElement); + expect(scrollRef.current).toBe(rootElement); + + let [expected] = getNativeScrollParents(ref.current!); + let actual = getScrollParent(ref.current!); + + expect(expected).toBe(containerRef.current); + expect(actual).toBe(scrollRef.current); + expect(actual).not.toBe(expected); + }); + }); + + describe('supports shadow DOM (enabled)', () => { + beforeAll(() => vi.mocked(shadowDOM).mockReturnValue(true)); + afterAll(() => vi.mocked(shadowDOM).mockReturnValue(false)); + + it('should the nearest scrollable ancestor', async () => { + let ref = createRef(); + let scrollRef = createRef(); + + await render( + + + + + +
+
+
+
+ + + + + + ); + + let [expected] = getNativeScrollParents(ref.current!); + let actual = getScrollParent(ref.current!); + + expect(actual).toBe(scrollRef.current); + expect(actual).toBe(expected); + }); + + it('should skip past a shadow boundary', async () => { + let ref = createRef(); + let scrollRef = createRef(); + + await render( + + + +
+ + +
+
+ + +
+ + + + ); + + let [expected] = getNativeScrollParents(ref.current!); + let actual = getScrollParent(ref.current!); + + expect(actual).toBe(scrollRef.current); + expect(actual).toBe(expected); + }); + + it('should skip past an assigned shadow slot', async () => { + let ref = createRef(); + let scrollRef = createRef(); + + await render( + + + + + +
+
+ +
+ +
+ + + + + ); + + let [expected] = getNativeScrollParents(ref.current!); + let actual = getScrollParent(ref.current!); + + expect(actual).toBe(scrollRef.current); + expect(actual).toBe(expected); + }); + + it('should not skip past a containing scrollable ancestor of an assigned slot', async () => { + let ref = createRef(); + let scrollRef = createRef(); + + await render( + + + + + +
+
+ +
+ +
+ + + + + ); + + let [expected] = getNativeScrollParents(ref.current!); + let actual = getScrollParent(ref.current!); + + expect(actual).toBe(scrollRef.current); + expect(actual).toBe(expected); + }); + }); +}); + +describe('getScrollParents (legacy)', () => { + describe('supports querying all scrollable ancestors', () => { + it('should return all scrollable ancestors nearest first', async () => { + let ref = createRef(); + let rootRef = createRef(); + let nearestRef = createRef(); + let containerRef = createRef(); + + await render( + + + +
+
+
+
+
+
+
+
+ + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let rootElement = ref.current?.ownerDocument.documentElement; + + expect(scrollingElement).toBe(rootElement); + expect(rootRef.current).toBe(rootElement); + + let expected = getNativeScrollParents(ref.current!); + let actual = getScrollParents(ref.current!); + + expect(actual).toEqual([nearestRef.current, containerRef.current, rootRef.current]); + expect(actual).toEqual(expected); + }); + + it('should not include a scrollable element itself', async () => { + let ref = createRef(); + let rootRef = createRef(); + let containerRef = createRef(); + + await render( + + + +
+
+
+
+
+
+
+ + + + ); + + let expected = getNativeScrollParents(ref.current!); + let actual = getScrollParents(ref.current!); + + expect(actual).toEqual([containerRef.current, rootRef.current]); + expect(actual).toEqual(expected); + }); + + it('should skip past a non-containing scrollable ancestor', async () => { + let ref = createRef(); + let rootRef = createRef(); + let containerRef = createRef(); + + await render( + + + +
+
+
+
+
+
+
+
+ + + + ); + + let expected = getNativeScrollParents(ref.current!); + let actual = getScrollParents(ref.current!); + + expect(actual).toEqual([containerRef.current, rootRef.current]); + expect(actual).toEqual(expected); + }); + + it('should skip past a non-containing scrollable ancestor to the root', async () => { + let ref = createRef(); + let rootRef = createRef(); + + await render( + + + +
+
+
+
+
+ + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let rootElement = ref.current?.ownerDocument.documentElement; + + expect(scrollingElement).toBe(rootElement); + expect(rootRef.current).toBe(rootElement); + + let expected = getNativeScrollParents(ref.current!); + let actual = getScrollParents(ref.current!); + + expect(actual).toEqual([rootRef.current]); + expect(actual).toEqual(expected); + }); + + it('should skip past a transformed containing block', async () => { + let ref = createRef(); + let rootRef = createRef(); + let nearestRef = createRef(); + + await render( + + + +
+
+
+
+
+
+
+ + + + ); + + let expected = getNativeScrollParents(ref.current!); + let actual = getScrollParents(ref.current!); + + expect(actual).toEqual([nearestRef.current, rootRef.current]); + expect(actual).toEqual(expected); + }); + + it('should return an empty list for a fixed element', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+
+
+ + + + ); + + let expected = getNativeScrollParents(ref.current!); + let actual = getScrollParents(ref.current!); + + expect(actual).toEqual([]); + expect(actual).toEqual(expected); + }); + + it('should return an empty list without a scrollable root', async () => { + let ref = createRef(); + let rootRef = createRef(); + + await render( + + + +
+ + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let rootElement = ref.current?.ownerDocument.documentElement; + + expect(scrollingElement).toBe(rootElement); + expect(rootRef.current).toBe(rootElement); + + let expected = getNativeScrollParents(ref.current!); + let actual = getScrollParents(ref.current!); + + expect(actual).toEqual([]); + expect(actual).toEqual(expected); + }); + + it('should return an empty list for a top layer element', async () => { + let ref = createRef(); + let rootRef = createRef(); + + await render( + + + +
+
+
+
+
+ + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let rootElement = ref.current?.ownerDocument.documentElement; + + expect(scrollingElement).toBe(rootElement); + expect(rootRef.current).toBe(rootElement); + + ref.current!.showPopover(); + + let expected = getNativeScrollParents(ref.current!); + let actual = getScrollParents(ref.current!); + + expect(actual).toEqual([]); + expect(actual).toEqual(expected); + }); + + it('should return the root scrolling element for a detached element', async () => { + let ref = createRef(); + let rootRef = createRef(); + + await render( + + + +
+
+ + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let rootElement = ref.current?.ownerDocument.documentElement; + + expect(scrollingElement).toBe(rootElement); + expect(rootRef.current).toBe(rootElement); + + let element = ref.current!.ownerDocument.createElement('div'); + + let expected = getNativeScrollParents(element); + let actualPotential = getScrollParents(element); + let actual = getScrollParents(element, true); + + expect(expected).toEqual([]); + expect(actualPotential).toEqual([rootRef.current]); + expect(actual).toEqual([rootRef.current]); + }); + + it('should return the root scrolling element under a scrollable root', async () => { + let ref = createRef(); + let rootRef = createRef(); + + await render( + + + +
+
+ + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let rootElement = ref.current?.ownerDocument.documentElement; + + expect(scrollingElement).toBe(rootElement); + expect(rootRef.current).toBe(rootElement); + + let expected = getNativeScrollParents(ref.current!); + let actual = getScrollParents(ref.current!); + + expect(actual).toEqual([rootRef.current]); + expect(actual).toEqual(expected); + }); + + it('should return the root scrolling element for the body', async () => { + let ref = createRef(); + let rootRef = createRef(); + + await render( + + + +
+ + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let rootElement = ref.current?.ownerDocument.documentElement; + + expect(scrollingElement).toBe(rootElement); + expect(rootRef.current).toBe(rootElement); + + let expected = getNativeScrollParents(ref.current!); + let actual = getScrollParents(ref.current!); + + expect(actual).toEqual([rootRef.current]); + expect(actual).toEqual(expected); + }); + + it('should return the root scrolling element for itself', async () => { + let ref = createRef(); + let rootRef = createRef(); + let mergedRef = mergeRefs(ref, rootRef); + + await render( + + + +
+ + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let rootElement = ref.current?.ownerDocument.documentElement; + + expect(scrollingElement).toBe(rootElement); + expect(rootRef.current).toBe(rootElement); + + let expected = getNativeScrollParents(ref.current!); + let actual = getScrollParents(ref.current!); + + expect(actual).toEqual([rootRef.current]); + expect(actual).toEqual(expected); + }); + }); + + describe('supports checking for actual overflow', () => { + it('should skip past a non-scrollable ancestor', async () => { + let ref = createRef(); + let rootRef = createRef(); + let nearestRef = createRef(); + let containerRef = createRef(); + + await render( + + + +
+
+
+
+
+
+
+ + + + ); + + let expected = getNativeScrollParents(ref.current!); + let actualPotential = getScrollParents(ref.current!); + let actual = getScrollParents(ref.current!, true); + + expect(actualPotential).toEqual([containerRef.current, nearestRef.current, rootRef.current]); + expect(actual).toEqual([nearestRef.current, rootRef.current]); + expect(actual).toEqual(expected); + }); + }); + + describe('supports quirks mode documents', () => { + it('should return all scrollable ancestors nearest first', async () => { + let ref = createRef(); + let rootRef = createRef(); + let nearestRef = createRef(); + + await render( + + + +
+
+
+
+
+ + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let bodyElement = ref.current?.ownerDocument.body; + + expect(scrollingElement).toBe(bodyElement); + expect(rootRef.current).toBe(bodyElement); + + let expected = getNativeScrollParents(ref.current!); + let actual = getScrollParents(ref.current!); + + expect(actual).toEqual([nearestRef.current, rootRef.current]); + expect(actual).toEqual(expected); + }); + + it('should return the root scrolling element for the root', async () => { + let ref = createRef(); + let rootRef = createRef(); + + await render( + + + +
+ + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let bodyElement = ref.current?.ownerDocument.body; + + expect(scrollingElement).toBe(bodyElement); + expect(rootRef.current).toBe(bodyElement); + + let expected = getNativeScrollParents(ref.current!); + let actual = getScrollParents(ref.current!); + + expect(actual).toEqual([rootRef.current]); + expect(actual).toEqual(expected); + }); + }); + + describe('supports shadow DOM (disabled)', () => { + beforeAll(() => vi.mocked(shadowDOM).mockReturnValue(false)); + + it('should return all scrollable ancestors nearest first', async () => { + let ref = createRef(); + let nearestRef = createRef(); + + await render( + + + + + +
+
+
+
+ + + + + + ); + + let expected = getNativeScrollParents(ref.current!); + let actual = getScrollParents(ref.current!); + + expect(actual).toEqual([nearestRef.current]); + expect(actual).toEqual(expected); + }); + + it('should not skip past a shadow boundary', async () => { + let ref = createRef(); + let rootRef = createRef(); + let containerRef = createRef(); + + await render( + + + +
+
+ + +
+
+ + +
+ + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let rootElement = ref.current?.ownerDocument.documentElement; + + expect(scrollingElement).toBe(rootElement); + expect(rootRef.current).toBe(rootElement); + + let expected = getNativeScrollParents(ref.current!); + let actual = getScrollParents(ref.current!); + + expect(expected).toEqual([containerRef.current, rootRef.current]); + expect(actual).toEqual([]); + expect(actual).not.toEqual(expected); + }); + + it('should not skip past an assigned shadow slot', async () => { + let ref = createRef(); + let rootRef = createRef(); + let containerRef = createRef(); + + await render( + + + +
+ + +
+
+ +
+ +
+ + + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let rootElement = ref.current?.ownerDocument.documentElement; + + expect(scrollingElement).toBe(rootElement); + expect(rootRef.current).toBe(rootElement); + + let expected = getNativeScrollParents(ref.current!); + let actual = getScrollParents(ref.current!); + + expect(expected).toEqual([containerRef.current, rootRef.current]); + expect(actual).toEqual([rootRef.current]); + expect(actual).not.toEqual(expected); + }); + }); + + describe('supports shadow DOM (enabled)', () => { + beforeAll(() => vi.mocked(shadowDOM).mockReturnValue(true)); + afterAll(() => vi.mocked(shadowDOM).mockReturnValue(false)); + + it('should return all scrollable ancestors nearest first', async () => { + let ref = createRef(); + let rootRef = createRef(); + let nearestRef = createRef(); + + await render( + + + +
+ + +
+
+
+
+ + + + + + ); + + let expected = getNativeScrollParents(ref.current!); + let actual = getScrollParents(ref.current!); + + expect(actual).toEqual([nearestRef.current, rootRef.current]); + expect(actual).toEqual(expected); + }); + + it('should skip past a shadow boundary', async () => { + let ref = createRef(); + let rootRef = createRef(); + let nearestRef = createRef(); + + await render( + + + +
+
+ + +
+
+ + +
+ + + + ); + + let expected = getNativeScrollParents(ref.current!); + let actual = getScrollParents(ref.current!); + + expect(actual).toEqual([nearestRef.current, rootRef.current]); + expect(actual).toEqual(expected); + }); + + it('should skip past an assigned shadow slot', async () => { + let ref = createRef(); + let rootRef = createRef(); + let nearestRef = createRef(); + + await render( + + + +
+ + +
+
+ +
+ +
+ + + + + ); + + let expected = getNativeScrollParents(ref.current!); + let actual = getScrollParents(ref.current!); + + expect(actual).toEqual([nearestRef.current, rootRef.current]); + expect(actual).toEqual(expected); + }); + + it('should not skip past a containing scrollable ancestor of an assigned slot', async () => { + let ref = createRef(); + let rootRef = createRef(); + let nearestRef = createRef(); + + await render( + + + +
+ + +
+
+ +
+ +
+ + + + + ); + + let expected = getNativeScrollParents(ref.current!); + let actual = getScrollParents(ref.current!); + + expect(actual).toEqual([nearestRef.current, rootRef.current]); + expect(actual).toEqual(expected); + }); + }); +}); + +describe.skipIf(!supportsModernSignature())('getScrollParent', () => { + describe('supports interaction modalities', () => { + it('should return a hidden ancestor under virtual modality', async () => { + let ref = createRef(); + let scrollRef = createRef(); + let containerRef = createRef(); + + await render( + + + +
+
+
+
+
+
+
+ + + + ); + + let [expected] = getNativeScrollParents(ref.current!); + let actualPointer = getScrollParent(ref.current!); + let actualVirtual = getScrollParent(ref.current!, {modality: 'virtual'}); + + expect(actualPointer).toBe(scrollRef.current); + expect(actualVirtual).toBe(containerRef.current); + expect(actualVirtual).toBe(expected); + }); + }); + + describe('supports axis isolation', () => { + it('should return the nearest scrollable ancestor of the queried axis', async () => { + let ref = createRef(); + let scrollYRef = createRef(); + let scrollXRef = createRef(); + + await render( + + + +
+
+
+
+
+
+
+ + + + ); + + let actualInline = getScrollParent(ref.current!, {axis: 'inline'}); + let actualBlock = getScrollParent(ref.current!, {axis: 'block'}); + + expect(actualInline).toBe(scrollXRef.current); + expect(actualBlock).toBe(scrollYRef.current); + }); + }); + + describe('supports scroll snapping', () => { + it('should return the nearest snappable ancestor', async () => { + let ref = createRef(); + let scrollRef = createRef(); + let containerRef = createRef(); + + await render( + + + +
+
+
+
+
+
+
+ + + + ); + + let actual = getScrollParent(ref.current!); + let actualSnapping = getScrollParent(ref.current!, {snappable: true}); + + expect(actual).toBe(containerRef.current); + expect(actualSnapping).toBe(scrollRef.current); + }); + }); + + describe('supports a boundary container', () => { + it('should return null past the boundary', async () => { + let ref = createRef(); + let scrollRef = createRef(); + let containerRef = createRef(); + + await render( + + + +
+
+
+
+
+
+ + + + ); + + let actual = getScrollParent(ref.current!, {container: containerRef.current}); + + // TODO(later): Revisit after fallback fix. + expect(actual).toBe(scrollRef.current); + }); + + it('should return the boundary itself', async () => { + let ref = createRef(); + let scrollRef = createRef(); + + await render( + + + +
+
+
+
+ + + + ); + + let [expected] = getNativeScrollParents(ref.current!); + let actual = getScrollParent(ref.current!, {container: scrollRef.current}); + + expect(actual).toBe(scrollRef.current); + expect(actual).toBe(expected); + }); + }); + + describe('supports legacy signature', () => { + it('should assert scrollable for the shorthand option', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+
+
+
+ + + + ); + + let expectedDefault = getScrollParent(ref.current!, {}); + let expectedPotential = getScrollParent(ref.current!, {scrollable: false}); + let expectedActual = getScrollParent(ref.current!, {scrollable: true}); + + let actualDefault = getScrollParent(ref.current!); + let actualPotential = getScrollParent(ref.current!, false); + let actualActual = getScrollParent(ref.current!, true); + + expect(actualDefault).toBe(expectedDefault); + expect(actualPotential).toBe(expectedPotential); + expect(actualActual).toBe(expectedActual); + expect(actualActual).not.toBe(actualPotential); + }); + }); +}); + +describe.skipIf(!supportsModernSignature())('getScrollParents', () => { + describe('supports interaction modalities', () => { + it('should include a hidden ancestor under virtual modality', async () => { + let ref = createRef(); + let rootRef = createRef(); + let nearestRef = createRef(); + let containerRef = createRef(); + + await render( + + + +
+
+
+
+
+
+
+
+ + + + ); + + let expected = getNativeScrollParents(ref.current!); + let actualPointer = getScrollParents(ref.current!); + let actualVirtual = getScrollParents(ref.current!, {modality: 'virtual'}); + + expect(actualPointer).toEqual([nearestRef.current, rootRef.current]); + expect(actualVirtual).toEqual([containerRef.current, nearestRef.current, rootRef.current]); + expect(actualVirtual).toEqual(expected); + }); + }); + + describe('supports axis isolation', () => { + it('should return the scrollable ancestors of the queried axis', async () => { + let ref = createRef(); + let rootRef = createRef(); + let scrollYRef = createRef(); + let scrollXRef = createRef(); + + await render( + + + +
+
+
+
+
+
+
+
+ + + + ); + + let actualInline = getScrollParents(ref.current!, {axis: 'inline'}); + let actualBlock = getScrollParents(ref.current!, {axis: 'block'}); + + expect(actualInline).toEqual([scrollXRef.current]); + expect(actualBlock).toEqual([scrollYRef.current, rootRef.current]); + }); + }); + + describe('supports scroll snapping', () => { + it('should return the snappable ancestors', async () => { + let ref = createRef(); + let scrollRef = createRef(); + let containerRef = createRef(); + + await render( + + + +
+
+
+
+
+
+
+
+ + + + ); + + let actualSnapping = getScrollParents(ref.current!, {snappable: true}); + + expect(actualSnapping).toEqual([scrollRef.current]); + }); + }); + + describe('supports a boundary container', () => { + it('should stop at the boundary', async () => { + let ref = createRef(); + let nearestRef = createRef(); + let containerRef = createRef(); + + await render( + + + +
+
+
+
+
+
+
+
+
+
+ + + + ); + + let actual = getScrollParents(ref.current!, {container: containerRef.current}); + + expect(actual).toEqual([nearestRef.current]); + }); + }); + + describe('supports legacy signature', () => { + it('should assert scrollable for the shorthand option', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+
+
+
+
+ + + + ); + + let expectedDefault = getScrollParents(ref.current!, {}); + let expectedPotential = getScrollParents(ref.current!, {scrollable: false}); + let expectedActual = getScrollParents(ref.current!, {scrollable: true}); + + let actualDefault = getScrollParents(ref.current!); + let actualPotential = getScrollParents(ref.current!, false); + let actualActual = getScrollParents(ref.current!, true); + + expect(actualDefault).toEqual(expectedDefault); + expect(actualPotential).toEqual(expectedPotential); + expect(actualActual).toEqual(expectedActual); + expect(actualActual).not.toEqual(actualPotential); + }); + }); +}); diff --git a/packages/react-aria/test/utils/isScrollable.browser.test.tsx b/packages/react-aria/test/utils/isScrollable.browser.test.tsx new file mode 100644 index 00000000000..7ed8456d4b4 --- /dev/null +++ b/packages/react-aria/test/utils/isScrollable.browser.test.tsx @@ -0,0 +1,1500 @@ +/* + * 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 {commands} from 'vitest/browser'; +import {createPortal} from 'react-dom'; +import {describe, expect, it} from 'vitest'; +import {isScrollable, ScrollableOptions} from '../../src/utils/isScrollable'; +import {Key} from '@react-types/shared'; +import React, {createRef, forwardRef, useImperativeHandle, useMemo} from 'react'; +import {render} from 'vitest-browser-react'; +import {TestContainer} from '@react-aria/test-utils'; +import {useId} from '../../src/utils/useId'; +import {useLayoutEffect} from '../../src/utils/useLayoutEffect'; + +declare module 'vitest/browser' { + interface BrowserCommands { + wheel: (deltaX: number, deltaY: number) => Promise; + } +} + +export const STYLESHEET = Object.freeze(` + .container { width: 100px; height: 100px; } + .content { width: 100%; height: 100%; min-width: 1px; min-height: 1px; } + + .scroll-y { height: 300vh; } + .scroll-x { width: 300vw; } + + .display-none { display: none; } + + .overflow-visible { overflow: visible; } + .overflow-hidden { overflow: hidden; } + .overflow-clip { overflow: clip; } + .overflow-scroll { overflow: scroll; } + .overflow-auto { overflow: auto; } + + .overflow-x-visible { overflow-x: visible; } + .overflow-x-hidden { overflow-x: hidden; } + .overflow-x-clip { overflow-x: clip; } + .overflow-x-scroll { overflow-x: scroll; } + .overflow-x-auto { overflow-x: auto; } + + .overflow-y-visible { overflow-y: visible; } + .overflow-y-hidden { overflow-y: hidden; } + .overflow-y-clip { overflow-y: clip; } + .overflow-y-scroll { overflow-y: scroll; } + .overflow-y-auto { overflow-y: auto; } + + .snap-none { scroll-snap-type: none; } + .snap-block { scroll-snap-type: block mandatory; } + .snap-inline { scroll-snap-type: inline mandatory; } + .snap-both { scroll-snap-type: both mandatory; } +`); + +interface DocumentProps extends React.PropsWithChildren { + doctype: string; + width?: number; + height?: number; +} + +class TestBench extends TestContainer { + constructor(type: string, key: Key) { + super(type); + + let window = this.getWindow(); + let iframe = this.getRootNode(); + let document = this.getDocument(); + + iframe.dataset.testId = String(key); + + let sheet = new window.CSSStyleSheet(); + sheet.replaceSync(STYLESHEET.trim()); + + document.adoptedStyleSheets.push(sheet); + + this.reset(); + } + + public override reset(): void { + let document = this.getDocument(); + + super.reset(); + + if (parseInt(React.version, 10) < 19) { + document.documentElement.remove(); + } + } +} + +const Document = forwardRef((props, ref) => { + let {width = 300, height = 300} = props; + + let id = useId(); + + let bench = useMemo(() => { + return new TestBench(props.doctype, id); + }, [props.doctype, id]); + + useImperativeHandle(ref, () => { + return bench.getDocument(); + }, [bench]); + + useLayoutEffect(() => { + let iframe = bench.getRootNode(); + iframe.width = String(width); + iframe.height = String(height); + + return () => bench.remove(); + }, [width, height, bench]); + + // @ts-expect-error + return createPortal(props.children, bench.getDocument()); +}); + +function supportsModernSignature(): boolean { + try { + // @ts-ignore + isScrollable(document); + return true; + } catch { + return false; + } +} + +async function isNativeScrollable( + element: HTMLElement, + options: ScrollableOptions = {} +): Promise { + let {axis = 'block', modality = 'pointer', scrollable = false} = options; + + let actions: Array<() => unknown> = []; + + let ownerWindow = element.ownerDocument.defaultView!; + let ownerDocument = element.ownerDocument; + + let frameElement = ownerWindow.frameElement!; + let contentElement = ownerDocument.createElement('div'); + + let isRoot = element.contains(ownerDocument.scrollingElement); + + let propagatingTarget = isRoot ? ownerDocument : element; + let containingElement = isRoot ? ownerDocument.documentElement : element; + let scrollingElement = isRoot ? ownerDocument.scrollingElement! : element; + + let rects = scrollingElement.getClientRects(); + let rect = scrollingElement.getBoundingClientRect(); + + let wheelUp: [number, number] = axis === 'block' ? [0, -100] : [-100, 0]; + let wheelDown: [number, number] = axis === 'block' ? [0, 100] : [100, 0]; + let scrollUp: ScrollToOptions = axis === 'block' ? {top: -100} : {left: -100}; + let scrollDown: ScrollToOptions = axis === 'block' ? {top: 100} : {left: 100}; + + if (!scrollable) { + try { + let scrollSize = axis === 'block' ? 'scrollHeight' : 'scrollWidth'; + let size = axis === 'block' ? 'height' : 'width'; + let crossSize = axis === 'block' ? 'width' : 'height'; + + contentElement.style[size] = element[scrollSize] + 1 + 'px'; + contentElement.style[crossSize] = '1px'; + + containingElement.append(contentElement); + + return await isNativeScrollable(element, {...options, scrollable: true}); + } finally { + contentElement.remove(); + } + } + + if (rects.length > 0 && modality === 'pointer') { + let id = frameElement.getAttribute('data-test-id'); + let selector = `iframe[data-test-id="${id}"]`; + + let left = Math.max(rect.left, 0); + let top = Math.max(rect.top, 0); + let right = Math.min(rect.right, ownerWindow.innerWidth); + let bottom = Math.min(rect.bottom, ownerWindow.innerHeight); + + let mouseX = frameElement.clientLeft + (left + right) / 2; + let mouseY = frameElement.clientTop + (top + bottom) / 2; + + await commands.mouseDownOnElement(selector, mouseX, mouseY); + await commands.mouseUp(); + + actions.push(() => commands.wheel(...wheelDown)); + actions.push(() => commands.wheel(...wheelUp)); + } else if (rects.length > 0) { + actions.push(() => scrollingElement.scrollBy(scrollDown)); + actions.push(() => scrollingElement.scrollBy(scrollUp)); + } + + try { + for (let scroll of actions) { + let signal = AbortSignal.timeout(100); + + let scrolled = new Promise(resolve => { + signal.addEventListener('abort', () => resolve(false), {once: true}); + propagatingTarget.addEventListener('scroll', () => resolve(true), { + once: true, + signal + }); + }); + + await scroll(); + if (await scrolled) return true; + } + + return false; + } finally { + scrollingElement.scroll(0, 0); + } +} + +describe('isScrollable (legacy)', () => { + describe('supports potentially-scrollable container', () => { + it('should return false for visible overflow', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = await isNativeScrollable(ref.current!); + let actual = isScrollable(ref.current!); + + expect(actual).toBe(false); + expect(actual).toBe(expected); + }); + + it('should return false for hidden overflow', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = await isNativeScrollable(ref.current!); + let actual = isScrollable(ref.current!); + + expect(actual).toBe(false); + expect(actual).toBe(expected); + }); + + it('should return true for auto overflow', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = await isNativeScrollable(ref.current!); + let actual = isScrollable(ref.current!); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + + it('should return true for scroll overflow', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = await isNativeScrollable(ref.current!); + let actual = isScrollable(ref.current!); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + + it('should return false for clip overflow', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = await isNativeScrollable(ref.current!); + let actual = isScrollable(ref.current!); + + expect(actual).toBe(false); + expect(actual).toBe(expected); + }); + + it('should return true for single axis overflow', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = await isNativeScrollable(ref.current!); + let actual = isScrollable(ref.current!); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + + it('should return true for visible root overflow', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = await isNativeScrollable(ref.current!); + let actual = isScrollable(ref.current!); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + + it('should return false for hidden root overflow', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = await isNativeScrollable(ref.current!); + let actual = isScrollable(ref.current!); + + expect(actual).toBe(false); + expect(actual).toBe(expected); + }); + + it('should return true for auto root overflow', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = await isNativeScrollable(ref.current!); + let actual = isScrollable(ref.current!); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + + it('should return true for scroll root overflow', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = await isNativeScrollable(ref.current!); + let actual = isScrollable(ref.current!); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + + it('should return false for clip root overflow', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = await isNativeScrollable(ref.current!); + let actual = isScrollable(ref.current!); + + expect(actual).toBe(false); + expect(actual).toBe(expected); + }); + }); + + describe('supports viewport overflow propagation', () => { + it('should return true for visible body overflow', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = await isNativeScrollable(ref.current!); + let actual = isScrollable(ref.current!); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + + it('should return false for hidden body overflow', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = await isNativeScrollable(ref.current!); + let actual = isScrollable(ref.current!); + + expect(actual).toBe(false); + expect(actual).toBe(expected); + }); + + it('should return true for auto body overflow', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = await isNativeScrollable(ref.current!); + let actual = isScrollable(ref.current!); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + + it('should return true for scroll body overflow', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = await isNativeScrollable(ref.current!); + let actual = isScrollable(ref.current!); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + + it('should return false for clip body overflow', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = await isNativeScrollable(ref.current!); + let actual = isScrollable(ref.current!); + + expect(actual).toBe(false); + expect(actual).toBe(expected); + }); + + it('should return false when propagating overflow', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = await isNativeScrollable(ref.current!); + let actual = isScrollable(ref.current!); + + expect(actual).toBe(false); + expect(actual).toBe(expected); + }); + + it('should return true for non-propagating overflow', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = await isNativeScrollable(ref.current!); + let actual = isScrollable(ref.current!); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + + it('should not propagate under hidden root overflow', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = await isNativeScrollable(ref.current!, {axis: 'inline'}); + let actual = isScrollable(ref.current!); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + + it('should not propagate when hidden', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = await isNativeScrollable(ref.current!); + let actual = isScrollable(ref.current!); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + + it('should not propagate under hidden root', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let actual = isScrollable(ref.current!); + + expect(actual).toBe(true); + }); + + it('should not propagate under single axis visible root overflow', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = await isNativeScrollable(ref.current!, {axis: 'block'}); + let actual = isScrollable(ref.current!); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + }); + + describe('supports checking for actual overflow', () => { + it('should return false for non-scrollable', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expectedY = await isNativeScrollable(ref.current!, {axis: 'block', scrollable: true}); + let expectedX = await isNativeScrollable(ref.current!, {axis: 'inline', scrollable: true}); + let actual = isScrollable(ref.current!, true); + + expect(actual).toBe(false); + expect(actual).toBe(expectedY || expectedX); + }); + + it('should return true for block scrollable', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = await isNativeScrollable(ref.current!, {axis: 'block', scrollable: true}); + let actual = isScrollable(ref.current!, true); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + + it('should return true for inline scrollable', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = await isNativeScrollable(ref.current!, {axis: 'inline', scrollable: true}); + let actual = isScrollable(ref.current!, true); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + + it('should return true for inline reverse scrollable', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = await isNativeScrollable(ref.current!, {axis: 'inline', scrollable: true}); + let actual = isScrollable(ref.current!, true); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + + it('should return false for non-scrollable root', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expectedY = await isNativeScrollable(ref.current!, {axis: 'block', scrollable: true}); + let expectedX = await isNativeScrollable(ref.current!, {axis: 'inline', scrollable: true}); + let actual = isScrollable(ref.current!, true); + + expect(actual).toBe(false); + expect(actual).toBe(expectedY || expectedX); + }); + + it('should return false for block scrollable under hidden block overflow', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expectedY = await isNativeScrollable(ref.current!, {axis: 'block', scrollable: true}); + let expectedX = await isNativeScrollable(ref.current!, {axis: 'inline', scrollable: true}); + let actual = isScrollable(ref.current!, true); + + expect(actual).toBe(false); + expect(actual).toBe(expectedY || expectedX); + }); + + it('should return false for inline scrollable under hidden inline overflow', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expectedY = await isNativeScrollable(ref.current!, {axis: 'block', scrollable: true}); + let expectedX = await isNativeScrollable(ref.current!, {axis: 'inline', scrollable: true}); + let actual = isScrollable(ref.current!, true); + + expect(actual).toBe(false); + expect(actual).toBe(expectedY || expectedX); + }); + + it('should return true for block scrollable root', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = await isNativeScrollable(ref.current!, {axis: 'block', scrollable: true}); + let actual = isScrollable(ref.current!, true); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + + it('should return true for inline scrollable root', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = await isNativeScrollable(ref.current!, {axis: 'inline', scrollable: true}); + let actual = isScrollable(ref.current!, true); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + + it('should return true for inline reverse scrollable root', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = await isNativeScrollable(ref.current!, {axis: 'inline', scrollable: true}); + let actual = isScrollable(ref.current!, true); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + + it('should return true for block reverse flex scrollable', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = await isNativeScrollable(ref.current!, {axis: 'block', scrollable: true}); + let actual = isScrollable(ref.current!, true); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + + it('should return true for inline reverse flex scrollable', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = await isNativeScrollable(ref.current!, {axis: 'inline', scrollable: true}); + let actual = isScrollable(ref.current!, true); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + + it('should return true for block reverse flex scrollable root', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = await isNativeScrollable(ref.current!, {axis: 'block', scrollable: true}); + let actual = isScrollable(ref.current!, true); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + }); + + describe('supports quirks mode documents', () => { + it('should return true for visible root overflow', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let bodyElement = ref.current?.ownerDocument.body; + + expect(scrollingElement).toBe(bodyElement); + + let expected = await isNativeScrollable(ref.current!); + let actual = isScrollable(ref.current!); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + + it('should return true for visible body overflow', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let bodyElement = ref.current?.ownerDocument.body; + + expect(scrollingElement).toBe(bodyElement); + + let expected = await isNativeScrollable(ref.current!); + let actual = isScrollable(ref.current!); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + + it('should return true for the scrollable root', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let scrollingElement = ref.current?.ownerDocument.scrollingElement; + let bodyElement = ref.current?.ownerDocument.body; + + expect(scrollingElement).toBe(bodyElement); + + let expected = await isNativeScrollable(ref.current!, {scrollable: true}); + let actual = isScrollable(ref.current!, true); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + }); +}); + +describe.skipIf(!supportsModernSignature())('isScrollable', () => { + describe('supports interaction modalities', () => { + it('should return true for hidden overflow under virtual modality', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = await isNativeScrollable(ref.current!, {modality: 'virtual'}); + let actual = isScrollable(ref.current!, {modality: 'virtual'}); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + expect(isScrollable(ref.current!, {modality: 'keyboard'})).toBe(actual); + }); + + it('should return false for clip overflow under virtual modality', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expected = await isNativeScrollable(ref.current!, {modality: 'virtual'}); + let actual = isScrollable(ref.current!, {modality: 'virtual'}); + + expect(actual).toBe(false); + expect(actual).toBe(expected); + }); + + it('should return true for hidden root overflow under virtual modality', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = await isNativeScrollable(ref.current!, {modality: 'virtual'}); + let actual = isScrollable(ref.current!, {modality: 'virtual'}); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + + it('should return true for clip root overflow under virtual modality', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = await isNativeScrollable(ref.current!, {modality: 'virtual'}); + let actual = isScrollable(ref.current!, {modality: 'virtual'}); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + + it('should return true for hidden body overflow under virtual modality', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = await isNativeScrollable(ref.current!, {modality: 'virtual'}); + let actual = isScrollable(ref.current!, {modality: 'virtual'}); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + + it('should return true for clip body overflow under virtual modality', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = await isNativeScrollable(ref.current!, {modality: 'virtual'}); + let actual = isScrollable(ref.current!, {modality: 'virtual'}); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + }); + + describe('supports axis isolation', () => { + it('should return true only for the scrollable axis', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expectedBlock = await isNativeScrollable(ref.current!, {axis: 'block'}); + let actualBlock = isScrollable(ref.current!, {axis: 'block'}); + + await new Promise(resolve => setTimeout(resolve, 100)); + + let expectedInline = await isNativeScrollable(ref.current!, {axis: 'inline'}); + let actualInline = isScrollable(ref.current!, {axis: 'inline'}); + + expect(actualBlock).toBe(false); + expect(actualBlock).toBe(expectedBlock); + expect(actualInline).toBe(true); + expect(actualInline).toBe(expectedInline); + }); + + it('should return true only for the overflowing axis', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expectedBlock = await isNativeScrollable(ref.current!, { + axis: 'block', + scrollable: true + }); + let actualBlock = isScrollable(ref.current!, { + axis: 'block', + scrollable: true + }); + + await new Promise(resolve => setTimeout(resolve, 100)); + + let expectedInline = await isNativeScrollable(ref.current!, { + axis: 'inline', + scrollable: true + }); + let actualInline = isScrollable(ref.current!, { + axis: 'inline', + scrollable: true + }); + + expect(actualBlock).toBe(true); + expect(actualBlock).toBe(expectedBlock); + expect(actualInline).toBe(false); + expect(actualInline).toBe(expectedInline); + }); + + it('should return true only for the scrollable root axis', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expectedBlock = await isNativeScrollable(ref.current!, {axis: 'block'}); + let actualBlock = isScrollable(ref.current!, {axis: 'block'}); + + await new Promise(resolve => setTimeout(resolve, 100)); + + let expectedInline = await isNativeScrollable(ref.current!, {axis: 'inline'}); + let actualInline = isScrollable(ref.current!, {axis: 'inline'}); + + expect(actualBlock).toBe(false); + expect(actualBlock).toBe(expectedBlock); + expect(actualInline).toBe(true); + expect(actualInline).toBe(expectedInline); + }); + }); + + describe('supports scroll snapping', () => { + it('should return true for block snappable', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let actualBlock = isScrollable(ref.current!, {snappable: true, axis: 'block'}); + let actualInline = isScrollable(ref.current!, {snappable: true, axis: 'inline'}); + + expect(actualBlock).toBe(true); + expect(actualInline).toBe(false); + }); + + it('should return true for inline snappable', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let actualBlock = isScrollable(ref.current!, {snappable: true, axis: 'block'}); + let actualInline = isScrollable(ref.current!, {snappable: true, axis: 'inline'}); + + expect(actualBlock).toBe(false); + expect(actualInline).toBe(true); + }); + + it('should return false for non-snappable', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let actual = isScrollable(ref.current!, {snappable: true}); + + expect(actual).toBe(false); + }); + + it('should return true for hidden overflow', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let actual = isScrollable(ref.current!, {snappable: true}); + + expect(actual).toBe(true); + }); + + it('should return true for root snappable', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let actualBlock = isScrollable(ref.current!, {snappable: true, axis: 'block'}); + let actualInline = isScrollable(ref.current!, {snappable: true, axis: 'inline'}); + + expect(actualBlock).toBe(true); + expect(actualInline).toBe(false); + }); + + it('should return false for body snappable', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let actual = isScrollable(ref.current!, {snappable: true}); + + expect(actual).toBe(false); + }); + }); + + describe('supports document nodes', () => { + it('should return true for visible root overflow', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = await isNativeScrollable(ref.current!.documentElement); + let actual = isScrollable(ref.current!); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + + it('should return false for hidden body overflow', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = await isNativeScrollable(ref.current!.documentElement); + let actual = isScrollable(ref.current!); + + expect(actual).toBe(false); + expect(actual).toBe(expected); + }); + + it('should return true for root scrollable', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = await isNativeScrollable(ref.current!.documentElement, {scrollable: true}); + let actual = isScrollable(ref.current!, {scrollable: true}); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + + it('should return true for visible root overflow in quirks mode', async () => { + let ref = createRef(); + + await render( + + + +
+ + + + ); + + let expected = await isNativeScrollable(ref.current!.documentElement); + let actual = isScrollable(ref.current!); + + expect(actual).toBe(true); + expect(actual).toBe(expected); + }); + }); + + describe('supports legacy signature', () => { + it('should assert scrollable for the shorthand option', async () => { + let ref = createRef(); + + await render( + + + +
+
+
+ + + + ); + + let expectedDefault = isScrollable(ref.current!, {}); + let expectedPotential = isScrollable(ref.current!, {scrollable: false}); + let expectedActual = isScrollable(ref.current!, {scrollable: true}); + + let actualDefault = isScrollable(ref.current!); + let actualPotential = isScrollable(ref.current!, false); + let actualActual = isScrollable(ref.current!, true); + + expect(actualDefault).toBe(expectedDefault); + expect(actualPotential).toBe(expectedPotential); + expect(actualActual).toBe(expectedActual); + expect(actualActual).not.toBe(actualPotential); + }); + }); +}); diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index c302f5df80a..810051ef494 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -198,6 +198,8 @@ declare module 'vitest/browser' { mouseDownOnElement: (selector: string, offsetX?: number, offsetY?: number) => Promise; // Same as above mouseUp: () => Promise; + // Wheel input at the current pointer position. + wheel: (deltaX: number, deltaY: number) => Promise; } } @@ -250,6 +252,8 @@ export default defineConfig({ test: { globals: true, pool: 'threads', + reporters: ['tree'], + fileParallelism: false, setupFiles: ['./test/browser/setup.ts'], include: ['packages/**/test/**/*.browser.test.{ts,tsx}'], browser: { @@ -324,14 +328,20 @@ export default defineConfig({ offsetX: number = 5, offsetY?: number ) => { - const box = await iframe.locator(selector).boundingBox(); - const x = box.x + offsetX; - const y = offsetY == null ? box.y + box.height / 2 : box.y + offsetY; + const locator = iframe.locator(selector); + const box = await locator.boundingBox(); + const width = await locator.evaluate((el: Element) => el.getBoundingClientRect().width); + const scale = box.width / width; + const x = box.x + offsetX * scale; + const y = offsetY == null ? box.y + box.height / 2 : box.y + offsetY * scale; await page.mouse.move(x, y); await page.mouse.down(); }, mouseUp: async ({page}: any) => { await page.mouse.up(); + }, + wheel: async ({page}: any, deltaX: number, deltaY: number) => { + await page.mouse.wheel(deltaX, deltaY); } } }, From c08058d04957021dd39612d129609cf5223ab8fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Sun, 20 Sep 2026 23:03:06 +0200 Subject: [PATCH 4/6] fix: ci on linux --- .../react-aria/test/utils/getScrollOffset.browser.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-aria/test/utils/getScrollOffset.browser.test.tsx b/packages/react-aria/test/utils/getScrollOffset.browser.test.tsx index 7d5e00c9d19..12e51ee9502 100644 --- a/packages/react-aria/test/utils/getScrollOffset.browser.test.tsx +++ b/packages/react-aria/test/utils/getScrollOffset.browser.test.tsx @@ -122,10 +122,10 @@ function getNativeMaxScrollOffset(element: HTMLElement, axis: Axis): number { try { let key = axis === 'block' ? 'top' : 'left'; - scrollingElement.scrollTo({[key]: Number.MAX_SAFE_INTEGER}); + scrollingElement.scrollTo({[key]: 1e6}); let max = getNativeScrollOffset(element, axis); - scrollingElement.scrollTo({[key]: Number.MIN_SAFE_INTEGER}); + scrollingElement.scrollTo({[key]: -1e6}); let min = getNativeScrollOffset(element, axis); return max !== 0 ? max : min; From 5120b05ff7442fd185c1ba4406d3a057e5bb7aa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Mon, 21 Sep 2026 10:39:53 +0200 Subject: [PATCH 5/6] chore: remove legacy test suite --- .../utils/getScrollParents.browser.test.tsx | 31 ++++++- .../test/utils/getScrollParents.test.ts | 86 ------------------- 2 files changed, 29 insertions(+), 88 deletions(-) delete mode 100644 packages/react-aria/test/utils/getScrollParents.test.ts diff --git a/packages/react-aria/test/utils/getScrollParents.browser.test.tsx b/packages/react-aria/test/utils/getScrollParents.browser.test.tsx index 44c45b3b910..c0fed00541e 100644 --- a/packages/react-aria/test/utils/getScrollParents.browser.test.tsx +++ b/packages/react-aria/test/utils/getScrollParents.browser.test.tsx @@ -1115,11 +1115,9 @@ describe('getScrollParents (legacy)', () => { let expected = getNativeScrollParents(element); let actualPotential = getScrollParents(element); - let actual = getScrollParents(element, true); expect(expected).toEqual([]); expect(actualPotential).toEqual([rootRef.current]); - expect(actual).toEqual([rootRef.current]); }); it('should return the root scrolling element under a scrollable root', async () => { @@ -1840,6 +1838,35 @@ describe.skipIf(!supportsModernSignature())('getScrollParents', () => { expect(actual).toEqual([nearestRef.current]); }); + + it('should include the boundary itself', async () => { + let ref = createRef(); + let nearestRef = createRef(); + let containerRef = createRef(); + + await render( + + + +
+
+
+
+
+
+
+
+ + + + ); + + let expected = getNativeScrollParents(ref.current!); + let actual = getScrollParents(ref.current!, {container: containerRef.current}); + + expect(actual).toEqual([nearestRef.current, containerRef.current]); + expect(actual).toEqual(expected.slice(0, 2)); + }); }); describe('supports legacy signature', () => { diff --git a/packages/react-aria/test/utils/getScrollParents.test.ts b/packages/react-aria/test/utils/getScrollParents.test.ts deleted file mode 100644 index ecc39f481cf..00000000000 --- a/packages/react-aria/test/utils/getScrollParents.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -/* - * 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); - - jest.spyOn(window, 'getComputedStyle').mockImplementation(() => { - return {overflow: 'visible'} as CSSStyleDeclaration; - }); - - 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); - - jest.spyOn(window, 'getComputedStyle').mockImplementation(() => { - return {overflow: 'visible'} as CSSStyleDeclaration; - }); - - let parents = getScrollParents(child); - expect(parents).not.toContain(plain); - expect(parents).not.toContain(document.body); - }); -}); From 86e35961f1bba3468c7862cc0609ad77d80ecdb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Mon, 21 Sep 2026 20:53:57 +0200 Subject: [PATCH 6/6] chore: silence ref warn --- .../react-aria/test/utils/getScrollParents.browser.test.tsx | 4 +++- vitest.browser.config.ts | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react-aria/test/utils/getScrollParents.browser.test.tsx b/packages/react-aria/test/utils/getScrollParents.browser.test.tsx index c0fed00541e..77a6c41a191 100644 --- a/packages/react-aria/test/utils/getScrollParents.browser.test.tsx +++ b/packages/react-aria/test/utils/getScrollParents.browser.test.tsx @@ -126,9 +126,11 @@ const ShadowRoot = forwardRef((props, forwarded ); }); -const Shadow = forwardRef(props => { +const Shadow = forwardRef((props, ref) => { let ctx = React.useContext(ShadowRootContext); + useImperativeHandle(ref, () => null, []); + if (ctx == null) return null; return createPortal(props.children, ctx); diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index 810051ef494..b687c090a2f 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -252,7 +252,6 @@ export default defineConfig({ test: { globals: true, pool: 'threads', - reporters: ['tree'], fileParallelism: false, setupFiles: ['./test/browser/setup.ts'], include: ['packages/**/test/**/*.browser.test.{ts,tsx}'],