diff --git a/packages/mui-material/src/Select/Select.test.js b/packages/mui-material/src/Select/Select.test.js index ffbc0c53770756..033784faf91de1 100644 --- a/packages/mui-material/src/Select/Select.test.js +++ b/packages/mui-material/src/Select/Select.test.js @@ -34,6 +34,74 @@ describe(' + none + Ten + , + ); + const trigger = screen.getByRole('combobox'); + + // Open the menu + fireEvent.mouseDown(trigger); + expect(screen.getByRole('listbox')).not.to.equal(null); + + // Simulate mouse up outside the menu. The mouseup target is the backdrop when the menu is opened. + fireEvent.mouseUp(screen.getByTestId('backdrop'), { clientX: 60, clientY: 10 }); + + // Menu should be closed now + expect(screen.queryByRole('listbox', { hidden: false })).to.equal(null); + }); + + it('should not close the menu when mouse is inside the trigger', () => { + render( + , + ); + const trigger = screen.getByRole('combobox'); + + // Open the menu + fireEvent.mouseDown(trigger); + expect(screen.getByRole('listbox')).not.to.equal(null); + + // Simulate mouse up inside the trigger + fireEvent.mouseUp(trigger, { clientX: 20, clientY: 20 }); + + // Menu should still be open + expect(screen.queryByRole('listbox', { hidden: false })).not.to.equal(null); + }); + + it('should not close the menu when releasing on menu paper', () => { + render( + , + ); + const trigger = screen.getByRole('combobox'); + + // Open the menu + fireEvent.mouseDown(trigger); + + // Simulate mouse up on menu paper + fireEvent.mouseUp(screen.getByTestId('paper')); + + // Menu should still be open + expect(screen.getByRole('listbox')).not.to.equal(null); + }); + }); + describe('prop: inputProps', () => { it('should be able to provide a custom classes property', () => { render( @@ -931,6 +999,23 @@ describe(' + Ten + Twenty + , + ); + + expect(paperRef.current).to.equal(screen.getByTestId('paper')); + }); }); describe('prop: SelectDisplayProps', () => { @@ -1352,6 +1437,72 @@ describe(' + Ten + Twenty + Thirty + , + ); + + const trigger = screen.getByRole('combobox'); + + expect(trigger).to.have.text('Ten'); + + // open the menu + fireEvent.mouseDown(trigger); + + const listbox = screen.queryByRole('listbox'); + expect(listbox).not.to.equal(null); + + const options = screen.getAllByRole('option'); + // Click second option + await user.click(options[1]); + + expect(trigger).to.have.text('Ten, Twenty'); + + // Menu is still open in case of multiple + expect(listbox).not.to.equal(null); + }); + + it('should be able to select the items on mouseup', async () => { + // Restore real timers — needed for `userEvent` to work correctly with async events. + clock.restore(); + + const { user } = render( + , + ); + + const trigger = screen.getByRole('combobox'); + + expect(trigger).to.have.text('Ten'); + + // Open the menu without releasing the mouse + await user.pointer({ keys: '[MouseLeft>]', target: trigger }); + + const listbox = screen.queryByRole('listbox'); + expect(listbox).not.to.equal(null); + + const options = screen.getAllByRole('option'); + // Mouse up on second option, release the mouse + await user.pointer( + { keys: '[/MouseLeft]', target: options[1] }, // mouseup + ); + + expect(trigger).to.have.text('Ten, Twenty'); + + // Menu is still open in case of multiple + expect(listbox).not.to.equal(null); + }); }); describe('prop: autoFocus', () => { @@ -1461,6 +1612,22 @@ describe(' + + Thirty + + , + ); + + const options = getAllByRole('option'); + fireEvent.mouseUp(options[0]); + + expect(onMouseUp.callCount).to.equal(1); + }); + // https://github.com/testing-library/react-testing-library/issues/322 // https://x.com/devongovett/status/1248306411508916224 it('should handle the browser autofill event and simple testing-library API', () => { @@ -1839,4 +2006,28 @@ describe(' + Ten + Twenty + Thirty + , + ); + + const trigger = screen.getByRole('combobox'); + + // open the menu + fireEvent.mouseDown(trigger); + expect(screen.queryByRole('listbox')).not.to.equal(null); + + const options = screen.getAllByRole('option'); + fireEvent.mouseUp(options[1]); + + expect(trigger).to.have.text('Twenty'); + + // Menu should be closed now + expect(screen.queryByRole('listbox', { hidden: false })).to.equal(null); + }); }); diff --git a/packages/mui-material/src/Select/SelectInput.js b/packages/mui-material/src/Select/SelectInput.js index 0b272bb98b1131..69fd13376bbb53 100644 --- a/packages/mui-material/src/Select/SelectInput.js +++ b/packages/mui-material/src/Select/SelectInput.js @@ -133,6 +133,11 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) { ...other } = props; + const paperProps = { + ...MenuProps.PaperProps, + ...MenuProps.slotProps?.paper, + }; + const [value, setValueState] = useControlled({ controlled: valueProp, default: defaultValue, @@ -146,10 +151,15 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) { const inputRef = React.useRef(null); const displayRef = React.useRef(null); + const paperRef = React.useRef(null); + const didPointerDownRef = React.useRef(false); + const [displayNode, setDisplayNode] = React.useState(null); const { current: isOpenControlled } = React.useRef(openProp != null); const [menuMinWidthState, setMenuMinWidthState] = React.useState(); + const handleRef = useForkRef(ref, inputRefProp); + const handlePaperRef = useForkRef(paperProps.ref, paperRef); const handleDisplayRef = React.useCallback((node) => { displayRef.current = node; @@ -233,6 +243,37 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) { event.preventDefault(); displayRef.current.focus(); + const doc = ownerDocument(event.currentTarget); + + function handleMouseUp(mouseEvent) { + if (!displayRef.current) { + return; + } + + // mouse is over the options/menuitem, don't close the menu + if (paperRef.current.contains(mouseEvent.target)) { + return; + } + + const triggerElement = displayRef.current.getBoundingClientRect(); + + // mouse is inside the trigger, don't close the menu + if ( + mouseEvent.clientX >= triggerElement.left && + mouseEvent.clientX <= triggerElement.right && + mouseEvent.clientY >= triggerElement.top && + mouseEvent.clientY <= triggerElement.bottom + ) { + return; + } + + // close the menu + update(false, mouseEvent); + } + + // `{ once: true }` to automatically remove the listener, see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#once + doc.addEventListener('mouseup', handleMouseUp, { once: true }); + update(true, event); }; @@ -257,7 +298,7 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) { } }; - const handleItemClick = (child) => (event) => { + const handleItemSelect = (child) => (event) => { let newValue; // We use the tabindex attribute to signal the available options. @@ -277,10 +318,6 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) { newValue = child.props.value; } - if (child.props.onClick) { - child.props.onClick(event); - } - if (value !== newValue) { setValueState(newValue); @@ -394,7 +431,26 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) { return React.cloneElement(child, { 'aria-selected': selected ? 'true' : 'false', - onClick: handleItemClick(child), + onPointerDown: () => { + didPointerDownRef.current = true; + }, + onClick: (event) => { + didPointerDownRef.current = false; + if (child.props.onClick) { + child.props.onClick(event); + } + handleItemSelect(child)(event); + }, + onMouseUp: (event) => { + if (didPointerDownRef.current) { + didPointerDownRef.current = false; + return; + } + if (child.props.onMouseUp) { + child.props.onMouseUp(event); + } + handleItemSelect(child)(event); + }, onKeyUp: (event) => { if (event.key === ' ') { // otherwise our MenuItems dispatches a click event @@ -482,11 +538,6 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) { const classes = useUtilityClasses(ownerState); - const paperProps = { - ...MenuProps.PaperProps, - ...MenuProps.slotProps?.paper, - }; - const listProps = { ...MenuProps.MenuListProps, ...MenuProps.slotProps?.list, @@ -572,6 +623,7 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) { }, paper: { ...paperProps, + ref: handlePaperRef, style: { minWidth: menuMinWidth, ...(paperProps != null ? paperProps.style : null),