From 03fcdebb3c61fd67dd8274eb8561f2ff2fa578f9 Mon Sep 17 00:00:00 2001 From: atomiks Date: Wed, 29 Apr 2026 09:31:58 +1000 Subject: [PATCH] [select] Allow mouse selection without highlight --- .../react/src/select/item/SelectItem.test.tsx | 98 +++++++++++++++++++ packages/react/src/select/item/SelectItem.tsx | 29 +++--- 2 files changed, 115 insertions(+), 12 deletions(-) diff --git a/packages/react/src/select/item/SelectItem.test.tsx b/packages/react/src/select/item/SelectItem.test.tsx index 44e7bc203de..2dbe08aa357 100644 --- a/packages/react/src/select/item/SelectItem.test.tsx +++ b/packages/react/src/select/item/SelectItem.test.tsx @@ -203,6 +203,37 @@ describe('', () => { expect(handleClick).toHaveBeenCalledOnce(); }); + it('should select an unhighlighted item with the mouse', async () => { + await render( + + + + + + + + one + two + + + + , + ); + + const option = screen.getByRole('option', { name: 'two' }); + + expect(option).not.toHaveAttribute('data-highlighted'); + + fireEvent.pointerDown(option, { pointerType: 'mouse' }); + fireEvent.mouseDown(option); + fireEvent.mouseUp(option); + fireEvent.click(option, { detail: 1 }); + + await waitFor(() => { + expect(screen.getByTestId('value').textContent).toBe('two'); + }); + }); + it('should focus the selected item upon opening the popup', async () => { const { user } = await render( @@ -372,6 +403,73 @@ describe('', () => { expect(handleClick).toHaveBeenCalledOnce(); }); + it('should select via drag-to-select when hover highlighting is disabled', async () => { + ignoreActWarnings(); + + await renderFakeTimers( + + + + + + + + one + two + + + + , + ); + + const trigger = screen.getByTestId('trigger'); + fireEvent.mouseDown(trigger); + await waitFor(() => expect(screen.queryByRole('listbox')).not.toBe(null)); + + const option = screen.getByRole('option', { name: 'two' }); + fireEvent.pointerEnter(option, { pointerType: 'mouse' }); + fireEvent.pointerMove(option, { pointerType: 'mouse' }); + + expect(option).not.toHaveAttribute('data-highlighted'); + + await act(async () => { + await clock.tickAsync(500); + }); + fireEvent.mouseUp(option); + + await waitFor(() => expect(screen.getByTestId('value').textContent).toBe('two')); + }); + + it('should ignore an opening click that did not start on the item', async () => { + ignoreActWarnings(); + + await renderFakeTimers( + + + + + + + + one + two + + + + , + ); + + const trigger = screen.getByTestId('trigger'); + fireEvent.pointerDown(trigger, { pointerType: 'mouse' }); + fireEvent.mouseDown(trigger); + await waitFor(() => expect(screen.queryByRole('listbox')).not.toBe(null)); + + const option = screen.getByRole('option', { name: 'one' }); + fireEvent.click(option, { detail: 1 }); + + expect(screen.getByTestId('value').textContent).toBe('Select font'); + }); + it('should not select item when onClick calls preventBaseUIHandler during drag-to-select', async () => { ignoreActWarnings(); const handleClick = vi.fn((event) => event.preventBaseUIHandler()); diff --git a/packages/react/src/select/item/SelectItem.tsx b/packages/react/src/select/item/SelectItem.tsx index be719665685..5e2cc43cc3d 100644 --- a/packages/react/src/select/item/SelectItem.tsx +++ b/packages/react/src/select/item/SelectItem.tsx @@ -114,7 +114,7 @@ export const SelectItem = React.memo( const lastKeyRef = React.useRef(null); const pointerTypeRef = React.useRef<'mouse' | 'touch' | 'pen'>('mouse'); - const didPointerDownRef = React.useRef(false); + const allowMouseSelectionRef = React.useRef(false); const { getButtonProps, buttonRef } = useButton({ disabled, @@ -151,6 +151,7 @@ export const SelectItem = React.memo( 'aria-selected': selected, tabIndex: highlighted ? 0 : -1, onTouchStart() { + allowMouseSelectionRef.current = false; selectionRef.current = { allowSelectedMouseUp: false, allowUnselectedMouseUp: false, @@ -165,7 +166,14 @@ export const SelectItem = React.memo( } }, onClick(event) { - didPointerDownRef.current = false; + const isMouseClick = event.type === 'click' && pointerTypeRef.current !== 'touch'; + const isVirtualClick = isMouseClick && event.nativeEvent.detail === 0; + // With alignItemWithTrigger, opening can place an item under the cursor. Real mouse + // clicks must start on the item, while virtual clicks keep the highlighted-item guard. + const isInvalidMouseClick = + isMouseClick && !allowMouseSelectionRef.current && (isVirtualClick ? !highlighted : true); + + allowMouseSelectionRef.current = false; // Prevent double commit on {Enter} if (event.type === 'keydown' && lastKeyRef.current === null) { @@ -175,7 +183,7 @@ export const SelectItem = React.memo( if ( disabled || (event.type === 'keydown' && lastKeyRef.current === ' ' && typingRef.current) || - (pointerTypeRef.current !== 'touch' && !highlighted) + isInvalidMouseClick ) { return; } @@ -188,31 +196,28 @@ export const SelectItem = React.memo( }, onPointerDown(event) { pointerTypeRef.current = event.pointerType; - didPointerDownRef.current = true; + allowMouseSelectionRef.current = true; }, onMouseUp() { if (disabled) { return; } - // Regular click (pointerdown on this element) if didPointerDownRef is set, otherwise drag-to-select - if (didPointerDownRef.current) { - didPointerDownRef.current = false; + // Regular clicks are committed by the click event. + if (allowMouseSelectionRef.current) { return; } const disallowSelectedMouseUp = !selectionRef.current.allowSelectedMouseUp && selected; const disallowUnselectedMouseUp = !selectionRef.current.allowUnselectedMouseUp && !selected; - if ( - disallowSelectedMouseUp || - disallowUnselectedMouseUp || - (pointerTypeRef.current !== 'touch' && !highlighted) - ) { + if (disallowSelectedMouseUp || disallowUnselectedMouseUp) { return; } + allowMouseSelectionRef.current = true; itemRef.current?.click(); + allowMouseSelectionRef.current = false; }, };