Repository: mui/material-ui (TypeScript)
Date: 2020-04-22 06:25:08+00:00
Task type: Feature

Issue:
[Autocomplete] Option highlighted event
- [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.

## Summary 💡

```
<Autocomplete
   onOptionHighlighted={(option: T, reason: 'keyboardNavigation' | 'mouseOver' | 'automatic') => { ... }}
   ...
/>
```

This event would trigger when an option is highlighted (such that pressing Enter would then select that option). For instance, it would trigger upon using the arrow keys to cycle through the options and report which option is currently highlighted.

## Motivation 🔦

Give context to the user on the highlighted option to allow him to make a better choice. Right now, we only know which option is selected after the user makes a choice; this would allow providing feedback proactively rather than reactively.


Issue discussion:
> Give context to the user on the highlighted option to allow him to make a better choice.

@aslupik How do you plan to implement this extra context? (assuming you have this callback available).
> > Give context to the user on the highlighted option to allow him to make a better choice.
> 
> @aslupik How do you plan to implement this extra context? (assuming you have this callback available).

I'm using Redux, so the callback would trigger an action allowing the rest of the application to know which option is currently highlighted. Then, a separate component on the page can subscribe to that and display information related to the highlighted option. This component updates dynamically as the user cycles through the options in the dropdown.
@aslupik Ok sounds fair. What do you think of this API to anticipate for a future case when somebody will need to control the highlighted option (at the cost of harming performance, I imagine)

```diff
diff --git a/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.d.ts b/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.d.ts
index 910be8d93..0dd380265 100644
--- a/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.d.ts
+++ b/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.d.ts
@@ -134,6 +134,18 @@ export interface UseAutocompleteCommonProps<T> {
    * @param {string} reason Can be: `"toggleInput"`, `"escape"`, `"select-option"`, `"blur"`.
    */
   onClose?: (event: React.ChangeEvent<{}>, reason: AutocompleteCloseReason) => void;
+  /**
+   * Callback  fired when the highlighted option changes.
+   *
+   * @param {object} event The event source of the callback.
+   * @param {T} option
+   * @param {string} reason Can be: `"keyboard"`, `"auto"`, `"mouseOver"`.
+   */
+  onHighlightChange?: (
+    event?: React.ChangeEvent<{}>,
+    option: T,
+    reason: AutocompleteHighlightChangeReason
+  );
   /**
    * Callback fired when the input value changes.
    *
@@ -178,6 +190,12 @@ export type AutocompleteChangeReason =
   | 'remove-option'
   | 'clear'
   | 'blur';
+
+export type AutocompleteHighlightChangeReason =
+  | 'keyboard'
+  | 'mouseOver'
+  | 'auto';
+
 export interface AutocompleteChangeDetails<T = string> {
   option: T;
 }
```
@oliviertassinari  That looks good to me!
I Will create a PR with this feature soon :) 

