From 98b25769a024858f3abdb7e1608dd4503585a2e4 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Mon, 6 Apr 2026 21:58:19 +0800 Subject: [PATCH 1/3] improve highlight tracking and selection state --- .../src/Autocomplete/Autocomplete.test.js | 279 ++++++++++++++++++ .../src/useAutocomplete/useAutocomplete.js | 91 +++++- 2 files changed, 363 insertions(+), 7 deletions(-) diff --git a/packages/mui-material/src/Autocomplete/Autocomplete.test.js b/packages/mui-material/src/Autocomplete/Autocomplete.test.js index 228b812abd0911..2bda82931201d7 100644 --- a/packages/mui-material/src/Autocomplete/Autocomplete.test.js +++ b/packages/mui-material/src/Autocomplete/Autocomplete.test.js @@ -631,6 +631,110 @@ describe('', () => { expect(handleChange.callCount).to.equal(1); expect(handleChange.args[0][1]).to.equal('a'); }); + + it('should not select a touch-highlighted option on blur', async () => { + const handleChange = spy(); + const options = ['one', 'two', 'three']; + const { user } = render( + } + />, + ); + + await user.pointer({ + keys: '[TouchA>]', + target: screen.getByRole('option', { name: 'two' }), + }); + await user.tab(); + + expect(handleChange.callCount).to.equal(0); + }); + + it('should not select a mouse-hovered option on blur even if already highlighted', async () => { + const handleChange = spy(); + const options = ['one', 'two', 'three']; + const { user } = render( + } + />, + ); + + // First option is programmatically highlighted by autoHighlight. + // Hovering it should still mark it as mouse-initiated and prevent + // autoSelect from committing it on blur. + await user.pointer({ target: screen.getByRole('option', { name: 'one' }) }); + await user.tab(); + + expect(handleChange.callCount).to.equal(0); + }); + + it('should not select a mouse-hovered option on blur', async () => { + const handleChange = spy(); + const options = ['one', 'two', 'three']; + const { user } = render( + } + />, + ); + + await user.pointer({ target: screen.getByRole('option', { name: 'two' }) }); + await user.tab(); + + expect(handleChange.callCount).to.equal(0); + }); + + it('should select a keyboard-highlighted option on blur', async () => { + const handleChange = spy(); + const options = ['one', 'two', 'three']; + const { user } = render( + } + />, + ); + + await user.keyboard('{ArrowDown}'); + await user.tab(); + + expect(handleChange.callCount).to.equal(1); + expect(handleChange.args[0][1]).to.equal('one'); + }); + + it('should select the first option on blur when autoHighlight is true', async () => { + const handleChange = spy(); + const options = ['one', 'two', 'three']; + const { user } = render( + } + />, + ); + + await user.tab(); + + expect(handleChange.callCount).to.equal(1); + expect(handleChange.args[0][1]).to.equal('one'); + }); }); describe('prop: multiple', () => { @@ -2652,6 +2756,181 @@ describe('', () => { expect(handleChange.args[0][1]).to.equal('あ'); }); + it('should prefer typed text over auto-highlighted match on Enter', async () => { + const handleChange = spy(); + const options = ['The Shawshank Redemption', 'The Godfather']; + const { user } = render( + } + />, + ); + + await user.type(screen.getByRole('combobox'), 'The{Enter}'); + + expect(handleChange.callCount).to.equal(1); + expect(handleChange.args[0][1]).to.equal('The'); + }); + + it('should prefer typed text after editing a selected value', async () => { + const handleChange = spy(); + const options = ['The Shawshank Redemption', 'The Godfather']; + const { user } = render( + } + />, + ); + + // Edit the text (still partially matches the selected value's option) + // and press Enter — should create free text, not re-select the old value + await user.keyboard('{Backspace}{Backspace}{Backspace}{Backspace}{Backspace}{Enter}'); + + expect(handleChange.callCount).to.equal(1); + expect(handleChange.args[0][1]).to.equal('The Godf'); + }); + + it('should select the highlighted option on Enter after keyboard navigation', async () => { + const handleChange = spy(); + const options = ['The Shawshank Redemption', 'The Godfather']; + const { user } = render( + } + />, + ); + + await user.type(screen.getByRole('combobox'), 'The'); + await user.keyboard('{ArrowDown}{Enter}'); + + expect(handleChange.callCount).to.equal(1); + expect(handleChange.args[0][1]).to.equal('The Shawshank Redemption'); + }); + + it('should select a mouse-hovered option on Enter after typing', async () => { + const handleChange = spy(); + const options = ['The Shawshank Redemption', 'The Godfather']; + const { user } = render( + } + />, + ); + + await user.type(screen.getByRole('combobox'), 'The'); + await user.pointer({ target: screen.getByRole('option', { name: 'The Godfather' }) }); + await user.keyboard('{Enter}'); + + expect(handleChange.callCount).to.equal(1); + expect(handleChange.args[0][1]).to.equal('The Godfather'); + }); + + it('should not select a touch-highlighted option after scroll on Enter', async () => { + const handleChange = spy(); + const options = ['one', 'two', 'three']; + const { user } = render( + } + />, + ); + const optionOne = screen.getByRole('option', { name: 'one' }); + + await user.pointer({ keys: '[TouchA>]', target: optionOne }); + fireEvent.scroll(screen.getByRole('listbox')); + await user.keyboard('{Enter}'); + + expect(handleChange.callCount).to.equal(0); + }); + + it('should allow Enter to select after touch-scroll then typing', async () => { + const handleChange = spy(); + const options = ['one', 'two', 'three']; + const { user } = render( + } + />, + ); + + // Touch-scroll makes the highlight stale + await user.pointer({ + keys: '[TouchA>]', + target: screen.getByRole('option', { name: 'one' }), + }); + fireEvent.scroll(screen.getByRole('listbox')); + + // Typing clears the stale scroll flag; autoHighlight re-highlights + await user.type(screen.getByRole('combobox'), 't'); + await user.keyboard('{Enter}'); + + expect(handleChange.callCount).to.equal(1); + expect(handleChange.args[0][1]).to.equal('two'); + }); + + it('should select an option on tap without scroll', async () => { + const handleChange = spy(); + const options = ['one', 'two', 'three']; + const { user } = render( + } + />, + ); + + await user.pointer([ + { keys: '[TouchA]', target: screen.getByRole('option', { name: 'one' }) }, + ]); + + expect(handleChange.callCount).to.equal(1); + expect(handleChange.args[0][1]).to.equal('one'); + }); + + it('should not misclassify scroll as touch after close and reopen', async () => { + const handleChange = spy(); + const options = ['one', 'two', 'three']; + const { user } = render( + } + />, + ); + + // Touch an option, then close by pressing Escape + await user.pointer({ + keys: '[TouchA>]', + target: screen.getByRole('option', { name: 'one' }), + }); + await user.keyboard('{Escape}'); + + // Reopen (first ArrowDown) and navigate (second ArrowDown), then Enter. + // The touch state should not leak into this new popup session. + await user.keyboard('{ArrowDown}{ArrowDown}{Enter}'); + + expect(handleChange.callCount).to.equal(1); + expect(handleChange.args[0][1]).to.equal('one'); + }); + it('should render endAdornment only when clear icon or popup icon is available', () => { const view = render( } />, diff --git a/packages/mui-material/src/useAutocomplete/useAutocomplete.js b/packages/mui-material/src/useAutocomplete/useAutocomplete.js index cdbe646f296be4..8277c277caa7f0 100644 --- a/packages/mui-material/src/useAutocomplete/useAutocomplete.js +++ b/packages/mui-material/src/useAutocomplete/useAutocomplete.js @@ -156,6 +156,21 @@ function useAutocomplete(props) { const defaultHighlighted = autoHighlight ? 0 : -1; const highlightedIndexRef = React.useRef(defaultHighlighted); + // Tracks how the current highlight was set: + // - 'keyboard' — arrow keys, Home/End, PageUp/PageDown + // - 'mouse' — handleOptionMouseMove + // - 'touch' — handleOptionTouchStart + // - null — programmatic (autoHighlight, value sync) + // + // This lets handleBlur and the Enter handler distinguish intentional + // interactions from incidental ones — e.g. autoSelect should not commit + // a highlight that came from a casual mouse hover. + /** @type {React.MutableRefObject} */ + const highlightReasonRef = React.useRef(null); + + const touchScrolledRef = React.useRef(false); + const isTouch = React.useRef(false); + // Calculate the initial inputValue on mount only. // useRef ensures it doesn't update dynamically with defaultValue or value props. const initialInputValue = React.useRef( @@ -347,6 +362,7 @@ function useAutocomplete(props) { const setHighlightedIndex = useEventCallback(({ event, index, reason }) => { highlightedIndexRef.current = index; + highlightReasonRef.current = reason ?? null; // does the index exist? if (index === -1) { @@ -427,6 +443,11 @@ function useAutocomplete(props) { return; } + if (reason === 'keyboard') { + touchScrolledRef.current = false; + isTouch.current = false; + } + const getNextIndex = () => { const maxIndex = filteredOptions.length - 1; @@ -538,6 +559,10 @@ function useAutocomplete(props) { // If it exists and the value and the inputValue haven't changed, just update its index, otherwise continue execution const previousHighlightedOptionIndex = getPreviousHighlightedOptionIndex(); if (previousHighlightedOptionIndex !== -1) { + // Bypass setHighlightedIndex to preserve the existing highlightReasonRef. + // The highlighted option still exists after the filteredOptions array changed + // (e.g. async fetch returns new options while the user is mid-navigation), + // so the original interaction reason (keyboard, mouse, etc.) still applies. highlightedIndexRef.current = previousHighlightedOptionIndex; return; } @@ -671,6 +696,7 @@ function useAutocomplete(props) { setOpenState(true); setInputPristine(true); + isTouch.current = false; if (onOpen) { onOpen(event); @@ -683,6 +709,7 @@ function useAutocomplete(props) { } setOpenState(false); + touchScrolledRef.current = false; if (onClose) { onClose(event, reason); @@ -705,8 +732,6 @@ function useAutocomplete(props) { setValueState(newValue); }; - const isTouch = React.useRef(false); - const selectNewValue = (event, option, reasonProp = 'selectOption', origin = 'options') => { let reason = reasonProp; let newValue = option; @@ -938,8 +963,24 @@ function useAutocomplete(props) { handleFocusItem(event, 'next'); } break; - case 'Enter': - if (highlightedIndexRef.current !== -1 && popupOpen) { + case 'Enter': { + // In freeSolo, only select the highlighted option if the user hasn't + // typed new text (inputPristine) or explicitly interacted with an option + // (keyboard, mouse, or touch — any non-null reason). This lets typed + // text win over a programmatic highlight (reason=null, e.g. from + // syncHighlightedIndex matching a previous value) while still honoring + // deliberate user interactions like hovering a suggestion then pressing Enter. + const shouldSelectHighlighted = + !freeSolo || inputPristine || highlightReasonRef.current !== null; + + if ( + highlightedIndexRef.current !== -1 && + popupOpen && + shouldSelectHighlighted && + // After a touch-scroll the highlight is stale (the user scrolled + // past it), so skip selection until the next deliberate interaction. + !touchScrolledRef.current + ) { const option = filteredOptions[highlightedIndexRef.current]; const disabled = getOptionDisabled ? getOptionDisabled(option) : false; @@ -967,6 +1008,7 @@ function useAutocomplete(props) { selectNewValue(event, inputValue, 'createOption', 'freeSolo'); } break; + } case 'Escape': if (popupOpen) { // Avoid Opera to exit fullscreen mode. @@ -1062,7 +1104,17 @@ function useAutocomplete(props) { firstFocus.current = true; ignoreFocus.current = false; - if (autoSelect && highlightedIndexRef.current !== -1 && popupOpen) { + // Auto-select the highlighted option on blur, but only if the highlight + // came from keyboard navigation or was set programmatically (autoHighlight). + // Mouse hover and touch should not trigger selection — the user may have + // moved the pointer over an option without intending to commit to it. + if ( + autoSelect && + highlightedIndexRef.current !== -1 && + popupOpen && + highlightReasonRef.current !== 'mouse' && + highlightReasonRef.current !== 'touch' + ) { selectNewValue(event, filteredOptions[highlightedIndexRef.current], 'blur'); } else if (autoSelect && freeSolo && inputValue !== '') { selectNewValue(event, inputValue, 'blur', 'freeSolo'); @@ -1075,10 +1127,11 @@ function useAutocomplete(props) { const handleInputChange = (event) => { const newValue = event.target.value; + const valueChanged = inputValue !== newValue; - if (inputValue !== newValue) { + if (valueChanged) { setInputValueState(newValue); - setInputPristine(false); + touchScrolledRef.current = false; if (onInputChange) { onInputChange(event, newValue, 'input'); @@ -1094,6 +1147,12 @@ function useAutocomplete(props) { } else { handleOpen(event); } + + // Called after handleOpen so it overrides handleOpen's setInputPristine(true) + // when the first keystroke also opens the popup. + if (valueChanged) { + setInputPristine(false); + } }; const handleOptionMouseMove = (event) => { @@ -1104,10 +1163,23 @@ function useAutocomplete(props) { index, reason: 'mouse', }); + } else { + // The option is already highlighted (e.g. programmatically via autoHighlight), + // but the user moved the mouse over it — mark as mouse-initiated so + // autoSelect on blur correctly treats this as incidental hover. + highlightReasonRef.current = 'mouse'; + } + // Only clear the touch-scroll flag for genuine mouse interactions. + // After a touch gesture, browsers fire compatibility mousemove events + // which should not clear the scroll guard. + if (!isTouch.current) { + touchScrolledRef.current = false; } + isTouch.current = false; }; const handleOptionTouchStart = (event) => { + touchScrolledRef.current = false; setHighlightedIndex({ event, index: Number(event.currentTarget.getAttribute('data-option-index')), @@ -1295,6 +1367,11 @@ function useAutocomplete(props) { // Prevent blur event.preventDefault(); }, + onScroll: () => { + if (isTouch.current) { + touchScrolledRef.current = true; + } + }, }), getOptionProps: ({ index, option }) => { const selected = isOptionSelected(option); From 451aabb9fc1e771e2b2889e08d601f01274efd74 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Tue, 7 Apr 2026 22:59:14 +0800 Subject: [PATCH 2/3] close popup on enter --- .../mui-material/src/Autocomplete/Autocomplete.test.js | 10 ++++++++-- .../src/useAutocomplete/useAutocomplete.js | 4 ++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/mui-material/src/Autocomplete/Autocomplete.test.js b/packages/mui-material/src/Autocomplete/Autocomplete.test.js index 2bda82931201d7..894afd60513fe9 100644 --- a/packages/mui-material/src/Autocomplete/Autocomplete.test.js +++ b/packages/mui-material/src/Autocomplete/Autocomplete.test.js @@ -2838,22 +2838,28 @@ describe('', () => { it('should not select a touch-highlighted option after scroll on Enter', async () => { const handleChange = spy(); + const handleClose = spy(); const options = ['one', 'two', 'three']; const { user } = render( } />, ); const optionOne = screen.getByRole('option', { name: 'one' }); - await user.pointer({ keys: '[TouchA>]', target: optionOne }); + // user.pointer({ keys: '[TouchA>]' }) fires pointerdown which moves focus + // on real devices, touchStart does not move focus + // therefore fireEvent is more correct here + fireEvent.touchStart(optionOne); fireEvent.scroll(screen.getByRole('listbox')); await user.keyboard('{Enter}'); expect(handleChange.callCount).to.equal(0); + expect(handleClose.callCount).to.equal(1); }); it('should allow Enter to select after touch-scroll then typing', async () => { diff --git a/packages/mui-material/src/useAutocomplete/useAutocomplete.js b/packages/mui-material/src/useAutocomplete/useAutocomplete.js index 8277c277caa7f0..49625c776ef340 100644 --- a/packages/mui-material/src/useAutocomplete/useAutocomplete.js +++ b/packages/mui-material/src/useAutocomplete/useAutocomplete.js @@ -1006,6 +1006,10 @@ function useAutocomplete(props) { event.preventDefault(); } selectNewValue(event, inputValue, 'createOption', 'freeSolo'); + } else if (popupOpen && touchScrolledRef.current) { + // The highlight is stale from a touch-scroll — close without selecting. + event.preventDefault(); + handleClose(event, 'escape'); } break; } From b04c1f0bb63d61b1350f0370ce2753c2e1f8c978 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Wed, 8 Apr 2026 05:52:10 +0800 Subject: [PATCH 3/3] adjust touch scroll guard clear --- .../src/useAutocomplete/useAutocomplete.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/mui-material/src/useAutocomplete/useAutocomplete.js b/packages/mui-material/src/useAutocomplete/useAutocomplete.js index 49625c776ef340..7ac84246ee8d23 100644 --- a/packages/mui-material/src/useAutocomplete/useAutocomplete.js +++ b/packages/mui-material/src/useAutocomplete/useAutocomplete.js @@ -710,6 +710,7 @@ function useAutocomplete(props) { setOpenState(false); touchScrolledRef.current = false; + highlightReasonRef.current = null; if (onClose) { onClose(event, reason); @@ -1173,13 +1174,15 @@ function useAutocomplete(props) { // autoSelect on blur correctly treats this as incidental hover. highlightReasonRef.current = 'mouse'; } - // Only clear the touch-scroll flag for genuine mouse interactions. - // After a touch gesture, browsers fire compatibility mousemove events - // which should not clear the scroll guard. + // Don't clear the touch-scroll guard while touch state is still latched. + // After a touch gesture, browsers may fire compatibility mousemove + // events; if those cleared the guard immediately, later compat events in + // the same sequence could be misclassified as a real mouse interaction. + // Touch state is cleared by the next deliberate interaction + // (keyboard nav, handleOptionClick, or handleOpen). if (!isTouch.current) { touchScrolledRef.current = false; } - isTouch.current = false; }; const handleOptionTouchStart = (event) => {