From af0c50d9ab29050107d0ba9da5561543c5a95c6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Thu, 3 Sep 2026 01:06:10 +0200 Subject: [PATCH] feat: box layout utilities --- .../@react-types/shared/src/collections.d.ts | 15 +- packages/@react-types/shared/src/index.d.ts | 1 + packages/@react-types/shared/src/layout.d.ts | 52 ++ .../exports/private/utils/domHelpers.ts | 9 +- .../exports/private/utils/layout.ts | 1 + .../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 +- packages/react-aria/src/utils/events.ts | 96 +++ .../react-aria/src/utils/isContainingBlock.ts | 29 + packages/react-aria/src/utils/layout.ts | 781 ++++++++++++++++++ .../react-aria/src/utils/layoutHelpers.ts | 166 ++++ .../src/utils/shadowdom/DOMFunctions.ts | 33 +- packages/react-aria/src/utils/typeHelpers.ts | 74 ++ 17 files changed, 1261 insertions(+), 105 deletions(-) create mode 100644 packages/@react-types/shared/src/layout.d.ts create mode 100644 packages/react-aria/exports/private/utils/layout.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/events.ts create mode 100644 packages/react-aria/src/utils/isContainingBlock.ts create mode 100644 packages/react-aria/src/utils/layout.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..79dc9a287e1 100644 --- a/packages/@react-types/shared/src/index.d.ts +++ b/packages/@react-types/shared/src/index.d.ts @@ -24,3 +24,4 @@ export * from './labelable'; export * from './orientation'; export * from './locale'; export * from './key'; +export * from './layout'; 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..6638d4d066e --- /dev/null +++ b/packages/@react-types/shared/src/layout.d.ts @@ -0,0 +1,52 @@ +/* + * 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 Precision = 'pixel' | 'sub-pixel' | 'device-pixel'; +export type Corner = 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight'; +export type Position = 'start' | 'center' | 'end'; + +export interface BoundingOptions { + /** The pixel precision to calculate the bound with. */ + precision?: Precision; + /** Whether or not to allow 2D transforms on the bound. */ + transform?: boolean; + /** The box-model to use when bounding. */ + model?: BoxModel; +} + +export type BoxModel = + | 'margin-box' + | 'scroll-margin-box' + | 'border-box' + | 'padding-box' + | 'scroll-padding-box' + | 'content-box'; + +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/layout.ts b/packages/react-aria/exports/private/utils/layout.ts new file mode 100644 index 00000000000..d80e958ed08 --- /dev/null +++ b/packages/react-aria/exports/private/utils/layout.ts @@ -0,0 +1 @@ +export {DOMBox} from '../../../src/utils/layout'; 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/events.ts b/packages/react-aria/src/utils/events.ts new file mode 100644 index 00000000000..b53d7b12b06 --- /dev/null +++ b/packages/react-aria/src/utils/events.ts @@ -0,0 +1,96 @@ +/* + * 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 {getEventTarget} from './shadowdom/DOMFunctions'; + +export interface SyntheticEventListener { + (event: T): void; +} + +/** + * An abstract base for producers of synthetic events. Mirrors the EventTarget API, but + * ref-counts its listeners in order to lazily (de-)attach from an event source. + */ +export abstract class SyntheticEventTarget { + protected listeners: Map>>; + protected connections: Set; + + protected abstract connect(): void; + protected abstract disconnect(): void; + + constructor() { + this.addEventListener = this.addEventListener.bind(this); + this.removeEventListener = this.removeEventListener.bind(this); + this.dispatchEvent = this.dispatchEvent.bind(this); + + this.connections = new Set(); + this.listeners = new Map(); + } + + /** + * The `addEventListener()` method sets up a function that will be called whenever + * the specified event is delivered to this target. + */ + public addEventListener( + type: K, + listener: SyntheticEventListener> + ): void { + let handler = listener as SyntheticEventListener; + let handlers = this.listeners.get(type); + + if (process.env.NODE_ENV === 'test' || handlers?.has(handler)) return; + if (this.listeners.size === 0) this.connect(); + + handlers ??= new Set(); + this.listeners.set(type, handlers); + handlers.add(handler); + } + + /** + * The `removeEventListener()` method removes an event listener from this target, + * which had previously been registered with addEventListener(). + */ + public removeEventListener( + type: K, + listener: SyntheticEventListener> + ): void { + let handler = listener as SyntheticEventListener; + let handlers = this.listeners.get(type); + + if (process.env.NODE_ENV === 'test' || !handlers?.has(handler)) return; + if (handlers.size === 1 && this.listeners.size === 1) this.disconnect(); + if (handlers.size === 1) this.listeners.delete(type); + + handlers.delete(handler); + } + + /** + * The `dispatchEvent()` method sends an Event to this target, (synchronously) + * invoking the affected event listeners in the appropriate order. + */ + public dispatchEvent(event: T): boolean { + let target: EventTarget | null = getEventTarget(event); + let handlers = new Set(this.listeners.get(event.type)); + + Reflect.defineProperty(event, 'target', { + value: target ?? this, + enumerable: true, + configurable: true + }); + + for (let listener of handlers) { + listener(event); + } + + return true; + } +} 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/layout.ts b/packages/react-aria/src/utils/layout.ts new file mode 100644 index 00000000000..d33c33f7102 --- /dev/null +++ b/packages/react-aria/src/utils/layout.ts @@ -0,0 +1,781 @@ +/* + * 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 {addEvent, getOwnerDocument, getOwnerWindow} from './domHelpers'; +import {BoundingNode, BoundingOptions, BoxModel} from '@react-types/shared'; +import {getOverflowingElement, getStylingElement, getVisualViewport} from './layoutHelpers'; +import {getPropagationTargets, nodeContains} from './shadowdom/DOMFunctions'; +import {isChrome, isIOS, isWebKit} from './platform'; +import {isDocument, isHTMLElement} from './typeHelpers'; +import {SyntheticEventTarget} from './events'; + +/** + * https://github.com/orgs/adobe/projects/19/views/30?filterQuery=overlay&pane=issue&itemId=5247317. + * + * Disclaimer: "DOMResizableBox" and "DOMBoxAnchor" are experimental preview to understand the + * context of why this has been built using OOP. Only "DOMBox" is required for scroll utilities! + * + * This file aims to provide the primitives for a smaller, more accurate and faster alternative + * to @floating-ui/dom, based on native CSS anchor positioning and CSS transitions. + * + * Unlike floating-ui, this implementation offers a synchronous, event-based API, which, coupled + * with #10102s work on interactive widgets, is able to directly plug into the existing codebase and + * fix most open issues attributed to useResizeObserver, useViewportSize and useOverlayPosition. + * + * Here is an outline over "DOMBox", "DOMResizableBox" and "DOMBoxAnchor": + * + * 1. A "DOMBox" is built via two bounding box implementations to cover either viewport or element + * bounding targets. Due to issues in Chrome, these currently vary enough to warrant an internal + * strategy pattern, but we can consider consolidation once the issue resolves. + * 2. On top, a "DOMResizableBox" implements an event emitter, which fires on resize. All box models + * may be supported through a ResizeObserver and a set of inline CSS transitions. This basically + * notifies us of changes in either size or box style (e.g. padding, scroll-padding). + * 3. A "DOMBoxAnchor" extends "DOMResizableBox" by position tracking. This is done through a hidden, + * non-layout-thrashing fixpos sentinel, which is positioned at the ICB origin and anchored to + * the target so its width & height correspond to the top & left coordinates. Position changes + * done in composite, e.g. transforms or scroll, are listened to or already followed natively. + * + * Hint: "DOMBoxAnchor" has only recently been enabled by CSS anchor positioning entering baseline, + * which coincides with RSPs required browser support range (last 2 majors). + * + * Fixes: Issue#7142, Issue#10036, Issue#10131, PR#9318 and more. + */ + +const BOX_SYMBOL = Symbol.for('react-aria-box'); + +const BOX_OPTIONS = Object.freeze>>({ + model: 'border-box', + precision: 'sub-pixel', + transform: true +}); + +interface DOMBoxStrategy { + model: NonNullable['model']>; + precision: NonNullable['precision']>; + transform: NonNullable['transform']>; + initialRect: DOMRect; + visibleRect: DOMRect; + boundingRect: DOMRect; + target: T; +} + +interface ElementBoxOptions extends BoundingOptions { + model?: BoxModel; +} + +interface DocumentBoxOptions extends BoundingOptions { + model?: Extract; +} + +type BoxOptions = Document extends T + ? DocumentBoxOptions + : ElementBoxOptions; + +class ElementBox implements DOMBoxStrategy { + public readonly model: NonNullable['model']>; + public readonly precision: NonNullable['precision']>; + public readonly transform: NonNullable['transform']>; + + public readonly target: T; + + constructor(target: T, options?: BoxOptions) { + let {model, precision, transform} = {...BOX_OPTIONS, ...options}; + + this.model = model; + this.precision = precision; + this.transform = transform; + + this.target = target; + } + + /** + * Returns the initial bounding rectangle of this target in frame coordinate space. + * Similar to `element.getBoundingClientRect()`, but only when rendered. + */ + public get initialRect(): DOMRect { + let rect = this.target.getBoundingClientRect(); + + if (rect.width > 0 && rect.height > 0) { + return new DOMRect(rect.x, rect.y, rect.width, rect.height); + } else { + return new DOMRect(); + } + } + + /** + * Returns the visible bounding rectangle of this target in frame coordinate space. + * Similar to `element.getBoundingClientRect()`, but intersected with all ancestors. + */ + public get visibleRect(): DOMRect { + throw new Error('Not implemented yet.'); + } + + /** + * Returns the bounding rectangle of this target in frame coordinate space. + * Similar to `element.getBoundingClientRect()`, but normalized across engines. + */ + public get boundingRect(): DOMRect { + let rect = this.initialRect; + + let ownerWindow = getOwnerWindow(this.target); + let ownerDocument = getOwnerDocument(this.target); + + let stylingElement = getStylingElement(this.target); + let style = ownerWindow.getComputedStyle(stylingElement); + + // If disabled, strip 2D transforms while attempting to preserve subpixel precision. + // This can be useful when positioning a bounding target relative to an animated anchor. + if (!this.transform && isHTMLElement(this.target)) { + if (style.transform !== 'none' && typeof ownerWindow.DOMMatrix !== 'undefined') { + let matrix = new DOMMatrix(style.transform); + + if (matrix && matrix.is2D) { + rect.width /= Math.hypot(matrix.a, matrix.b) || 1; + rect.height /= Math.hypot(matrix.c, matrix.d) || 1; + } + } + + if (Math.abs(rect.width - this.target.offsetWidth) >= 1) { + rect.width = this.target.offsetWidth; + } + + if (Math.abs(rect.height - this.target.offsetHeight) >= 1) { + rect.height = this.target.offsetHeight; + } + } + + if (rect.width <= 0 || rect.height <= 0) { + return new DOMRect(); + } + + if (this.model === 'scroll-margin-box') { + rect.y -= parseFloat(style.scrollMarginTop) || 0; + rect.height += parseFloat(style.scrollMarginTop) || 0; + rect.height += parseFloat(style.scrollMarginBottom) || 0; + rect.x -= parseFloat(style.scrollMarginLeft) || 0; + rect.width += parseFloat(style.scrollMarginLeft) || 0; + rect.width += parseFloat(style.scrollMarginRight) || 0; + } else if (this.model === 'margin-box') { + rect.y -= parseFloat(style.marginTop) || 0; + rect.height += parseFloat(style.marginTop) || 0; + rect.height += parseFloat(style.marginBottom) || 0; + rect.x -= parseFloat(style.marginLeft) || 0; + rect.width += parseFloat(style.marginLeft) || 0; + rect.width += parseFloat(style.marginRight) || 0; + } + + if (this.model.endsWith('padding-box') || this.model.endsWith('content-box')) { + let clientTop = parseFloat(style.borderTopWidth) || 0; + let clientLeft = parseFloat(style.borderLeftWidth) || 0; + let clientBottom = parseFloat(style.borderBottomWidth) || 0; + let clientRight = parseFloat(style.borderRightWidth) || 0; + + // A node containing the root overflowing element shall assert as its document. + if (!nodeContains(this.target, getOverflowingElement(ownerDocument))) { + let gutterAlign = style.direction === 'rtl' ? 'left' : 'right'; + + let innerWidth = Math.max(0, rect.width - clientLeft - clientRight); + let innerHeight = Math.max(0, rect.height - clientTop - clientBottom); + + let gutterWidth = Math.max(0, innerWidth - this.target.clientWidth); + let gutterHeight = Math.max(0, innerHeight - this.target.clientHeight); + + // https://bugs.webkit.org/show_bug.cgi?id=318043 + if (/both-edges/.test(style.scrollbarGutter) && isWebKit()) gutterWidth *= 2; + if (/both-edges/.test(style.scrollbarGutter)) gutterAlign = 'both-edges'; + + // WebKit on IOS always positions the scrollbar on the right. + if (isIOS() && isWebKit()) gutterAlign = 'right'; + + if (gutterAlign === 'left') { + rect.x += gutterWidth; + rect.width -= gutterWidth; + rect.height -= gutterHeight; + } else if (gutterAlign === 'right') { + rect.width -= gutterWidth; + rect.height -= gutterHeight; + } else if (gutterAlign === 'both-edges') { + rect.x += gutterWidth / 2; + rect.width -= gutterWidth; + rect.height -= gutterHeight; + } + } + + rect.y += clientTop; + rect.height -= clientTop; + rect.height -= clientBottom; + rect.x += clientLeft; + rect.width -= clientLeft; + rect.width -= clientRight; + } + + if (this.model === 'scroll-padding-box') { + rect.y += parseFloat(style.scrollPaddingTop) || 0; + rect.height -= parseFloat(style.scrollPaddingTop) || 0; + rect.height -= parseFloat(style.scrollPaddingBottom) || 0; + rect.x += parseFloat(style.scrollPaddingLeft) || 0; + rect.width -= parseFloat(style.scrollPaddingLeft) || 0; + rect.width -= parseFloat(style.scrollPaddingRight) || 0; + } + + if (this.model === 'content-box') { + rect.y += parseFloat(style.paddingTop) || 0; + rect.height -= parseFloat(style.paddingTop) || 0; + rect.height -= parseFloat(style.paddingBottom) || 0; + rect.x += parseFloat(style.paddingLeft) || 0; + rect.width -= parseFloat(style.paddingLeft) || 0; + rect.width -= parseFloat(style.paddingRight) || 0; + } + + if (rect.width > 0 && rect.height > 0) { + return new DOMRect(rect.x, rect.y, rect.width, rect.height); + } else { + return new DOMRect(); + } + } +} + +class DocumentBox implements DOMBoxStrategy { + private static sentinels: WeakMap = new WeakMap(); + + private hiddenBox: DOMBox; + + public readonly model: NonNullable['model']>; + public readonly precision: NonNullable['precision']>; + public readonly transform: NonNullable['transform']>; + + public readonly target: T; + + constructor(target: T, options?: BoxOptions) { + let {model, precision, transform} = {...BOX_OPTIONS, ...options}; + + // Yield a hidden fixpos sentinel in the top-layer to measure the ICB. + // This is necessary due to issues with stable scrollbar gutters in Chrome. + // An id is provided so ResizableBox can attach its ResizeObserver. + // https://issues.chromium.org/issues/503187943 + let sentinel = DocumentBox.sentinels.get(target); + + if (sentinel == null) { + sentinel ??= target.createElement('div'); + sentinel.id = 'react-aria-icb-sentinel'; + sentinel.popover = 'manual'; + sentinel.style.all = 'initial'; + sentinel.style.display = 'block'; + sentinel.style.position = 'fixed'; + sentinel.style.visibility = 'hidden'; + sentinel.style.pointerEvents = 'none'; + sentinel.style.inset = '0'; + } + + // An ICB sentinel connects at construction and is never disconnected. + if (!sentinel.isConnected && typeof sentinel.showPopover === 'function') { + target.documentElement.appendChild(sentinel); + sentinel.showPopover(); + } else if (!sentinel.isConnected) { + target.documentElement.appendChild(sentinel); + } + + DocumentBox.sentinels.set(target, sentinel); + + this.hiddenBox = new DOMBox(sentinel); + + this.model = model; + this.precision = precision; + this.transform = transform; + + this.target = target; + } + + /** + * Returns the initial bounding rectangle of this target in frame coordinate space. + * Similar to `documentElement.clientWidth/clientHeight`, but only when rendered. + */ + public get initialRect(): DOMRect { + let ownerWindow = getOwnerWindow(this.target); + let ownerDocument = getOwnerDocument(this.target); + + let rect = this.hiddenBox.boundingRect; + + // Fallback to the window if the sentinel isnt rendered, e.g. in JSDOM. + if (rect.width === 0 || rect.height === 0) { + rect.height ||= ownerDocument.documentElement.clientHeight; + rect.height ||= ownerWindow.innerHeight || 0; + rect.width ||= ownerDocument.documentElement.clientWidth; + rect.width ||= ownerWindow.innerWidth || 0; + } + + if (rect.width > 0 && rect.height > 0) { + return new DOMRect(rect.x, rect.y, rect.width, rect.height); + } else { + return new DOMRect(); + } + } + + /** + * Returns the visible bounding rectangle of this target in frame coordinate space. + * Similar to `window.visualViewport.width/height`, but normalized across engines. + */ + public get visibleRect(): DOMRect { + let rect = this.initialRect; + + let ownerWindow = getOwnerWindow(this.target); + let visualViewport = getVisualViewport(this.target); + + // Chrome positions fixpos elements in visual coordinate space. + // https://issues.chromium.org/issues/40916847 + rect.x = Math.max(0, isChrome() && !isWebKit() ? 0 : rect.x); + rect.y = Math.max(0, isChrome() && !isWebKit() ? 0 : rect.y); + + if (visualViewport == null) return rect; + + // WebKit misreports offset values during pans so calculate from the page instead. + // https://bugs.webkit.org/show_bug.cgi?id=170981 + let offsetLeft = Math.max(0, visualViewport.pageLeft - ownerWindow.scrollX); + let offsetRight = Math.max(0, rect.right - offsetLeft - visualViewport.width); + let offsetTop = Math.max(0, visualViewport.pageTop - ownerWindow.scrollY); + let offsetBottom = Math.max(0, rect.bottom - offsetTop - visualViewport.height); + + rect.y += offsetTop; + rect.height -= offsetTop; + rect.height -= offsetBottom; + rect.x += offsetLeft; + rect.width -= offsetLeft; + rect.width -= offsetRight; + + if (rect.width > 0 && rect.height > 0) { + return new DOMRect(rect.x, rect.y, rect.width, rect.height); + } else { + return new DOMRect(); + } + } + + /** + * Returns the bounding rectangle of this target in frame coordinate space. + * Similar to `documentElement.clientWidth/clientHeight`, but normalized across engines. + */ + public get boundingRect(): DOMRect { + let rect = this.initialRect; + + let ownerWindow = getOwnerWindow(this.target); + + let stylingElement = getStylingElement(this.target); + let style = ownerWindow.getComputedStyle(stylingElement); + + if (this.model === 'scroll-padding-box') { + rect.y += parseFloat(style.scrollPaddingTop) || 0; + rect.height -= parseFloat(style.scrollPaddingTop) || 0; + rect.height -= parseFloat(style.scrollPaddingBottom) || 0; + rect.x += parseFloat(style.scrollPaddingLeft) || 0; + rect.width -= parseFloat(style.scrollPaddingLeft) || 0; + rect.width -= parseFloat(style.scrollPaddingRight) || 0; + } + + // https://www.w3.org/TR/css-scroll-snap-1/#optimal-viewing-region. + if (this.model === 'padding-box' || this.model === 'scroll-padding-box') { + let visibleRect = this.visibleRect; + + let left = Math.max(rect.x, visibleRect.x); + let right = Math.min(rect.right, visibleRect.right); + let top = Math.max(rect.y, visibleRect.y); + let bottom = Math.min(rect.bottom, visibleRect.bottom); + + rect.y = top; + rect.height = bottom - top; + rect.x = left; + rect.width = right - left; + } + + if (rect.width > 0 && rect.height > 0) { + return new DOMRect(rect.x, rect.y, rect.width, rect.height); + } else { + return new DOMRect(); + } + } +} + +/** + * Represents a bounding box in a document layout. + */ +export class DOMBox implements DOMBoxStrategy { + private strategy: DOMBoxStrategy; + + public readonly model: NonNullable['model']>; + public readonly precision: NonNullable['precision']>; + public readonly transform: NonNullable['transform']>; + + public readonly target: T; + + constructor(target: T & Element, options?: BoxOptions); + constructor(target: T & Document, options?: BoxOptions); + constructor(target: T, options?: BoxOptions); + constructor(target: T, options?: BoxOptions) { + if (typeof window === 'undefined' || window.navigator == null) { + throw new Error(`${this.constructor.name} must be rendered client-only.`); + } + + this.strategy = isDocument(target) + ? new DocumentBox(target as T & Document, options as BoxOptions) + : new ElementBox(target as T & Element, options as BoxOptions); + + this.model = this.strategy.model; + this.precision = this.strategy.precision; + this.transform = this.strategy.transform; + + this.target = target; + } + + /** + * Returns the initial bounding rectangle of this target in frame coordinate space. + * Similar to `node.getBoundingClientRect()`, but only when rendered. + */ + public get initialRect(): DOMRect { + let rect = this.strategy.initialRect; + + let ownerWindow = getOwnerWindow(this.target); + + if (this.precision === 'pixel') { + rect.x = Math.round(rect.x); + rect.y = Math.round(rect.y); + rect.width = Math.round(rect.width); + rect.height = Math.round(rect.height); + } else if (this.precision === 'device-pixel') { + rect.x *= ownerWindow.devicePixelRatio || 1; + rect.y *= ownerWindow.devicePixelRatio || 1; + rect.width *= ownerWindow.devicePixelRatio || 1; + rect.height *= ownerWindow.devicePixelRatio || 1; + } + + return rect; + } + + /** + * Returns the visible bounding rectangle in frame coordinate space. + * Similar to `node.getBoundingClientRect()`, but intersected with all ancestors. + */ + public get visibleRect(): DOMRect { + let rect = this.strategy.visibleRect; + + let ownerWindow = getOwnerWindow(this.target); + + if (this.precision === 'pixel') { + rect.x = Math.round(rect.x); + rect.y = Math.round(rect.y); + rect.width = Math.round(rect.width); + rect.height = Math.round(rect.height); + } else if (this.precision === 'device-pixel') { + rect.x *= ownerWindow.devicePixelRatio || 1; + rect.y *= ownerWindow.devicePixelRatio || 1; + rect.width *= ownerWindow.devicePixelRatio || 1; + rect.height *= ownerWindow.devicePixelRatio || 1; + } + + return rect; + } + + /** + * Returns the bounding rectangle of this target in frame coordinate space. + * Similar to `node.getBoundingClientRect()`, but normalized across engines. + */ + public get boundingRect(): DOMRect { + let rect = this.strategy.boundingRect; + + let ownerWindow = getOwnerWindow(this.target); + + if (this.precision === 'pixel') { + rect.x = Math.round(rect.x); + rect.y = Math.round(rect.y); + rect.width = Math.round(rect.width); + rect.height = Math.round(rect.height); + } else if (this.precision === 'device-pixel') { + rect.x *= ownerWindow.devicePixelRatio || 1; + rect.y *= ownerWindow.devicePixelRatio || 1; + rect.width *= ownerWindow.devicePixelRatio || 1; + rect.height *= ownerWindow.devicePixelRatio || 1; + } + + return rect; + } +} + +/** + * An event emitter for size changes of a bounding box inside a layout. + * Similar to the `ResizeObserver`, but extended by size tracking of all box models. + */ +export class DOMResizableBox + extends SyntheticEventTarget + implements DOMBoxStrategy +{ + private boundingBox: DOMBox; + + protected head: DOMRect; + protected last: DOMRect; + protected observer: ResizeObserver; + + protected changedAt: number = 0; + + public readonly model: NonNullable['model']>; + public readonly precision: NonNullable['precision']>; + public readonly transform: NonNullable['transform']>; + + public readonly target: T; + + constructor(target: T & Element, options?: BoxOptions); + constructor(target: T & Document, options?: BoxOptions); + constructor(target: T, options?: BoxOptions); + constructor(target: T, options?: BoxOptions) { + super(); + + this.update = this.update.bind(this); + + this.observer = new ResizeObserver(this.update); + this.boundingBox = new DOMBox(target, options as BoxOptions); + + this.head = this.last = this.boundingBox.boundingRect; + + this.model = this.boundingBox.model; + this.precision = this.boundingBox.precision; + this.transform = this.boundingBox.transform; + + this.target = target; + } + + /** + * Returns the initial bounding rectangle of this target in frame coordinate space. + * Similar to `node.getBoundingClientRect()`, but only when rendered. + */ + public get initialRect(): DOMRect { + return this.boundingBox.initialRect; + } + + /** + * Returns the visible bounding rectangle in frame coordinate space. + * Similar to `node.getBoundingClientRect()`, but intersected with all ancestors. + */ + public get visibleRect(): DOMRect { + return this.boundingBox.visibleRect; + } + + /** + * Returns the bounding rectangle of this target in frame coordinate space. + * Similar to `node.getBoundingClientRect()`, but normalized across engines. + */ + public get boundingRect(): DOMRect { + return this.boundingBox.boundingRect; + } + + protected connect(): void { + let model: BoxModel = this.model; + + let resizeTarget: Element | null = isDocument(this.target) + ? this.target.getElementById('react-aria-icb-sentinel') + : this.target; + + // Constrained to "border-box" and "content-box" models until we actually need more. + // Support for remaining box models can be added through (discrete) CSS transitions. + // https://github.com/LeaVerou/style-observer/blob/main/src/element-style-observer.js + if (model !== 'border-box' && model !== 'content-box') { + throw new Error(`${this.constructor.name} does not support "${model}" yet.`); + } + + if (resizeTarget == null) { + throw new Error(`${this.constructor.name} could not find its target.`); + } + + this.observer.observe(resizeTarget, {box: model}); + this.connections.add(() => this.observer.unobserve(resizeTarget)); + } + + protected disconnect(): void { + this.connections.forEach(fn => fn()); + this.connections.clear(); + this.changedAt = 0; + } + + protected update(): void { + let prev = this.head; + let next = this.boundingRect; + + if (next.width !== prev.width || next.height !== prev.height) { + let event = new BoxChangeEvent({ + boundingRect: next, + model: this.model, + precision: this.precision, + transform: this.transform + }); + + this.head = next; + this.last = prev; + + this.changedAt = event.timeStamp; + + this.dispatchEvent(event); + } + } +} + +/** + * An event emitter for position or size changes of a bounding box inside a layout. + * Similar to the `ResizeObserver`, but extended by position tracking of all box models. + */ +export class DOMBoxAnchor extends DOMResizableBox { + private static sentinels: WeakMap = new WeakMap(); + + protected animationFrame: number = 0; + + constructor(target: T, options?: BoxOptions) { + super(target, options); + + // Yield a hidden sentinel in the top-layer to anchor to this target. This effectively + // converts offsets, e.g. top/left, into resize observable values, e.g. width/height. + let sentinel = DOMBoxAnchor.sentinels.get(target); + + if (sentinel == null) { + sentinel = target.ownerDocument.createElement('div'); + sentinel.id = `react-aria-anchor-${crypto.randomUUID()}`; + sentinel.popover = 'manual'; + sentinel.style.all = 'initial'; + sentinel.style.display = 'block'; + sentinel.style.position = 'fixed'; + sentinel.style.visibility = 'hidden'; + sentinel.style.pointerEvents = 'none'; + sentinel.style.right = `anchor(--${sentinel.id} left)`; + sentinel.style.bottom = `anchor(--${sentinel.id} top)`; + sentinel.style.top = '0'; + sentinel.style.left = '0'; + sentinel[BOX_SYMBOL] = 0; + } + + DOMBoxAnchor.sentinels.set(target, sentinel); + } + + protected override connect(): void { + let anchorTarget = DOMBoxAnchor.sentinels.get(this.target); + + // Constrained to "border-box" model until we actually need more. + // Support for remaining box models can be added through (discrete) CSS transitions. + // https://github.com/LeaVerou/style-observer/blob/main/src/element-style-observer.js + if (this.model !== 'border-box') { + throw new Error(`${this.constructor.name} does not support "${this.model}" yet.`); + } + + if (anchorTarget == null) { + throw new Error(`${this.constructor.name} could not find its target.`); + } + + if (!anchorTarget.isConnected && typeof anchorTarget.showPopover === 'function') { + this.target.ownerDocument.documentElement.appendChild(anchorTarget); + anchorTarget.showPopover(); + } else if (!anchorTarget.isConnected) { + this.target.ownerDocument.documentElement.appendChild(anchorTarget); + } + + if (anchorTarget[BOX_SYMBOL] === 0) { + let anchorName = this.target.style.getPropertyValue('anchor-name'); + let anchorNames = anchorName.split(',').map(name => name.trim()); + + let filtered = anchorNames.filter(name => name); + + this.target.style.setProperty( + 'anchor-name', + filtered.concat(`--${anchorTarget.id}`).join(', ') + ); + } + + this.observer.observe(anchorTarget, {box: 'border-box'}); + ++anchorTarget[BOX_SYMBOL]; + + this.connections.add(() => { + if (anchorTarget[BOX_SYMBOL] === 1) { + let anchorName = this.target.style.getPropertyValue('anchor-name'); + let anchorNames = anchorName.split(',').map(name => name.trim()); + + let filtered = anchorNames.filter(name => name && name !== `--${anchorTarget.id}`); + + if (filtered.length > 0) { + this.target.style.setProperty('anchor-name', filtered.join(', ')); + anchorTarget.remove(); + } else { + this.target.style.removeProperty('anchor-name'); + anchorTarget.remove(); + } + } + + this.observer.unobserve(anchorTarget); + --anchorTarget[BOX_SYMBOL]; + }); + + this.connections.add( + addEvent(getPropagationTargets(this.target), 'scroll', this.update, { + capture: true, + passive: true + }) + ); + + super.connect(); + } + + protected override disconnect(): void { + window.cancelAnimationFrame(this.animationFrame); + this.animationFrame = 0; + + super.disconnect(); + } + + protected override update(): void { + let prev = this.head; + let next = this.boundingRect; + + if (Object.keys(next.toJSON()).some(key => next[key] !== prev[key])) { + let event = new BoxChangeEvent({ + boundingRect: next, + model: this.model, + precision: this.precision, + transform: this.transform + }); + + this.head = next; + this.last = prev; + + this.changedAt = event.timeStamp; + + super.dispatchEvent(event); + } + + if (performance.now() - this.changedAt <= 150) { + this.animationFrame ||= window.requestAnimationFrame(() => { + this.animationFrame = 0; + this.update(); + }); + + super.disconnect(); + } else if (this.changedAt !== 0) { + this.connect(); + } + } +} + +/** + * An event for layout changes of a bounding box. + */ +export class BoxChangeEvent extends CustomEvent> { + declare public readonly type: 'react-aria-boxchange'; + + public readonly boundingRect: DOMRect; + + constructor(init?: Omit) { + let {model, precision, transform} = {...BOX_OPTIONS, ...init}; + + super('react-aria-boxchange', {detail: {model, precision, transform}}); + + this.boundingRect = DOMRect.fromRect(init?.boundingRect); + } +} 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'; +}