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 @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions packages/@react-aria/calendar/src/useRangeCalendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -66,7 +66,7 @@ export function useRangeCalendar<T extends DateValue>(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();
}
};
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
35 changes: 31 additions & 4 deletions packages/@react-aria/combobox/src/useComboBox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 = 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;
}

Expand Down Expand Up @@ -358,6 +375,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
// 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
4 changes: 2 additions & 2 deletions packages/@react-aria/datepicker/src/useDatePicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -84,7 +84,7 @@ export function useDatePicker<T extends DateValue>(props: AriaDatePickerProps<T>
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);
Expand Down
4 changes: 2 additions & 2 deletions packages/@react-aria/datepicker/src/useDateRangePicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -116,7 +116,7 @@ export function useDateRangePicker<T extends DateValue>(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);
Expand Down
9 changes: 5 additions & 4 deletions packages/@react-aria/focus/src/FocusScope.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
isChrome,
isFocusable,
isTabbable,
nodeContains,
ShadowTreeWalker,
useLayoutEffect
} from '@react-aria/utils';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -822,7 +823,7 @@ export function createFocusManager(ref: RefObject<Element | null>, 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;
Expand All @@ -843,7 +844,7 @@ export function createFocusManager(ref: RefObject<Element | null>, 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);
Expand Down
20 changes: 17 additions & 3 deletions packages/@react-aria/interactions/src/useFocusWithin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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;
}

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

Expand Down Expand Up @@ -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.
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
}}>
{props.children}
</PortalContext.Provider>
);
Expand Down
32 changes: 28 additions & 4 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} 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 @@ -85,16 +106,19 @@ 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;
}
}

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
Loading