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/2] 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/2] 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. */