Repository: mui/material-ui (TypeScript)
Date: 2020-02-01 19:39:43+00:00
Task type: Bug Fix

Issue:
[Autocomplete] Input cleared unexpectedly with freeSolo, autoHighlight and autoSelect
<!-- Provide a general summary of the issue in the Title above -->

An `Autocomplete` with `freeSolo`, `autoHighlight` and `autoSelect` props set to `true` clears its input when it loses focus and the input value is not an exact or partial match for any of the  provided options.

<!--
  Thank you very much for contributing to Material-UI by creating an issue! ❤️
  To avoid duplicate issues we ask you to check off the following list.
-->

<!-- Checked checkbox should look like this: [x] -->

- [x] The issue is present in the latest release.
- [x] I have searched the [issues](https://github.com/mui-org/material-ui/issues) of this repository and believe that this is not a duplicate.

## Current Behavior 😯

<!-- Describe what happens instead of the expected behavior. -->
See above. This only occurs once; going back and entering the same free string causes it to persist until the input is cleared again (either manually or by clicking the clear button), which seems to put it back into the problematic state. Note that the string must contain letters that appear in any of the option labels for this to occur.

## Expected Behavior 🤔

The input value should not be cleared on blur as long as `freeSolo` is `true`.

## Steps to Reproduce 🕹

<!--
  Provide a link to a live example (you can use codesandbox.io) and an unambiguous set of steps to reproduce this bug.
  Include code to reproduce, if relevant (which it most likely is).

  This codesandbox.io template _may_ be a good starting point:
  https://codesandbox.io/s/github/mui-org/material-ui/tree/master/examples/create-react-app

  If you're using typescript a better starting point would be
  https://codesandbox.io/s/github/mui-org/material-ui/tree/master/examples/create-react-app-with-typescript

  If YOU DO NOT take time to provide a codesandbox.io reproduction, should the COMMUNITY take time to help you?
-->

Codesandbox: https://codesandbox.io/s/create-react-app-76xd5

Steps:

1. Enter a string into the Autocomplete input which is *not a substring of any of the option labels, but contains letters that appear in at least one of them* (e.g. "aaa").
2. Tab to the next field.
3. Tab back to the Autocomplete and enter the same string.
4. Tab to the next field.

To reset, click the clear button or backspace over the input field.

## Context 🔦

<!--
  What are you trying to accomplish? How has this issue affected you?
  Providing context helps us come up with a solution that is most useful in the real world.
-->
I am trying to build a simple autocomplete field which permits free values but optimizes entry of provided options first (hence this combination of props).

## Your Environment 🌎

<!--
  Include as many relevant details about the environment with which you experienced the bug.
  If you encounter issues with typescript please include version and tsconfig.
-->

| Tech        | Version |
| ----------- | ------- |
| Material-UI | latest |
| Material-UI Lab | 4.0.0-alpha.34 |
| React       | latest |
| Browser     | Firefox 70.0.1, Chrome 78.0.3904.108 |




Issue discussion:
@reiv Thanks for the bug report, I can confirm two issues:

1. type <kbd>a</kbd> x2, type <kbd>Backspace</kbd> => observe that the first option is not highlighted.
2. type <kbd>a</kbd> x2, blur => observe that a `undefined` value is selected.

What do you think of the following diff?

```diff
diff --git a/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.js b/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.js
index a8fd29e1a..df72a0980 100644
--- a/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.js
+++ b/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.js
@@ -114,7 +114,6 @@ export default function useAutocomplete(props) {
   const [focusedTag, setFocusedTag] = React.useState(-1);
   const defaultHighlighted = autoHighlight ? 0 : -1;
   const highlightedIndexRef = React.useRef(defaultHighlighted);
-  const selectedIndexRef = React.useRef(-1);

   function setHighlightedIndex(index, mouse = false) {
     highlightedIndexRef.current = index;
@@ -350,7 +349,6 @@ export default function useAutocomplete(props) {

     const nextIndex = validOptionIndex(getNextIndex(), direction);
     setHighlightedIndex(nextIndex);
-    selectedIndexRef.current = nextIndex;

     if (autoComplete && diff !== 'reset') {
       if (nextIndex === -1) {
@@ -429,8 +427,6 @@ export default function useAutocomplete(props) {
     }

     resetInputValue(event, newValue);
-
-    selectedIndexRef.current = -1;
   };

   function validTagIndex(index, direction) {
@@ -615,8 +611,8 @@ export default function useAutocomplete(props) {
       return;
     }

-    if (autoSelect && selectedIndexRef.current !== -1) {
-      handleValue(event, filteredOptions[selectedIndexRef.current]);
+    if (autoSelect && highlightedIndexRef.current !== -1 && popupOpen) {
+      handleValue(event, filteredOptions[highlightedIndexRef.current]);
     } else if (!freeSolo) {
       resetInputValue(event, value);
     }
@@ -673,8 +669,13 @@ export default function useAutocomplete(props) {
       return;
     }

-    // Restore the focus to the correct option.
-    setHighlightedIndex(highlightedIndexRef.current);
+    // Automatically select the first option as the listbox become visible.
+    if (highlightedIndexRef.current === -1 && autoHighlight) {
+      changeHighlightedIndex('reset', 'next');
+    } else {
+      // Restore the focus to the correct option.
+      setHighlightedIndex(highlightedIndexRef.current);
+    }
   });

   const handlePopupIndicator = event => {
```

Basically:

- use the same variable for highlighting and auto selection
- only auto select if a highlighted option is visible
- if a listbox become visible, give it an option to auto highlight

Do you want to submit a pull request? :)
@oliviertassinari I tried out your suggested changes and while it does fix the original issue I'm not sure if treating "highlighted" and "selected" option as the same is the way to go. For instance, an option could be inadvertently selected by simply mousing over it, which feels weird.

