From 5ada93fad39b5ae6886887231e2bbf19e74bfebb Mon Sep 17 00:00:00 2001 From: Gdhanush_13 Date: Tue, 26 May 2026 12:37:06 +0530 Subject: [PATCH 1/2] fix(combobox): close popover on outside click when menuTrigger=focus Re-add useInteractOutside to useComboBox to handle closing the combobox popover when clicking outside (e.g. on a modal overlay). The ComboBox popover is non-modal so isDismissable is false in useOverlay, which means useOverlay's useInteractOutside does not handle outside clicks for it. Additionally, track when the menu was closed by an outside interaction to prevent menuTrigger='focus' from immediately reopening the popover when focus is programmatically returned to the input (e.g. after a modal overlay dismiss). Fixes adobe#10074 --- .../react-aria/src/combobox/useComboBox.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/react-aria/src/combobox/useComboBox.ts b/packages/react-aria/src/combobox/useComboBox.ts index facfade5cfb..9f4c7bc8c17 100644 --- a/packages/react-aria/src/combobox/useComboBox.ts +++ b/packages/react-aria/src/combobox/useComboBox.ts @@ -53,6 +53,7 @@ import {privateValidationStateProp} from 'react-stately/private/form/useFormVali import {useEvent} from '../utils/useEvent'; import {useFormReset} from '../utils/useFormReset'; import {useId} from '../utils/useId'; +import {useInteractOutside} from '../interactions/useInteractOutside'; import {useLabels} from '../utils/useLabels'; import {useLocalizedStringFormatter} from '../i18n/useLocalizedStringFormatter'; import {useMenuTrigger} from '../menu/useMenuTrigger'; @@ -239,6 +240,11 @@ export function useComboBox( } }; + // Track when the menu was closed by an outside interaction so that + // we can prevent it from reopening when focus returns to the input + // (e.g. after a modal overlay dismiss returns focus). + let closedByInteractOutsideRef = useRef(false); + let onBlur = (e: FocusEvent) => { let blurFromButton = buttonRef?.current && buttonRef.current === e.relatedTarget; let blurIntoPopover = nodeContains(popoverRef.current, e.relatedTarget); @@ -264,6 +270,16 @@ export function useComboBox( } state.setFocused(true); + + // If the menu was just closed by an outside interaction and focus + // returned to the input (e.g. overlay dismiss), close it again + // to prevent menuTrigger="focus" from reopening the popover. + if (closedByInteractOutsideRef.current) { + closedByInteractOutsideRef.current = false; + if (state.isOpen) { + state.setOpen(false); + } + } }; let valueId = useValueId([ @@ -461,6 +477,21 @@ export function useComboBox( : 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 => { + let target = getEventTarget(e) as Element; + if (nodeContains(buttonRef?.current, target) || nodeContains(inputRef.current, target)) { + return; + } + closedByInteractOutsideRef.current = true; + state.close(); + }, + isDisabled: !state.isOpen + }); + return { labelProps, buttonProps: { From de2213387681791f91ed1394c31c30314e36bdc0 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 22 Jun 2026 12:32:31 +1000 Subject: [PATCH 2/2] add test --- .../test/ComboBox.test.js | 60 ++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/packages/react-aria-components/test/ComboBox.test.js b/packages/react-aria-components/test/ComboBox.test.js index dfa993ecef8..cec419141a6 100644 --- a/packages/react-aria-components/test/ComboBox.test.js +++ b/packages/react-aria-components/test/ComboBox.test.js @@ -13,14 +13,23 @@ import {act} from '@testing-library/react'; import {Button} from '../src/Button'; import {ComboBox, ComboBoxContext, ComboBoxValue} from '../src/ComboBox'; +import {Dialog, DialogTrigger} from '../src/Dialog'; import {FieldError} from '../src/FieldError'; -import {fireEvent, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; +import { + fireEvent, + installPointerEvent, + pointerMap, + render, + within +} from '@react-spectrum/test-utils-internal'; import {Form} from '../src/Form'; import {Header} from '../src/Header'; +import {Heading} from '../src/Heading'; import {Input} from '../src/Input'; import {Label} from '../src/Label'; import {ListBox, ListBoxItem, ListBoxLoadMoreItem, ListBoxSection} from '../src/ListBox'; import {ListLayout} from 'react-stately/useVirtualizerState'; +import {Modal} from '../src/Modal'; import {Popover} from '../src/Popover'; import React, {useState} from 'react'; import {Text} from '../src/Text'; @@ -1062,4 +1071,53 @@ describe('ComboBox', () => { rerender(); expect(input.closest('.react-aria-ComboBox')).toHaveAttribute('data-readonly'); }); + + describe('inside dialog', () => { + installPointerEvent(); + it('should close a focus triggered combobox when clicking outside the dialog', async () => { + let onFocusChange = jest.fn(); + let {container} = render( + + + + + Subscribe to our newsletter + + + + + ); + let dialogTester = testUtilUser.createTester('Dialog', {root: container}); + await dialogTester.open(); + let comboboxTester = testUtilUser.createTester('ComboBox', {root: dialogTester.getDialog()}); + await user.tab(); + await act(() => { + jest.runAllTimers(); + }); + expect(comboboxTester.getListbox()).toBeVisible(); + expect(onFocusChange).toHaveBeenCalledTimes(1); + expect(onFocusChange).toHaveBeenLastCalledWith(true); + let overlay = document.querySelector('.react-aria-ModalOverlay'); + await user.pointer({target: overlay, keys: '[MouseLeft>]'}); + // user event runs all the events in order, but focus scope needs a moment to restore focus + await act(() => { + jest.runAllTimers(); + }); + await user.pointer({target: overlay, keys: '[/MouseLeft]'}); + act(() => jest.runAllTimers()); + expect(comboboxTester.getListbox()).toBeNull(); + expect(onFocusChange).toHaveBeenCalledTimes(3); + expect(onFocusChange).toHaveBeenLastCalledWith(true); + overlay = document.querySelector('.react-aria-ModalOverlay'); + await user.pointer({target: overlay, keys: '[MouseLeft>]'}); + await act(() => { + jest.runAllTimers(); + }); + await user.pointer({target: overlay, keys: '[/MouseLeft]'}); + act(() => jest.runAllTimers()); + expect(comboboxTester.getListbox()).toBeNull(); + expect(onFocusChange).toHaveBeenCalledTimes(5); + expect(onFocusChange).toHaveBeenLastCalledWith(true); + }); + }); });