Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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
573 changes: 573 additions & 0 deletions packages/@react-spectrum/s2/stories/ShadowDOM.stories.tsx

Large diffs are not rendered by default.

125 changes: 124 additions & 1 deletion packages/react-aria-components/test/Popover.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@
* governing permissions and limitations under the License.
*/

import {act, fireEvent, pointerMap, render} from '@react-spectrum/test-utils-internal';
import {act, createShadowRoot, fireEvent, pointerMap, render} from '@react-spectrum/test-utils-internal';
import {Button} from '../src/Button';
import {Dialog, DialogTrigger} from '../src/Dialog';
import {enableShadowDOM} from 'react-stately/private/flags/flags';
import {Menu, MenuItem, MenuTrigger} from '../src/Menu';
import {OverlayArrow} from '../src/OverlayArrow';
import {Popover} from '../src/Popover';
import {Pressable} from 'react-aria/private/interactions/Pressable';
import React, {useRef} from 'react';
import {screen} from 'shadow-dom-testing-library';
import {UNSAFE_PortalProvider} from 'react-aria/PortalProvider';
import userEvent from '@testing-library/user-event';

Expand Down Expand Up @@ -289,4 +292,124 @@ describe('Popover', () => {
let dialog = getByRole('dialog');
expect(dialog).toBeInTheDocument();
});

if (parseInt(React.version, 10) >= 17) {
// This one works outside shadow dom as well because everything is inside the same shadow root.
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();

function ShadowApp() {
return (
<MenuTrigger>
<Button>
Open
</Button>
<Popover>
<Menu onAction={onAction}>
<MenuItem key="new">New…</MenuItem>
<MenuItem key="open">Open…</MenuItem>
<MenuItem key="save">Save</MenuItem>
<MenuItem key="save-as">Save as…</MenuItem>
<MenuItem key="print">Print…</MenuItem>
</Menu>
</Popover>
</MenuTrigger>
);
}
render(
<UNSAFE_PortalProvider getContainer={() => portal}> 1
<ShadowApp />
</UNSAFE_PortalProvider>,
{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();
});
}
});

if (parseInt(React.version, 10) >= 17) {
describe('Popover with Shadow DOM and UNSAFE_PortalProvider', () => {
let user;
beforeAll(() => {
enableShadowDOM();
user = userEvent.setup({delay: null, pointerMap});
jest.useFakeTimers();
});

afterEach(() => {
act(() => jest.runAllTimers());
});


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();
function ShadowApp() {
return (
<MenuTrigger>
<Button>
Open
</Button>
<Popover>
<Menu onAction={onAction}>
<MenuItem key="new">New…</MenuItem>
<MenuItem key="open">Open…</MenuItem>
<MenuItem key="save">Save</MenuItem>
<MenuItem key="save-as">Save as…</MenuItem>
<MenuItem key="print">Print…</MenuItem>
</Menu>
</Popover>
</MenuTrigger>
);
}
render(
<UNSAFE_PortalProvider getContainer={() => portal}> 1
<ShadowApp />
</UNSAFE_PortalProvider>,
{container: appContainer}
);

let button = await screen.findByShadowRole('button');
fireEvent.click(button); // not sure why user.click doesn't work here
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();
});
});
}
3 changes: 2 additions & 1 deletion packages/react-aria/src/combobox/useComboBox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,9 @@ export function useComboBox<T, M extends SelectionMode = 'single'>(props: AriaCo
};

