Skip to content

Commit 102593e

Browse files
committed
[combobox] Ensure popup closes when tabbing out of popover
1 parent 81141cc commit 102593e

File tree

2 files changed

+157
-32
lines changed

2 files changed

+157
-32
lines changed

packages/react/src/combobox/popup/ComboboxPopup.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export const ComboboxPopup = React.forwardRef(function ComboboxPopup(
4444
const mounted = useStore(store, selectors.mounted);
4545
const open = useStore(store, selectors.open);
4646
const openMethod = useStore(store, selectors.openMethod);
47+
const modal = useStore(store, selectors.modal);
4748
const transitionStatus = useStore(store, selectors.transitionStatus);
4849
const inputInsidePopup = useStore(store, selectors.inputInsidePopup);
4950
const inputElement = useStore(store, selectors.inputElement);
@@ -114,7 +115,7 @@ export const ComboboxPopup = React.forwardRef(function ComboboxPopup(
114115
<FloatingFocusManager
115116
context={floatingRootContext}
116117
disabled={!mounted}
117-
modal={!inputInsidePopup}
118+
modal={inputInsidePopup ? modal : false}
118119
openInteractionType={openMethod}
119120
initialFocus={resolvedInitialFocus}
120121
returnFocus={resolvedFinalFocus}

packages/react/src/combobox/root/ComboboxRoot.test.tsx

Lines changed: 155 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createRenderer, isJSDOM, popupConformanceTests } from '#test-utils';
44
import { expect } from 'chai';
55
import { spy } from 'sinon';
66
import { Combobox } from '@base-ui-components/react/combobox';
7+
import { Popover } from '@base-ui-components/react/popover';
78
import { Dialog } from '@base-ui-components/react/dialog';
89
import { Field } from '@base-ui-components/react/field';
910
import { Form } from '@base-ui-components/react/form';
@@ -38,41 +39,164 @@ describe('<Combobox.Root />', () => {
3839
combobox: true,
3940
});
4041

41-
it('does not focus input when closing via trigger click (input inside popup)', async () => {
42-
const { user } = await render(
43-
<Combobox.Root items={['One', 'Two', 'Three']}>
44-
<Combobox.Trigger data-testid="trigger">
45-
<Combobox.Value />
46-
</Combobox.Trigger>
47-
<Combobox.Portal>
48-
<Combobox.Positioner>
49-
<Combobox.Popup aria-label="Demo">
50-
<Combobox.Input data-testid="input" />
51-
<Combobox.List>
52-
{(item: string) => (
53-
<Combobox.Item key={item} value={item}>
54-
{item}
55-
</Combobox.Item>
56-
)}
57-
</Combobox.List>
58-
</Combobox.Popup>
59-
</Combobox.Positioner>
60-
</Combobox.Portal>
61-
</Combobox.Root>,
62-
);
42+
describe('focus management', () => {
43+
it('does not focus input when closing via trigger click (input inside popup)', async () => {
44+
const { user } = await render(
45+
<Combobox.Root items={['One', 'Two', 'Three']}>
46+
<Combobox.Trigger data-testid="trigger">
47+
<Combobox.Value />
48+
</Combobox.Trigger>
49+
<Combobox.Portal>
50+
<Combobox.Positioner>
51+
<Combobox.Popup aria-label="Demo">
52+
<Combobox.Input data-testid="input" />
53+
<Combobox.List>
54+
{(item: string) => (
55+
<Combobox.Item key={item} value={item}>
56+
{item}
57+
</Combobox.Item>
58+
)}
59+
</Combobox.List>
60+
</Combobox.Popup>
61+
</Combobox.Positioner>
62+
</Combobox.Portal>
63+
</Combobox.Root>,
64+
);
6365

64-
const trigger = screen.getByTestId('trigger');
65-
await user.click(trigger);
66+
const trigger = screen.getByTestId('trigger');
67+
await user.click(trigger);
6668

67-
expect(await screen.findByRole('listbox')).not.to.equal(null);
69+
expect(await screen.findByRole('listbox')).not.to.equal(null);
6870

69-
const input = await waitFor(() =>
70-
screen.getAllByRole('combobox').find((element) => element.tagName === 'INPUT'),
71-
);
72-
expect(input).toHaveFocus();
71+
const input = await waitFor(() =>
72+
screen.getAllByRole('combobox').find((element) => element.tagName === 'INPUT'),
73+
);
74+
expect(input).toHaveFocus();
75+
76+
await user.click(trigger);
77+
expect(trigger).toHaveFocus();
78+
});
79+
80+
it('applies aria-hidden to outside nodes when the input renders outside the popup', async () => {
81+
await render(
82+
<div>
83+
<button data-testid="before">before</button>
84+
<Combobox.Root defaultOpen>
85+
<Combobox.Input />
86+
<Combobox.Portal>
87+
<Combobox.Positioner>
88+
<Combobox.Popup>
89+
<Combobox.List>
90+
<Combobox.Item value="apple">Apple</Combobox.Item>
91+
</Combobox.List>
92+
</Combobox.Popup>
93+
</Combobox.Positioner>
94+
</Combobox.Portal>
95+
</Combobox.Root>
96+
<button data-testid="after">after</button>
97+
</div>,
98+
);
99+
100+
await waitFor(() => {
101+
expect(screen.getByTestId('before').closest('[aria-hidden="true"]')).not.to.equal(null);
102+
expect(screen.getByTestId('after').closest('[aria-hidden="true"]')).not.to.equal(null);
103+
});
104+
expect(screen.getByRole('listbox')).not.to.have.attribute('aria-hidden');
105+
});
106+
107+
it('does not apply aria-hidden to outside nodes when the input renders inside the popup', async () => {
108+
await render(
109+
<div>
110+
<button data-testid="before">before</button>
111+
<Combobox.Root defaultOpen>
112+
<Combobox.Trigger>Toggle</Combobox.Trigger>
113+
<Combobox.Portal>
114+
<Combobox.Positioner>
115+
<Combobox.Popup>
116+
<Combobox.Input />
117+
<Combobox.List>
118+
<Combobox.Item value="apple">Apple</Combobox.Item>
119+
</Combobox.List>
120+
</Combobox.Popup>
121+
</Combobox.Positioner>
122+
</Combobox.Portal>
123+
</Combobox.Root>
124+
<button data-testid="after">after</button>
125+
</div>,
126+
);
127+
128+
expect(screen.getByTestId('before')).not.to.have.attribute('aria-hidden', 'true');
129+
expect(screen.getByTestId('after')).not.to.have.attribute('aria-hidden', 'true');
130+
expect(screen.getByRole('listbox')).not.to.have.attribute('aria-hidden');
131+
});
73132

74-
await user.click(trigger);
75-
expect(trigger).toHaveFocus();
133+
it('applies aria-hidden to outside nodes when the input renders inside the popup and is modal', async () => {
134+
await render(
135+
<div>
136+
<button data-testid="before">before</button>
137+
<Combobox.Root modal defaultOpen>
138+
<Combobox.Trigger>Toggle</Combobox.Trigger>
139+
<Combobox.Portal>
140+
<Combobox.Positioner>
141+
<Combobox.Popup>
142+
<Combobox.Input />
143+
<Combobox.List>
144+
<Combobox.Item value="apple">Apple</Combobox.Item>
145+
</Combobox.List>
146+
</Combobox.Popup>
147+
</Combobox.Positioner>
148+
</Combobox.Portal>
149+
</Combobox.Root>
150+
<button data-testid="after">after</button>
151+
</div>,
152+
);
153+
154+
expect(screen.getByTestId('before').closest('[aria-hidden="true"]')).not.to.equal(null);
155+
expect(screen.getByTestId('after').closest('[aria-hidden="true"]')).not.to.equal(null);
156+
expect(screen.getByRole('listbox')).not.to.have.attribute('aria-hidden');
157+
});
158+
159+
it('closes when tabbing forward from the last tabbable element inside a popover', async () => {
160+
const { user } = await render(
161+
<React.Fragment>
162+
<Popover.Root defaultOpen>
163+
<Popover.Trigger>Toggle</Popover.Trigger>
164+
<Popover.Portal>
165+
<Popover.Positioner>
166+
<Popover.Popup>
167+
<Combobox.Root defaultOpen>
168+
<Combobox.Input />
169+
<Combobox.Portal>
170+
<Combobox.Positioner>
171+
<Combobox.Popup data-testid="combobox-popup">
172+
<Combobox.List>
173+
<Combobox.Item value="apple">Apple</Combobox.Item>
174+
</Combobox.List>
175+
</Combobox.Popup>
176+
</Combobox.Positioner>
177+
</Combobox.Portal>
178+
</Combobox.Root>
179+
</Popover.Popup>
180+
</Popover.Positioner>
181+
</Popover.Portal>
182+
</Popover.Root>
183+
<button data-testid="after" />
184+
</React.Fragment>,
185+
);
186+
187+
await flushMicrotasks();
188+
189+
const input = screen.getByRole('combobox');
190+
expect(screen.queryByTestId('combobox-popup')).not.to.equal(null);
191+
192+
await user.click(input);
193+
await user.tab();
194+
195+
await waitFor(() => {
196+
expect(screen.queryByTestId('combobox-popup')).to.equal(null);
197+
});
198+
expect(screen.getByTestId('after')).toHaveFocus();
199+
});
76200
});
77201

78202
describe('selection behavior', () => {

0 commit comments

Comments
 (0)