Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b3e18bc
chore: additional shadow dom tests
snowystinger Feb 10, 2026
67ab690
test from 8806
snowystinger Feb 10, 2026
baff7aa
fix lint
snowystinger Feb 10, 2026
3fb01ce
from 7751
snowystinger Feb 10, 2026
89a2531
Add storybook story
snowystinger Feb 11, 2026
ccbfa4b
disable failing test for the moment so i get a build
snowystinger Feb 12, 2026
2ae2e90
skip other react 16 failure
snowystinger Feb 12, 2026
a57fef8
skip next react 16 failure
snowystinger Feb 12, 2026
02dc628
add combobox to the story
snowystinger Feb 12, 2026
6aa4a70
fix: combobox interactoutside
snowystinger Feb 12, 2026
139b202
fix dependencies
snowystinger Feb 12, 2026
a2a5116
fix lint
snowystinger Feb 12, 2026
d021c96
fix focus move to input
snowystinger Feb 12, 2026
f52affe
fix the non-shadow case again
snowystinger Feb 12, 2026
ff5ce98
Merge branch 'main' into additional-shadow-dom-tests
snowystinger Feb 12, 2026
ed13ba2
Add all of our S2 components so we can test manually
snowystinger Feb 13, 2026
4dee5ed
Merge branch 'main' into additional-shadow-dom-tests
snowystinger Feb 16, 2026
e9b5172
skip react 16 tests since it doesn't support shadow dom well with the…
snowystinger Feb 16, 2026
20a6537
fix lint
snowystinger Feb 16, 2026
af1f2cf
Merge branch 'main' into additional-shadow-dom-tests
snowystinger Mar 26, 2026
5b4a808
fix merge
snowystinger Mar 26, 2026
7952a83
remove skip and explain
snowystinger Mar 26, 2026
579ec2e
There's not enough support in 16/17 for this
snowystinger Mar 26, 2026
a571c1d
Merge branch 'main' into additional-shadow-dom-tests
snowystinger Mar 31, 2026
c1d46ba
add story with two shadow root setup, app + portal
snowystinger Mar 31, 2026
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
659 changes: 659 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/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