Repository: mui/material-ui (TypeScript)
Date: 2020-09-24 00:58:22+00:00
Task type: Bug Fix

Issue:
[Autocomplete] Accessibility error
<!-- Provide a general summary of the issue in the Title above -->

As of @material-ui/lab v4.0.0-alpha.52 the Autocomplete component fails jest a11y tests when the input field is filled. From some mild debugging I found that after the input field has been populated this aria attribute is added:
`aria-controls="mui-12209-popup"`
the id on this input element is
`id="mui-12209"`
This seems like a mismatched aria attribute?

<!--
  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 😯

Fails jest tests due to an a11y issue with a mismatched aria attribute after the input field is filled.

<!-- Describe what happens instead of the expected behavior. -->

## Expected Behavior 🤔

Remains accessible when input field has been populated.

<!-- Describe what should happen. -->

## 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).

  You should use the official codesandbox template as a starting point: https://material-ui.com/r/issue-template

  If you have an issue concerning TypeScript please start from this TypeScript playground: https://material-ui.com/r/ts-issue-template

  Issues without some form of live example have a longer response time.
-->

Steps:

1. put a data-testid on the input field via input props
```typescript
<Autocomplete<string>
  inputValue={props.searchInput}
  onInputChange={handleOnSearchInputChange}
  openOnFocus
  freeSolo
  disableClearable
  options={props.searchOptions}
  renderInput={params => (
     <TextField
      {...params}
      variant='outlined'
      placeholder={props.searchPlaceholder}
      inputProps={{
        ...params.inputProps,
        'aria-label': 'search input',
        'data-testid': 'autocomplete-input'
       }}
      />
  )}
/>
```
2. change the input in jest
```typescript
const input = utils.getByTestId('autocomplete-input');
fireEvent.change(input, { target: { value: 'my doctor' } });
```
3. test for a11y
```typescript
expect(await axe(input)).toHaveNoViolations();
```

## 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 simply trying to test a11y when the autocomplete input has been filled

## 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 core | v4.11.0  |
| Material-UI lab | v4.0.0-alpha.52  |
| React       |    16.13.1     |
| Browser     |   jest      |
| TypeScript  |      3.9.7   |

note:  Issue began on v4.0.0-alpha.52. v4.0.0-alpha.51 passes a11y as expected.


Issue discussion:
What violation did it report? 
![image](https://user-images.githubusercontent.com/26290074/90893692-715c7500-e38d-11ea-98e4-46dbbe1e5672.png)

@tylerjlawson Do you think that you could set up a live reproduction? Maybe with a repository or codesandbox? 
https://github.com/tylerjlawson/mui-lab-issue-repro

I did some more digging and isolated the issue (I think). See the README in this reproduction repo for more info.
Hi, has anyone been able to make any progress on this issue?
@tylerjlawson if you could, it would be great :) 
@tylerjlawson I had a look at the issue. Your test case is with a combobox displaying the "No options" message. I think that the solution is to differentiate the state where the popup is visible, we can use it to display a loader, a helping text, etc. and the case where the component has a listbox available as a popup. The following diff solves the issue:

```diff
diff --git a/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.js b/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.js
index a9d8d48de3..e11cae301b 100644
--- a/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.js
+++ b/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.js
@@ -206,6 +206,8 @@ export default function useAutocomplete(props) {
       )
     : [];

+  const listboxAvailable = open && filteredOptions.length > 0;
+
   if (process.env.NODE_ENV !== 'production') {
     if (value !== null && !freeSolo && options.length > 0) {
       const missingValue = (multiple ? value : [value]).filter(
@@ -932,9 +934,9 @@ export default function useAutocomplete(props) {

   return {
     getRootProps: (other = {}) => ({
-      'aria-owns': popupOpen ? `${id}-popup` : null,
+      'aria-owns': listboxAvailable ? `${id}-listbox` : null,
       role: 'combobox',
-      'aria-expanded': popupOpen,
+      'aria-expanded': listboxAvailable,
       ...other,
       onKeyDown: handleKeyDown(other),
       onMouseDown: handleMouseDown,
@@ -955,7 +957,7 @@ export default function useAutocomplete(props) {
       // only have an opinion about this when closed
       'aria-activedescendant': popupOpen ? '' : null,
       'aria-autocomplete': autoComplete ? 'both' : 'list',
-      'aria-controls': popupOpen ? `${id}-popup` : null,
+      'aria-controls': listboxAvailable ? `${id}-listbox` : null,
       // Disable browser's suggestion that might overlap with the popup.
       // Handle autocomplete but not autofill.
       autoComplete: 'off',
@@ -979,7 +981,7 @@ export default function useAutocomplete(props) {
     }),
     getListboxProps: () => ({
       role: 'listbox',
-      id: `${id}-popup`,
+      id: `${id}-listbox`,
       'aria-labelledby': `${id}-label`,
       ref: handleListboxRef,
       onMouseDown: (event) => {
```