Also, when clearing the input, the first available option is autoselected on blur, unless the listbox is dismissed first. `autoSelect` should not affect the selection when no input value is given, should it? So:

```diff
// Automatically select the first option as the listbox becomes visible.
- if (highlightedIndexRef.current === -1 && autoHighlight) {
+ if (inputValue && highlightedIndexRef.current === -1 && autoHighlight) {
    changeHighlightedIndex('reset', 'next');
```
@reiv Thanks for the feedback:
- I think that it's important to keep the selected and highlighted state in sync. If we don't, we would open the door to unpredictable option selection (with no visual preliminary feedback).
- I think that the highlighted option should be selected, even if the field is empty on blur. If you don't want to behavior, remove `autoHighlight` or `autoSelect`.
@reiv  @oliviertassinari Can I pick it up?

Solution patch:
diff --git a/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.js b/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.js
--- a/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.js
+++ b/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.js
@@ -130,7 +130,6 @@ export default function useAutocomplete(props) {
   const [focusedTag, setFocusedTag] = React.useState(-1);
   const defaultHighlighted = autoHighlight ? 0 : -1;
   const highlightedIndexRef = React.useRef(defaultHighlighted);
-  const selectedIndexRef = React.useRef(-1);
 
   function setHighlightedIndex(index, mouse = false) {
     highlightedIndexRef.current = index;
@@ -373,7 +372,6 @@ export default function useAutocomplete(props) {
 
     const nextIndex = validOptionIndex(getNextIndex(), direction);
     setHighlightedIndex(nextIndex);
-    selectedIndexRef.current = nextIndex;
 
     if (autoComplete && diff !== 'reset') {
       if (nextIndex === -1) {
@@ -454,8 +452,6 @@ export default function useAutocomplete(props) {
     if (!disableCloseOnSelect) {
       handleClose(event);
     }
-
-    selectedIndexRef.current = -1;
   };
 
   function validTagIndex(index, direction) {
@@ -652,8 +648,8 @@ export default function useAutocomplete(props) {
       return;
     }
 
-    if (autoSelect && selectedIndexRef.current !== -1) {
-      selectNewValue(event, filteredOptions[selectedIndexRef.current]);
+    if (autoSelect && highlightedIndexRef.current !== -1 && popupOpen) {
+      selectNewValue(event, filteredOptions[highlightedIndexRef.current]);
     } else if (autoSelect && freeSolo && inputValue !== '') {
       selectNewValue(event, inputValue, 'freeSolo');
     } else if (!freeSolo) {
@@ -726,8 +722,13 @@ export default function useAutocomplete(props) {
       return;
     }
 
-    // Restore the focus to the correct option.
-    setHighlightedIndex(highlightedIndexRef.current);
+    // Automatically select the first option as the listbox become visible.
+    if (highlightedIndexRef.current === -1 && autoHighlight) {
+      changeHighlightedIndex('reset', 'next');
+    } else {
+      // Restore the focus to the correct option.
+      setHighlightedIndex(highlightedIndexRef.current);
+    }
   });
 
   const handlePopupIndicator = event => {


Test patch:
diff --git a/packages/material-ui-lab/src/Autocomplete/Autocomplete.test.js b/packages/material-ui-lab/src/Autocomplete/Autocomplete.test.js
--- a/packages/material-ui-lab/src/Autocomplete/Autocomplete.test.js
+++ b/packages/material-ui-lab/src/Autocomplete/Autocomplete.test.js
@@ -49,7 +49,51 @@ describe('<Autocomplete />', () => {
     });
   });
 
+  describe('prop: autoHighlight', () => {
+    it('should set the focus on the first item', () => {
+      const options = ['one', 'two'];
+      const { getByRole } = render(
+        <Autocomplete
+          freeSolo
+          autoHighlight
+          options={options}
+          renderInput={params => <TextField autoFocus {...params} />}
+        />,
+      );
+
+      function checkHighlightIs(expected) {
+        expect(getByRole('listbox').querySelector('li[data-focus]')).to.have.text(expected);
+      }
+
+      checkHighlightIs('one');
+      fireEvent.change(document.activeElement, { target: { value: 'oo' } });
+      fireEvent.change(document.activeElement, { target: { value: 'o' } });
+      checkHighlightIs('one');
+    });
+  });
+
   describe('prop: autoSelect', () => {
+    it('should not clear on blur when value does not match any option', () => {
+      const handleChange = spy();
+      const options = ['one', 'two'];
+
+      render(
+        <Autocomplete
+          freeSolo
+          autoSelect
+          options={options}
+          onChange={handleChange}
+          renderInput={params => <TextField autoFocus {...params} />}
+        />,
+      );
+      fireEvent.change(document.activeElement, { target: { value: 'o' } });
+      fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
+      fireEvent.change(document.activeElement, { target: { value: 'oo' } });
+      document.activeElement.blur();
+      expect(handleChange.callCount).to.equal(1);
+      expect(handleChange.args[0][1]).to.deep.equal('oo');
+    });
+
     it('should add new value when autoSelect & multiple on blur', () => {
       const handleChange = spy();
       const options = ['one', 'two'];