diff --git a/packages/@react-spectrum/s2/stories/ShadowDOM.stories.tsx b/packages/@react-spectrum/s2/stories/ShadowDOM.stories.tsx new file mode 100644 index 00000000000..12743649d28 --- /dev/null +++ b/packages/@react-spectrum/s2/stories/ShadowDOM.stories.tsx @@ -0,0 +1,573 @@ +/* + * Copyright 2024 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-spectrum/s2/page.css'; + +import { + Accordion, + AccordionItem, + AccordionItemPanel, + AccordionItemTitle, + ActionBar, + ActionButton, + ActionButtonGroup, + ActionMenu, + AlertDialog, + Avatar, + Badge, + Breadcrumb, + Breadcrumbs, + Button, + ButtonGroup, + Calendar, + Card, + CardPreview, + CardView, + Cell, + Checkbox, + CheckboxGroup, + Collection, + ColorArea, + ColorField, + ColorSlider, + ColorSwatch, + ColorSwatchPicker, + ColorWheel, + Column, + ComboBox, + ComboBoxItem, + Content, + DatePicker, + DateRangePicker, + Dialog, + DialogTrigger, + Disclosure, + DisclosureHeader, + DisclosurePanel, + DisclosureTitle, + Divider, + DropZone, + Footer, + Form, + Header, + Heading, + IllustratedMessage, + Image, + InlineAlert, + Link, + Menu, + MenuItem, + MenuTrigger, + Meter, + NumberField, + Picker, + PickerItem, + ProgressBar, + ProgressCircle, + Provider, + Radio, + RadioGroup, + RangeCalendar, + RangeSlider, + Row, + SearchField, + SegmentedControl, + SegmentedControlItem, + SelectBox, + SelectBoxGroup, + Skeleton, + SkeletonCollection, + Slider, + StatusLight, + SubmenuTrigger, + Switch, + Tab, + TableBody, + TableHeader, + TableView, + TabList, + TabPanel, + Tabs, + Tag, + TagGroup, + Text, + TextField, + TimeField, + ToggleButton, + ToggleButtonGroup, + Tooltip, + TooltipTrigger, + TreeView, + TreeViewItem, + TreeViewItemContent, + useAsyncList +} from '@react-spectrum/s2'; +import {action} from 'storybook/actions'; +import AlertNotice from '../spectrum-illustrations/linear/AlertNotice'; +import {AriaCardViewProps as CardViewProps} from '@react-types/card'; +import {createRoot} from 'react-dom/client'; +import {enableShadowDOM} from 'react-stately/private/flags/flags'; +import type {Meta, StoryObj} from '@storybook/react'; +import PaperAirplane from '../spectrum-illustrations/linear/Paperairplane'; +import Server from '../spectrum-illustrations/linear/Server'; +import StarFilled1 from '../spectrum-illustrations/linear/Star'; +import {style} from '../style' with {type: 'macro'}; +import {UNSAFE_PortalProvider} from 'react-aria'; +import {useEffect, useRef} from 'react'; + +enableShadowDOM(); + +const meta: Meta = { + title: 'ShadowDOM' +}; + +export default meta; + +function ShadowDOMMenuContent() { + const hostRef = useRef(null); + const portalContainerRef = useRef(null); + const rootRef = useRef | null>(null); + + useEffect(() => { + const host = hostRef.current; + if (!host) { + return; + } + + const shadowRoot = host.attachShadow({mode: 'open'}); + + // So S2 theme variables apply: :host in the copied CSS targets the shadow host. + const scheme = document.documentElement.getAttribute('data-color-scheme'); + if (scheme) { + host.setAttribute('data-color-scheme', scheme); + } + + // Copy all styles from the document into the shadow root so S2 (and Storybook) styles apply. + // Shadow DOM does not inherit styles; we must duplicate every stylesheet. + const styleRoot = document.createElement('div'); + styleRoot.setAttribute('data-shadow-styles', ''); + for (const node of document.head.children) { + if (node.tagName === 'LINK' && (node as HTMLLinkElement).rel === 'stylesheet') { + const link = node as HTMLLinkElement; + const clone = document.createElement('link'); + clone.rel = 'stylesheet'; + clone.href = link.href; + styleRoot.appendChild(clone); + } else if (node.tagName === 'STYLE') { + const style = node as HTMLStyleElement; + const clone = style.cloneNode(true) as HTMLStyleElement; + styleRoot.appendChild(clone); + } + } + shadowRoot.appendChild(styleRoot); + + const appContainer = document.createElement('div'); + appContainer.id = 'shadow-app'; + shadowRoot.appendChild(appContainer); + + const portalContainer = document.createElement('div'); + portalContainer.id = 'shadow-portal'; + shadowRoot.appendChild(portalContainer); + portalContainerRef.current = portalContainer; + + const root = createRoot(appContainer); + rootRef.current = root; + root.render( + + portalContainerRef.current}> +
+

Buttons & actions

+
+ + Link + + + + + + Action + + Copy + Paste + + Toggle + + Left + Center + Right + + + Edit + Duplicate + Delete + + + + + Edit + + Duplicate + + In place + Elsewhere + + + Delete + + + + + + {({close}) => ( + <> + Sky over roof + Dialog title +
Header
+ + {[...Array(3)].map((_, i) => +

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in

+ )} +
+
Don't show this again
+ + + + + + )} +
+
+ + + + Are you sure? + + +
+ +

Form controls

+
+ + + + + + + + Checkbox + + A + B + + Switch + + One + Two + + + + + Chocolate + Mint + Vanilla + + + Chocolate + Mint + Vanilla + + + + + Amazon Web Services + Reliable cloud infrastructure + + + + Microsoft Azure + + + + Google Cloud Platform + + + + IBM Cloud + Hybrid cloud solutions + + + + A + B + C + +
+ +

Navigation & layout

+
+ + Home + Docs + Page + + + + Tab 1 + Tab 2 + + Panel 1 + Panel 2 + + + + Section + Content + + + + + Disclosure + + Panel content + +
+ +

Color

+
+ + + + + + + + + + + +
+ +

Status & feedback

+
+ Badge + Positive + Negative + + + + Placeholder + + Alert title + Inline alert body with more detail about what happened or what to do next. + + + + Tooltip text + +
+ +

Content & data

+
+ + + No results + Try adjusting your search or filters to find what you need. + + + + Tag 1 + Tag 2 + + +
+ + + + + Drop zone + +
+

Card view

+
+ +
+

Table

+
+ }> + + Name + Value + + + + Row 1 A + Row 1 B + + + Row 2 A + Row 2 B + + + +
+

Tree

+
+ + + Node 1 + + + Node 2 + + +
+
+
+
+ ); + + return () => { + root.unmount(); + rootRef.current = null; + portalContainerRef.current = null; + }; + }, []); + + return
; +} + +export const MenuInShadowRoot: StoryObj = { + render: () => , + parameters: { + } +}; + + +const cardViewStyles = style({ + width: 'screen', + maxWidth: 'full', + height: 600 +}); + +type Item = { + id: number, + user: { + name: string, + profile_image: { small: string } + }, + urls: { regular: string }, + description: string, + alt_description: string, + width: number, + height: number +}; + +const avatarSize = { + XS: 16, + S: 20, + M: 24, + L: 28, + XL: 32 +} as const; + +function PhotoCard({item, layout}: {item: Item, layout: string}) { + return ( + + {({size}) => (<> + + ( +
+ +
+ )} /> +
+ + {item.description || item.alt_description} + {size !== 'XS' && + Test + } +
+ + {item.user.name} +
+
+ )} +
+ ); +} + +const ExampleRender = (args: Omit, 'children' | 'layout'>) => { + let list = useAsyncList({ + async load({signal, cursor, items}) { + let page = cursor || 1; + let res = await fetch( + `https://api.unsplash.com/topics/nature/photos?page=${page}&per_page=30&client_id=AJuU-FPh11hn7RuumUllp4ppT8kgiLS7LtOHp_sp4nc`, + {signal} + ); + let nextItems = await res.json(); + // Filter duplicates which might be returned by the API. + let existingKeys = new Set(items.map(i => i.id)); + nextItems = nextItems.filter(i => !existingKeys.has(i.id) && (i.description || i.alt_description)); + return {items: nextItems, cursor: nextItems.length ? page + 1 : null}; + } + }); + + let loadingState = args.loadingState === 'idle' ? list.loadingState : args.loadingState; + let items = loadingState === 'loading' ? [] : list.items; + + return ( + + + {item => } + + {(loadingState === 'loading' || loadingState === 'loadingMore') && ( + + {() => ( + + )} + + )} + + ); +}; diff --git a/packages/react-aria-components/test/Popover.test.js b/packages/react-aria-components/test/Popover.test.js index 8947b9f7047..981bc5e2061 100644 --- a/packages/react-aria-components/test/Popover.test.js +++ b/packages/react-aria-components/test/Popover.test.js @@ -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'; @@ -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 ( + + + + + New… + Open… + Save + Save as… + Print… + + + + ); + } + render( + portal}> 1 + + , + {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 ( + + + + + New… + Open… + Save + Save as… + Print… + + + + ); + } + render( + portal}> 1 + + , + {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(); + }); + }); +} diff --git a/packages/react-aria/src/combobox/useComboBox.ts b/packages/react-aria/src/combobox/useComboBox.ts index e712e347c6b..45c7326d3c7 100644 --- a/packages/react-aria/src/combobox/useComboBox.ts +++ b/packages/react-aria/src/combobox/useComboBox.ts @@ -202,8 +202,9 @@ export function useComboBox(props: AriaCo }; let onBlur = (e: FocusEvent) => { - 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; diff --git a/packages/react-aria/src/interactions/utils.ts b/packages/react-aria/src/interactions/utils.ts index 54d10330d02..f37173f8e4a 100644 --- a/packages/react-aria/src/interactions/utils.ts +++ b/packages/react-aria/src/interactions/utils.ts @@ -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'; @@ -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. @@ -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) { @@ -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; }; diff --git a/packages/react-aria/test/focus/FocusScope.test.js b/packages/react-aria/test/focus/FocusScope.test.js index 185f3447820..2154b948f20 100644 --- a/packages/react-aria/test/focus/FocusScope.test.js +++ b/packages/react-aria/test/focus/FocusScope.test.js @@ -20,6 +20,7 @@ import {Provider} from '@adobe/react-spectrum/Provider'; import React, {useEffect, useState} from 'react'; import ReactDOM from 'react-dom'; import {Example as StorybookExample} from '../../stories/focus/FocusScope.stories'; +import {UNSAFE_PortalProvider} from '../../src/overlays/PortalProvider'; import {useEvent} from '../../src/utils/useEvent'; import userEvent from '@testing-library/user-event'; @@ -2025,199 +2026,404 @@ describe('FocusScope', function () { }); }); -describe('FocusScope with Shadow DOM', function () { - let user; +if (parseInt(React.version, 10) >= 17) { + describe('FocusScope with Shadow DOM', function () { + let user; - beforeAll(() => { - enableShadowDOM(); - user = userEvent.setup({delay: null, pointerMap}); - }); + beforeAll(() => { + enableShadowDOM(); + user = userEvent.setup({delay: null, pointerMap}); + }); - beforeEach(() => { - jest.useFakeTimers(); - }); - afterEach(() => { - // make sure to clean up any raf's that may be running to restore focus on unmount - act(() => {jest.runAllTimers();}); - }); + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + // make sure to clean up any raf's that may be running to restore focus on unmount + act(() => {jest.runAllTimers();}); + }); - it('should contain focus within the shadow DOM scope', async function () { - const {shadowRoot} = createShadowRoot(); - const FocusableComponent = () => ReactDOM.createPortal( - + it('should contain focus within the shadow DOM scope', async function () { + const {shadowRoot} = createShadowRoot(); + const FocusableComponent = () => ReactDOM.createPortal( + + + + + , + shadowRoot + ); + + const {unmount} = render(); + + const input1 = shadowRoot.querySelector('[data-testid="input1"]'); + const input2 = shadowRoot.querySelector('[data-testid="input2"]'); + const input3 = shadowRoot.querySelector('[data-testid="input3"]'); + + // Simulate focusing the first input + act(() => {input1.focus();}); + expect(document.activeElement).toBe(shadowRoot.host); + expect(shadowRoot.activeElement).toBe(input1); + + // Simulate tabbing through inputs + await user.tab(); + expect(shadowRoot.activeElement).toBe(input2); + + await user.tab(); + expect(shadowRoot.activeElement).toBe(input3); + + // Simulate tabbing back to the first input + await user.tab(); + expect(shadowRoot.activeElement).toBe(input1); + + // Cleanup + unmount(); + document.body.removeChild(shadowRoot.host); + }); + + it('should manage focus within nested shadow DOMs', async function () { + const {shadowRoot: parentShadowRoot} = createShadowRoot(); + const nestedDiv = document.createElement('div'); + parentShadowRoot.appendChild(nestedDiv); + const childShadowRoot = nestedDiv.attachShadow({mode: 'open'}); + + const FocusableComponent = () => ReactDOM.createPortal( - - , - shadowRoot - ); + , childShadowRoot); - const {unmount} = render(); + const {unmount} = render(); - const input1 = shadowRoot.querySelector('[data-testid="input1"]'); - const input2 = shadowRoot.querySelector('[data-testid="input2"]'); - const input3 = shadowRoot.querySelector('[data-testid="input3"]'); + const input1 = childShadowRoot.querySelector('[data-testid=input1]'); + const input2 = childShadowRoot.querySelector('[data-testid=input2]'); - // Simulate focusing the first input - act(() => {input1.focus();}); - expect(document.activeElement).toBe(shadowRoot.host); - expect(shadowRoot.activeElement).toBe(input1); + act(() => {input1.focus();}); + expect(childShadowRoot.activeElement).toBe(input1); - // Simulate tabbing through inputs - await user.tab(); - expect(shadowRoot.activeElement).toBe(input2); + await user.tab(); + expect(childShadowRoot.activeElement).toBe(input2); - await user.tab(); - expect(shadowRoot.activeElement).toBe(input3); + // Cleanup + unmount(); + document.body.removeChild(parentShadowRoot.host); + }); - // Simulate tabbing back to the first input - await user.tab(); - expect(shadowRoot.activeElement).toBe(input1); + /** + * document.body + * ├── div#outside-shadow (contains ) + * │ ├── input (focus can be restored here) + * │ └── shadow-root + * │ └── Your custom elements and focusable elements here + * └── Other elements + */ + it('should restore focus to the element outside shadow DOM on unmount, with FocusScope outside as well', async () => { + const App = () => ( + <> + + + +
+ + ); - // Cleanup - unmount(); - document.body.removeChild(shadowRoot.host); - }); + const {getByTestId} = render(); + const shadowHost = document.getElementById('shadow-host'); + const shadowRoot = shadowHost.attachShadow({mode: 'open'}); - it('should manage focus within nested shadow DOMs', async function () { - const {shadowRoot: parentShadowRoot} = createShadowRoot(); - const nestedDiv = document.createElement('div'); - parentShadowRoot.appendChild(nestedDiv); - const childShadowRoot = nestedDiv.attachShadow({mode: 'open'}); + const FocusableComponent = () => ReactDOM.createPortal( + + + + + , + shadowRoot + ); - const FocusableComponent = () => ReactDOM.createPortal( - - - , childShadowRoot); + const {unmount} = render(); - const {unmount} = render(); + const input1 = shadowRoot.querySelector('[data-testid="input1"]'); + act(() => { input1.focus(); }); + expect(shadowRoot.activeElement).toBe(input1); - const input1 = childShadowRoot.querySelector('[data-testid=input1]'); - const input2 = childShadowRoot.querySelector('[data-testid=input2]'); + const externalInput = getByTestId('outside'); + act(() => { externalInput.focus(); }); + expect(document.activeElement).toBe(externalInput); - act(() => {input1.focus();}); - expect(childShadowRoot.activeElement).toBe(input1); + act(() => { + jest.runAllTimers(); + }); - await user.tab(); - expect(childShadowRoot.activeElement).toBe(input2); + unmount(); - // Cleanup - unmount(); - document.body.removeChild(parentShadowRoot.host); - }); + expect(document.activeElement).toBe(externalInput); + }); - /** - * document.body - * ├── div#outside-shadow (contains ) - * │ ├── input (focus can be restored here) - * │ └── shadow-root - * │ └── Your custom elements and focusable elements here - * └── Other elements - */ - it('should restore focus to the element outside shadow DOM on unmount, with FocusScope outside as well', async () => { - const App = () => ( - <> - - - -
- - ); + /** + * Test case: https://github.com/adobe/react-spectrum/issues/1472 + */ + it('should autofocus and lock tab navigation inside shadow DOM', async function () { + const {shadowRoot, shadowHost} = createShadowRoot(); - const {getByTestId} = render(); - const shadowHost = document.getElementById('shadow-host'); - const shadowRoot = shadowHost.attachShadow({mode: 'open'}); + const FocusableComponent = () => ReactDOM.createPortal( + + + + + , + shadowRoot + ); - const FocusableComponent = () => ReactDOM.createPortal( - - - - - , - shadowRoot - ); + const {unmount} = render(); - const {unmount} = render(); + const input1 = shadowRoot.querySelector('[data-testid="input1"]'); + const input2 = shadowRoot.querySelector('[data-testid="input2"]'); + const button = shadowRoot.querySelector('[data-testid="button"]'); - const input1 = shadowRoot.querySelector('[data-testid="input1"]'); - act(() => { input1.focus(); }); - expect(shadowRoot.activeElement).toBe(input1); + // Simulate focusing the first input and tab through the elements + act(() => {input1.focus();}); + expect(shadowRoot.activeElement).toBe(input1); - const externalInput = getByTestId('outside'); - act(() => { externalInput.focus(); }); - expect(document.activeElement).toBe(externalInput); + // Hit TAB key + await user.tab(); + expect(shadowRoot.activeElement).toBe(input2); - act(() => { - jest.runAllTimers(); + // Hit TAB key + await user.tab(); + expect(shadowRoot.activeElement).toBe(button); + + // Simulate tab again to check if focus loops back to the first input + await user.tab(); + expect(shadowRoot.activeElement).toBe(input1); + + // Cleanup + unmount(); + document.body.removeChild(shadowHost); }); - unmount(); - expect(document.activeElement).toBe(externalInput); - }); + it('should reproduce the specific issue #8675: Menu items in popover close immediately with UNSAFE_PortalProvider', async function () { + const {shadowRoot, cleanup} = createShadowRoot(); + let actionExecuted = false; + let menuClosed = false; - /** - * Test case: https://github.com/adobe/react-spectrum/issues/1472 - */ - it('should autofocus and lock tab navigation inside shadow DOM', async function () { - const {shadowRoot, shadowHost} = createShadowRoot(); + // Create portal container within the shadow DOM for the popover + const popoverPortal = document.createElement('div'); + popoverPortal.setAttribute('data-testid', 'popover-portal'); + shadowRoot.appendChild(popoverPortal); - const FocusableComponent = () => ReactDOM.createPortal( - - - - - , - shadowRoot - ); + // This reproduces the exact scenario described in the issue + function WebComponentWithReactApp() { + const [isPopoverOpen, setIsPopoverOpen] = React.useState(true); - const {unmount} = render(); + const handleMenuAction = key => { + actionExecuted = true; + // In the original issue, this never executes because the popover closes first + console.log('Menu action executed:', key); + }; - const input1 = shadowRoot.querySelector('[data-testid="input1"]'); - const input2 = shadowRoot.querySelector('[data-testid="input2"]'); - const button = shadowRoot.querySelector('[data-testid="button"]'); + return ( + shadowRoot}> +
+ + {/* Portal the popover overlay to simulate real-world usage */} + {isPopoverOpen && + ReactDOM.createPortal( + +
+ +
+ + +
+
+
+
, + popoverPortal + )} +
+
+ ); + } - // Simulate focusing the first input and tab through the elements - act(() => {input1.focus();}); - expect(shadowRoot.activeElement).toBe(input1); + const {unmount} = render(); - // Hit TAB key - await user.tab(); - expect(shadowRoot.activeElement).toBe(input2); + // Wait for rendering + act(() => { + jest.runAllTimers(); + }); - // Hit TAB key - await user.tab(); - expect(shadowRoot.activeElement).toBe(button); + // Query elements from shadow DOM + const saveMenuItem = shadowRoot.querySelector('[data-testid="menu-item-save"]'); + const exportMenuItem = shadowRoot.querySelector('[data-testid="menu-item-export"]'); + const menuContainer = shadowRoot.querySelector('[data-testid="menu-container"]'); + const popoverOverlay = shadowRoot.querySelector('[data-testid="popover-overlay"]'); + // const closeButton = shadowRoot.querySelector('[data-testid="close-popover"]'); - // Simulate tab again to check if focus loops back to the first input - await user.tab(); - expect(shadowRoot.activeElement).toBe(input1); + // Verify the menu is initially visible in shadow DOM + expect(popoverOverlay).not.toBeNull(); + expect(menuContainer).not.toBeNull(); + expect(saveMenuItem).not.toBeNull(); + expect(exportMenuItem).not.toBeNull(); - // Cleanup - unmount(); - document.body.removeChild(shadowHost); - }); -}); + // Focus the first menu item + act(() => { + saveMenuItem.focus(); + }); + expect(shadowRoot.activeElement).toBe(saveMenuItem); -describe('Unmounting cleanup', () => { - beforeAll(() => { - jest.useFakeTimers(); - }); - afterAll(() => { - jest.runAllTimers(); + // Click the menu item - this should execute the onAction handler, NOT close the menu + await user.click(saveMenuItem); + + // The action should have been executed (this would fail in the buggy version) + expect(actionExecuted).toBe(true); + + // The menu should still be open (this would fail in the buggy version where it closes immediately) + expect(menuClosed).toBe(false); + expect(shadowRoot.querySelector('[data-testid="menu-container"]')).not.toBeNull(); + + // Test focus containment within the menu + act(() => { + saveMenuItem.focus(); + }); + await user.tab(); + expect(shadowRoot.activeElement).toBe(exportMenuItem); + + await user.tab(); + // Focus should wrap back to first item due to containment + expect(shadowRoot.activeElement).toBe(saveMenuItem); + + // Cleanup + unmount(); + cleanup(); + }); + + it('should handle web component scenario with multiple nested portals and UNSAFE_PortalProvider', async function () { + const {shadowRoot, cleanup} = createShadowRoot(); + + // Create nested portal containers within the shadow DOM + const modalPortal = document.createElement('div'); + modalPortal.setAttribute('data-testid', 'modal-portal'); + shadowRoot.appendChild(modalPortal); + + const tooltipPortal = document.createElement('div'); + tooltipPortal.setAttribute('data-testid', 'tooltip-portal'); + shadowRoot.appendChild(tooltipPortal); + + function ComplexWebComponent() { + const [showModal, setShowModal] = React.useState(true); + const [showTooltip] = React.useState(true); + + return ( + shadowRoot}> +
+ + + {/* Modal with its own focus scope */} + {showModal && + ReactDOM.createPortal( + +
+ + + +
+
, + modalPortal + )} + + {/* Tooltip with nested focus scope */} + {showTooltip && + ReactDOM.createPortal( + +
+ +
+
, + tooltipPortal + )} +
+
+ ); + } + + const {unmount} = render(); + + const modalButton1 = shadowRoot.querySelector('[data-testid="modal-button-1"]'); + const modalButton2 = shadowRoot.querySelector('[data-testid="modal-button-2"]'); + const tooltipAction = shadowRoot.querySelector('[data-testid="tooltip-action"]'); + + // Due to autoFocus, the first modal button should be focused + act(() => { + jest.runAllTimers(); + }); + expect(shadowRoot.activeElement).toBe(modalButton1); + + // Tab navigation should work within the modal + await user.tab(); + expect(shadowRoot.activeElement).toBe(modalButton2); + + // Focus should be contained within the modal due to the contain prop + await user.tab(); + // Should cycle to the close button + expect(shadowRoot.activeElement.getAttribute('data-testid')).toBe('close-modal'); + + await user.tab(); + // Should wrap back to first modal button + expect(shadowRoot.activeElement).toBe(modalButton1); + + // The tooltip button should be focusable when we explicitly focus it + act(() => { + tooltipAction.focus(); + }); + act(() => { + jest.runAllTimers(); + }); + // But due to modal containment, focus should be restored back to modal + expect(shadowRoot.activeElement).toBe(modalButton1); + + // Cleanup + unmount(); + cleanup(); + }); }); - // this test will fail in the 'afterAll' if there are any rafs left over - it('should not leak request animation frames', () => { - let tree = render( - - - - - ); - let buttons = tree.getAllByRole('button'); - act(() => buttons[0].focus()); - act(() => buttons[1].focus()); - act(() => buttons[1].blur()); + describe('Unmounting cleanup', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + afterAll(() => { + jest.runAllTimers(); + }); + + // this test will fail in the 'afterAll' if there are any rafs left over + it('should not leak request animation frames', () => { + let tree = render( + + + + + ); + let buttons = tree.getAllByRole('button'); + act(() => buttons[0].focus()); + act(() => buttons[1].focus()); + act(() => buttons[1].blur()); + }); }); -}); +} diff --git a/packages/react-aria/test/interactions/useInteractOutside.test.js b/packages/react-aria/test/interactions/useInteractOutside.test.js index 7697ab17769..48cacd1c920 100644 --- a/packages/react-aria/test/interactions/useInteractOutside.test.js +++ b/packages/react-aria/test/interactions/useInteractOutside.test.js @@ -10,10 +10,13 @@ * governing permissions and limitations under the License. */ -import {fireEvent, installPointerEvent, render, waitFor} from '@react-spectrum/test-utils-internal'; +import {act, createShadowRoot, fireEvent, installPointerEvent, pointerMap, render, waitFor} from '@react-spectrum/test-utils-internal'; +import {enableShadowDOM} from '@react-stately/flags'; import React, {useEffect, useRef} from 'react'; import ReactDOM, {createPortal} from 'react-dom'; +import {UNSAFE_PortalProvider} from '../../src/overlays/PortalProvider'; import {useInteractOutside} from '../../src/interactions/useInteractOutside'; +import userEvent from '@testing-library/user-event'; function Example(props) { let ref = useRef(); @@ -593,3 +596,88 @@ describe('useInteractOutside shadow DOM extended tests', function () { cleanup(); }); }); + +describe('useInteractOutside with Shadow DOM and UNSAFE_PortalProvider', () => { + let user; + + beforeAll(() => { + enableShadowDOM(); + user = userEvent.setup({delay: null, pointerMap}); + }); + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + act(() => { + jest.runAllTimers(); + }); + }); + + it('should handle interact outside events with UNSAFE_PortalProvider in shadow DOM', async () => { + const {shadowRoot, cleanup} = createShadowRoot(); + let interactOutsideTriggered = false; + + // Create portal container within the shadow DOM for the popover + const popoverPortal = document.createElement('div'); + popoverPortal.setAttribute('data-testid', 'popover-portal'); + shadowRoot.appendChild(popoverPortal); + + function ShadowInteractOutsideExample() { + const ref = useRef(); + useInteractOutside({ + ref, + onInteractOutside: () => { + interactOutsideTriggered = true; + } + }); + + return ( + shadowRoot}> +
+ {ReactDOM.createPortal( + <> +
+ + +
+ + , + popoverPortal + )} +
+
+ ); + } + + const {unmount} = render(); + + const target = shadowRoot.querySelector('[data-testid="target"]'); + const innerButton = shadowRoot.querySelector( + '[data-testid="inner-button"]' + ); + const outsideButton = shadowRoot.querySelector( + '[data-testid="outside-button"]' + ); + + // Click inside the target - should NOT trigger interact outside + await user.click(innerButton); + expect(interactOutsideTriggered).toBe(false); + + // Click the target itself - should NOT trigger interact outside + await user.click(target); + expect(interactOutsideTriggered).toBe(false); + + // Click outside the target within shadow DOM - should trigger interact outside + await user.click(outsideButton); + expect(interactOutsideTriggered).toBe(true); + + // Cleanup + unmount(); + cleanup(); + }); +}); diff --git a/packages/react-aria/test/overlays/useOverlay.test.js b/packages/react-aria/test/overlays/useOverlay.test.js index 58367686829..1d394b1a8aa 100644 --- a/packages/react-aria/test/overlays/useOverlay.test.js +++ b/packages/react-aria/test/overlays/useOverlay.test.js @@ -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 '../../src/utils/mergeProps'; import React, {useRef} from 'react'; +import ReactDOM from 'react-dom'; import {useOverlay} from '../../src/overlays/useOverlay'; function Example(props) { let ref = useRef(); let {overlayProps, underlayProps} = useOverlay(props, ref); return ( -
-
+
+
{props.children}
@@ -129,3 +142,78 @@ describe('useOverlay', function () { expect(onClose).toHaveBeenCalledTimes(1); }); }); + +describe('useOverlay with shadow dom', () => { + 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( + { + return target === underlay; + }} />, + shadowRoot + ); + + const {unmount} = render(); + + 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( + target !== underlay} />, + shadowRoot + ); + + const {unmount} = render(); + + underlay = shadowRoot.querySelector("[data-testid='underlay']"); + + pressStart(underlay); + pressEnd(underlay); + expect(onClose).not.toHaveBeenCalled(); + + // Cleanup + unmount(); + cleanup(); + }); + }); +}); diff --git a/packages/react-aria/test/overlays/usePopover.test.tsx b/packages/react-aria/test/overlays/usePopover.test.tsx index 4f3560d5f27..b97024634e3 100644 --- a/packages/react-aria/test/overlays/usePopover.test.tsx +++ b/packages/react-aria/test/overlays/usePopover.test.tsx @@ -10,11 +10,15 @@ * governing permissions and limitations under the License. */ -import {fireEvent, render} from '@react-spectrum/test-utils-internal'; +import {act, createShadowRoot, fireEvent, pointerMap, render} from '@react-spectrum/test-utils-internal'; +import {enableShadowDOM} from '@react-stately/flags'; import {OverlayTriggerProps, useOverlayTriggerState} from 'react-stately/useOverlayTriggerState'; import React, {useRef} from 'react'; +import ReactDOM from 'react-dom'; +import {UNSAFE_PortalProvider} from '../../src/overlays/PortalProvider'; import {useOverlayTrigger} from '../../src/overlays/useOverlayTrigger'; import {usePopover} from '../../src/overlays/usePopover'; +import userEvent from '@testing-library/user-event'; function Example(props: OverlayTriggerProps) { const triggerRef = useRef(null); @@ -40,3 +44,129 @@ describe('usePopover', () => { expect(onOpenChange).not.toHaveBeenCalled(); }); }); + +if (parseInt(React.version, 10) >= 17) { + describe('usePopover with Shadow DOM and UNSAFE_PortalProvider', () => { + let user; + + beforeAll(() => { + enableShadowDOM(); + user = userEvent.setup({delay: null, pointerMap}); + }); + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + act(() => { + jest.runAllTimers(); + }); + }); + + it('should handle popover interactions with UNSAFE_PortalProvider in shadow DOM', async () => { + const {shadowRoot} = createShadowRoot(); + let triggerClicked = false; + let popoverInteracted = false; + + const popoverPortal = document.createElement('div'); + popoverPortal.setAttribute('data-testid', 'popover-portal'); + shadowRoot.appendChild(popoverPortal); + + function ShadowPopoverExample() { + const triggerRef = useRef(null); + const popoverRef = useRef(null); + const state = useOverlayTriggerState({ + defaultOpen: false + }); + + useOverlayTrigger({type: 'listbox'}, state, triggerRef); + const {popoverProps} = usePopover( + { + triggerRef, + popoverRef, + placement: 'bottom start' + }, + state + ); + + return ( + shadowRoot as unknown as HTMLElement}> +
+ + {ReactDOM.createPortal( + <> + {state.isOpen && ( +
+ + +
+ )} + , + popoverPortal + )} + +
+
+ ); + } + + const {unmount} = render(); + + const trigger = document.body.querySelector('[data-testid="popover-trigger"]'); + + // Click trigger to open popover + await user.click(trigger); + expect(triggerClicked).toBe(true); + + // Verify popover opened in shadow DOM + const popoverContent = shadowRoot.querySelector('[data-testid="popover-content"]'); + expect(popoverContent).toBeInTheDocument(); + + // Interact with popover content + const popoverAction = shadowRoot.querySelector('[data-testid="popover-action"]'); + await user.click(popoverAction); + expect(popoverInteracted).toBe(true); + + // Popover should still be open after interaction + expect(shadowRoot.querySelector('[data-testid="popover-content"]')).toBeInTheDocument(); + + // Close popover + const closeButton = shadowRoot.querySelector('[data-testid="close-popover"]'); + await user.click(closeButton); + + // Wait for any cleanup + act(() => { + jest.runAllTimers(); + }); + + // Cleanup + unmount(); + document.body.removeChild(shadowRoot.host); + }); + }); +}