Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions packages/react/src/select/trigger/SelectTrigger.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,47 @@ describe('<Select.Trigger />', () => {
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(
<Select.Root onOpenChange={onOpenChange}>
<Select.Trigger data-testid="trigger">
<Select.Value />
</Select.Trigger>
<Select.Portal>
<Select.Positioner alignItemWithTrigger={false}>
<Select.Popup>
<Select.Item value="a">a</Select.Item>
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>,
);

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();

Expand Down
36 changes: 34 additions & 2 deletions packages/react/src/select/trigger/SelectTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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 });
});
},
Expand Down
Loading