let onBlur = (e: FocusEvent<HTMLInputElement>) => {
let blurFromButton = buttonRef?.current && buttonRef.current === e.relatedTarget;
let blurFromButton = nodeContains(buttonRef.current, e.relatedTarget as Element);
let blurIntoPopover = nodeContains(popoverRef.current, e.relatedTarget);

// Ignore blur if focused moved to the button(if exists) or into the popover.
if (blurFromButton || blurIntoPopover) {
return;
Expand Down
56 changes: 37 additions & 19 deletions packages/react-aria/src/interactions/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@

import {FocusableElement} from '@react-types/shared';
import {focusWithoutScrolling} from '../utils/focusWithoutScrolling';
import {getActiveElement, getEventTarget} from '../utils/shadowdom/DOMFunctions';
import {getOwnerWindow} from '../utils/domHelpers';
import {getActiveElement, getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions';
import {getOwnerWindow, isShadowRoot} from '../utils/domHelpers';
import {isFocusable} from '../utils/isFocusable';
import {FocusEvent as ReactFocusEvent, SyntheticEvent, useCallback, useRef} from 'react';
import {useLayoutEffect} from '../utils/useLayoutEffect';
Expand Down Expand Up @@ -114,21 +114,39 @@ export function preventFocus(target: FocusableElement | null): (() => void) | un
}

let window = getOwnerWindow(target);
let activeElement = window.document.activeElement as FocusableElement | null;
let activeElement = getActiveElement(window.document) as FocusableElement | null;
if (!activeElement || activeElement === target) {
return;
}

// Listen on the target's root (document or shadow root) so we catch focus events inside
// shadow DOM; they do not reach the main window.
let targetRoot = target?.getRootNode();
let root =
(targetRoot != null && isShadowRoot(targetRoot))
? targetRoot
: getOwnerWindow(target);

// Focus is "moving to target" when it moves to the button or to a descendant of the button
// (e.g. SVG icon)
let isFocusMovingToTarget = (focusTarget: Element | null) =>
focusTarget === target || (focusTarget != null && nodeContains(target, focusTarget));
// Blur/focusout events have their target as the element losing focus. Stop propagation when
// that is the previously focused element (activeElement) or a descendant (e.g. in shadow DOM).
let isBlurFromActiveElement = (eventTarget: Element | null) =>
eventTarget === activeElement ||
(activeElement != null && eventTarget != null && nodeContains(activeElement, eventTarget));

ignoreFocusEvent = true;
let isRefocusing = false;
let onBlur = (e: FocusEvent) => {
if (getEventTarget(e) === activeElement || isRefocusing) {
let onBlur: EventListener = (e) => {
if (isBlurFromActiveElement(getEventTarget(e) as Element) || isRefocusing) {
e.stopImmediatePropagation();
}
};

let onFocusOut = (e: FocusEvent) => {
if (getEventTarget(e) === activeElement || isRefocusing) {
let onFocusOut: EventListener = (e) => {
if (isBlurFromActiveElement(getEventTarget(e) as Element) || isRefocusing) {
e.stopImmediatePropagation();

// If there was no focusable ancestor, we don't expect a focus event.
Expand All @@ -141,14 +159,14 @@ export function preventFocus(target: FocusableElement | null): (() => void) | un
}
};

let onFocus = (e: FocusEvent) => {
if (getEventTarget(e) === target || isRefocusing) {
let onFocus: EventListener = (e) => {
if (isFocusMovingToTarget(getEventTarget(e) as Element) || isRefocusing) {
e.stopImmediatePropagation();
}
};

let onFocusIn = (e: FocusEvent) => {
if (getEventTarget(e) === target || isRefocusing) {
let onFocusIn: EventListener = (e) => {
if (isFocusMovingToTarget(getEventTarget(e) as Element) || isRefocusing) {
e.stopImmediatePropagation();

if (!isRefocusing) {
Expand All @@ -159,17 +177,17 @@ export function preventFocus(target: FocusableElement | null): (() => void) | un
}
};

window.addEventListener('blur', onBlur, true);
window.addEventListener('focusout', onFocusOut, true);
window.addEventListener('focusin', onFocusIn, true);
window.addEventListener('focus', onFocus, true);
root.addEventListener('blur', onBlur, true);
root.addEventListener('focusout', onFocusOut, true);
root.addEventListener('focusin', onFocusIn, true);
root.addEventListener('focus', onFocus, true);

let cleanup = () => {
cancelAnimationFrame(raf);
window.removeEventListener('blur', onBlur, true);
window.removeEventListener('focusout', onFocusOut, true);
window.removeEventListener('focusin', onFocusIn, true);
window.removeEventListener('focus', onFocus, true);
root.removeEventListener('blur', onBlur, true);
root.removeEventListener('focusout', onFocusOut, true);
root.removeEventListener('focusin', onFocusIn, true);
root.removeEventListener('focus', onFocus, true);
ignoreFocusEvent = false;
isRefocusing = false;
};
Expand Down
Loading
Loading