Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,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",
Expand Down
1 change: 1 addition & 0 deletions packages/@react-aria/combobox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
33 changes: 30 additions & 3 deletions packages/@react-aria/combobox/src/useComboBox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The 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: {
Expand Down
42 changes: 37 additions & 5 deletions packages/@react-aria/interactions/src/useFocusVisible.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,24 @@ function isValidKey(e: KeyboardEvent) {
function handleKeyboardEvent(e: KeyboardEvent) {
hasEventBeforeFocus = true;
if (!(openLink as any).isOpening && isValidKey(e)) {
// In Shadow DOM, e.target may be retargeted to the shadow host (e.g., a DIV).
// Use composedPath() to get the actual element inside the shadow root.
let actualTarget = e.composedPath?.()?.[0] as Element | undefined || e.target as Element;

// Check if the actual target is a text input element
let isTextInputTarget = actualTarget instanceof HTMLInputElement && !nonTextInputTypes.has(actualTarget.type) ||
actualTarget instanceof HTMLTextAreaElement ||
(actualTarget instanceof HTMLElement && actualTarget.isContentEditable);

// For text inputs, only Tab/Escape should trigger keyboard modality (focus visible)
// Other keys (typing content) should not show focus ring
let isFocusVisibleKey = FOCUS_VISIBLE_INPUT_KEYS[e.key];

// Skip setting keyboard modality for content keys in text inputs
if (isTextInputTarget && !isFocusVisibleKey) {
return;
}

currentModality = 'keyboard';
currentPointerType = 'keyboard';
triggerChangeHandlers('keyboard', e);
Expand Down Expand Up @@ -310,11 +328,25 @@ function isKeyboardFocusEvent(isTextInput: boolean, modality: Modality, e: Handl

// For keyboard events that occur on a non-input element that will move focus into input element (aka ArrowLeft going from Datepicker button to the main input group)
// we need to rely on the user passing isTextInput into here. This way we can skip toggling focus visiblity for said input element
isTextInput = isTextInput ||
(document.activeElement instanceof IHTMLInputElement && !nonTextInputTypes.has(document.activeElement.type)) ||
document.activeElement instanceof IHTMLTextAreaElement ||
(document.activeElement instanceof IHTMLElement && document.activeElement.isContentEditable);
return !(isTextInput && modality === 'keyboard' && e instanceof IKeyboardEvent && !FOCUS_VISIBLE_INPUT_KEYS[e.key]);
//
// In Shadow DOM, document.activeElement returns the shadow host, not the actual focused element.
// So we also check e.target (the actual event target) which correctly reflects the focused element
// even inside shadow roots.
let activeElement = document.activeElement;
let eventTarget = e?.target as Element | null;

// Check both document.activeElement and event target for text input detection
// This handles Shadow DOM where activeElement is the host but target is the actual input
let activeIsTextInput = activeElement instanceof IHTMLInputElement && !nonTextInputTypes.has(activeElement.type) ||
activeElement instanceof IHTMLTextAreaElement ||
(activeElement instanceof IHTMLElement && activeElement.isContentEditable);

let targetIsTextInput = eventTarget instanceof IHTMLInputElement && !nonTextInputTypes.has(eventTarget.type) ||
eventTarget instanceof IHTMLTextAreaElement ||
(eventTarget instanceof IHTMLElement && eventTarget.isContentEditable);

isTextInput = isTextInput || activeIsTextInput || targetIsTextInput;
return !(isTextInput && modality === 'keyboard' && e instanceof IKeyboardEvent && !FOCUS_VISIBLE_INPUT_KEYS[(e as KeyboardEvent).key]);
}

/**
Expand Down
16 changes: 15 additions & 1 deletion packages/@react-aria/interactions/src/useFocusWithin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,21 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult {
// 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 && !nodeContains(e.currentTarget as Element, 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();

Expand Down
13 changes: 10 additions & 3 deletions packages/@react-aria/overlays/src/PortalProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
Copy link
Member

Choose a reason for hiding this comment

The 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>
);
Expand Down
30 changes: 27 additions & 3 deletions packages/@react-aria/overlays/src/ariaHideOutside.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/

import {getOwnerWindow, nodeContains} from '@react-aria/utils';
import {createShadowTreeWalker, getOwnerDocument, getOwnerWindow, nodeContains} from '@react-aria/utils';
const supportsInert = typeof HTMLElement !== 'undefined' && 'inert' in HTMLElement.prototype;

interface AriaHideOutsideOptions {
Expand Down Expand Up @@ -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".
Expand All @@ -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}
);
Expand Down
50 changes: 34 additions & 16 deletions packages/@react-aria/overlays/src/calculatePosition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ interface PositionOpts {
offset: number,
crossOffset: number,
maxHeight?: number,
arrowBoundaryOffset?: number
arrowBoundaryOffset?: number,
containerBounds?: DOMRect | null
}

type HeightGrowthDirection = 'top' | 'bottom';
Expand Down Expand Up @@ -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 {
Copy link
Member

Choose a reason for hiding this comment

The 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;
Expand All @@ -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));
Expand Down Expand Up @@ -529,7 +545,8 @@ export function calculatePosition(opts: PositionOpts): PositionResult {
crossOffset,
maxHeight,
arrowSize = 0,
arrowBoundaryOffset = 0
arrowBoundaryOffset = 0,
containerBounds
} = opts;

let visualViewport = getVisualViewport();
Expand All @@ -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
Expand Down
46 changes: 46 additions & 0 deletions packages/@react-aria/overlays/src/containerBoundsUtils.ts
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';
}
}

2 changes: 2 additions & 0 deletions packages/@react-aria/overlays/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading