Skip to content

Commit 2614b0a

Browse files
committed
fix: Select2 - Opening click retargeted to body no longer closes popup
- track pressStartedInside; ignore click that ends a press begun inside - portalled popup can mount under cursor, retargeting click outside - add regression tests for opening-click retarget and outside-gesture close
1 parent f372372 commit 2614b0a

2 files changed

Lines changed: 54 additions & 7 deletions

File tree

src/components/Select/Select2.test.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState } from 'react';
2-
import { render, screen, waitFor } from '@testing-library/react';
2+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
33
import userEvent from '@testing-library/user-event';
44
import '@testing-library/jest-dom';
55

@@ -118,6 +118,32 @@ describe('Select2 open state', () => {
118118
await waitFor(() => expect(isOpened()).toBe(false));
119119
});
120120

121+
test('the opening click does not close when it retargets outside mid-gesture', () => {
122+
render(<Controlled isSearchable label="Label" />);
123+
124+
// A portalled popup can mount under the cursor; the click then targets a
125+
// common ancestor outside the select (here document.body) even though the
126+
// press started on the trigger.
127+
fireEvent.pointerDown(getSearchInput());
128+
expect(isOpened()).toBe(true);
129+
130+
fireEvent.pointerUp(document.body);
131+
fireEvent.click(document.body);
132+
expect(isOpened()).toBe(true);
133+
});
134+
135+
test('a pointer gesture that started outside closes', () => {
136+
render(<Controlled isSearchable label="Label" />);
137+
138+
fireEvent.pointerDown(getSearchInput());
139+
expect(isOpened()).toBe(true);
140+
141+
fireEvent.pointerDown(document.body);
142+
fireEvent.pointerUp(document.body);
143+
fireEvent.click(document.body);
144+
expect(isOpened()).toBe(false);
145+
});
146+
121147
test('closes on blur when the focus leaves the select', async () => {
122148
render(
123149
<>

src/components/Select/Select2.tsx

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export function Select2(props: T.Props) {
8181
const contentRef = useRef<HTMLDivElement>(null);
8282
const scrollInnerRef = useRef<HTMLDivElement>(null);
8383
const pointerPressedInside = useRef(false);
84+
const pressStartedInside = useRef(false);
8485
const pointerPressTimer = useRef(0);
8586
const blurTimer = useRef(0);
8687

@@ -242,23 +243,43 @@ export function Select2(props: T.Props) {
242243
else open();
243244
};
244245

246+
const markPressInside = (isInside: boolean) => {
247+
window.clearTimeout(pointerPressTimer.current);
248+
pointerPressedInside.current = isInside;
249+
pointerPressTimer.current = window.setTimeout(
250+
() => (pointerPressedInside.current = false),
251+
POINTER_PRESS_TTL
252+
);
253+
};
254+
245255
useEvent({
246256
event: 'pointerdown',
247257
isCapture: true,
248258
callback: e => {
249-
window.clearTimeout(pointerPressTimer.current);
250-
pointerPressedInside.current = isInsideSelect(e.target);
251-
pointerPressTimer.current = window.setTimeout(
252-
() => (pointerPressedInside.current = false),
253-
POINTER_PRESS_TTL
254-
);
259+
pressStartedInside.current = isInsideSelect(e.target);
260+
markPressInside(pressStartedInside.current);
261+
},
262+
});
263+
264+
useEvent({
265+
event: 'pointerup',
266+
isCapture: true,
267+
callback: () => {
268+
// The release can land on the portalled popup that mounted under the
269+
// cursor mid-gesture, so the up-target is not a reliable inside signal.
270+
if (pressStartedInside.current) markPressInside(true);
271+
pressStartedInside.current = false;
255272
},
256273
});
257274

258275
useEvent({
259276
event: 'click',
260277
isActive: isOpen,
261278
callback: e => {
279+
// The click concluding a press that started inside must not count as an
280+
// outside click: the portalled popup can mount under the cursor, which
281+
// retargets the click to a common ancestor outside the select.
282+
if (pointerPressedInside.current) return;
262283
if (!isInsideSelect(e.target)) close();
263284
},
264285
});

0 commit comments

Comments
 (0)