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
3 changes: 3 additions & 0 deletions packages/@react-aria/overlays/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@
"@react-types/shared": "^3.31.0",
"@swc/helpers": "^0.5.0"
},
"devDependencies": {
"@react-stately/flags": "^3.1.2"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
Expand Down
5 changes: 3 additions & 2 deletions packages/@react-aria/overlays/src/useOverlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/

import {DOMAttributes, RefObject} from '@react-types/shared';
import {getEventTarget} from '@react-aria/utils';
import {isElementInChildOfActiveScope} from '@react-aria/focus';
import {useEffect} from 'react';
import {useFocusWithin, useInteractOutside} from '@react-aria/interactions';
Expand Down Expand Up @@ -91,7 +92,7 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject<Element | nul
};

let onInteractOutsideStart = (e: PointerEvent) => {
if (!shouldCloseOnInteractOutside || shouldCloseOnInteractOutside(e.target as Element)) {
if (!shouldCloseOnInteractOutside || shouldCloseOnInteractOutside(getEventTarget(e))) {
if (visibleOverlays[visibleOverlays.length - 1] === ref) {
e.stopPropagation();
e.preventDefault();
Expand All @@ -100,7 +101,7 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject<Element | nul
};

let onInteractOutside = (e: PointerEvent) => {
if (!shouldCloseOnInteractOutside || shouldCloseOnInteractOutside(e.target as Element)) {
if (!shouldCloseOnInteractOutside || shouldCloseOnInteractOutside(getEventTarget(e))) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there are some 'relatedTarget' checks below, can we dive into a shadowDOM for those as well? i don't know if there is a composedPath for them

I could see this being an issue if getEventTarget is contained in e.relatedTarget or if the e.relatedTarget is a shadowroot and contains both targets that should be ignored as well as ones which shouldn't.
For example, if someone made a Toast Container inside a shadow dom, then clicking the toasts probably shouldn't count as outside but maybe clicking between individual toasts should for some reason.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that isChildOfActiveScope might be problematic as well if the activescope is within a shadowroot

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it weird that we call an external API with something that should be inside a shadowroot, it'd unintentionally leak shadow dom internals through our API

We don't do this in the pressEvents, though the use case is obviously a little different

if (visibleOverlays[visibleOverlays.length - 1] === ref) {
e.stopPropagation();
e.preventDefault();
Expand Down
96 changes: 92 additions & 4 deletions packages/@react-aria/overlays/test/useOverlay.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,30 @@
* governing permissions and limitations under the License.
*/

import {fireEvent, installMouseEvent, installPointerEvent, render} from '@react-spectrum/test-utils-internal';
import {
createShadowRoot,
fireEvent,
installMouseEvent,
installPointerEvent,
render
} from '@react-spectrum/test-utils-internal';
import {enableShadowDOM} from '@react-stately/flags';
import {mergeProps} from '@react-aria/utils';
import React, {useRef} from 'react';
import ReactDOM from 'react-dom';
import {useOverlay} from '../';

function Example(props) {
let ref = useRef();
let {overlayProps, underlayProps} = useOverlay(props, ref);
return (
<div {...mergeProps(underlayProps, props.underlayProps || {})}>
<div ref={ref} {...overlayProps} data-testid={props['data-testid'] || 'test'}>
<div
{...mergeProps(underlayProps, props.underlayProps || {})}
data-testid={'underlay'}>
<div
ref={ref}
{...overlayProps}
data-testid={props['data-testid'] || 'test'}>
{props.children}
</div>
</div>
Expand Down Expand Up @@ -56,7 +69,7 @@ describe('useOverlay', function () {
expect(document.activeElement).toBe(input);
});

it('should hide the overlay when clicking outside if isDismissble is true', function () {
it('should hide the overlay when clicking outside if isDismissable is true', function () {
let onClose = jest.fn();
render(<Example isOpen onClose={onClose} isDismissable />);
pressStart(document.body);
Expand Down Expand Up @@ -140,3 +153,78 @@ describe('useOverlay', function () {
});
});
});

describe('useOverlay with shadow dom', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think these tests are going to be enough, usually overlays portal out to be a direct child of the body element, which would actually take them out of a shadowroot potentially

This can also be changed with UNSTABLE_PortalProvider

We'll need tests using the RAC Popover at a minimum

beforeAll(() => {
enableShadowDOM();
});

describe.each`
type | prepare | actions
${'Mouse Events'} | ${installMouseEvent} | ${[(el) => fireEvent.mouseDown(el, {button: 0}), (el) => fireEvent.mouseUp(el, {button: 0})]}
${'Pointer Events'} | ${installPointerEvent} | ${[(el) => fireEvent.pointerDown(el, {button: 0, pointerId: 1}), (el) => {fireEvent.pointerUp(el, {button: 0, pointerId: 1}); fireEvent.click(el, {button: 0, pointerId: 1});}]}
${'Touch Events'} | ${() => {}} | ${[(el) => fireEvent.touchStart(el, {changedTouches: [{identifier: 1}]}), (el) => fireEvent.touchEnd(el, {changedTouches: [{identifier: 1}]})]}
`('$type', ({actions: [pressStart, pressEnd], prepare}) => {
prepare();

it('should not close the overlay when clicking outside if shouldCloseOnInteractOutside returns true', function () {
const {shadowRoot, cleanup} = createShadowRoot();

let onClose = jest.fn();
let underlay;

const WrapperComponent = () =>
ReactDOM.createPortal(
<Example
isOpen
onClose={onClose}
isDismissable
shouldCloseOnInteractOutside={(target) => {
return target === underlay;
}} />,
shadowRoot
);

const {unmount} = render(<WrapperComponent />);

underlay = shadowRoot.querySelector("[data-testid='underlay']");

pressStart(underlay);
pressEnd(underlay);
expect(onClose).toHaveBeenCalled();

// Cleanup
unmount();
cleanup();
});

it('should not close the overlay when clicking outside if shouldCloseOnInteractOutside returns false', function () {
const {shadowRoot, cleanup} = createShadowRoot();

let onClose = jest.fn();
let underlay;

const WrapperComponent = () =>
ReactDOM.createPortal(
<Example
isOpen
onClose={onClose}
isDismissable
shouldCloseOnInteractOutside={(target) => target !== underlay} />,
shadowRoot
);

const {unmount} = render(<WrapperComponent />);

underlay = shadowRoot.querySelector("[data-testid='underlay']");

pressStart(underlay);
pressEnd(underlay);
expect(onClose).not.toHaveBeenCalled();

// Cleanup
unmount();
cleanup();
});
});
});
1 change: 1 addition & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5912,6 +5912,7 @@ __metadata:
"@react-aria/ssr": "npm:^3.9.10"
"@react-aria/utils": "npm:^3.30.0"
"@react-aria/visually-hidden": "npm:^3.8.26"
"@react-stately/flags": "npm:^3.1.2"
"@react-stately/overlays": "npm:^3.6.18"
"@react-types/button": "npm:^3.13.0"
"@react-types/overlays": "npm:^3.9.0"
Expand Down