Skip to content

Commit 50e009e

Browse files
authored
fix: combobox interactoutside (#9646)
* fix: combobox interactoutside * fix dependencies * fix case where use clicks the input
1 parent daafdd5 commit 50e009e

6 files changed

Lines changed: 146 additions & 4 deletions

File tree

packages/@react-aria/combobox/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"dependencies": {
2929
"@react-aria/focus": "^3.21.4",
3030
"@react-aria/i18n": "^3.12.15",
31+
"@react-aria/interactions": "^3.27.0",
3132
"@react-aria/listbox": "^3.15.2",
3233
"@react-aria/live-announcer": "^3.4.4",
3334
"@react-aria/menu": "^3.20.0",

packages/@react-aria/combobox/src/useComboBox.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {getChildNodes, getItemCount} from '@react-stately/collections';
2525
import intlMessages from '../intl/*.json';
2626
import {ListKeyboardDelegate, useSelectableCollection} from '@react-aria/selection';
2727
import {privateValidationStateProp} from '@react-stately/form';
28+
import {useInteractOutside} from '@react-aria/interactions';
2829
import {useLocalizedStringFormatter} from '@react-aria/i18n';
2930
import {useMenuTrigger} from '@react-aria/menu';
3031
import {useTextField} from '@react-aria/textfield';
@@ -225,7 +226,7 @@ export function useComboBox<T, M extends SelectionMode = 'single'>(props: AriaCo
225226
}, inputRef);
226227

227228
useFormReset(inputRef, state.defaultValue, state.setValue);
228-
229+
229230
// Press handlers for the ComboBox button
230231
let onPress = (e: PressEvent) => {
231232
if (e.pointerType === 'touch') {
@@ -365,6 +366,20 @@ export function useComboBox<T, M extends SelectionMode = 'single'>(props: AriaCo
365366
state.close();
366367
} : undefined);
367368

369+
// usePopover -> useOverlay calls useInteractOutside, but ComboBox is non-modal, so `isDismissable` is false
370+
// Because of this, onInteractOutside is not passed to useInteractOutside, so we need to call it here.
371+
useInteractOutside({
372+
ref: popoverRef,
373+
onInteractOutside: (e) => {
374+
let target = getEventTarget(e) as Element;
375+
if (nodeContains(buttonRef?.current, target) || nodeContains(inputRef.current, target)) {
376+
return;
377+
}
378+
state.close();
379+
},
380+
isDisabled: !state.isOpen
381+
});
382+
368383
return {
369384
labelProps,
370385
buttonProps: {

packages/@react-spectrum/s2/stories/ComboBox.stories.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {Avatar, Button, ComboBox, ComboBoxItem, ComboBoxSection, Content, ContextualHelp, Footer, Form, Header, Heading, Link, Text} from '../src';
13+
import {Avatar, Button, ComboBox, ComboBoxItem, ComboBoxSection, Content, ContextualHelp, Dialog, DialogTrigger, Footer, Form, Header, Heading, Link, Text} from '../src';
1414
import {categorizeArgTypes, getActionArgs} from './utils';
1515
import {ComboBoxProps} from 'react-aria-components';
1616
import DeviceDesktopIcon from '../s2wf-icons/S2_Icon_DeviceDesktop_20_N.svg';
@@ -362,3 +362,25 @@ export function WithCreateOption() {
362362
</ComboBox>
363363
);
364364
}
365+
366+
export const ComboboxInsideDialog: Story = {
367+
render: (args) => (
368+
<DialogTrigger>
369+
<Button>Open</Button>
370+
<Dialog isDismissible>
371+
<Heading>Combo Box in a Dialog</Heading>
372+
<Content>
373+
<ComboBox {...args}>
374+
<ComboBoxItem>Aardvark</ComboBoxItem>
375+
<ComboBoxItem>Cat</ComboBoxItem>
376+
<ComboBoxItem>Dog</ComboBoxItem>
377+
<ComboBoxItem>Kangaroo</ComboBoxItem>
378+
<ComboBoxItem>Panda</ComboBoxItem>
379+
<ComboBoxItem>Snake</ComboBoxItem>
380+
</ComboBox>
381+
</Content>
382+
</Dialog>
383+
</DialogTrigger>
384+
),
385+
args: Example.args
386+
};

packages/@react-spectrum/s2/test/Combobox.test.tsx

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@
1111
*/
1212

1313
jest.mock('@react-aria/live-announcer');
14-
import {act, pointerMap, render, setupIntersectionObserverMock, within} from '@react-spectrum/test-utils-internal';
14+
import {act, fireEvent, pointerMap, render, setupIntersectionObserverMock, within} from '@react-spectrum/test-utils-internal';
1515
import {announce} from '@react-aria/live-announcer';
16-
import {ComboBox, ComboBoxItem, Content, ContextualHelp, Heading, Text} from '../src';
16+
import {Button, ComboBox, ComboBoxItem, Content, ContextualHelp, Dialog, DialogTrigger, Heading, Text} from '../src';
1717
import React from 'react';
1818
import {User} from '@react-aria/test-utils';
1919
import userEvent from '@testing-library/user-event';
@@ -213,4 +213,55 @@ describe('Combobox', () => {
213213
expect(tree.getAllByText('Contents')[1]).toBeVisible();
214214
warn.mockRestore();
215215
});
216+
217+
it('should close the combobox when clicking outside the combobox on a dialog backdrop', async () => {
218+
let tree = render(
219+
<DialogTrigger>
220+
<Button>Open</Button>
221+
<Dialog isDismissible>
222+
<Heading>Combo Box in a Dialog</Heading>
223+
<Content>
224+
<ComboBox label="test">
225+
<ComboBoxItem>Aardvark</ComboBoxItem>
226+
<ComboBoxItem>Cat</ComboBoxItem>
227+
<ComboBoxItem>Dog</ComboBoxItem>
228+
<ComboBoxItem>Kangaroo</ComboBoxItem>
229+
<ComboBoxItem>Panda</ComboBoxItem>
230+
<ComboBoxItem>Snake</ComboBoxItem>
231+
</ComboBox>
232+
</Content>
233+
</Dialog>
234+
</DialogTrigger>
235+
);
236+
237+
let dialogTester = testUtilUser.createTester('Dialog', {root: tree.container, interactionType: 'mouse'});
238+
await dialogTester.open();
239+
expect(dialogTester.dialog).toBeVisible();
240+
act(() => {
241+
jest.runAllTimers();
242+
});
243+
let comboboxTester = testUtilUser.createTester('ComboBox', {root: dialogTester.dialog!, interactionType: 'mouse'});
244+
await comboboxTester.open();
245+
246+
expect(comboboxTester.listbox).toBeVisible();
247+
act(() => {
248+
jest.runAllTimers();
249+
});
250+
let backdrop = document.querySelector('[style*="--visual-viewport-height"]');
251+
// can't use userEvent here for some reason
252+
fireEvent.mouseDown(backdrop!, {button: 0});
253+
fireEvent.mouseUp(backdrop!, {button: 0});
254+
act(() => {
255+
jest.runAllTimers();
256+
});
257+
expect(comboboxTester.listbox).toBeNull();
258+
259+
260+
fireEvent.mouseDown(backdrop!, {button: 0});
261+
fireEvent.mouseUp(backdrop!, {button: 0});
262+
act(() => {
263+
jest.runAllTimers();
264+
});
265+
expect(dialogTester.dialog).toBeNull();
266+
});
216267
});

packages/react-aria-components/test/ComboBox.test.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -761,4 +761,56 @@ describe('ComboBox', () => {
761761
expect(onChange).toHaveBeenCalledTimes(2);
762762
expect(onChange).toHaveBeenLastCalledWith(['1']);
763763
});
764+
765+
it('should not close the combobox when clicking on the input', async () => {
766+
let onOpenChange = jest.fn();
767+
let {container, getByRole} = render(<TestComboBox onOpenChange={onOpenChange} />);
768+
let comboboxTester = testUtilUser.createTester('ComboBox', {root: container});
769+
await comboboxTester.open();
770+
act(() => {
771+
jest.runAllTimers();
772+
});
773+
expect(comboboxTester.listbox).toBeVisible();
774+
expect(comboboxTester.combobox).toHaveFocus();
775+
expect(onOpenChange).toHaveBeenCalledTimes(1);
776+
onOpenChange.mockClear();
777+
778+
await user.click(getByRole('combobox'));
779+
act(() => {
780+
jest.runAllTimers();
781+
});
782+
expect(comboboxTester.listbox).toBeVisible();
783+
expect(comboboxTester.combobox).toHaveFocus();
784+
expect(onOpenChange).toHaveBeenCalledTimes(0);
785+
});
786+
787+
it('should close the combobox when clicking on the button, and it should reopen if clicked again', async () => {
788+
let onOpenChange = jest.fn();
789+
let {container, getByRole, getAllByRole} = render(<TestComboBox onOpenChange={onOpenChange} />);
790+
let comboboxTester = testUtilUser.createTester('ComboBox', {root: container});
791+
await comboboxTester.open();
792+
act(() => {
793+
jest.runAllTimers();
794+
});
795+
expect(comboboxTester.listbox).toBeVisible();
796+
expect(onOpenChange).toHaveBeenCalledTimes(1);
797+
onOpenChange.mockClear();
798+
799+
await user.click(getAllByRole('button', {hidden: true})[0]);
800+
act(() => {
801+
jest.runAllTimers();
802+
});
803+
expect(comboboxTester.listbox).toBeNull();
804+
expect(comboboxTester.combobox).toHaveFocus();
805+
expect(onOpenChange).toHaveBeenCalledTimes(1);
806+
onOpenChange.mockClear();
807+
808+
await user.click(getByRole('button'));
809+
act(() => {
810+
jest.runAllTimers();
811+
});
812+
expect(comboboxTester.listbox).toBeVisible();
813+
expect(comboboxTester.combobox).toHaveFocus();
814+
expect(onOpenChange).toHaveBeenCalledTimes(1);
815+
});
764816
});

yarn.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5544,6 +5544,7 @@ __metadata:
55445544
dependencies:
55455545
"@react-aria/focus": "npm:^3.21.4"
55465546
"@react-aria/i18n": "npm:^3.12.15"
5547+
"@react-aria/interactions": "npm:^3.27.0"
55475548
"@react-aria/listbox": "npm:^3.15.2"
55485549
"@react-aria/live-announcer": "npm:^3.4.4"
55495550
"@react-aria/menu": "npm:^3.20.0"

0 commit comments

Comments
 (0)