diff --git a/packages/react/src/select/trigger/SelectTrigger.test.tsx b/packages/react/src/select/trigger/SelectTrigger.test.tsx
index 43ac94c4d7a..6f62ce3728c 100644
--- a/packages/react/src/select/trigger/SelectTrigger.test.tsx
+++ b/packages/react/src/select/trigger/SelectTrigger.test.tsx
@@ -145,6 +145,47 @@ describe('', () => {
expect(trigger).toHaveAttribute('aria-expanded', 'true');
});
+ it('does not close on a later mouseup when the opening click already ended', async () => {
+ const onOpenChange = vi.fn();
+ await render(
+
+
+
+
+
+
+
+ a
+
+
+
+ ,
+ );
+
+ const trigger = screen.getByTestId('trigger');
+ trigger.getBoundingClientRect = () =>
+ DOMRect.fromRect({ x: 100, y: 100, width: 100, height: 40 });
+
+ fireEvent.pointerDown(trigger, { pointerType: 'mouse', button: 0 });
+ fireEvent.mouseDown(trigger, { button: 0 });
+ fireEvent.mouseUp(trigger, { button: 0, clientX: 120, clientY: 120 });
+
+ await act(async () => {
+ await wait(20);
+ });
+
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
+ onOpenChange.mockClear();
+
+ fireEvent.mouseUp(document.body, { button: 0, clientX: 94, clientY: 120 });
+ await act(async () => {
+ await wait(20);
+ });
+
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
+ expect(onOpenChange).not.toHaveBeenCalled();
+ });
+
it('closes the popup when the press is dragged off the trigger and released', async () => {
const trigger = await pressTrigger();
diff --git a/packages/react/src/select/trigger/SelectTrigger.tsx b/packages/react/src/select/trigger/SelectTrigger.tsx
index c10336d03ac..679dfc43a5e 100644
--- a/packages/react/src/select/trigger/SelectTrigger.tsx
+++ b/packages/react/src/select/trigger/SelectTrigger.tsx
@@ -101,6 +101,19 @@ export const SelectTrigger = React.forwardRef(function SelectTrigger(
const timeoutFocus = useTimeout();
const timeoutMouseDown = useTimeout();
const selectedDelayTimeout = useTimeout();
+ const openingReleaseListenerRef = React.useRef<{
+ doc: Document;
+ markReleased: () => void;
+ } | null>(null);
+
+ const detachOpeningReleaseListener = React.useCallback(() => {
+ const pending = openingReleaseListenerRef.current;
+ if (pending == null) {
+ return;
+ }
+ pending.doc.removeEventListener('mouseup', pending.markReleased, { capture: true });
+ openingReleaseListenerRef.current = null;
+ }, []);
React.useEffect(() => {
if (open) {
@@ -125,9 +138,14 @@ export const SelectTrigger = React.forwardRef(function SelectTrigger(
};
timeoutMouseDown.clear();
+ detachOpeningReleaseListener();
return undefined;
- }, [open, store, timeoutMouseDown, selectedDelayTimeout]);
+ }, [open, store, timeoutMouseDown, selectedDelayTimeout, detachOpeningReleaseListener]);
+
+ React.useEffect(() => {
+ return detachOpeningReleaseListener;
+ }, [detachOpeningReleaseListener]);
const mergedProps: HTMLProps = mergeProps<'button'>(
triggerProps,
@@ -201,8 +219,22 @@ export const SelectTrigger = React.forwardRef(function SelectTrigger(
store.setOpen(false, createChangeEventDetails(REASONS.cancelOpen, mouseEvent));
}
- // Firefox can fire this upon mousedown
+ // Firefox can fire `mouseup` during `mousedown`. Arm the close listener on the
+ // next task, and skip it when this press was already released.
+ let released = false;
+ function markReleased() {
+ released = true;
+ }
+
+ detachOpeningReleaseListener();
+ doc.addEventListener('mouseup', markReleased, { capture: true });
+ openingReleaseListenerRef.current = { doc, markReleased };
+
timeoutMouseDown.start(0, () => {
+ detachOpeningReleaseListener();
+ if (released) {
+ return;
+ }
doc.addEventListener('mouseup', handleMouseUp, { once: true });
});
},