diff --git a/packages/react-aria-components/test/ComboBox.browser.test.tsx b/packages/react-aria-components/test/ComboBox.browser.test.tsx index 9eeae701923..60f3f7e1adf 100644 --- a/packages/react-aria-components/test/ComboBox.browser.test.tsx +++ b/packages/react-aria-components/test/ComboBox.browser.test.tsx @@ -26,7 +26,10 @@ import {Label} from '../src/Label'; import {ListBox, ListBoxItem} from '../src/ListBox'; 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 '@testing-library/user-event'; +import {within} from '@testing-library/dom'; function TestComboBox() { return ( @@ -119,4 +122,74 @@ describe('Shadow DOM', () => { root.unmount(); document.body.removeChild(outerHost); }); + + it.each` + interactionType + ${'mouse'} + ${'keyboard'} + `('selects an option via $interactionType', async ({interactionType}) => { + const host = document.createElement('div'); + const shadowRoot = host.attachShadow({mode: 'open'}); + const appContainer = document.createElement('div'); + shadowRoot.appendChild(appContainer); + document.body.appendChild(host); + const portalHost = document.createElement('div'); + const shadowPortal = portalHost.attachShadow({mode: 'open'}); + const portalContainer = document.createElement('div'); + shadowPortal.appendChild(portalContainer); + const button = document.createElement('button'); + shadowPortal.appendChild(button); + document.body.appendChild(portalHost); + const root = createRoot(appContainer); + + root.render( + portalContainer}> + + + ); + + await new Promise(resolve => setTimeout(resolve, 100)); + const trigger = shadowRoot.querySelector('button')!; + await open(); + const listbox = getListbox(); + expect(listbox).not.toBeNull(); + + if (interactionType === 'keyboard') { + await userEvent.keyboard('{ArrowDown}'); + await userEvent.keyboard('[Enter]'); + } else if (interactionType === 'mouse') { + const option = within(listbox!).queryAllByRole('option')[1]; + await userEvent.click(option); + } + + await new Promise(resolve => setTimeout(resolve, 100)); + expect(getListbox()).toBeNull(); + expect(getCombobox()).toHaveValue('Dog'); + await open(); + expect(getListbox()).not.toBeNull(); + await userEvent.click(button); + expect(getListbox()).toBeNull(); + root.unmount(); + document.body.removeChild(host); + document.body.removeChild(portalHost); + + async function open() { + if (interactionType === 'mouse') { + await userEvent.click(trigger); + } else if (interactionType === 'keyboard') { + // oxlint-disable-next-line rsp-rules/act-events-test + getCombobox()?.focus(); + await userEvent.keyboard('{ArrowDown}'); + } + } + + function getCombobox() { + return shadowRoot.querySelector('[role="combobox"]'); + } + + function getListbox() { + const listBoxId = getCombobox()?.getAttribute('aria-controls'); + return listBoxId ? shadowPortal.getElementById(listBoxId) || null : null; + } + }); }); diff --git a/packages/react-aria/src/combobox/useComboBox.ts b/packages/react-aria/src/combobox/useComboBox.ts index e8d688e8104..9d9dc6f7b96 100644 --- a/packages/react-aria/src/combobox/useComboBox.ts +++ b/packages/react-aria/src/combobox/useComboBox.ts @@ -28,7 +28,7 @@ import { import {AriaListBoxOptions} from '../listbox/useListBox'; import {chain} from '../utils/chain'; import {ComboBoxProps, ComboBoxState, SelectionMode} from 'react-stately/useComboBoxState'; -import {dispatchVirtualFocus} from '../focus/virtualFocus'; +import {dispatchVirtualFocus, getOriginalRelatedTarget} from '../focus/virtualFocus'; import { ElementType, FocusEvent, @@ -268,7 +268,10 @@ export function useComboBox( let onBlur = (e: FocusEvent) => { let blurFromButton = nodeContains(buttonRef.current, e.relatedTarget as Element); - let blurIntoPopover = nodeContains(popoverRef.current, e.relatedTarget); + let blurIntoPopover = nodeContains( + popoverRef.current, + getOriginalRelatedTarget(e.nativeEvent) as Element + ); // Ignore blur if focused moved to the button(if exists) or into the popover. if (blurFromButton || blurIntoPopover) { diff --git a/packages/react-aria/src/focus/virtualFocus.ts b/packages/react-aria/src/focus/virtualFocus.ts index a50248a1f82..6bf79880845 100644 --- a/packages/react-aria/src/focus/virtualFocus.ts +++ b/packages/react-aria/src/focus/virtualFocus.ts @@ -1,5 +1,6 @@ import {getActiveElement} from '../utils/shadowdom/DOMFunctions'; import {getOwnerDocument} from '../utils/domHelpers'; +import {shadowDOM} from 'react-stately/private/flags/flags'; export function moveVirtualFocus(to: Element | null): void { let from = getVirtuallyFocusedElement(getOwnerDocument(to)); @@ -14,13 +15,13 @@ export function moveVirtualFocus(to: Element | null): void { } export function dispatchVirtualBlur(from: Element, to: Element | null): void { - from.dispatchEvent(new FocusEvent('blur', {relatedTarget: to})); - from.dispatchEvent(new FocusEvent('focusout', {bubbles: true, relatedTarget: to})); + from.dispatchEvent(createFocusEvent('blur', {relatedTarget: to})); + from.dispatchEvent(createFocusEvent('focusout', {bubbles: true, relatedTarget: to})); } export function dispatchVirtualFocus(to: Element, from: Element | null): void { - to.dispatchEvent(new FocusEvent('focus', {relatedTarget: from})); - to.dispatchEvent(new FocusEvent('focusin', {bubbles: true, relatedTarget: from})); + to.dispatchEvent(createFocusEvent('focus', {relatedTarget: from})); + to.dispatchEvent(createFocusEvent('focusin', {bubbles: true, relatedTarget: from})); } export function getVirtuallyFocusedElement(document: Document): Element | null { @@ -32,3 +33,23 @@ export function getVirtuallyFocusedElement(document: Document): Element | null { return activeElement; } + +let originalRelatedTargetMap: WeakMap | null = null; +function createFocusEvent(type: string, eventInitDict?: FocusEventInit) { + const event = new FocusEvent(type, eventInitDict); + if (shadowDOM()) { + originalRelatedTargetMap ??= new WeakMap(); + const relatedTarget = event.relatedTarget; + if (relatedTarget !== null) { + originalRelatedTargetMap.set(event, relatedTarget); + } + } + return event; +} + +export function getOriginalRelatedTarget(event: FocusEvent): EventTarget | null { + if (!shadowDOM()) { + return event.relatedTarget; + } + return originalRelatedTargetMap?.get(event) ?? event.relatedTarget; +}