diff --git a/packages/@react-aria/combobox/package.json b/packages/@react-aria/combobox/package.json index ba86abc89db..9adc131c993 100644 --- a/packages/@react-aria/combobox/package.json +++ b/packages/@react-aria/combobox/package.json @@ -28,6 +28,7 @@ "dependencies": { "@react-aria/focus": "^3.21.4", "@react-aria/i18n": "^3.12.15", + "@react-aria/interactions": "^3.27.0", "@react-aria/listbox": "^3.15.2", "@react-aria/live-announcer": "^3.4.4", "@react-aria/menu": "^3.20.0", diff --git a/packages/@react-aria/combobox/src/useComboBox.ts b/packages/@react-aria/combobox/src/useComboBox.ts index ba165958ced..c7950d63345 100644 --- a/packages/@react-aria/combobox/src/useComboBox.ts +++ b/packages/@react-aria/combobox/src/useComboBox.ts @@ -25,6 +25,7 @@ import {getChildNodes, getItemCount} from '@react-stately/collections'; import intlMessages from '../intl/*.json'; import {ListKeyboardDelegate, useSelectableCollection} from '@react-aria/selection'; import {privateValidationStateProp} from '@react-stately/form'; +import {useInteractOutside} from '@react-aria/interactions'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useMenuTrigger} from '@react-aria/menu'; import {useTextField} from '@react-aria/textfield'; @@ -182,8 +183,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; @@ -225,7 +227,7 @@ export function useComboBox(props: AriaCo }, inputRef); useFormReset(inputRef, state.defaultValue, state.setValue); - + // Press handlers for the ComboBox button let onPress = (e: PressEvent) => { if (e.pointerType === 'touch') { @@ -365,6 +367,19 @@ export function useComboBox(props: AriaCo state.close(); } : undefined); + // usePopover -> useOverlay calls useInteractOutside, but ComboBox is non-modal, so `isDismissable` is false + // Because of this, onInteractOutside is not passed to useInteractOutside, so we need to call it here. + useInteractOutside({ + ref: popoverRef, + onInteractOutside: (e) => { + if (nodeContains(buttonRef?.current, getEventTarget(e) as Element)) { + return; + } + state.close(); + }, + isDisabled: !state.isOpen + }); + return { labelProps, buttonProps: { diff --git a/packages/@react-aria/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index 38648c12635..855eecd8e4f 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -20,6 +20,7 @@ import {Provider} from '@react-spectrum/provider'; import React, {useEffect, useState} from 'react'; import ReactDOM from 'react-dom'; import {Example as StorybookExample} from '../stories/FocusScope.stories'; +import {UNSAFE_PortalProvider} from '@react-aria/overlays'; import {useEvent} from '@react-aria/utils'; import userEvent from '@testing-library/user-event'; @@ -2004,199 +2005,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/interactions/src/utils.ts b/packages/@react-aria/interactions/src/utils.ts index 10eeca42bf5..c82a61122a8 100644 --- a/packages/@react-aria/interactions/src/utils.ts +++ b/packages/@react-aria/interactions/src/utils.ts @@ -11,7 +11,7 @@ */ import {FocusableElement} from '@react-types/shared'; -import {focusWithoutScrolling, getActiveElement, getEventTarget, getOwnerWindow, isFocusable, useLayoutEffect} from '@react-aria/utils'; +import {focusWithoutScrolling, getActiveElement, getEventTarget, getOwnerWindow, isFocusable, isShadowRoot, nodeContains, useLayoutEffect} from '@react-aria/utils'; import {FocusEvent as ReactFocusEvent, SyntheticEvent, useCallback, useRef} from 'react'; // Turn a native event into a React synthetic event. @@ -110,21 +110,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. @@ -137,14 +155,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) { @@ -155,17 +173,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/interactions/test/useInteractOutside.test.js b/packages/@react-aria/interactions/test/useInteractOutside.test.js index cdc2aa07a40..4feaa1ab4f5 100644 --- a/packages/@react-aria/interactions/test/useInteractOutside.test.js +++ b/packages/@react-aria/interactions/test/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 '@react-aria/overlays'; import {useInteractOutside} from '../'; +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/overlays/test/useOverlay.test.js b/packages/@react-aria/overlays/test/useOverlay.test.js index 2f686f0f287..f0c34f7b3c9 100644 --- a/packages/@react-aria/overlays/test/useOverlay.test.js +++ b/packages/@react-aria/overlays/test/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 '@react-aria/utils'; import React, {useRef} from 'react'; +import ReactDOM from 'react-dom'; import {useOverlay} from '../'; function Example(props) { let ref = useRef(); let {overlayProps, underlayProps} = useOverlay(props, ref); return ( -
-
+
+
{props.children}
@@ -140,3 +153,78 @@ describe('useOverlay', function () { }); }); }); + +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/overlays/test/usePopover.test.tsx b/packages/@react-aria/overlays/test/usePopover.test.tsx index 1b65f9edf23..1564a1a36e4 100644 --- a/packages/@react-aria/overlays/test/usePopover.test.tsx +++ b/packages/@react-aria/overlays/test/usePopover.test.tsx @@ -10,10 +10,13 @@ * 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 {type OverlayTriggerProps, useOverlayTriggerState} from '@react-stately/overlays'; import React, {useRef} from 'react'; -import {useOverlayTrigger, usePopover} from '../'; +import ReactDOM from 'react-dom'; +import {UNSAFE_PortalProvider, useOverlayTrigger, usePopover} from '../'; +import userEvent from '@testing-library/user-event'; function Example(props: OverlayTriggerProps) { const triggerRef = useRef(null); @@ -39,3 +42,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); + }); + }); +} diff --git a/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx b/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx index 3514cdee326..647fc8d49c0 100644 --- a/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx +++ b/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {Avatar, Button, ComboBox, ComboBoxItem, ComboBoxSection, Content, ContextualHelp, Footer, Form, Header, Heading, Link, Text} from '../src'; +import {Avatar, Button, ComboBox, ComboBoxItem, ComboBoxSection, Content, ContextualHelp, Dialog, DialogTrigger, Footer, Form, Header, Heading, Link, Text} from '../src'; import {categorizeArgTypes, getActionArgs} from './utils'; import {ComboBoxProps} from 'react-aria-components'; import DeviceDesktopIcon from '../s2wf-icons/S2_Icon_DeviceDesktop_20_N.svg'; @@ -362,3 +362,25 @@ export function WithCreateOption() { ); } + +export const ComboboxInsideDialog: Story = { + render: (args) => ( + + + + Combo Box in a Dialog + + + Aardvark + Cat + Dog + Kangaroo + Panda + Snake + + + + + ), + args: Example.args +}; 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..5682199a6b5 --- /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 '../src'; +import {action} from '@storybook/addon-actions'; +import AlertNotice from '../spectrum-illustrations/linear/AlertNotice'; +import {CardViewProps} from '@react-types/card'; +import {createRoot} from 'react-dom/client'; +import {enableShadowDOM} from '@react-stately/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-spectrum/s2/test/Combobox.test.tsx b/packages/@react-spectrum/s2/test/Combobox.test.tsx index e90c8119a0a..10fd6dbb869 100644 --- a/packages/@react-spectrum/s2/test/Combobox.test.tsx +++ b/packages/@react-spectrum/s2/test/Combobox.test.tsx @@ -11,9 +11,9 @@ */ jest.mock('@react-aria/live-announcer'); -import {act, pointerMap, render, setupIntersectionObserverMock, within} from '@react-spectrum/test-utils-internal'; +import {act, fireEvent, pointerMap, render, setupIntersectionObserverMock, within} from '@react-spectrum/test-utils-internal'; import {announce} from '@react-aria/live-announcer'; -import {ComboBox, ComboBoxItem, Content, ContextualHelp, Heading, Text} from '../src'; +import {Button, ComboBox, ComboBoxItem, Content, ContextualHelp, Dialog, DialogTrigger, Heading, Text} from '../src'; import React from 'react'; import {User} from '@react-aria/test-utils'; import userEvent from '@testing-library/user-event'; @@ -213,4 +213,55 @@ describe('Combobox', () => { expect(tree.getAllByText('Contents')[1]).toBeVisible(); warn.mockRestore(); }); + + it('should close the combobox when clicking outside the combobox on a dialog backdrop', async () => { + let tree = render( + + + + Combo Box in a Dialog + + + Aardvark + Cat + Dog + Kangaroo + Panda + Snake + + + + + ); + + let dialogTester = testUtilUser.createTester('Dialog', {root: tree.container, interactionType: 'mouse'}); + await dialogTester.open(); + expect(dialogTester.dialog).toBeVisible(); + act(() => { + jest.runAllTimers(); + }); + let comboboxTester = testUtilUser.createTester('ComboBox', {root: dialogTester.dialog!, interactionType: 'mouse'}); + await comboboxTester.open(); + + expect(comboboxTester.listbox).toBeVisible(); + act(() => { + jest.runAllTimers(); + }); + let backdrop = document.querySelector('[style*="--visual-viewport-height"]'); + // can't use userEvent here for some reason + fireEvent.mouseDown(backdrop!, {button: 0}); + fireEvent.mouseUp(backdrop!, {button: 0}); + act(() => { + jest.runAllTimers(); + }); + expect(comboboxTester.listbox).toBeNull(); + + + fireEvent.mouseDown(backdrop!, {button: 0}); + fireEvent.mouseUp(backdrop!, {button: 0}); + act(() => { + jest.runAllTimers(); + }); + expect(dialogTester.dialog).toBeNull(); + }); }); diff --git a/packages/react-aria-components/test/Popover.test.js b/packages/react-aria-components/test/Popover.test.js index 957468b0f24..61dc1a1449a 100644 --- a/packages/react-aria-components/test/Popover.test.js +++ b/packages/react-aria-components/test/Popover.test.js @@ -10,9 +10,11 @@ * governing permissions and limitations under the License. */ -import {act, pointerMap, render} from '@react-spectrum/test-utils-internal'; -import {Button, Dialog, DialogTrigger, OverlayArrow, Popover, Pressable} from '../'; +import {act, createShadowRoot, fireEvent, pointerMap, render} from '@react-spectrum/test-utils-internal'; +import {Button, Dialog, DialogTrigger, Menu, MenuItem, MenuTrigger, OverlayArrow, Popover, Pressable} from '../'; +import {enableShadowDOM} from '@react-stately/flags'; import React, {useRef} from 'react'; +import {screen} from 'shadow-dom-testing-library'; import {UNSAFE_PortalProvider} from '@react-aria/overlays'; import userEvent from '@testing-library/user-event'; @@ -281,4 +283,123 @@ describe('Popover', () => { let dialog = getByRole('dialog'); expect(dialog).toBeInTheDocument(); }); + + // how does this test pass?? it should fail because we don't have the shadow dom flag enabled, also shouldn't be + // able to click the button just like in the other describe block + it.skip('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/yarn.lock b/yarn.lock index eb08c077e2c..27e146577ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5539,6 +5539,7 @@ __metadata: dependencies: "@react-aria/focus": "npm:^3.21.4" "@react-aria/i18n": "npm:^3.12.15" + "@react-aria/interactions": "npm:^3.27.0" "@react-aria/listbox": "npm:^3.15.2" "@react-aria/live-announcer": "npm:^3.4.4" "@react-aria/menu": "npm:^3.20.0"