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..6dc42b1269f --- /dev/null +++ b/packages/@react-spectrum/s2/stories/ShadowDOM.stories.tsx @@ -0,0 +1,731 @@ +/* + * 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; + +/** + * Clone document stylesheets into a new div; each shadow root needs its own copy (appendChild moves + * nodes). + */ +function createClonedDocumentStyleRoot(): HTMLDivElement { + 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); + } + } + return styleRoot; +} + +/** + * Nested `createRoot` must not unmount synchronously during Storybook/parent React commit — defer + * to avoid "unmount while already rendering". + */ +function unmountRootDeferred(root: ReturnType): void { + queueMicrotask(() => { + root.unmount(); + }); +} + +function ShadowDOMContained() { + 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. + shadowRoot.appendChild(createClonedDocumentStyleRoot()); + + 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}> + + + + ); + + return () => { + rootRef.current = null; + portalContainerRef.current = null; + unmountRootDeferred(root); + }; + }, []); + + return
; +} + +function ShadowDOMPortalToBody() { + const hostRef = useRef(null); + const portalHostRef = useRef(null); + const portalContainerRef = useRef(null); + const rootRef = useRef | null>(null); + + useEffect(() => { + const host = hostRef.current; + const portalHost = portalHostRef.current; + if (!host || !portalHost) { + return; + } + + const shadowRoot = host.attachShadow({mode: 'open'}); + const shadowPortal = portalHost.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); + portalHost.setAttribute('data-color-scheme', scheme); + } + + // Each shadow root needs its own style clone — reusing one node only leaves styles in the last root. + shadowRoot.appendChild(createClonedDocumentStyleRoot()); + shadowPortal.appendChild(createClonedDocumentStyleRoot()); + + const appContainer = document.createElement('div'); + appContainer.id = 'shadow-app'; + shadowRoot.appendChild(appContainer); + + const portalContainer = document.createElement('div'); + portalContainer.id = 'shadow-portal'; + shadowPortal.appendChild(portalContainer); + portalContainerRef.current = portalContainer; + + const root = createRoot(appContainer); + rootRef.current = root; + root.render( + + portalContainerRef.current}> + + + + ); + + return () => { + rootRef.current = null; + portalContainerRef.current = null; + unmountRootDeferred(root); + }; + }, []); + + // Two light-DOM siblings, each with its own open shadow root: app vs portaled overlays. + return ( + <> +
+
+ + ); +} + +function AllComponents() { + return ( +
+

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 + + +
+
+ ); +} + +export const AllIn1Shadow: StoryObj = { + render: () => , + parameters: {} +}; + +export const MultipleShadows: 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-spectrum/s2/test/DateRangePicker.browser.test.tsx b/packages/@react-spectrum/s2/test/DateRangePicker.browser.test.tsx new file mode 100644 index 00000000000..4e248f15ea0 --- /dev/null +++ b/packages/@react-spectrum/s2/test/DateRangePicker.browser.test.tsx @@ -0,0 +1,90 @@ +/* + * Copyright 2026 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 '../src/page'; + +import {createRoot} from 'react-dom/client'; +import {DateRangePicker} from '../src/DateRangePicker'; +import {enableShadowDOM} from 'react-stately/private/flags/flags'; +import {expect, it, vi} from 'vitest'; +import {parseDate} from '@internationalized/date'; +import {Provider} from '../src/Provider'; +import React from 'react'; +import {UNSAFE_PortalProvider} from 'react-aria/PortalProvider'; +import {userEvent} from 'vitest/browser'; + +// Must be enabled before mounting. This flag is one-way and cannot be turned off. +enableShadowDOM(); + +// Firefox has a bug that leaks a focus event and causes another test to fail. +let isFirefox = /firefox/i.test(navigator.userAgent); + +it.skipIf(isFirefox)('DateRangePicker opens and selects a range inside a shadow root', async () => { + let onChange = vi.fn(); + + let host = document.createElement('div'); + document.body.appendChild(host); + let shadowRoot = host.attachShadow({mode: 'open'}); + let appContainer = document.createElement('div'); + shadowRoot.appendChild(appContainer); + // Portal the calendar overlay into the same shadow root (the real web-component scenario), + // rather than letting it default to the light-DOM document.body. + let portal = document.createElement('div'); + shadowRoot.appendChild(portal); + + // Match on the date portion of a day cell's aria-label so the query is robust to the weekday + // prefix / "selected" suffix. Cells live in the shadow root because of the portal above. + let findDay = (dateText: string) => + Array.from(shadowRoot.querySelectorAll('[role="button"]')).find(el => + el.getAttribute('aria-label')?.includes(dateText) + ) as HTMLElement | undefined; + + let root = createRoot(appContainer); + root.render( + + portal}> + {/* A fixed defaultValue pins the visible month to January 2024 so the test is + deterministic regardless of today's date. */} + + + + ); + + // Open the calendar via its trigger button. + await expect.poll(() => shadowRoot.querySelector('button[aria-label="Calendar"]')).not.toBeNull(); + let calendarButton = shadowRoot.querySelector( + 'button[aria-label="Calendar"]' + ) as HTMLButtonElement; + await userEvent.click(calendarButton); + + await expect.poll(() => shadowRoot.querySelector('[role="grid"]')).not.toBeNull(); + + // Select a new range: click the start day, then the end day (both in the visible January 2024). + await expect.poll(() => findDay('January 20, 2024')).toBeTruthy(); + await userEvent.click(findDay('January 20, 2024')!); + + await expect.poll(() => findDay('January 25, 2024')).toBeTruthy(); + await userEvent.click(findDay('January 25, 2024')!); + + // The newly selected range should be committed. + await expect.poll(() => onChange.mock.calls.length).toBeGreaterThan(0); + let selected = onChange.mock.calls.at(-1)![0]; + expect(selected.start.toString()).toBe('2024-01-20'); + expect(selected.end.toString()).toBe('2024-01-25'); + + root.unmount(); + document.body.removeChild(host); +}); diff --git a/packages/react-aria-components/test/RangeCalendar.shadow.test.tsx b/packages/react-aria-components/test/RangeCalendar.shadow.test.tsx new file mode 100644 index 00000000000..cab3807c057 --- /dev/null +++ b/packages/react-aria-components/test/RangeCalendar.shadow.test.tsx @@ -0,0 +1,275 @@ +/* + * Copyright 2026 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 { + act, + createShadowRoot, + fireEvent, + installPointerEvent, + render, + within +} from '@react-spectrum/test-utils-internal'; +import {Button} from '../src/Button'; +import {CalendarCell, CalendarGrid, CalendarHeading, RangeCalendar} from '../src/Calendar'; +import {CalendarDate} from '@internationalized/date'; +import {enableShadowDOM} from 'react-stately/private/flags/flags'; +import React from 'react'; + +let TestCalendar = props => ( + +
+ + + +
+ {date => } +
+); + +if (parseInt(React.version, 10) >= 17) { + describe('RangeCalendar shadow DOM', () => { + installPointerEvent(); + + beforeAll(() => { + enableShadowDOM(); + }); + + let pointerOpts = { + pointerType: 'mouse', + pointerId: 1, + width: 1, + height: 1, + detail: 1, + pressure: 0.5 + }; + let pointerClick = (element: Element) => { + fireEvent.pointerDown(element, pointerOpts); + fireEvent.pointerUp(element, pointerOpts); + fireEvent.click(element, {detail: 1}); + }; + + let renderInShadowRoot = (calendarProps = {}, attachTo?: HTMLElement) => { + let {shadowRoot, cleanup} = createShadowRoot(attachTo); + let container = document.createElement('div'); + shadowRoot.appendChild(container); + let onChange = jest.fn(); + render( + , + {container} + ); + + return { + onChange, + shadowRoot, + cleanup, + calendar: shadowRoot.querySelector('[role="application"]')!, + grid: shadowRoot.querySelector('[role="grid"]')! + }; + }; + + it('should support selecting a range by clicking two dates', () => { + let {grid, onChange, cleanup} = renderInShadowRoot(); + + let startCell = within(grid).getByText('17'); + pointerClick(startCell); + + expect(startCell).toHaveAttribute('data-selection-start', 'true'); + expect(startCell).toHaveAttribute('data-selection-end', 'true'); + expect(onChange).not.toHaveBeenCalled(); + + let endCell = within(grid).getByText('23'); + pointerClick(endCell); + + expect(startCell).toHaveAttribute('data-selection-start', 'true'); + expect(endCell).toHaveAttribute('data-selection-end', 'true'); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith({ + start: new CalendarDate(2019, 6, 17), + end: new CalendarDate(2019, 6, 23) + }); + + cleanup(); + }); + + it('should support selecting a range by dragging', () => { + let {grid, onChange, cleanup} = renderInShadowRoot(); + + fireEvent.pointerDown(within(grid).getByText('17'), pointerOpts); + fireEvent.pointerLeave(within(grid).getByText('17'), pointerOpts); + fireEvent.pointerEnter(within(grid).getByText('20'), pointerOpts); + fireEvent.pointerLeave(within(grid).getByText('20'), pointerOpts); + fireEvent.pointerEnter(within(grid).getByText('23'), pointerOpts); + expect(onChange).not.toHaveBeenCalled(); + + let endCell = within(grid).getByText('23'); + fireEvent.pointerUp(endCell, pointerOpts); + fireEvent.click(endCell, {detail: 1}); + + expect(within(grid).getByText('17')).toHaveAttribute('data-selection-start', 'true'); + expect(endCell).toHaveAttribute('data-selection-end', 'true'); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith({ + start: new CalendarDate(2019, 6, 17), + end: new CalendarDate(2019, 6, 23) + }); + + cleanup(); + }); + + it('should not commit the selection when pressing the month navigation buttons', () => { + let {calendar, grid, onChange, cleanup} = renderInShadowRoot(); + + pointerClick(within(grid).getByText('17')); + expect(onChange).not.toHaveBeenCalled(); + + pointerClick(within(calendar).getAllByRole('button', {name: /Next/i})[0]); + expect(onChange).not.toHaveBeenCalled(); + + pointerClick(within(grid).getByText('5')); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith({ + start: new CalendarDate(2019, 6, 17), + end: new CalendarDate(2019, 7, 5) + }); + + cleanup(); + }); + + it('should not clear the selection when clicking a date with commitBehavior="clear"', () => { + let {grid, onChange, cleanup} = renderInShadowRoot({ + commitBehavior: 'clear', + defaultValue: {start: new CalendarDate(2019, 6, 10), end: new CalendarDate(2019, 6, 20)} + }); + + let startCell = within(grid).getByText('17'); + pointerClick(startCell); + + expect(startCell).toHaveAttribute('data-selection-start', 'true'); + expect(onChange).not.toHaveBeenCalled(); + + pointerClick(within(grid).getByText('23')); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith({ + start: new CalendarDate(2019, 6, 17), + end: new CalendarDate(2019, 6, 23) + }); + + cleanup(); + }); + + it('should support selecting a range inside nested shadow roots', () => { + let outer = createShadowRoot(); + let wrapper = document.createElement('div'); + outer.shadowRoot.appendChild(wrapper); + let {grid, onChange, cleanup} = renderInShadowRoot({}, wrapper); + + pointerClick(within(grid).getByText('17')); + expect(onChange).not.toHaveBeenCalled(); + + pointerClick(within(grid).getByText('23')); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith({ + start: new CalendarDate(2019, 6, 17), + end: new CalendarDate(2019, 6, 23) + }); + + cleanup(); + outer.cleanup(); + }); + + it('should commit the selection when tabbing away mid selection', () => { + let {shadowRoot, grid, onChange, cleanup} = renderInShadowRoot(); + let outsideButton = document.createElement('button'); + document.body.appendChild(outsideButton); + + pointerClick(within(grid).getByText('17')); + fireEvent.pointerLeave(within(grid).getByText('17'), pointerOpts); + fireEvent.pointerEnter(within(grid).getByText('23'), pointerOpts); + expect(onChange).not.toHaveBeenCalled(); + + // userEvent's tab doesn't work in shadow, so fire the focus/blur events the browser + // would. The focused cell blurs with the outside button as relatedTarget, and the button + // takes focus. The blur path commits via relatedTarget rather than the pointerup target, + // so it must still resolve the outside control as outside the calendar across the boundary. + let focusedCell = shadowRoot.activeElement!; + fireEvent.keyDown(focusedCell, {key: 'Tab'}); + act(() => { + outsideButton.focus(); + }); + fireEvent.keyUp(outsideButton, {key: 'Tab'}); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith({ + start: new CalendarDate(2019, 6, 17), + end: new CalendarDate(2019, 6, 23) + }); + + document.body.removeChild(outsideButton); + cleanup(); + }); + + it('should commit the selection when releasing a drag outside the calendar', () => { + let {grid, onChange, cleanup} = renderInShadowRoot(); + + fireEvent.pointerDown(within(grid).getByText('17'), pointerOpts); + fireEvent.pointerLeave(within(grid).getByText('17'), pointerOpts); + fireEvent.pointerEnter(within(grid).getByText('23'), pointerOpts); + fireEvent.pointerLeave(within(grid).getByText('23'), pointerOpts); + expect(onChange).not.toHaveBeenCalled(); + + fireEvent.pointerUp(document.body, pointerOpts); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith({ + start: new CalendarDate(2019, 6, 17), + end: new CalendarDate(2019, 6, 23) + }); + + cleanup(); + }); + + it('should commit the selection when releasing a drag outside the calendar but inside the shadow root', () => { + let {shadowRoot, grid, onChange, cleanup} = renderInShadowRoot(); + let sibling = document.createElement('div'); + shadowRoot.appendChild(sibling); + + fireEvent.pointerDown(within(grid).getByText('17'), pointerOpts); + fireEvent.pointerLeave(within(grid).getByText('17'), pointerOpts); + fireEvent.pointerEnter(within(grid).getByText('23'), pointerOpts); + fireEvent.pointerLeave(within(grid).getByText('23'), pointerOpts); + expect(onChange).not.toHaveBeenCalled(); + + fireEvent.pointerUp(sibling, pointerOpts); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith({ + start: new CalendarDate(2019, 6, 17), + end: new CalendarDate(2019, 6, 23) + }); + + cleanup(); + }); + }); +} else { + describe('RangeCalendar shadow DOM', () => { + it('should not run tests in React 16, we do not support it anyways', () => { + expect(true).toBe(true); + }); + }); +} diff --git a/packages/react-aria-components/test/ShadowDOMFocus.browser.test.tsx b/packages/react-aria-components/test/ShadowDOMFocus.browser.test.tsx new file mode 100644 index 00000000000..45c67f0adb0 --- /dev/null +++ b/packages/react-aria-components/test/ShadowDOMFocus.browser.test.tsx @@ -0,0 +1,199 @@ +/* + * Copyright 2026 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. + */ + +// These tests exercise real component interactions inside a shadow root in a real browser, +// which jsdom cannot reproduce (focus events retarget to the shadow host). ComboBox and +// NumberField both use preventFocusOnPress on their trigger/stepper buttons, so pressing +// those controls relies on preventFocus() correctly handling shadow-DOM focus events. + +import {Button} from '../src/Button'; +import {ComboBox} from '../src/ComboBox'; +import {createRoot} from 'react-dom/client'; +import {enableShadowDOM} from 'react-stately/private/flags/flags'; +import {expect, it, vi} from 'vitest'; +import {Group} from '../src/Group'; +import {Input} from '../src/Input'; +import {Label} from '../src/Label'; +import {ListBox, ListBoxItem} from '../src/ListBox'; +import {Menu, MenuItem, MenuTrigger} from '../src/Menu'; +import {NumberField} from '../src/NumberField'; +import {Popover} from '../src/Popover'; +import React from 'react'; +import {UNSAFE_PortalProvider} from 'react-aria/PortalProvider'; +import {User} from '@react-aria/test-utils'; +import {userEvent} from 'vitest/browser'; + +enableShadowDOM(); + +// Firefox has a bug that leaks a focus event and causes another test to fail. +let isFirefox = /firefox/i.test(navigator.userAgent); + +function mountInShadow(ui: React.ReactElement) { + let host = document.createElement('div'); + document.body.appendChild(host); + let shadowRoot = host.attachShadow({mode: 'open'}); + let mountPoint = document.createElement('div'); + shadowRoot.appendChild(mountPoint); + let root = createRoot(mountPoint); + root.render(ui); + return { + host, + shadowRoot, + mountPoint, + cleanup: () => { + root.unmount(); + document.body.removeChild(host); + } + }; +} + +function TestComboBox() { + return ( + + + + + + + Cat + Dog + Kangaroo + + + + ); +} + +function TestNumberField() { + return ( + + + + + + + + + ); +} + +it.skipIf(isFirefox)( + 'ComboBox opens by clicking its trigger, keeps focus in the input, and selects an option inside a shadow root', + async () => { + let testUtilUser = new User(); + let {shadowRoot, mountPoint, cleanup} = mountInShadow(); + await expect.poll(() => mountPoint.querySelector('input')).not.toBeNull(); + + // Use the tester only to locate elements; drive interactions with real browser events so the + // native focus behavior (and shadow-DOM retargeting) is reproduced. (@react-aria/test-utils' + // user-event and vitest's browser userEvent differ for focus events.) + let comboboxTester = testUtilUser.createTester('ComboBox', {root: mountPoint}); + let input = comboboxTester.getCombobox() as HTMLInputElement; + let trigger = comboboxTester.getTrigger(); + + await userEvent.click(input); + await expect.poll(() => shadowRoot.activeElement).toBe(input); + + // Opening via the chevron should keep focus in the input, not move it to the button, and it + // should still work in shadow DOM. + await userEvent.click(trigger); + await expect.poll(() => comboboxTester.getListbox()).not.toBeNull(); + await expect.poll(() => shadowRoot.activeElement).toBe(input); + + // The listbox portals to the light DOM. + let dog = comboboxTester.getOptions().find(o => o.textContent === 'Dog')!; + await userEvent.click(dog); + + await expect.poll(() => comboboxTester.getListbox()).toBeNull(); + await expect.poll(() => input.value).toBe('Dog'); + + cleanup(); + } +); + +it.skipIf(isFirefox)( + 'NumberField keeps focus in the input while clicking the stepper inside a shadow root', + async () => { + let {shadowRoot, mountPoint, cleanup} = mountInShadow(); + await expect.poll(() => mountPoint.querySelector('input')).not.toBeNull(); + + let input = shadowRoot.querySelector('input') as HTMLInputElement; + let incrementButton = shadowRoot.querySelector('[slot="increment"]') as HTMLButtonElement; + + expect(input.value).toBe('0'); + + await userEvent.click(input); + await expect.poll(() => shadowRoot.activeElement).toBe(input); + + // Clicking the stepper must increment the value while keeping focus in the input so the user + // can keep editing (the stepper uses preventFocusOnPress). + await userEvent.click(incrementButton); + await expect.poll(() => input.value).toBe('1'); + await expect.poll(() => shadowRoot.activeElement).toBe(input); + + await userEvent.click(incrementButton); + await expect.poll(() => input.value).toBe('2'); + await expect.poll(() => shadowRoot.activeElement).toBe(input); + + cleanup(); + } +); + +it.skipIf(isFirefox)( + 'Menu opens from its trigger and fires onAction with the overlay portaled into the same shadow root', + async () => { + let host = document.createElement('div'); + document.body.appendChild(host); + let shadowRoot = host.attachShadow({mode: 'open'}); + let appContainer = document.createElement('div'); + shadowRoot.appendChild(appContainer); + // The overlay portals into a container inside the same shadow root via UNSAFE_PortalProvider. + let portal = document.createElement('div'); + shadowRoot.appendChild(portal); + + let onAction = vi.fn(); + function App() { + return ( + portal}> + + + + + New… + Open… + Save + + + + + ); + } + let root = createRoot(appContainer); + root.render(); + await expect.poll(() => appContainer.querySelector('button')).not.toBeNull(); + + // Opening via the trigger (preventFocusOnPress) must open the menu and keep it open so its + // items stay interactable inside the shadow root. + let button = appContainer.querySelector('button') as HTMLButtonElement; + await userEvent.click(button); + await expect.poll(() => shadowRoot.querySelector('[role="menu"]')).not.toBeNull(); + + let openItem = Array.from(shadowRoot.querySelectorAll('[role="menuitem"]')).find( + item => item.textContent?.trim() === 'Open…' + ) as HTMLElement; + await userEvent.click(openItem); + await expect(onAction).toHaveBeenCalledTimes(1); + + root.unmount(); + document.body.removeChild(host); + } +); diff --git a/packages/react-aria/src/calendar/useRangeCalendar.ts b/packages/react-aria/src/calendar/useRangeCalendar.ts index 7708c00c56d..6aa7a1e9e93 100644 --- a/packages/react-aria/src/calendar/useRangeCalendar.ts +++ b/packages/react-aria/src/calendar/useRangeCalendar.ts @@ -13,7 +13,7 @@ import {AriaLabelingProps, DOMProps, FocusableElement, RefObject} from '@react-types/shared'; import {CalendarAria, useCalendarBase} from './useCalendarBase'; import {DateValue, RangeCalendarState} from 'react-stately/useRangeCalendarState'; -import {isFocusWithin, nodeContains} from '../utils/shadowdom/DOMFunctions'; +import {getEventTarget, isFocusWithin, nodeContains} from '../utils/shadowdom/DOMFunctions'; import {RangeCalendarProps} from 'react-stately/useRangeCalendarState'; import {useEvent} from '../utils/useEvent'; import {useRef} from 'react'; @@ -76,7 +76,7 @@ export function useRangeCalendar( return; } - let target = e.target as Element; + let target = getEventTarget(e) as Element; if ( ref.current && isFocusWithin(ref.current) && diff --git a/packages/react-aria/src/combobox/useComboBox.ts b/packages/react-aria/src/combobox/useComboBox.ts index 4887ea689eb..1b2a0f063f7 100644 --- a/packages/react-aria/src/combobox/useComboBox.ts +++ b/packages/react-aria/src/combobox/useComboBox.ts @@ -267,8 +267,9 @@ export function useComboBox( }); 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 ede93c0ea50..21878b6833a 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'; @@ -125,21 +125,36 @@ export function preventFocus(target: FocusableElement | null): (() => void) | un } let ownerWindow = getOwnerWindow(target); - let activeElement = ownerWindow.document.activeElement as FocusableElement | null; + let activeElement = getActiveElement(ownerWindow.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. @@ -152,14 +167,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) { @@ -170,17 +185,17 @@ export function preventFocus(target: FocusableElement | null): (() => void) | un } }; - ownerWindow.addEventListener('blur', onBlur, true); - ownerWindow.addEventListener('focusout', onFocusOut, true); - ownerWindow.addEventListener('focusin', onFocusIn, true); - ownerWindow.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); - ownerWindow.removeEventListener('blur', onBlur, true); - ownerWindow.removeEventListener('focusout', onFocusOut, true); - ownerWindow.removeEventListener('focusin', onFocusIn, true); - ownerWindow.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 e5c3a50b3b2..e5ed0e1d3c4 100644 --- a/packages/react-aria/test/focus/FocusScope.test.js +++ b/packages/react-aria/test/focus/FocusScope.test.js @@ -27,6 +27,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'; @@ -2218,218 +2219,311 @@ 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( - - - - - , - shadowRoot - ); + it('should contain focus within the shadow DOM scope', async function () { + const {shadowRoot} = createShadowRoot(); + 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 input3 = shadowRoot.querySelector('[data-testid="input3"]'); + 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(); + // 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); }); - expect(document.activeElement).toBe(shadowRoot.host); - expect(shadowRoot.activeElement).toBe(input1); - // Simulate tabbing through inputs - await user.tab(); - expect(shadowRoot.activeElement).toBe(input2); + 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'}); - await user.tab(); - expect(shadowRoot.activeElement).toBe(input3); + const FocusableComponent = () => + ReactDOM.createPortal( + + + + , + childShadowRoot + ); - // Simulate tabbing back to the first input - await user.tab(); - expect(shadowRoot.activeElement).toBe(input1); + const {unmount} = render(); - // Cleanup - unmount(); - document.body.removeChild(shadowRoot.host); - }); + const input1 = childShadowRoot.querySelector('[data-testid=input1]'); + const input2 = childShadowRoot.querySelector('[data-testid=input2]'); - 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'}); + act(() => { + input1.focus(); + }); + expect(childShadowRoot.activeElement).toBe(input1); - const FocusableComponent = () => - ReactDOM.createPortal( - - - - , - childShadowRoot + await user.tab(); + expect(childShadowRoot.activeElement).toBe(input2); + + // Cleanup + unmount(); + document.body.removeChild(parentShadowRoot.host); + }); + + /** + * 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 = () => ( + <> + + + +
+ ); - const {unmount} = render(); + const {getByTestId} = render(); + const shadowHost = document.getElementById('shadow-host'); + const shadowRoot = shadowHost.attachShadow({mode: 'open'}); - const input1 = childShadowRoot.querySelector('[data-testid=input1]'); - const input2 = childShadowRoot.querySelector('[data-testid=input2]'); + const FocusableComponent = () => + ReactDOM.createPortal( + + + + + , + shadowRoot + ); - act(() => { - input1.focus(); + const {unmount} = render(); + + const input1 = shadowRoot.querySelector('[data-testid="input1"]'); + act(() => { + input1.focus(); + }); + expect(shadowRoot.activeElement).toBe(input1); + + const externalInput = getByTestId('outside'); + act(() => { + externalInput.focus(); + }); + expect(document.activeElement).toBe(externalInput); + + act(() => { + jest.runAllTimers(); + }); + + unmount(); + + expect(document.activeElement).toBe(externalInput); }); - expect(childShadowRoot.activeElement).toBe(input1); - await user.tab(); - expect(childShadowRoot.activeElement).toBe(input2); + /** + * 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(); - // Cleanup - unmount(); - document.body.removeChild(parentShadowRoot.host); - }); + const FocusableComponent = () => + ReactDOM.createPortal( + + + + + , + shadowRoot + ); - /** - * 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 = () => ( - <> - - - -
- - ); + const {unmount} = render(); - const {getByTestId} = render(); - const shadowHost = document.getElementById('shadow-host'); - const shadowRoot = shadowHost.attachShadow({mode: 'open'}); + const input1 = shadowRoot.querySelector('[data-testid="input1"]'); + const input2 = shadowRoot.querySelector('[data-testid="input2"]'); + const button = shadowRoot.querySelector('[data-testid="button"]'); - const FocusableComponent = () => - ReactDOM.createPortal( - - - - - , - shadowRoot - ); + // 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); - const input1 = shadowRoot.querySelector('[data-testid="input1"]'); - act(() => { - input1.focus(); - }); - expect(shadowRoot.activeElement).toBe(input1); + // Hit TAB key + await user.tab(); + expect(shadowRoot.activeElement).toBe(button); - const externalInput = getByTestId('outside'); - act(() => { - externalInput.focus(); - }); - expect(document.activeElement).toBe(externalInput); + // Simulate tab again to check if focus loops back to the first input + await user.tab(); + expect(shadowRoot.activeElement).toBe(input1); - act(() => { - jest.runAllTimers(); + // Cleanup + unmount(); + document.body.removeChild(shadowHost); }); - unmount(); + it('should handle web component scenario with multiple nested portals and UNSAFE_PortalProvider', async function () { + const {shadowRoot, cleanup} = createShadowRoot(); - expect(document.activeElement).toBe(externalInput); - }); + // Create nested portal containers within the shadow DOM + const modalPortal = document.createElement('div'); + modalPortal.setAttribute('data-testid', 'modal-portal'); + shadowRoot.appendChild(modalPortal); - /** - * 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 tooltipPortal = document.createElement('div'); + tooltipPortal.setAttribute('data-testid', 'tooltip-portal'); + shadowRoot.appendChild(tooltipPortal); - const FocusableComponent = () => - ReactDOM.createPortal( - - - - - , - shadowRoot - ); + function ComplexWebComponent() { + const [showModal, setShowModal] = React.useState(true); + const [showTooltip] = React.useState(true); - const {unmount} = render(); + return ( + shadowRoot}> +
+ - const input1 = shadowRoot.querySelector('[data-testid="input1"]'); - const input2 = shadowRoot.querySelector('[data-testid="input2"]'); - const button = shadowRoot.querySelector('[data-testid="button"]'); + {/* Modal with its own focus scope */} + {showModal && + ReactDOM.createPortal( + +
+ + + +
+
, + modalPortal + )} - // Simulate focusing the first input and tab through the elements - act(() => { - input1.focus(); - }); - expect(shadowRoot.activeElement).toBe(input1); + {/* Tooltip with nested focus scope */} + {showTooltip && + ReactDOM.createPortal( + +
+ +
+
, + tooltipPortal + )} +
+
+ ); + } - // Hit TAB key - await user.tab(); - expect(shadowRoot.activeElement).toBe(input2); + const {unmount} = render(); - // Hit TAB key - await user.tab(); - expect(shadowRoot.activeElement).toBe(button); + 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"]'); - // Simulate tab again to check if focus loops back to the first input - await user.tab(); - expect(shadowRoot.activeElement).toBe(input1); + // Due to autoFocus, the first modal button should be focused + act(() => { + jest.runAllTimers(); + }); + expect(shadowRoot.activeElement).toBe(modalButton1); - // Cleanup - unmount(); - document.body.removeChild(shadowHost); - }); -}); + // Tab navigation should work within the modal + await user.tab(); + expect(shadowRoot.activeElement).toBe(modalButton2); -describe('Unmounting cleanup', () => { - beforeAll(() => { - jest.useFakeTimers(); - }); - afterAll(() => { - jest.runAllTimers(); + // 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.shadow.test.js b/packages/react-aria/test/interactions/useInteractOutside.shadow.test.js new file mode 100644 index 00000000000..a14d0fd0a54 --- /dev/null +++ b/packages/react-aria/test/interactions/useInteractOutside.shadow.test.js @@ -0,0 +1,247 @@ +/* + * Copyright 2020 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 { + act, + createShadowRoot, + fireEvent, + pointerMap, + render +} from '@react-spectrum/test-utils-internal'; +import {enableShadowDOM} from '@react-stately/flags'; +import React, {useEffect, useRef} from 'react'; +import ReactDOM from 'react-dom'; +import {UNSAFE_PortalProvider} from '../../src/overlays/PortalProvider'; +import {useInteractOutside} from '../../src/interactions/useInteractOutside'; +import userEvent from '@testing-library/user-event'; + +describe('useInteractOutside shadow DOM', function () { + // Helper function to create a shadow root and render the component inside it + function createShadowRootAndRender(ui) { + const shadowHost = document.createElement('div'); + document.body.appendChild(shadowHost); + const shadowRoot = shadowHost.attachShadow({mode: 'open'}); + + function WrapperComponent() { + return ReactDOM.createPortal(ui, shadowRoot); + } + + render(); + return {shadowRoot, cleanup: () => document.body.removeChild(shadowHost)}; + } + + function App({onInteractOutside}) { + const ref = useRef(null); + useInteractOutside({ref, onInteractOutside}); + + return ( +
+
+
+
+
+
+ ); + } + + it('does not trigger when clicking inside popover', function () { + const onInteractOutside = jest.fn(); + const {shadowRoot, cleanup} = createShadowRootAndRender( + + ); + + const insidePopover = shadowRoot.getElementById('inside-popover'); + fireEvent.mouseDown(insidePopover); + fireEvent.mouseUp(insidePopover); + + expect(onInteractOutside).not.toHaveBeenCalled(); + cleanup(); + }); + + it('does not trigger when clicking the popover', function () { + const onInteractOutside = jest.fn(); + const {shadowRoot, cleanup} = createShadowRootAndRender( + + ); + + const popover = shadowRoot.getElementById('popover'); + fireEvent.mouseDown(popover); + fireEvent.mouseUp(popover); + + expect(onInteractOutside).not.toHaveBeenCalled(); + cleanup(); + }); + + it('triggers when clicking outside the popover', function () { + const onInteractOutside = jest.fn(); + const {cleanup} = createShadowRootAndRender(); + + // Clicking on the document body outside the shadow DOM + fireEvent.mouseDown(document.body); + fireEvent.mouseUp(document.body); + + expect(onInteractOutside).toHaveBeenCalledTimes(1); + cleanup(); + }); + + it('triggers when clicking a button outside the shadow dom altogether', function () { + const onInteractOutside = jest.fn(); + const {cleanup} = createShadowRootAndRender(); + // Button outside shadow DOM and component + const button = document.createElement('button'); + document.body.appendChild(button); + + fireEvent.mouseDown(button); + fireEvent.mouseUp(button); + + expect(onInteractOutside).toHaveBeenCalledTimes(1); + document.body.removeChild(button); + cleanup(); + }); +}); + +describe('useInteractOutside shadow DOM extended tests', function () { + // Setup function similar to previous tests, but includes a dynamic element scenario + function createShadowRootAndRender(ui) { + const shadowHost = document.createElement('div'); + document.body.appendChild(shadowHost); + const shadowRoot = shadowHost.attachShadow({mode: 'open'}); + + function WrapperComponent() { + return ReactDOM.createPortal(ui, shadowRoot); + } + + render(); + return {shadowRoot, cleanup: () => document.body.removeChild(shadowHost)}; + } + + function App({onInteractOutside, includeDynamicElement = false}) { + const ref = useRef(null); + useInteractOutside({ref, onInteractOutside}); + + useEffect(() => { + if (includeDynamicElement) { + const dynamicEl = document.createElement('div'); + dynamicEl.id = 'dynamic-outside'; + document.body.appendChild(dynamicEl); + + return () => document.body.removeChild(dynamicEl); + } + }, [includeDynamicElement]); + + return ( +
+
+
+
+
+
+ ); + } + + it('correctly identifies interaction with dynamically added external elements', function () { + jest.useFakeTimers(); + const onInteractOutside = jest.fn(); + const {cleanup} = createShadowRootAndRender( + + ); + + const dynamicEl = document.getElementById('dynamic-outside'); + fireEvent.mouseDown(dynamicEl); + fireEvent.mouseUp(dynamicEl); + + expect(onInteractOutside).toHaveBeenCalledTimes(1); + + 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/interactions/useInteractOutside.test.js b/packages/react-aria/test/interactions/useInteractOutside.test.js index 4c50ad95ffa..c2eb0ba8802 100644 --- a/packages/react-aria/test/interactions/useInteractOutside.test.js +++ b/packages/react-aria/test/interactions/useInteractOutside.test.js @@ -10,9 +10,9 @@ * governing permissions and limitations under the License. */ +import {createPortal} from 'react-dom'; import {fireEvent, installPointerEvent, render, waitFor} from '@react-spectrum/test-utils-internal'; -import React, {useEffect, useRef} from 'react'; -import ReactDOM, {createPortal} from 'react-dom'; +import React, {useRef} from 'react'; import {useInteractOutside} from '../../src/interactions/useInteractOutside'; function Example(props) { @@ -444,144 +444,3 @@ describe('useInteractOutside (iframes)', function () { }); }); }); - -describe('useInteractOutside shadow DOM', function () { - // Helper function to create a shadow root and render the component inside it - function createShadowRootAndRender(ui) { - const shadowHost = document.createElement('div'); - document.body.appendChild(shadowHost); - const shadowRoot = shadowHost.attachShadow({mode: 'open'}); - - function WrapperComponent() { - return ReactDOM.createPortal(ui, shadowRoot); - } - - render(); - return {shadowRoot, cleanup: () => document.body.removeChild(shadowHost)}; - } - - function App({onInteractOutside}) { - const ref = useRef(null); - useInteractOutside({ref, onInteractOutside}); - - return ( -
-
-
-
-
-
- ); - } - - it('does not trigger when clicking inside popover', function () { - const onInteractOutside = jest.fn(); - const {shadowRoot, cleanup} = createShadowRootAndRender( - - ); - - const insidePopover = shadowRoot.getElementById('inside-popover'); - fireEvent.mouseDown(insidePopover); - fireEvent.mouseUp(insidePopover); - - expect(onInteractOutside).not.toHaveBeenCalled(); - cleanup(); - }); - - it('does not trigger when clicking the popover', function () { - const onInteractOutside = jest.fn(); - const {shadowRoot, cleanup} = createShadowRootAndRender( - - ); - - const popover = shadowRoot.getElementById('popover'); - fireEvent.mouseDown(popover); - fireEvent.mouseUp(popover); - - expect(onInteractOutside).not.toHaveBeenCalled(); - cleanup(); - }); - - it('triggers when clicking outside the popover', function () { - const onInteractOutside = jest.fn(); - const {cleanup} = createShadowRootAndRender(); - - // Clicking on the document body outside the shadow DOM - fireEvent.mouseDown(document.body); - fireEvent.mouseUp(document.body); - - expect(onInteractOutside).toHaveBeenCalledTimes(1); - cleanup(); - }); - - it('triggers when clicking a button outside the shadow dom altogether', function () { - const onInteractOutside = jest.fn(); - const {cleanup} = createShadowRootAndRender(); - // Button outside shadow DOM and component - const button = document.createElement('button'); - document.body.appendChild(button); - - fireEvent.mouseDown(button); - fireEvent.mouseUp(button); - - expect(onInteractOutside).toHaveBeenCalledTimes(1); - document.body.removeChild(button); - cleanup(); - }); -}); - -describe('useInteractOutside shadow DOM extended tests', function () { - // Setup function similar to previous tests, but includes a dynamic element scenario - function createShadowRootAndRender(ui) { - const shadowHost = document.createElement('div'); - document.body.appendChild(shadowHost); - const shadowRoot = shadowHost.attachShadow({mode: 'open'}); - - function WrapperComponent() { - return ReactDOM.createPortal(ui, shadowRoot); - } - - render(); - return {shadowRoot, cleanup: () => document.body.removeChild(shadowHost)}; - } - - function App({onInteractOutside, includeDynamicElement = false}) { - const ref = useRef(null); - useInteractOutside({ref, onInteractOutside}); - - useEffect(() => { - if (includeDynamicElement) { - const dynamicEl = document.createElement('div'); - dynamicEl.id = 'dynamic-outside'; - document.body.appendChild(dynamicEl); - - return () => document.body.removeChild(dynamicEl); - } - }, [includeDynamicElement]); - - return ( -
-
-
-
-
-
- ); - } - - it('correctly identifies interaction with dynamically added external elements', function () { - jest.useFakeTimers(); - const onInteractOutside = jest.fn(); - const {cleanup} = createShadowRootAndRender( - - ); - - const dynamicEl = document.getElementById('dynamic-outside'); - fireEvent.mouseDown(dynamicEl); - fireEvent.mouseUp(dynamicEl); - - expect(onInteractOutside).toHaveBeenCalledTimes(1); - - cleanup(); - }); -}); diff --git a/packages/react-aria/test/overlays/useOverlay.shadow.test.js b/packages/react-aria/test/overlays/useOverlay.shadow.test.js new file mode 100644 index 00000000000..440f127e9e0 --- /dev/null +++ b/packages/react-aria/test/overlays/useOverlay.shadow.test.js @@ -0,0 +1,116 @@ +/* + * Copyright 2020 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 { + 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} +
+
+ ); +} + +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 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.shadow.test.tsx b/packages/react-aria/test/overlays/usePopover.shadow.test.tsx new file mode 100644 index 00000000000..db59756f9c2 --- /dev/null +++ b/packages/react-aria/test/overlays/usePopover.shadow.test.tsx @@ -0,0 +1,153 @@ +/* + * 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 {act, createShadowRoot, pointerMap, render} from '@react-spectrum/test-utils-internal'; +import {enableShadowDOM} from '@react-stately/flags'; +import React, {useRef} from 'react'; +import ReactDOM from 'react-dom'; +import {UNSAFE_PortalProvider} from '../../src/overlays/PortalProvider'; +import {useOverlayTrigger} from '../../src/overlays/useOverlayTrigger'; +import {useOverlayTriggerState} from 'react-stately/useOverlayTriggerState'; +import {usePopover} from '../../src/overlays/usePopover'; +import userEvent from '@testing-library/user-event'; + +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); + }); + }); +} else { + // Jest requires there be at least one test in the suite + describe('empty test', () => { + it('should pass', () => { + expect(true).toBe(true); + }); + }); +}