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..bb511e156d7 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'; @@ -225,7 +226,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 +366,20 @@ 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) => { + let target = getEventTarget(e) as Element; + if (nodeContains(buttonRef?.current, target) || nodeContains(inputRef.current, target)) { + return; + } + state.close(); + }, + isDisabled: !state.isOpen + }); + return { labelProps, buttonProps: { 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/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/ComboBox.test.js b/packages/react-aria-components/test/ComboBox.test.js index badb75271e1..f84db0b02e9 100644 --- a/packages/react-aria-components/test/ComboBox.test.js +++ b/packages/react-aria-components/test/ComboBox.test.js @@ -761,4 +761,56 @@ describe('ComboBox', () => { expect(onChange).toHaveBeenCalledTimes(2); expect(onChange).toHaveBeenLastCalledWith(['1']); }); + + it('should not close the combobox when clicking on the input', async () => { + let onOpenChange = jest.fn(); + let {container, getByRole} = render(); + let comboboxTester = testUtilUser.createTester('ComboBox', {root: container}); + await comboboxTester.open(); + act(() => { + jest.runAllTimers(); + }); + expect(comboboxTester.listbox).toBeVisible(); + expect(comboboxTester.combobox).toHaveFocus(); + expect(onOpenChange).toHaveBeenCalledTimes(1); + onOpenChange.mockClear(); + + await user.click(getByRole('combobox')); + act(() => { + jest.runAllTimers(); + }); + expect(comboboxTester.listbox).toBeVisible(); + expect(comboboxTester.combobox).toHaveFocus(); + expect(onOpenChange).toHaveBeenCalledTimes(0); + }); + + it('should close the combobox when clicking on the button, and it should reopen if clicked again', async () => { + let onOpenChange = jest.fn(); + let {container, getByRole, getAllByRole} = render(); + let comboboxTester = testUtilUser.createTester('ComboBox', {root: container}); + await comboboxTester.open(); + act(() => { + jest.runAllTimers(); + }); + expect(comboboxTester.listbox).toBeVisible(); + expect(onOpenChange).toHaveBeenCalledTimes(1); + onOpenChange.mockClear(); + + await user.click(getAllByRole('button', {hidden: true})[0]); + act(() => { + jest.runAllTimers(); + }); + expect(comboboxTester.listbox).toBeNull(); + expect(comboboxTester.combobox).toHaveFocus(); + expect(onOpenChange).toHaveBeenCalledTimes(1); + onOpenChange.mockClear(); + + await user.click(getByRole('button')); + act(() => { + jest.runAllTimers(); + }); + expect(comboboxTester.listbox).toBeVisible(); + expect(comboboxTester.combobox).toHaveFocus(); + expect(onOpenChange).toHaveBeenCalledTimes(1); + }); }); diff --git a/yarn.lock b/yarn.lock index bc855cd44a8..da85bec0c3c 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"