Solution patch:
diff --git a/docs/pages/api-docs/autocomplete.md b/docs/pages/api-docs/autocomplete.md
--- a/docs/pages/api-docs/autocomplete.md
+++ b/docs/pages/api-docs/autocomplete.md
@@ -69,6 +69,7 @@ The `MuiAutocomplete` name can be used for providing [default props](/customizat
 | <span class="prop-name">noOptionsText</span> | <span class="prop-type">node</span> | <span class="prop-default">'No options'</span> | Text to display when there are no options.<br>For localization purposes, you can use the provided [translations](/guides/localization/). |
 | <span class="prop-name">onChange</span> | <span class="prop-type">func</span> |  | Callback fired when the value changes.<br><br>**Signature:**<br>`function(event: object, value: T, reason: string) => void`<br>*event:* The event source of the callback.<br>*value:* The new value of the component.<br>*reason:* One of "create-option", "select-option", "remove-option", "blur" or "clear". |
 | <span class="prop-name">onClose</span> | <span class="prop-type">func</span> |  | Callback fired when the popup requests to be closed. Use in controlled mode (see open).<br><br>**Signature:**<br>`function(event: object, reason: string) => void`<br>*event:* The event source of the callback.<br>*reason:* Can be: `"toggleInput"`, `"escape"`, `"select-option"`, `"blur"`. |
+| <span class="prop-name">onHighlightChange</span> | <span class="prop-type">func</span> |  | Callback fired when the highlight option changes.<br><br>**Signature:**<br>`function(event: object, option: T, reason: string) => void`<br>*event:* The event source of the callback.<br>*option:* The highlighted option.<br>*reason:* Can be: `"keyboard"`, `"auto"`, `"mouse"`. |
 | <span class="prop-name">onInputChange</span> | <span class="prop-type">func</span> |  | Callback fired when the input value changes.<br><br>**Signature:**<br>`function(event: object, value: string, reason: string) => void`<br>*event:* The event source of the callback.<br>*value:* The new value of the text input.<br>*reason:* Can be: `"input"` (user input), `"reset"` (programmatic change), `"clear"`. |
 | <span class="prop-name">onOpen</span> | <span class="prop-type">func</span> |  | Callback fired when the popup requests to be opened. Use in controlled mode (see open).<br><br>**Signature:**<br>`function(event: object) => void`<br>*event:* The event source of the callback. |
 | <span class="prop-name">open</span> | <span class="prop-type">bool</span> |  | Control the popup` open state. |
diff --git a/packages/material-ui-lab/src/Autocomplete/Autocomplete.js b/packages/material-ui-lab/src/Autocomplete/Autocomplete.js
--- a/packages/material-ui-lab/src/Autocomplete/Autocomplete.js
+++ b/packages/material-ui-lab/src/Autocomplete/Autocomplete.js
@@ -279,6 +279,7 @@ const Autocomplete = React.forwardRef(function Autocomplete(props, ref) {
     noOptionsText = 'No options',
     onChange,
     onClose,
+    onHighlightChange,
     onInputChange,
     onOpen,
     open,
@@ -729,6 +730,14 @@ Autocomplete.propTypes = {
    * @param {string} reason Can be: `"toggleInput"`, `"escape"`, `"select-option"`, `"blur"`.
    */
   onClose: PropTypes.func,
+  /**
+   * Callback fired when the highlight option changes.
+   *
+   * @param {object} event The event source of the callback.
+   * @param {T} option The highlighted option.
+   * @param {string} reason Can be: `"keyboard"`, `"auto"`, `"mouse"`.
+   */
+  onHighlightChange: PropTypes.func,
   /**
    * Callback fired when the input value changes.
    *
diff --git a/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.d.ts b/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.d.ts
--- a/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.d.ts
+++ b/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.d.ts
@@ -170,6 +170,18 @@ export interface UseAutocompleteCommonProps<T> {
    * @param {object} event The event source of the callback.
    */
   onOpen?: (event: React.ChangeEvent<{}>) => void;
+  /**
+   * Callback fired when the highlight option changes.
+   *
+   * @param {object} event The event source of the callback.
+   * @param {T} option The highlighted option.
+   * @param {string} reason Can be: `"keyboard"`, `"auto"`, `"mouse"`.
+   */
+  onHighlightChange?: (
+    event: React.ChangeEvent<{}>,
+    option: T | null,
+    reason: AutocompleteHighlightChangeReason
+  ) => void;
   /**
    * Control the popup` open state.
    */
@@ -189,6 +201,8 @@ export interface UseAutocompleteCommonProps<T> {
   selectOnFocus?: boolean;
 }
 
+export type AutocompleteHighlightChangeReason = 'keyboard' | 'mouse' | 'auto';
+
 export type AutocompleteChangeReason =
   | 'create-option'
   | 'select-option'
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
@@ -99,6 +99,7 @@ export default function useAutocomplete(props) {
     multiple = false,
     onChange,
     onClose,
+    onHighlightChange,
     onInputChange,
     onOpen,
     open: openProp,
@@ -120,7 +121,7 @@ export default function useAutocomplete(props) {
   const defaultHighlighted = autoHighlight ? 0 : -1;
   const highlightedIndexRef = React.useRef(defaultHighlighted);
 
-  const setHighlightedIndex = useEventCallback((index, mouse = false) => {
+  const setHighlightedIndex = useEventCallback((index, reason = 'auto', event) => {
     highlightedIndexRef.current = index;
     // does the index exist?
     if (index === -1) {
@@ -145,6 +146,10 @@ export default function useAutocomplete(props) {
       return;
     }
 
+    if (onHighlightChange) {
+      onHighlightChange(event, options[index], reason);
+    }
+
     if (index === -1) {
       listboxNode.scrollTop = 0;
       return;
@@ -163,7 +168,7 @@ export default function useAutocomplete(props) {
     //
     // Consider this API instead once it has a better browser support:
     // .scrollIntoView({ scrollMode: 'if-needed', block: 'nearest' });
-    if (listboxNode.scrollHeight > listboxNode.clientHeight && !mouse) {
+    if (listboxNode.scrollHeight > listboxNode.clientHeight && reason !== 'mouse') {
       const element = option;
 
       const scrollBottom = listboxNode.clientHeight + listboxNode.scrollTop;
@@ -332,7 +337,7 @@ export default function useAutocomplete(props) {
     }
   }
 
-  const changeHighlightedIndex = useEventCallback((diff, direction) => {
+  const changeHighlightedIndex = useEventCallback((diff, direction, reason = 'auto', event) => {
     if (!popupOpen) {
       return;
     }
@@ -382,7 +387,7 @@ export default function useAutocomplete(props) {
     };
 
     const nextIndex = validOptionIndex(getNextIndex(), direction);
-    setHighlightedIndex(nextIndex);
+    setHighlightedIndex(nextIndex, reason, event);
 
     if (autoComplete && diff !== 'reset') {
       if (nextIndex === -1) {
@@ -627,38 +632,38 @@ export default function useAutocomplete(props) {
         if (popupOpen) {
           // Prevent scroll of the page
           event.preventDefault();
-          changeHighlightedIndex('start', 'next');
+          changeHighlightedIndex('start', 'next', 'keyboard', event);
         }
         break;
       case 'End':
         if (popupOpen) {
           // Prevent scroll of the page
           event.preventDefault();
-          changeHighlightedIndex('end', 'previous');
+          changeHighlightedIndex('end', 'previous', 'keyboard', event);
         }
         break;
       case 'PageUp':
         // Prevent scroll of the page
         event.preventDefault();
-        changeHighlightedIndex(-pageSize, 'previous');
+        changeHighlightedIndex(-pageSize, 'previous', 'keyboard', event);
         handleOpen(event);
         break;
       case 'PageDown':
         // Prevent scroll of the page
         event.preventDefault();
-        changeHighlightedIndex(pageSize, 'next');
+        changeHighlightedIndex(pageSize, 'next', 'keyboard', event);
         handleOpen(event);
         break;
       case 'ArrowDown':
         // Prevent cursor move
         event.preventDefault();
-        changeHighlightedIndex(1, 'next');
+        changeHighlightedIndex(1, 'next', 'keyboard', event);
         handleOpen(event);
         break;
       case 'ArrowUp':
         // Prevent cursor move
         event.preventDefault();
-        changeHighlightedIndex(-1, 'previous');
+        changeHighlightedIndex(-1, 'previous', 'keyboard', event);
         handleOpen(event);
         break;
       case 'ArrowLeft':
@@ -791,7 +796,7 @@ export default function useAutocomplete(props) {
 
   const handleOptionMouseOver = (event) => {
     const index = Number(event.currentTarget.getAttribute('data-option-index'));
-    setHighlightedIndex(index, 'mouse');
+    setHighlightedIndex(index, 'mouse', event);
   };
 
   const handleOptionTouchStart = () => {


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
@@ -1549,4 +1549,66 @@ describe('<Autocomplete />', () => {
       expect(container.querySelector(`.${classes.root}`)).to.have.class(classes.fullWidth);
     });
   });
+
+  describe('prop: onHighlightChange', () => {
+    it('should trigger event when default value is passed', () => {
+      const handleChange = spy();
+      const options = ['one', 'two', 'three'];
+      render(
+        <Autocomplete
+          defaultValue={options[0]}
+          onHighlightChange={handleChange}
+          options={options}
+          open
+          renderInput={(params) => <TextField autoFocus {...params} />}
+        />,
+      );
+      expect(handleChange.callCount).to.equal(1);
+      expect(handleChange.args[0][0]).to.equal(undefined);
+      expect(handleChange.args[0][1]).to.equal(options[0]);
+      expect(handleChange.args[0][2]).to.equal('auto');
+    });
+
+    it('should support keyboard event', () => {
+      const handleChange = spy();
+      const options = ['one', 'two', 'three'];
+      render(
+        <Autocomplete
+          onHighlightChange={handleChange}
+          options={options}
+          open
+          renderInput={(params) => <TextField autoFocus {...params} />}
+        />,
+      );
+      fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
+      expect(handleChange.callCount).to.equal(2);
+      expect(handleChange.args[1][0]).to.not.equal(undefined);
+      expect(handleChange.args[1][1]).to.equal(options[0]);
+      expect(handleChange.args[1][2]).to.equal('keyboard');
+      fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
+      expect(handleChange.callCount).to.equal(3);
+      expect(handleChange.args[2][0]).to.not.equal(undefined);
+      expect(handleChange.args[2][1]).to.equal(options[1]);
+      expect(handleChange.args[2][2]).to.equal('keyboard');
+    });
+
+    it('should support mouse event', () => {
+      const handleChange = spy();
+      const options = ['one', 'two', 'three'];
+      const { getAllByRole } = render(
+        <Autocomplete
+          onHighlightChange={handleChange}
+          options={options}
+          open
+          renderInput={(params) => <TextField autoFocus {...params} />}
+        />,
+      );
+      const firstOption = getAllByRole('option')[0];
+      fireEvent.mouseOver(firstOption);
+      expect(handleChange.callCount).to.equal(2);
+      expect(handleChange.args[1][0]).to.not.equal(undefined);
+      expect(handleChange.args[1][1]).to.equal(options[0]);
+      expect(handleChange.args[1][2]).to.equal('mouse');
+    });
+  });
 });