-
Notifications
You must be signed in to change notification settings - Fork 1.4k
fix: add shadow dom support for portal provider (S2 v1.0.0, S1 v3.46.0) #9369
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
938becc
93127b7
4c9c2ea
374666d
bdc5605
20a5495
437687d
7af3dda
3749a86
2428d44
373b723
680c6c1
8c8f294
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<T>(props: AriaComboBoxOptions<T>, state: ComboBoxSta | |
| }; | ||
|
|
||
| let onBlur = (e: FocusEvent<HTMLInputElement>) => { | ||
| let blurFromButton = buttonRef?.current && buttonRef.current === e.relatedTarget; | ||
| let blurIntoPopover = nodeContains(popoverRef.current, 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; | ||
| } | ||
|
|
||
|
|
@@ -360,6 +377,16 @@ export function useComboBox<T>(props: AriaComboBoxOptions<T>, state: ComboBoxSta | |
| state.close(); | ||
| } : undefined); | ||
|
|
||
| // Add interact outside handling for the popover to support Shadow DOM contexts | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. does this fix: ComboBox Popover does not close when clicking on a sibling GridList component or Can't click ModalOverlay to close an open Combobox Seems tangentially related to shadow dom possibly but also happens outside of shadow doms? |
||
| // where blur events don't fire when clicking non-focusable elements | ||
| useInteractOutside({ | ||
| ref: popoverRef, | ||
| onInteractOutside: () => { | ||
| state.setFocused(false); | ||
| }, | ||
| isDisabled: !state.isOpen | ||
| }); | ||
|
|
||
| return { | ||
| labelProps, | ||
| buttonProps: { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<PortalProviderContextValue> = 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 ( | ||
| <PortalContext.Provider value={{getContainer: getContainer === null ? undefined : getContainer ?? ctxGetContainer}}> | ||
| <PortalContext.Provider | ||
| value={{ | ||
| getContainer: getContainer === null ? undefined : getContainer ?? ctxGetContainer, | ||
| getContainerBounds: getContainerBounds === null ? undefined : getContainerBounds ?? ctxGetContainerBounds | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hopefully unneeded, but otherwise, see if we can use boundaryElement instead of introducing a new/different way of doing boundaries |
||
| }}> | ||
| {props.children} | ||
| </PortalContext.Provider> | ||
| ); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. note to self, I want to move changes to calculate position to a separate PR since they are so complicated also, with baseline support for anchor positioning, we should check if that solves these issues so that we don't need to make any changes to calculate position if at all possible |
||
| 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)); | ||
|
|
@@ -528,7 +544,8 @@ export function calculatePosition(opts: PositionOpts): PositionResult { | |
| crossOffset, | ||
| maxHeight, | ||
| arrowSize = 0, | ||
| arrowBoundaryOffset = 0 | ||
| arrowBoundaryOffset = 0, | ||
| containerBounds | ||
| } = opts; | ||
|
|
||
| let visualViewport = getVisualViewport(); | ||
|
|
@@ -555,8 +572,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); | ||
|
|
||
| // There are several difference cases of how to calculate the containerOffsetWithBoundary: | ||
| // - boundaryElement is body or HTML and the container is an arbitrary element in the boundary (aka submenu with parent menu as container in v3) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'; | ||
| } | ||
| } | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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]); | ||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do not follow what this block of changes is for, do you have an example?