diff --git a/package.json b/package.json index f8771242416..f4029ef358a 100644 --- a/package.json +++ b/package.json @@ -187,6 +187,7 @@ "regenerator-runtime": "0.13.3", "rehype-stringify": "^9.0.4", "rimraf": "^6.0.1", + "shadow-dom-testing-library": "^1.13.1", "sharp": "^0.33.5", "storybook": "^8.6.14", "storybook-dark-mode": "^4.0.2", diff --git a/packages/@react-aria/calendar/src/useRangeCalendar.ts b/packages/@react-aria/calendar/src/useRangeCalendar.ts index 87695d4268d..21b92fd0bd5 100644 --- a/packages/@react-aria/calendar/src/useRangeCalendar.ts +++ b/packages/@react-aria/calendar/src/useRangeCalendar.ts @@ -14,7 +14,7 @@ import {AriaRangeCalendarProps, DateValue} from '@react-types/calendar'; import {CalendarAria, useCalendarBase} from './useCalendarBase'; import {FocusableElement, RefObject} from '@react-types/shared'; import {RangeCalendarState} from '@react-stately/calendar'; -import {useEvent} from '@react-aria/utils'; +import {nodeContains, useEvent} from '@react-aria/utils'; import {useRef} from 'react'; /** @@ -66,7 +66,7 @@ export function useRangeCalendar(props: AriaRangeCalendarPr if (!ref.current) { return; } - if ((!e.relatedTarget || !ref.current.contains(e.relatedTarget)) && state.anchorDate) { + if ((!e.relatedTarget || !nodeContains(ref.current, e.relatedTarget as Element)) && state.anchorDate) { state.selectFocusedDate(); } }; diff --git a/packages/@react-aria/combobox/package.json b/packages/@react-aria/combobox/package.json index 8fbf1a90c14..61da09db182 100644 --- a/packages/@react-aria/combobox/package.json +++ b/packages/@react-aria/combobox/package.json @@ -28,6 +28,7 @@ "dependencies": { "@react-aria/focus": "^3.21.3", "@react-aria/i18n": "^3.12.14", + "@react-aria/interactions": "^3.25.6", "@react-aria/listbox": "^3.15.1", "@react-aria/live-announcer": "^3.4.4", "@react-aria/menu": "^3.19.4", diff --git a/packages/@react-aria/combobox/src/useComboBox.ts b/packages/@react-aria/combobox/src/useComboBox.ts index 0929bd00c9d..169f38e51e7 100644 --- a/packages/@react-aria/combobox/src/useComboBox.ts +++ b/packages/@react-aria/combobox/src/useComboBox.ts @@ -16,7 +16,7 @@ import {AriaComboBoxProps} from '@react-types/combobox'; import {ariaHideOutside} from '@react-aria/overlays'; import {AriaListBoxOptions, getItemId, listData} from '@react-aria/listbox'; import {BaseEvent, DOMAttributes, KeyboardDelegate, LayoutDelegate, PressEvent, RefObject, RouterOptions, ValidationResult} from '@react-types/shared'; -import {chain, getActiveElement, getOwnerDocument, isAppleDevice, mergeProps, useEvent, useLabels, useRouter, useUpdateEffect} from '@react-aria/utils'; +import {chain, getActiveElement, getOwnerDocument, isAppleDevice, mergeProps, nodeContains, useEvent, useLabels, useRouter, useUpdateEffect} from '@react-aria/utils'; import {ComboBoxState} from '@react-stately/combobox'; import {dispatchVirtualFocus} from '@react-aria/focus'; import {FocusEvent, InputHTMLAttributes, KeyboardEvent, TouchEvent, useEffect, useMemo, useRef} from 'react'; @@ -25,6 +25,7 @@ import {getChildNodes, getItemCount} from '@react-stately/collections'; import intlMessages from '../intl/*.json'; import {ListKeyboardDelegate, useSelectableCollection} from '@react-aria/selection'; import {privateValidationStateProp} from '@react-stately/form'; +import {useInteractOutside} from '@react-aria/interactions'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useMenuTrigger} from '@react-aria/menu'; import {useTextField} from '@react-aria/textfield'; @@ -180,10 +181,26 @@ export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxSta }; let onBlur = (e: FocusEvent) => { - let blurFromButton = buttonRef?.current && buttonRef.current === e.relatedTarget; - let blurIntoPopover = popoverRef.current?.contains(e.relatedTarget); + let blurFromButton = buttonRef?.current && nodeContains(buttonRef.current, e.relatedTarget as Element); + let blurIntoPopover = popoverRef.current && nodeContains(popoverRef.current, e.relatedTarget as Element); + + // Special handling for Shadow DOM: When focus moves into a shadow root portal, + // relatedTarget is retargeted to the shadow HOST, not the content inside. + // Check if relatedTarget is a shadow host that CONTAINS our popover. + let blurIntoShadowHostWithPopover = false; + if (!blurIntoPopover && e.relatedTarget && popoverRef.current) { + let relatedEl = e.relatedTarget as Element; + if ('shadowRoot' in relatedEl && (relatedEl as any).shadowRoot) { + // relatedTarget is a shadow host - check if popover is inside its shadow root + let shadowRoot = (relatedEl as any).shadowRoot; + if (nodeContains(shadowRoot, popoverRef.current) && !nodeContains(shadowRoot, inputRef.current)) { + blurIntoShadowHostWithPopover = true; + } + } + } + // Ignore blur if focused moved to the button(if exists) or into the popover. - if (blurFromButton || blurIntoPopover) { + if (blurFromButton || blurIntoPopover || blurIntoShadowHostWithPopover) { return; } @@ -358,6 +375,16 @@ export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxSta state.close(); } : undefined); + // Add interact outside handling for the popover to support Shadow DOM contexts + // where blur events don't fire when clicking non-focusable elements + useInteractOutside({ + ref: popoverRef, + onInteractOutside: () => { + state.setFocused(false); + }, + isDisabled: !state.isOpen + }); + return { labelProps, buttonProps: { diff --git a/packages/@react-aria/datepicker/src/useDatePicker.ts b/packages/@react-aria/datepicker/src/useDatePicker.ts index f768b34df88..18fcd09a73b 100644 --- a/packages/@react-aria/datepicker/src/useDatePicker.ts +++ b/packages/@react-aria/datepicker/src/useDatePicker.ts @@ -17,7 +17,7 @@ import {CalendarProps} from '@react-types/calendar'; import {createFocusManager} from '@react-aria/focus'; import {DatePickerState} from '@react-stately/datepicker'; import {DOMAttributes, GroupDOMAttributes, KeyboardEvent, RefObject, ValidationResult} from '@react-types/shared'; -import {filterDOMProps, mergeProps, useDescription, useId} from '@react-aria/utils'; +import {filterDOMProps, mergeProps, nodeContains, useDescription, useId} from '@react-aria/utils'; // @ts-ignore import intlMessages from '../intl/*.json'; import {privateValidationStateProp} from '@react-stately/form'; @@ -84,7 +84,7 @@ export function useDatePicker(props: AriaDatePickerProps onBlurWithin: e => { // Ignore when focus moves into the popover. let dialog = document.getElementById(dialogId); - if (!dialog?.contains(e.relatedTarget)) { + if (!dialog || !nodeContains(dialog, e.relatedTarget as Element)) { isFocused.current = false; props.onBlur?.(e); props.onFocusChange?.(false); diff --git a/packages/@react-aria/datepicker/src/useDateRangePicker.ts b/packages/@react-aria/datepicker/src/useDateRangePicker.ts index 6e9c748455b..e0384873106 100644 --- a/packages/@react-aria/datepicker/src/useDateRangePicker.ts +++ b/packages/@react-aria/datepicker/src/useDateRangePicker.ts @@ -18,7 +18,7 @@ import {DateRange, RangeCalendarProps} from '@react-types/calendar'; import {DateRangePickerState} from '@react-stately/datepicker'; import {DEFAULT_VALIDATION_RESULT, mergeValidation, privateValidationStateProp} from '@react-stately/form'; import {DOMAttributes, GroupDOMAttributes, KeyboardEvent, RefObject, ValidationResult} from '@react-types/shared'; -import {filterDOMProps, mergeProps, useDescription, useId} from '@react-aria/utils'; +import {filterDOMProps, mergeProps, nodeContains, useDescription, useId} from '@react-aria/utils'; import {focusManagerSymbol, roleSymbol} from './useDateField'; // @ts-ignore import intlMessages from '../intl/*.json'; @@ -116,7 +116,7 @@ export function useDateRangePicker(props: AriaDateRangePick onBlurWithin: e => { // Ignore when focus moves into the popover. let dialog = document.getElementById(dialogId); - if (!dialog?.contains(e.relatedTarget)) { + if (!dialog || !nodeContains(dialog, e.relatedTarget as Element)) { isFocused.current = false; props.onBlur?.(e); props.onFocusChange?.(false); diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index 6dad540400a..0f3334993b4 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -19,6 +19,7 @@ import { isChrome, isFocusable, isTabbable, + nodeContains, ShadowTreeWalker, useLayoutEffect } from '@react-aria/utils'; @@ -440,7 +441,7 @@ function isElementInScope(element?: Element | null, scope?: Element[] | null) { if (!scope) { return false; } - return scope.some(node => node.contains(element)); + return scope.some(node => nodeContains(node, element)); } function isElementInChildScope(element: Element, scope: ScopeRef = null) { @@ -771,7 +772,7 @@ export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions { acceptNode(node) { // Skip nodes inside the starting node. - if (opts?.from?.contains(node)) { + if (opts?.from && nodeContains(opts.from, node)) { return NodeFilter.FILTER_REJECT; } @@ -822,7 +823,7 @@ export function createFocusManager(ref: RefObject, defaultOption let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts; let node = from || getActiveElement(getOwnerDocument(root)); let walker = getFocusableTreeWalker(root, {tabbable, accept}); - if (root.contains(node)) { + if (nodeContains(root, node)) { walker.currentNode = node!; } let nextNode = walker.nextNode() as FocusableElement; @@ -843,7 +844,7 @@ export function createFocusManager(ref: RefObject, defaultOption let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts; let node = from || getActiveElement(getOwnerDocument(root)); let walker = getFocusableTreeWalker(root, {tabbable, accept}); - if (root.contains(node)) { + if (nodeContains(root, node)) { walker.currentNode = node!; } else { let next = last(walker); diff --git a/packages/@react-aria/interactions/src/useFocusWithin.ts b/packages/@react-aria/interactions/src/useFocusWithin.ts index 9e1c839b612..4223569b7b9 100644 --- a/packages/@react-aria/interactions/src/useFocusWithin.ts +++ b/packages/@react-aria/interactions/src/useFocusWithin.ts @@ -54,14 +54,28 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult { let onBlur = useCallback((e: FocusEvent) => { // Ignore events bubbling through portals. - if (!e.currentTarget.contains(e.target)) { + if (!nodeContains(e.currentTarget as Element, e.target as Element)) { return; } // We don't want to trigger onBlurWithin and then immediately onFocusWithin again // when moving focus inside the element. Only trigger if the currentTarget doesn't // include the relatedTarget (where focus is moving). - if (state.current.isFocusWithin && !(e.currentTarget as Element).contains(e.relatedTarget as Element)) { + let relatedTargetInside = nodeContains(e.currentTarget as Element, e.relatedTarget as Element); + + // Special handling for Shadow DOM: When focus moves into a shadow root, the relatedTarget + // is the shadow host, not the actual element inside. Check if the shadow host's shadow root + // contains the currentTarget (the overlay that's inside the shadow root). + if (!relatedTargetInside && e.relatedTarget && 'shadowRoot' in e.relatedTarget) { + let shadowHost = e.relatedTarget as Element; + let shadowRoot = (shadowHost as any).shadowRoot; + if (shadowRoot && nodeContains(shadowRoot, e.currentTarget as Element)) { + // Focus is moving within the same shadow root that contains the overlay + relatedTargetInside = true; + } + } + + if (state.current.isFocusWithin && !relatedTargetInside) { state.current.isFocusWithin = false; removeAllGlobalListeners(); @@ -78,7 +92,7 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult { let onSyntheticFocus = useSyntheticBlurEvent(onBlur); let onFocus = useCallback((e: FocusEvent) => { // Ignore events bubbling through portals. - if (!e.currentTarget.contains(e.target)) { + if (!nodeContains(e.currentTarget as Element, e.target as Element)) { return; } diff --git a/packages/@react-aria/interactions/src/useInteractOutside.ts b/packages/@react-aria/interactions/src/useInteractOutside.ts index 9f413630ca3..b9580fabc1d 100644 --- a/packages/@react-aria/interactions/src/useInteractOutside.ts +++ b/packages/@react-aria/interactions/src/useInteractOutside.ts @@ -15,7 +15,7 @@ // NOTICE file in the root directory of this source tree. // See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions -import {getOwnerDocument, useEffectEvent} from '@react-aria/utils'; +import {getOwnerDocument, nodeContains, useEffectEvent} from '@react-aria/utils'; import {RefObject} from '@react-types/shared'; import {useEffect, useRef} from 'react'; @@ -121,7 +121,7 @@ function isValidEvent(event, ref) { if (event.target) { // if the event target is no longer in the document, ignore const ownerDocument = event.target.ownerDocument; - if (!ownerDocument || !ownerDocument.documentElement.contains(event.target)) { + if (!ownerDocument || !nodeContains(ownerDocument.documentElement, event.target)) { return false; } // If the target is within a top layer element (e.g. toasts), ignore. diff --git a/packages/@react-aria/overlays/src/PortalProvider.tsx b/packages/@react-aria/overlays/src/PortalProvider.tsx index 1105191af35..be3c66b3196 100644 --- a/packages/@react-aria/overlays/src/PortalProvider.tsx +++ b/packages/@react-aria/overlays/src/PortalProvider.tsx @@ -15,6 +15,8 @@ import React, {createContext, JSX, ReactNode, useContext} from 'react'; export interface PortalProviderProps { /** Should return the element where we should portal to. Can clear the context by passing null. */ getContainer?: (() => HTMLElement | null) | null, + /** Returns the visual bounds of the container where overlays should be constrained. Used for shadow DOM and iframe scenarios. */ + getContainerBounds?: (() => DOMRect | null) | null, /** The content of the PortalProvider. Should contain all children that want to portal their overlays to the element returned by the provided `getContainer()`. */ children: ReactNode } @@ -27,10 +29,15 @@ export const PortalContext: React.Context = createCo * Sets the portal container for all overlay elements rendered by its children. */ export function UNSAFE_PortalProvider(props: PortalProviderProps): JSX.Element { - let {getContainer} = props; - let {getContainer: ctxGetContainer} = useUNSAFE_PortalContext(); + let {getContainer, getContainerBounds} = props; + let {getContainer: ctxGetContainer, getContainerBounds: ctxGetContainerBounds} = useUNSAFE_PortalContext(); + return ( - + {props.children} ); diff --git a/packages/@react-aria/overlays/src/ariaHideOutside.ts b/packages/@react-aria/overlays/src/ariaHideOutside.ts index 753c2a926a3..e456a53c566 100644 --- a/packages/@react-aria/overlays/src/ariaHideOutside.ts +++ b/packages/@react-aria/overlays/src/ariaHideOutside.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {getOwnerWindow} from '@react-aria/utils'; +import {createShadowTreeWalker, getOwnerDocument, getOwnerWindow, nodeContains} from '@react-aria/utils'; const supportsInert = typeof HTMLElement !== 'undefined' && 'inert' in HTMLElement.prototype; interface AriaHideOutsideOptions { @@ -71,6 +71,27 @@ export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOpt } let acceptNode = (node: Element) => { + // Special handling for shadow hosts: If a shadow host contains a visible target, + // ensure it's not hidden (even if previously marked inert by parent overlays). + // Must check this BEFORE hiddenNodes check to handle nested overlay scenarios. + if ('shadowRoot' in node && (node as any).shadowRoot) { + let shadowRoot = (node as any).shadowRoot; + for (let target of visibleNodes) { + if (!shadowRoot.contains(target)) { + continue; + } + visibleNodes.add(node); + if (getHidden(node)) { + setHidden(node, false); + let count = refCountMap.get(node); + if (count && count > 0) { + refCountMap.set(node, count - 1); + } + } + return NodeFilter.FILTER_REJECT; + } + } + // Skip this node and its children if it is one of the target nodes, or a live announcer. // Also skip children of already hidden nodes, as aria-hidden is recursive. An exception is // made for elements with role="row" since VoiceOver on iOS has issues hiding elements with role="row". @@ -85,7 +106,7 @@ export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOpt // Skip this node but continue to children if one of the targets is inside the node. for (let target of visibleNodes) { - if (node.contains(target)) { + if (nodeContains(node, target)) { return NodeFilter.FILTER_SKIP; } } @@ -93,8 +114,11 @@ export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOpt return NodeFilter.FILTER_ACCEPT; }; - let walker = document.createTreeWalker( - root, + let rootElement = root?.nodeType === Node.ELEMENT_NODE ? (root as Element) : null; + let doc = getOwnerDocument(rootElement); + let walker = createShadowTreeWalker( + doc, + root || doc, NodeFilter.SHOW_ELEMENT, {acceptNode} ); diff --git a/packages/@react-aria/overlays/src/calculatePosition.ts b/packages/@react-aria/overlays/src/calculatePosition.ts index e5df4569701..00d860d2b2c 100644 --- a/packages/@react-aria/overlays/src/calculatePosition.ts +++ b/packages/@react-aria/overlays/src/calculatePosition.ts @@ -58,7 +58,8 @@ interface PositionOpts { offset: number, crossOffset: number, maxHeight?: number, - arrowBoundaryOffset?: number + arrowBoundaryOffset?: number, + containerBounds?: DOMRect | null } type HeightGrowthDirection = 'top' | 'bottom'; @@ -105,7 +106,7 @@ const PARSED_PLACEMENT_CACHE = {}; let getVisualViewport = () => typeof document !== 'undefined' ? window.visualViewport : null; -function getContainerDimensions(containerNode: Element, visualViewport: VisualViewport | null): Dimensions { +function getContainerDimensions(containerNode: Element, visualViewport: VisualViewport | null, containerBounds?: DOMRect | null): Dimensions { let width = 0, height = 0, totalWidth = 0, totalHeight = 0, top = 0, left = 0; let scroll: Position = {}; let isPinchZoomedIn = (visualViewport?.scale ?? 1) > 1; @@ -118,17 +119,32 @@ function getContainerDimensions(containerNode: Element, visualViewport: VisualVi let documentElement = document.documentElement; totalWidth = documentElement.clientWidth; totalHeight = documentElement.clientHeight; - width = visualViewport?.width ?? totalWidth; - height = visualViewport?.height ?? totalHeight; - scroll.top = documentElement.scrollTop || containerNode.scrollTop; - scroll.left = documentElement.scrollLeft || containerNode.scrollLeft; - - // The goal of the below is to get a top/left value that represents the top/left of the visual viewport with - // respect to the layout viewport origin. This combined with the scrollTop/scrollLeft will allow us to calculate - // coordinates/values with respect to the visual viewport or with respect to the layout viewport. - if (visualViewport) { - top = visualViewport.offsetTop; - left = visualViewport.offsetLeft; + + // If container bounds are provided (e.g., from PortalProvider for shadow DOM/iframe scenarios), + // use those instead of calculating from window/document + if (containerBounds) { + width = containerBounds.width; + height = containerBounds.height; + top = containerBounds.top; + left = containerBounds.left; + // When using containerBounds, scroll should be relative to the container's position + scroll.top = 0; + scroll.left = 0; + } else { + // Default/legacy method: use visualViewport if available, otherwise use document dimensions + width = visualViewport?.width ?? totalWidth; + height = visualViewport?.height ?? totalHeight; + + scroll.top = documentElement.scrollTop || containerNode.scrollTop; + scroll.left = documentElement.scrollLeft || containerNode.scrollLeft; + + // The goal of the below is to get a top/left value that represents the top/left of the visual viewport with + // respect to the layout viewport origin. This combined with the scrollTop/scrollLeft will allow us to calculate + // coordinates/values with respect to the visual viewport or with respect to the layout viewport. + if (visualViewport) { + top = visualViewport.offsetTop; + left = visualViewport.offsetLeft; + } } } else { ({width, height, top, left} = getOffset(containerNode, false)); @@ -529,7 +545,8 @@ export function calculatePosition(opts: PositionOpts): PositionResult { crossOffset, maxHeight, arrowSize = 0, - arrowBoundaryOffset = 0 + arrowBoundaryOffset = 0, + containerBounds } = opts; let visualViewport = getVisualViewport(); @@ -556,8 +573,9 @@ export function calculatePosition(opts: PositionOpts): PositionResult { // a height/width that matches the visual viewport size rather than the body's height/width (aka for zoom it will be zoom adjusted size) // and a top/left that is adjusted as well (will return the top/left of the zoomed in viewport, or 0,0 for a non-zoomed body) // Otherwise this returns the height/width of a arbitrary boundary element, and its top/left with respect to the viewport (NOTE THIS MEANS IT DOESNT INCLUDE SCROLL) - let boundaryDimensions = getContainerDimensions(boundaryElement, visualViewport); - let containerDimensions = getContainerDimensions(container, visualViewport); + // If containerBounds are provided, use them to constrain the boundary dimensions (e.g., for shadow DOM containers) + let boundaryDimensions = getContainerDimensions(boundaryElement, visualViewport, containerBounds); + let containerDimensions = getContainerDimensions(container, visualViewport, containerBounds); // If the container is the HTML element wrapping the body element, the retrieved scrollTop/scrollLeft will be equal to the // body element's scroll. Set the container's scroll values to 0 since the overlay's edge position value in getDelta don't then need to be further offset // by the container scroll since they are essentially the same containing element and thus in the same coordinate system diff --git a/packages/@react-aria/overlays/src/containerBoundsUtils.ts b/packages/@react-aria/overlays/src/containerBoundsUtils.ts new file mode 100644 index 00000000000..91839579d56 --- /dev/null +++ b/packages/@react-aria/overlays/src/containerBoundsUtils.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2025 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 React from 'react'; + +/** + * Applies container bounds positioning to a style object. + * When containerBounds are provided, positions the element relative to the container instead of the viewport. + */ +export function applyContainerBounds( + style: React.CSSProperties, + containerBounds: DOMRect | null | undefined, + options?: { + /** Whether to add flexbox centering (for modals). */ + center?: boolean + } +): void { + if (!containerBounds) { + return; + } + + const {center = false} = options || {}; + + // Set positioning relative to container bounds + style.position = 'fixed'; + style.top = containerBounds.top + 'px'; + style.left = containerBounds.left + 'px'; + style.width = containerBounds.width + 'px'; + style.height = containerBounds.height + 'px'; + + // Add flexbox centering if requested + if (center) { + style.display = 'flex'; + style.flexDirection = 'column'; + } +} + diff --git a/packages/@react-aria/overlays/src/index.ts b/packages/@react-aria/overlays/src/index.ts index cf37e048e7d..58c42380ea2 100644 --- a/packages/@react-aria/overlays/src/index.ts +++ b/packages/@react-aria/overlays/src/index.ts @@ -20,6 +20,8 @@ export {usePopover} from './usePopover'; export {useModalOverlay} from './useModalOverlay'; export {Overlay, useOverlayFocusContain} from './Overlay'; export {UNSAFE_PortalProvider, useUNSAFE_PortalContext} from './PortalProvider'; +export {useIsInShadowRoot} from './useIsInShadowRoot'; +export {applyContainerBounds} from './containerBoundsUtils'; export type {AriaPositionProps, PositionAria} from './useOverlayPosition'; export type {AriaOverlayProps, OverlayAria} from './useOverlay'; diff --git a/packages/@react-aria/overlays/src/useIsInShadowRoot.ts b/packages/@react-aria/overlays/src/useIsInShadowRoot.ts new file mode 100644 index 00000000000..8a7568de282 --- /dev/null +++ b/packages/@react-aria/overlays/src/useIsInShadowRoot.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2025 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 {isShadowRoot} from '@react-aria/utils'; +import {useMemo} from 'react'; +import {useUNSAFE_PortalContext} from './PortalProvider'; + +/** + * Checks if the current component is rendering inside a Shadow DOM. + * This is useful for conditionally applying styles or behaviors that are incompatible + * with Shadow DOM encapsulation, such as `isolation: isolate` which can interfere + * with stacking contexts for absolutely positioned overlays. + * + * @returns {boolean} True if rendering inside a Shadow DOM, false otherwise. + */ +export function useIsInShadowRoot(): boolean { + let {getContainer} = useUNSAFE_PortalContext(); + + return useMemo(() => { + // Check if the portal container is within a shadow root + if (getContainer) { + try { + let container = getContainer(); + if (container) { + let root = container.getRootNode?.(); + if (root && isShadowRoot(root)) { + return true; + } + } + } catch { + // Ignore errors, assume not in shadow root + } + } + return false; + }, [getContainer]); +} + diff --git a/packages/@react-aria/overlays/src/useOverlayPosition.ts b/packages/@react-aria/overlays/src/useOverlayPosition.ts index 59c61a08075..5719764583e 100644 --- a/packages/@react-aria/overlays/src/useOverlayPosition.ts +++ b/packages/@react-aria/overlays/src/useOverlayPosition.ts @@ -17,6 +17,7 @@ import {useCallback, useEffect, useRef, useState} from 'react'; import {useCloseOnScroll} from './useCloseOnScroll'; import {useLayoutEffect, useResizeObserver} from '@react-aria/utils'; import {useLocale} from '@react-aria/i18n'; +import {useUNSAFE_PortalContext} from './PortalProvider'; export interface AriaPositionProps extends PositionProps { /** @@ -91,6 +92,7 @@ let visualViewport = typeof document !== 'undefined' ? window.visualViewport : n */ export function useOverlayPosition(props: AriaPositionProps): PositionAria { let {direction} = useLocale(); + let {getContainerBounds} = useUNSAFE_PortalContext(); let { arrowSize, targetRef, @@ -178,6 +180,9 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { overlay.style.maxHeight = (window.visualViewport?.height ?? window.innerHeight) + 'px'; } + // Get container bounds if available from PortalProvider + let containerBounds = getContainerBounds?.() || null; + let position = calculatePosition({ placement: translateRTL(placement, direction), overlayNode: overlayRef.current, @@ -190,7 +195,8 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { crossOffset, maxHeight, arrowSize: arrowSize ?? (arrowRef?.current ? getRect(arrowRef.current, true).width : 0), - arrowBoundaryOffset + arrowBoundaryOffset, + containerBounds }); if (!position.position) { diff --git a/packages/@react-aria/select/src/useSelect.ts b/packages/@react-aria/select/src/useSelect.ts index daebc1d3910..d7cd3d2986a 100644 --- a/packages/@react-aria/select/src/useSelect.ts +++ b/packages/@react-aria/select/src/useSelect.ts @@ -13,7 +13,7 @@ import {AriaButtonProps} from '@react-types/button'; import {AriaListBoxOptions} from '@react-aria/listbox'; import {AriaSelectProps, SelectionMode} from '@react-types/select'; -import {chain, filterDOMProps, mergeProps, useId} from '@react-aria/utils'; +import {chain, filterDOMProps, mergeProps, nodeContains, useId} from '@react-aria/utils'; import {DOMAttributes, KeyboardDelegate, RefObject, ValidationResult} from '@react-types/shared'; import {FocusEvent, useMemo} from 'react'; import {HiddenSelectProps} from './HiddenSelect'; @@ -223,7 +223,7 @@ export function useSelect(props: AriaSele disallowEmptySelection: true, linkBehavior: 'selection', onBlur: (e) => { - if (e.currentTarget.contains(e.relatedTarget as Node)) { + if (nodeContains(e.currentTarget as Element, e.relatedTarget as Element)) { return; } diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index f44cb0a123d..75c8ea1f92e 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, getActiveElement, isCtrlKeyPressed, isTabbable, mergeProps, scrollIntoView, scrollIntoViewport, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils'; +import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, getActiveElement, isCtrlKeyPressed, isTabbable, mergeProps, nodeContains, scrollIntoView, scrollIntoViewport, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils'; import {dispatchVirtualFocus, getFocusableTreeWalker, moveVirtualFocus} from '@react-aria/focus'; import {DOMAttributes, FocusableElement, FocusStrategy, Key, KeyboardDelegate, RefObject} from '@react-types/shared'; import {flushSync} from 'react-dom'; @@ -394,7 +394,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions let onBlur = (e) => { // Don't set blurred and then focused again if moving focus within the collection. - if (!e.currentTarget.contains(e.relatedTarget as HTMLElement)) { + if (!nodeContains(e.currentTarget as Element, e.relatedTarget as Element)) { manager.setFocused(false); } }; diff --git a/packages/@react-aria/toolbar/src/useToolbar.ts b/packages/@react-aria/toolbar/src/useToolbar.ts index b94bb988c57..820e6bdda75 100644 --- a/packages/@react-aria/toolbar/src/useToolbar.ts +++ b/packages/@react-aria/toolbar/src/useToolbar.ts @@ -12,7 +12,7 @@ import {AriaLabelingProps, Orientation, RefObject} from '@react-types/shared'; import {createFocusManager} from '@react-aria/focus'; -import {filterDOMProps, useLayoutEffect} from '@react-aria/utils'; +import {filterDOMProps, nodeContains, useLayoutEffect} from '@react-aria/utils'; import {HTMLAttributes, KeyboardEventHandler, useRef, useState} from 'react'; import {useLocale} from '@react-aria/i18n'; @@ -101,7 +101,7 @@ export function useToolbar(props: AriaToolbarProps, ref: RefObject(null); const onBlur = (e) => { - if (!e.currentTarget.contains(e.relatedTarget) && !lastFocused.current) { + if (!nodeContains(e.currentTarget as Element, e.relatedTarget as Element) && !lastFocused.current) { lastFocused.current = e.target; } }; @@ -110,7 +110,7 @@ export function useToolbar(props: AriaToolbarProps, ref: RefObject { - if (lastFocused.current && !e.currentTarget.contains(e.relatedTarget) && ref.current?.contains(e.target)) { + if (lastFocused.current && !nodeContains(e.currentTarget as Element, e.relatedTarget as Element) && ref.current?.contains(e.target)) { lastFocused.current?.focus(); lastFocused.current = null; } diff --git a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts index 1f822a0ef17..bb849088fca 100644 --- a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts +++ b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts @@ -61,7 +61,7 @@ export const getActiveElement = (doc: Document = document): Element | null => { * ShadowDOM safe version of event.target. */ export function getEventTarget(event: T): Element { - if (shadowDOM() && (event.target as HTMLElement).shadowRoot) { + if (shadowDOM() && (event.target as HTMLElement)?.shadowRoot) { if (event.composedPath) { return event.composedPath()[0] as Element; } diff --git a/packages/@react-aria/utils/src/useViewportSize.ts b/packages/@react-aria/utils/src/useViewportSize.ts index 30fc9d26385..5bc40a1c7c3 100644 --- a/packages/@react-aria/utils/src/useViewportSize.ts +++ b/packages/@react-aria/utils/src/useViewportSize.ts @@ -21,9 +21,38 @@ interface ViewportSize { let visualViewport = typeof document !== 'undefined' && window.visualViewport; +// Lazy import to avoid circular dependency issues +// useUNSAFE_PortalContext is only used if available +let portalContextModule: typeof import('@react-aria/overlays') | null = null; +function getPortalContext() { + if (!portalContextModule) { + try { + portalContextModule = require('@react-aria/overlays'); + } catch { + return null; + } + } + return portalContextModule; +} + export function useViewportSize(): ViewportSize { let isSSR = useIsSSR(); - let [size, setSize] = useState(() => isSSR ? {width: 0, height: 0} : getViewportSize()); + let portalModule = getPortalContext(); + let getContainerBounds = portalModule?.useUNSAFE_PortalContext?.()?.getContainerBounds; + let containerBounds = getContainerBounds?.() || null; + + let [size, setSize] = useState(() => { + if (isSSR) { + return {width: 0, height: 0}; + } + + // If container bounds are provided, use those; otherwise use window viewport + if (containerBounds) { + return {width: containerBounds.width, height: containerBounds.height}; + } + + return getViewportSize(); + }); useEffect(() => { // Use visualViewport api to track available height even on iOS virtual keyboard opening @@ -34,7 +63,16 @@ export function useViewportSize(): ViewportSize { } setSize(size => { - let newSize = getViewportSize(); + // Re-measure container bounds if available, otherwise use window viewport + let newBounds = getContainerBounds?.(); + let newSize: ViewportSize; + + if (newBounds) { + newSize = {width: newBounds.width, height: newBounds.height}; + } else { + newSize = getViewportSize(); + } + if (newSize.width === size.width && newSize.height === size.height) { return size; } @@ -55,7 +93,15 @@ export function useViewportSize(): ViewportSize { frame = requestAnimationFrame(() => { if (!document.activeElement || !willOpenKeyboard(document.activeElement)) { setSize(size => { - let newSize = {width: window.innerWidth, height: window.innerHeight}; + let newSize: ViewportSize; + let newBounds = getContainerBounds?.(); + + if (newBounds) { + newSize = {width: newBounds.width, height: newBounds.height}; + } else { + newSize = {width: window.innerWidth, height: window.innerHeight}; + } + if (newSize.width === size.width && newSize.height === size.height) { return size; } @@ -83,7 +129,7 @@ export function useViewportSize(): ViewportSize { visualViewport.removeEventListener('resize', onResize); } }; - }, []); + }, [getContainerBounds]); return size; } diff --git a/packages/@react-spectrum/overlays/src/Modal.tsx b/packages/@react-spectrum/overlays/src/Modal.tsx index 6a8e34450c4..132c04966d3 100644 --- a/packages/@react-spectrum/overlays/src/Modal.tsx +++ b/packages/@react-spectrum/overlays/src/Modal.tsx @@ -9,8 +9,7 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ - -import {AriaModalOverlayProps, useModalOverlay} from '@react-aria/overlays'; +import {applyContainerBounds, AriaModalOverlayProps, useModalOverlay, useUNSAFE_PortalContext} from '@react-aria/overlays'; import {classNames, useDOMRef, useStyleProps} from '@react-spectrum/utils'; import {DOMRef, RefObject, StyleProps} from '@react-types/shared'; import modalStyles from '@adobe/spectrum-css-temp/components/modal/vars.css'; @@ -86,20 +85,47 @@ let ModalWrapper = forwardRef(function (props: ModalWrapperProps, ref: Forwarded ); let viewport = useViewportSize(); - let style: any = { + let {getContainerBounds} = useUNSAFE_PortalContext(); + let containerBounds = getContainerBounds?.(); + + let wrapperStyle: React.CSSProperties & { + '--spectrum-visual-viewport-height'?: string + } = { '--spectrum-visual-viewport-height': viewport.height + 'px' }; + + let modalStyle: React.CSSProperties | undefined = undefined; + + // If container bounds are provided, position the wrapper relative to the container + // This ensures modals are centered within the web component's bounds, not the full viewport + applyContainerBounds(wrapperStyle, containerBounds, {center: true}); + + // For fullscreen modals, override the fixed positioning to be relative to the wrapper + // The CSS has position: fixed with top/bottom/left/right: 40px, which positions relative to viewport + // We need to make it relative to the container instead + if (containerBounds && (typeVariant === 'fullscreen' || typeVariant === 'fullscreenTakeover')) { + modalStyle = { + position: 'absolute', + top: typeVariant === 'fullscreen' ? '40px' : '0', + left: typeVariant === 'fullscreen' ? '40px' : '0', + right: typeVariant === 'fullscreen' ? '40px' : '0', + bottom: typeVariant === 'fullscreen' ? '40px' : '0', + width: typeVariant === 'fullscreen' ? 'calc(100% - 80px)' : '100%', + height: typeVariant === 'fullscreen' ? 'calc(100% - 80px)' : '100%' + }; + } // Attach Transition's nodeRef to outer most wrapper for node.reflow: https://github.com/reactjs/react-transition-group/blob/c89f807067b32eea6f68fd6c622190d88ced82e2/src/Transition.js#L231 return (
-
+
{children}
diff --git a/packages/@react-spectrum/overlays/src/Underlay.tsx b/packages/@react-spectrum/overlays/src/Underlay.tsx index da9911834d8..83b04357586 100644 --- a/packages/@react-spectrum/overlays/src/Underlay.tsx +++ b/packages/@react-spectrum/overlays/src/Underlay.tsx @@ -9,7 +9,7 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ - +import {applyContainerBounds, useUNSAFE_PortalContext} from '@react-aria/overlays'; import {classNames} from '@react-spectrum/utils'; import {isScrollable} from '@react-aria/utils'; import React, {JSX} from 'react'; @@ -21,20 +21,34 @@ interface UnderlayProps { } export function Underlay({isOpen, isTransparent, ...otherProps}: UnderlayProps): JSX.Element { + let {getContainerBounds} = useUNSAFE_PortalContext(); + let containerBounds = getContainerBounds?.(); + let pageHeight: number | undefined = undefined; if (typeof document !== 'undefined') { let scrollingElement = isScrollable(document.body) ? document.body : document.scrollingElement || document.documentElement; // Prevent Firefox from adding scrollbars when the page has a fractional height. let fractionalHeightDifference = scrollingElement.getBoundingClientRect().height % 1; pageHeight = scrollingElement.scrollHeight - fractionalHeightDifference; + + // If container bounds are provided, use those height instead + if (containerBounds) { + pageHeight = containerBounds.height; + } } + let style: React.CSSProperties = {height: pageHeight}; + + // If container bounds are provided, position the underlay relative to the container + applyContainerBounds(style, containerBounds); + return (
k === 'light' || k === 'dark').join(' ') }; + // Skip isolation: isolate when rendering in a shadow root to prevent + // stacking context issues with absolutely positioned overlays + if (isInShadowRoot && style.isolation === 'isolate') { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let {isolation, ...restStyle} = style; + style = restStyle; + } + let hasWarned = useRef(false); useEffect(() => { if (direction && domRef.current) { diff --git a/packages/react-aria-components/src/Modal.tsx b/packages/react-aria-components/src/Modal.tsx index 5aeccea36a5..7c9bc55b015 100644 --- a/packages/react-aria-components/src/Modal.tsx +++ b/packages/react-aria-components/src/Modal.tsx @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {AriaModalOverlayProps, DismissButton, Overlay, useIsSSR, useModalOverlay} from 'react-aria'; +import {applyContainerBounds, AriaModalOverlayProps, DismissButton, Overlay, useIsSSR, useModalOverlay, useUNSAFE_PortalContext} from 'react-aria'; import { ClassNameOrFunction, ContextValue, @@ -184,20 +184,37 @@ function ModalOverlayInner({UNSTABLE_portalContainer, ...props}: ModalOverlayInn }); let viewport = useViewportSize(); + let {getContainerBounds} = useUNSAFE_PortalContext(); + let pageHeight: number | undefined = undefined; + let containerBounds = getContainerBounds?.(); + if (typeof document !== 'undefined') { let scrollingElement = isScrollable(document.body) ? document.body : document.scrollingElement || document.documentElement; // Prevent Firefox from adding scrollbars when the page has a fractional height. let fractionalHeightDifference = scrollingElement.getBoundingClientRect().height % 1; pageHeight = scrollingElement.scrollHeight - fractionalHeightDifference; + + // If container bounds are provided, use those height instead + if (containerBounds) { + pageHeight = containerBounds.height; + } } - let style = { + // Build style object with CSS custom properties and positioning + let style: React.CSSProperties & { + '--visual-viewport-height'?: string, + '--page-height'?: string + } = { ...renderProps.style, '--visual-viewport-height': viewport.height + 'px', '--page-height': pageHeight !== undefined ? pageHeight + 'px' : undefined }; + // If container bounds are provided, position the overlay relative to the container + // This ensures modals render within the web component's bounds, not the full viewport + applyContainerBounds(style, containerBounds, {center: true}); + return (
{ - if (isDialog && props.trigger !== 'SubmenuTrigger' && ref.current && !ref.current.contains(document.activeElement)) { + if (isDialog && props.trigger !== 'SubmenuTrigger' && ref.current && !nodeContains(ref.current, document.activeElement)) { focusSafely(ref.current); } }, [isDialog, ref, props.trigger]); diff --git a/packages/react-aria-components/test/Popover.test.js b/packages/react-aria-components/test/Popover.test.js index 01778af113b..f86137e301c 100644 --- a/packages/react-aria-components/test/Popover.test.js +++ b/packages/react-aria-components/test/Popover.test.js @@ -10,9 +10,10 @@ * governing permissions and limitations under the License. */ -import {act, pointerMap, render} from '@react-spectrum/test-utils-internal'; -import {Button, Dialog, DialogTrigger, OverlayArrow, Popover, Pressable} from '../'; +import {act, createShadowRoot, pointerMap, render} from '@react-spectrum/test-utils-internal'; +import {Button, Dialog, DialogTrigger, Menu, MenuItem, MenuTrigger, OverlayArrow, Popover, Pressable} from '../'; import React, {useRef} from 'react'; +import {screen} from 'shadow-dom-testing-library'; import {UNSAFE_PortalProvider} from '@react-aria/overlays'; import userEvent from '@testing-library/user-event'; @@ -273,4 +274,56 @@ describe('Popover', () => { let dialog = getByRole('dialog'); expect(dialog).toBeInTheDocument(); }); + + it('test overlay and overlay trigger inside the same shadow root to have interactable content', async function () { + const {shadowRoot, cleanup} = createShadowRoot(); + + const appContainer = document.createElement('div'); + appContainer.setAttribute('id', 'appRoot'); + shadowRoot.appendChild(appContainer); + + const portal = document.createElement('div'); + portal.id = 'shadow-dom-portal'; + shadowRoot.appendChild(portal); + + const onAction = jest.fn(); + const user = userEvent.setup({delay: null, pointerMap}); + + function ShadowApp() { + return ( + + + + + New… + Open… + Save + Save as… + Print… + + + + ); + } + render( + portal}> + + , + {container: appContainer} + ); + + let button = await screen.findByShadowRole('button'); + await user.click(button); + let menu = await screen.findByShadowRole('menu'); + expect(menu).toBeVisible(); + let items = await screen.findAllByShadowRole('menuitem'); + let openItem = items.find(item => item.textContent?.trim() === 'Open…'); + expect(openItem).toBeVisible(); + + await user.click(openItem); + expect(onAction).toHaveBeenCalledTimes(1); + cleanup(); + }); }); diff --git a/packages/react-aria/src/index.ts b/packages/react-aria/src/index.ts index 5a9339044d4..b51270b13c9 100644 --- a/packages/react-aria/src/index.ts +++ b/packages/react-aria/src/index.ts @@ -31,7 +31,7 @@ export {useListBox, useListBoxSection, useOption} from '@react-aria/listbox'; export {useMenu, useMenuItem, useMenuSection, useMenuTrigger, useSubmenuTrigger} from '@react-aria/menu'; export {useMeter} from '@react-aria/meter'; export {useNumberField} from '@react-aria/numberfield'; -export {DismissButton, ModalProvider, Overlay, OverlayContainer, OverlayProvider, useModal, useModalOverlay, useModalProvider, useOverlay, useOverlayPosition, useOverlayTrigger, usePopover, usePreventScroll, UNSAFE_PortalProvider, useUNSAFE_PortalContext} from '@react-aria/overlays'; +export {applyContainerBounds, DismissButton, ModalProvider, Overlay, OverlayContainer, OverlayProvider, useModal, useModalOverlay, useModalProvider, useOverlay, useOverlayPosition, useOverlayTrigger, usePopover, usePreventScroll, UNSAFE_PortalProvider, useUNSAFE_PortalContext} from '@react-aria/overlays'; export {useProgressBar} from '@react-aria/progress'; export {useRadio, useRadioGroup} from '@react-aria/radio'; export {useSearchField} from '@react-aria/searchfield'; diff --git a/yarn.lock b/yarn.lock index 273906bc616..21ca184c87d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5489,6 +5489,7 @@ __metadata: dependencies: "@react-aria/focus": "npm:^3.21.3" "@react-aria/i18n": "npm:^3.12.14" + "@react-aria/interactions": "npm:^3.25.6" "@react-aria/listbox": "npm:^3.15.1" "@react-aria/live-announcer": "npm:^3.4.4" "@react-aria/menu": "npm:^3.19.4" @@ -5686,7 +5687,7 @@ __metadata: languageName: unknown linkType: soft -"@react-aria/interactions@npm:^3.1.0, @react-aria/interactions@npm:^3.25.1, @react-aria/interactions@npm:^3.26.0, @react-aria/interactions@workspace:packages/@react-aria/interactions": +"@react-aria/interactions@npm:^3.1.0, @react-aria/interactions@npm:^3.25.1, @react-aria/interactions@npm:^3.25.6, @react-aria/interactions@npm:^3.26.0, @react-aria/interactions@workspace:packages/@react-aria/interactions": version: 0.0.0-use.local resolution: "@react-aria/interactions@workspace:packages/@react-aria/interactions" dependencies: @@ -24814,6 +24815,7 @@ __metadata: regenerator-runtime: "npm:0.13.3" rehype-stringify: "npm:^9.0.4" rimraf: "npm:^6.0.1" + shadow-dom-testing-library: "npm:^1.13.1" sharp: "npm:^0.33.5" storybook: "npm:^8.6.14" storybook-dark-mode: "npm:^4.0.2" @@ -26078,6 +26080,15 @@ __metadata: languageName: node linkType: hard +"shadow-dom-testing-library@npm:^1.13.1": + version: 1.13.1 + resolution: "shadow-dom-testing-library@npm:1.13.1" + peerDependencies: + "@testing-library/dom": ">= 8" + checksum: 10c0/cd0a5e7799f868af665235d0812bdbcfbfe4461681ef35ce0fba4d460d395f3fa0e95df5c8fec4686ba30286a62c4e7ba48013e67646977726aa13363479d70f + languageName: node + linkType: hard + "shallow-clone@npm:^3.0.0": version: 3.0.1 resolution: "shallow-clone@npm:3.0.1"