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;
+}