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 }); }); },