material-ui icon indicating copy to clipboard operation
material-ui copied to clipboard

[Autocomplete] Differentiate hover & keyboard states

Open caseyjhol opened this issue 5 years ago • 16 comments

With the current implementation, if you hover over an option, and then move your cursor outside of the option menu, the last option you were hovering over remains highlighted. I would like to remove this highlight programmatically.

  • [x] I have searched the issues of this repository and believe that this is not a duplicate.

#20615 is related, but it has been closed.

Summary 💡

If setHighlightedIndex were exposed (along with the option to pass null for the index to remove the highlighted option), I could call setHighlightedIndex({ index: null}) to remove the highlight. Alternatively, if Autocomplete had a clearHighlight or removeHighlight prop that was simply a boolean, Autocomplete could check its status and call setHighlightedIndex({ index: null}) internally if it was ever set to true. Or, a removeHighlightOnExit prop could be set.

Examples 🌈

As you can see below, after moving the cursor outside of the menu, pressing "Enter" searches for the text in the input - "material ui" rather than the previously highlighted option "material ui textfield." autocomplete

Motivation 🔦

With the current implementation, if you hover over an option, and then move your cursor outside of the option menu, the last option you were hovering over remains highlighted. This means that pressing "Enter" automatically selects the last selected option. I would like to implement functionality similar to Google's search, so that if you if press "Enter" it redirects you to a search results page. I added a custom onMouseLeave function to ListboxProps that removes [data-focus] from the highlighted option (to remove its styling) and removes [aria-activedescendant] from the input element when hovering outside of the menu. Then, in my Autocomplete onChange function, I'm checking for the existence of [aria-activedescendant] on the input. If it doesn't exist, it means the cursor is outside of the menu and it returns (thus not selecting anything), and allows the form to submit. This is working fine; however, if a user hovers outside of the menu (removing the highlight) and presses the down arrow on their keyboard, the option below the previously highlighted option is selected, rather than the first option as one would expect. There is no method to reset the highlighted index.

caseyjhol avatar Dec 09 '20 18:12 caseyjhol

@caseyjhol Interesting. I can't remember why I have made the hover change the highlighted item, and not only the keyboard. Recently, we have done #23323 to visually differentiate the two states.

What if we made your example the default? Well, not your example of the exact default, I think that Chrome does it better with its search bar, but you get the idea: isolate keyboard and hover. @eps1lon What do you think?

Regarding the API, it's a duplicate of #20852.

oliviertassinari avatar Dec 09 '20 20:12 oliviertassinari

@oliviertassinari I think it would be great if keyboard and hover were isolated. With my current workaround, if the cursor happens to be underneath the input while typing in the input, and I hit "Enter" to perform the search, it actually selects the option that happens to be highlighted by the cursor.

The existing autocomplete hover functionality is identical to GitHub's, so perhaps you were taking inspiration from them. I've always found GitHub's autocomplete frustrating as I often find myself accidentally searching all of GitHub instead of the current repository. Google has keyboard control and hover isolated from each other, and I find it much more user friendly. Let me know if there's anything else you'd like me to clarify. Thanks!

caseyjhol avatar Dec 09 '20 20:12 caseyjhol

I often find myself accidentally searching all of GitHub instead of the current repository.

Oh! I have experienced the same multiple times but could never figure out why. Maybe that's it 😆. Hated it.

oliviertassinari avatar Dec 09 '20 20:12 oliviertassinari

@tcho0501 and I will take a try at it!

jackcwu avatar Dec 12 '20 08:12 jackcwu

if you hover over an option, and then move your cursor outside of the option menu, the last option you were hovering over remains highlighted

That sounds like a bug to me. Pressing enter should not activate the item that you hover.

Edit: I consider it a bug because mouse and focus are two different cursors. As far as I know the default behavior on the web does not link these cursors. We could make an argument that these should be linked but that should be a consistent behavior in your app not just one component.

eps1lon avatar Dec 12 '20 18:12 eps1lon

@eps1lon On macOS, the hover & keyboard focus shares the same state, it's even true for a native <select>. I'm not sure it's a bug but it seems that we all agree we should differentiate the states, e.g. https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-1/menubar-1.html#. Following the behavior of react-select was probably not the best call.

oliviertassinari avatar Dec 12 '20 18:12 oliviertassinari

@eps1lon On macOS, the hover & keyboard focus shares the same state, it's even true for a native <select>.

Sure, but what I meant was not the visual states but the actual "cursors": mouse cursor and keyboard cursor. So it's not the states that look the same but the cursors that are linked. Though a native select does work that way it seems.

I think we should rather implement the behavior where mouse and keyboard have different cursors. That's how google.com works, or the autocomplete in chromes navigation bar, or the autocomplete for labels on github. It's surprising to me why the native select works the way it does. Though autocomplete and <select> are different UI patterns so I don't think they need to have the same cursor behavior.