We have no tests for the "No options" displayed case, it could be a good opportunity to cover it. Do you want to work on a pull request? :)

On a broader note, I wonder if it would make sense to run *axe* along with the describe conformance steps.
> On a broader note, I wonder if it would make sense to run axe along with the describe conformance steps.

Very likely to report a lot of false positives. The goal is to fix a11y issues and not to provide axe integration. We could probably fix more actual a11y issues with the time spent on fixing integration issues with 3rd party testing tools.
@oliviertassinari are you asking me to make a PR for the "no options" displayed case only, or to also include the change you made in you diff? I would guess that you already made a PR for the diff that you included, but I didn't see any PRs that reference this thread, so I just wanted to make sure I was clear on what you are asking for. 

Thank for finding that fix by the way, that will be very helpful.
@tylerjlawson If you agree with the changes, a pull request would definitely help. We will likely want to add/update the test cases.
Ok, the changes you made make sense to me. I will work on a PR.
@tylerjlawson Awesome, thanks :)

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
@@ -206,6 +206,8 @@ export default function useAutocomplete(props) {
       )
     : [];
 
+  const listboxAvailable = open && filteredOptions.length > 0;
+
   if (process.env.NODE_ENV !== 'production') {
     if (value !== null && !freeSolo && options.length > 0) {
       const missingValue = (multiple ? value : [value]).filter(
@@ -932,9 +934,9 @@ export default function useAutocomplete(props) {
 
   return {
     getRootProps: (other = {}) => ({
-      'aria-owns': popupOpen ? `${id}-popup` : null,
+      'aria-owns': listboxAvailable ? `${id}-listbox` : null,
       role: 'combobox',
-      'aria-expanded': popupOpen,
+      'aria-expanded': listboxAvailable,
       ...other,
       onKeyDown: handleKeyDown(other),
       onMouseDown: handleMouseDown,
@@ -955,7 +957,7 @@ export default function useAutocomplete(props) {
       // only have an opinion about this when closed
       'aria-activedescendant': popupOpen ? '' : null,
       'aria-autocomplete': autoComplete ? 'both' : 'list',
-      'aria-controls': popupOpen ? `${id}-popup` : null,
+      'aria-controls': listboxAvailable ? `${id}-listbox` : null,
       // Disable browser's suggestion that might overlap with the popup.
       // Handle autocomplete but not autofill.
       autoComplete: 'off',
@@ -979,7 +981,7 @@ export default function useAutocomplete(props) {
     }),
     getListboxProps: () => ({
       role: 'listbox',
-      id: `${id}-popup`,
+      id: `${id}-listbox`,
       'aria-labelledby': `${id}-label`,
       ref: handleListboxRef,
       onMouseDown: (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
@@ -1338,6 +1338,22 @@ describe('<Autocomplete />', () => {
 
       expect(htmlOptions[0].innerHTML).to.equal('one');
     });
+
+    it('should display a no options message if no options are available', () => {
+      const { getByRole } = render(
+        <Autocomplete
+          {...defaultProps}
+          renderInput={(params) => <TextField autoFocus {...params} />}
+        />,
+      );
+
+      const combobox = getByRole('combobox');
+      const textbox = getByRole('textbox');
+      expect(combobox).to.have.attribute('aria-expanded', 'false');
+      expect(combobox).to.not.have.attribute('aria-owns');
+      expect(textbox).to.not.have.attribute('aria-controls');
+      expect(document.querySelector(`.${classes.paper}`)).to.have.text('No options');
+    });
   });
 
   describe('enter', () => {
diff --git a/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.test.js b/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.test.js
--- a/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.test.js
+++ b/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.test.js
@@ -24,6 +24,15 @@ describe('createFilterOptions', () => {
     expect(filterOptions(options, { inputValue: 'a', getOptionLabel })).to.deep.equal([options[0]]);
   });
 
+  it('filters without error with empty option set', () => {
+    const filterOptions = createFilterOptions();
+
+    const getOptionLabel = (option) => option.name;
+    const options = [];
+
+    expect(filterOptions(options, { inputValue: 'a', getOptionLabel })).to.deep.equal([]);
+  });
+
   describe('option: limit', () => {
     it('limits the number of suggested options to be shown', () => {
       const filterOptions = createFilterOptions({ limit: 2 });