diff --git a/packages/mui-material/src/Select/Select.test.js b/packages/mui-material/src/Select/Select.test.js
index 4599548c848d92..c3818ad13c4d93 100644
--- a/packages/mui-material/src/Select/Select.test.js
+++ b/packages/mui-material/src/Select/Select.test.js
@@ -33,6 +33,54 @@ describe('', () => {
skip: ['componentProp', 'componentsProp', 'themeVariants', 'themeStyleOverrides'],
}));
+ describe('WCAG 2.5.2 - Pointer Cancellation', () => {
+ it('should close the menu when dragging away and releasing', () => {
+ const { getByRole, queryByRole } = render(
+ ,
+ );
+ const trigger = getByRole('combobox');
+
+ // Open the menu with left mouse button
+ fireEvent.mouseDown(trigger, { button: 0 });
+ expect(getByRole('listbox')).not.to.equal(null);
+
+ // Simulate mouse move to initiate drag
+ fireEvent.mouseMove(document.body);
+
+ // Simulate mouse up outside any menu items
+ fireEvent.mouseUp(document.body);
+
+ // Menu should be closed now
+ expect(queryByRole('listbox', { hidden: false })).to.equal(null);
+ });
+
+ it('should not close the menu when releasing on a menu item after dragging', () => {
+ const { getByRole, getAllByRole } = render(
+ ,
+ );
+ const trigger = getByRole('combobox');
+
+ // Open the menu
+ fireEvent.mouseDown(trigger, { button: 0 });
+ const options = getAllByRole('option');
+
+ // Simulate mouse move to initiate drag
+ fireEvent.mouseMove(document.body);
+
+ // Simulate mouse up on a menu item
+ fireEvent.mouseUp(options[0]);
+
+ // Menu should still be open
+ expect(getByRole('listbox')).not.to.equal(null);
+ });
+ });
+
describe('prop: inputProps', () => {
it('should be able to provide a custom classes property', () => {
render(
diff --git a/packages/mui-material/src/Select/SelectInput.js b/packages/mui-material/src/Select/SelectInput.js
index 58255517fe8eac..7fe7f28933eb86 100644
--- a/packages/mui-material/src/Select/SelectInput.js
+++ b/packages/mui-material/src/Select/SelectInput.js
@@ -162,6 +162,11 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) {
const anchorElement = displayNode?.parentNode;
+ const [isPointerDown, setIsPointerDown] = React.useState(false);
+ const dragSelectRef = React.useRef({
+ isDragging: false,
+ });
+
React.useImperativeHandle(
handleRef,
() => ({
@@ -210,20 +215,23 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) {
return undefined;
}, [labelId]);
- const update = (open, event) => {
- if (open) {
- if (onOpen) {
- onOpen(event);
+ const update = React.useCallback(
+ (open, event) => {
+ if (open) {
+ if (onOpen) {
+ onOpen(event);
+ }
+ } else if (onClose) {
+ onClose(event);
}
- } else if (onClose) {
- onClose(event);
- }
- if (!isOpenControlled) {
- setMenuMinWidthState(autoWidth ? null : anchorElement.clientWidth);
- setOpenState(open);
- }
- };
+ if (!isOpenControlled) {
+ setMenuMinWidthState(autoWidth ? null : anchorElement.clientWidth);
+ setOpenState(open);
+ }
+ },
+ [autoWidth, anchorElement, isOpenControlled, onOpen, onClose, setOpenState],
+ );
const handleMouseDown = (event) => {
// Ignore everything but left-click
@@ -234,6 +242,10 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) {
event.preventDefault();
displayRef.current.focus();
+ // Mark that we've initiated a pointer interaction
+ setIsPointerDown(true);
+
+ // Open the menu immediately
update(true, event);
};
@@ -258,6 +270,56 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) {
}
};
+ React.useEffect(() => {
+ if (isPointerDown) {
+ const doc = ownerDocument(displayRef.current);
+
+ const handleGlobalMouseUp = (event) => {
+ setIsPointerDown(false);
+
+ if (dragSelectRef.current.isDragging) {
+ // If we're dragging and the mouse is released, check where it was released
+ dragSelectRef.current.isDragging = false;
+
+ // Check if mouse is over a menu item
+ const targetElement = event.target;
+ let menuItem = null;
+
+ // Find if we released on a menu item (checking up the parent chain)
+ let current = targetElement;
+ while (current && !menuItem) {
+ // Check if this element has role="option"
+ if (current.getAttribute && current.getAttribute('role') === 'option') {
+ menuItem = current;
+ }
+ current = current.parentElement;
+ }
+
+ if (!menuItem) {
+ // If released outside menu items, close the menu
+ update(false, event);
+ }
+ }
+ };
+
+ const handleGlobalMouseMove = () => {
+ if (isPointerDown) {
+ dragSelectRef.current.isDragging = true;
+ }
+ };
+
+ doc.addEventListener('mouseup', handleGlobalMouseUp);
+ doc.addEventListener('mousemove', handleGlobalMouseMove);
+
+ return () => {
+ doc.removeEventListener('mouseup', handleGlobalMouseUp);
+ doc.removeEventListener('mousemove', handleGlobalMouseMove);
+ };
+ }
+
+ return undefined;
+ }, [isPointerDown, update]);
+
const handleItemClick = (child) => (event) => {
let newValue;