eps1lon avatar Dec 12 '20 19:12 eps1lon

Note that Chromium and Firefox behave differently about native autocomplete. Suppose this example: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/select

On chromium, open select box, type "cat" (or any option present), so the option would be highlighted, now move your mouse through all options, from top to bottom and leave the menu, the last item option is highlighted, and pressing Enter would select it. But in Firefox, doing the same, would result in nothing being selected. I find Firefox's behavior more reasonable as I have moved my mouse cursor out of select box.

But with material-ui, what led me to this issue, is using weird behavior of autoSelect regarding mouse move. If I open combo options, search nothing, move my mouse through items, click somewhere outside of combo, the last highlighted option is selected. That is very annoying for my ui, and I prefer not doing workarounds, like setting highlightedIndex programmatically. I can't think of a use case where this would be preferable.

https://user-images.githubusercontent.com/5755214/104171507-fff29100-5417-11eb-858d-68dbb1f9d0a0.mp4

aghArdeshir avatar Jan 11 '21 10:01 aghArdeshir

Correct, it's exactly the problem the issue is meant to solve. Feel free to work on a pull request to fix this.

oliviertassinari avatar Jan 11 '21 15:01 oliviertassinari

This is my ugly workaround:

const lastHighlightedItem = React.useRef<{ value: string | null }>({
  value: null,
});
const onHighlightChange = React.useCallback(function(_event, item, reason) {
  if (reason !== 'mouse') lastHighlightedItem.current.value = item;
}, []);

const onBlur = React.useCallback(() => {
  lastHighlightedItem.current.value = null;
}, []);

const onKeyDown = React.useCallback(
  function(keyDownEvent) {
    if (keyDownEvent.key === 'Tab' && lastHighlightedItem.current.value) {
      let valueToSet = extractValue(lastHighlightedItem.current.value);
      if (multiple)
        valueToSet = extractValue([lastHighlightedItem.current.value]);
      setValue(valueToSet, {append: multiple});
    } else if (keyDownEvent.key === 'Escape') {
      lastHighlightedItem.current.value = null;
    }
  },
  [extractValue, multiple, setValue]
);

The extractValue, setValue and {append: true} parts are specific to my code

aghArdeshir avatar Jan 12 '21 13:01 aghArdeshir

@caseyjhol @oliviertassinari Any updates on this issue?

FrenchMajesty avatar Oct 26 '21 13:10 FrenchMajesty

I am encountering the same issue. It's difficult to hack around the issue, and most other autocomplete that has free text (like Google) seem to follow the behaviour of differentiating the hover and keyboard state. Would love a update.

sanders54 avatar Feb 02 '22 12:02 sanders54

Totally agree this one would be great to fix, we are struggling with it as well

Snipx avatar Apr 27 '22 10:04 Snipx

The behavior is not consistent with how auto-complete works on any search engine that provides suggestion either. Unfortunately, we've built our entire UI Kit library on top of the functionality that comes from the library so it's a difficult thing for us to work around.

It would require us to do significant engineering at lowest level of abstraction within our UI Kit to address this short coming. Had we have been aware of this, we probably would have done more homework from the onset and would have written our own input using the basic HTML <input /> as our base and leveraged some other 3rd-party libraries.

We are kind of stuck without a path forward unless we commit many hours of hard work to address the behavior.

With many other bigger priorities we can't focus on this like that but this problem certainly feels like a thorn on my spine that I can't reach but I seriously wish someone else who can would remove for me.

FrenchMajesty avatar Jun 14 '22 21:06 FrenchMajesty

So, what is the preferred behavior? Would we want this to default to remove the selected item on exit? And then provide a way to preserve it? Or do we want the opposite to protect backward compatibility?

joeheyming avatar Oct 15 '23 17:10 joeheyming

I am encountering the same issue. Hover state persists even when cursor leaves the select box, trying to match Select component styling/behavior. Do we have any workaround??

SunilKumar33 avatar Jul 18 '24 07:07 SunilKumar33

@SunilKumar33

I hesitate to call this a workaround, it's just what's possible with the available data:

The function signature for onChange is this:

(
  event: React.SyntheticEvent, 
  value: any, 
  reason: AutocompleteChangeReason, // <----- use this
  details?: AutocompleteChangeDetails<any> | undefined
) => void

- you can use the `reason` value in your `onChange` handler like this:

```ts
const onSelect = (
  event: React.SyntheticEvent<Element, Event>,
  value: YourValueType | null,
  reason: AutocompleteChangeReason,
  details?: AutocompleteChangeDetails<any> | undefined,
) => {
  if (reason === "blur") {
       return undefined
  }

  // your code to handle an actual click on an option item
}

frankmeza avatar Apr 04 '25 16:04 frankmeza