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('', () => {
skip: ['componentProp', 'componentsProp', 'themeVariants', 'themeStyleOverrides'],
}));
+ describe('Pointer Cancellation', () => {
+ beforeEach(function beforeEachCallback() {
+ // Run these tests only in browser because JSDOM doesn't have getBoundingClientRect() API
+ if (/jsdom/.test(window.navigator.userAgent)) {
+ this.skip();
+ }
+ });
+
+ it('should close the menu when mouse is outside the select', () => {
+ render(
+ ,
+ );
+ 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('', () => {
expect(backdrop.style).to.have.property('backgroundColor', 'red');
});
+
+ it('should merge ref coming from paper props', () => {
+ const paperRef = React.createRef();
+
+ render(
+ ,
+ );
+
+ expect(paperRef.current).to.equal(screen.getByTestId('paper'));
+ });
});
describe('prop: SelectDisplayProps', () => {
@@ -1352,6 +1437,72 @@ describe('', () => {
combinedStyle,
);
});
+
+ it('should be able to select the items on click of options', 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
+ 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('', () => {
expect(onClick.callCount).to.equal(1);
});
+ it('should pass onMouseUp prop to MenuItem', () => {
+ const onMouseUp = spy();
+ const { getAllByRole } = render(
+ ,
+ );
+
+ 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('', () => {
expect(container.querySelector('.MuiSelect-iconFilled')).not.to.equal(null);
expect(container.querySelector('.MuiSelect-filled ~ .MuiSelect-icon')).not.to.equal(null);
});
+
+ it('should select the item on mouse up', () => {
+ render(
+ ,
+ );
+
+ 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),