Search code examples
reactjsmaterial-uiaccessibility

How can I tab into a button inside Material UI Autocomplete custom popper


I am working on a customization of Material UI's Autocomplete. I've customized the Popper component to include a button at the end of the options list, however I need to make this button accessible via the keyboard (via the Tab button, for example).

I have tried adding the tabIndex=0 property to the button, but this doesn't change the default behavior - pressing Tab closed the Autocomplete.

What can I do to make the button reachable via keyboard commands?

Link to sandbox: https://codesandbox.io/s/compassionate-butterfly-f4rn8z?file=/demo.tsx:0-1072

import TextField from "@mui/material/TextField";
import Autocomplete from "@mui/material/Autocomplete";
import Popper from "@mui/material/Popper";
import Button from "@mui/material/Button";

// Top 100 films as rated by IMDb users. http://www.imdb.com/chart/top
const top100Films = [
  { label: "The Shawshank Redemption", year: 1994 },
  { label: "The Godfather", year: 1972 },
  { label: "The Godfather: Part II", year: 1974 },
];

export default function ComboBox() {
  const handleNotFound = () => {
    alert("Not found");
  };

  return (
    <Autocomplete
      disablePortal
      id="combo-box-demo"
      options={top100Films}
      sx={{ width: 300 }}
      renderInput={(params) => <TextField {...params} label="Movie" />}
      PopperComponent={(props) => (
        <Popper {...props} placement="bottom">
          {props.children}
          <div>
            <Button type="button" tabIndex={0} onMouseDown={handleNotFound}>
              I don't see my movie
            </Button>
          </div>
        </Popper>
      )}
    />
  );
}

Solution

  • I’m afraid in terms of ARIA there are not many options.

    Limitations set by ARIA

    Authors MUST ensure the popup element associated with a combobox has a role of listbox

    In other words, the element that is hidden and shown is the listbox, there is no way to add another container.

    List boxes contain children whose role is option or elements whose role is group which in turn contain children whose role is option.

    This forbids any other role (except generic or none) inside the listbox, so no button can be placed inside the popup. i.e. listbox.

    Authors MUST manage focus on the following container roles: […] listbox

    This practically forbids usage of Tab for navigation inside the popup, so tabindex="0" is not an option.

    So you’d need the popup to have another role than listbox to break out of this logic. dialog would be an option, but this requires a lot of rendering and event logic to get right.

    Options with Material UI and Popper

    I recommend adding an “I don't see my movie” option inside the listbox, and maybe grouping the actual movies in a group. Or simply a button next to the combobox, to make things easier.

    Grouped movies and Not found option

    For better orientation, grouping options by found movies and other would help to distinguish “I don’t see my movie” from actual movies.

    You then can use the onChange event to react to the user picking that option.

    const moviesGroup = "Matching Movies";
    
    // Top 100 films as rated by IMDb users. http://www.imdb.com/chart/top
    const top100Films = [
      { label: "The Shawshank Redemption", year: 1994, isMovie: moviesGroup },
      { label: "The Godfather", year: 1972, isMovie: moviesGroup },
      { label: "The Godfather: Part II", year: 1974, isMovie: moviesGroup },
    ];
    
    const options = [
      ...top100Films,
      { label: "I don’t find my movie", isMovie: "Other Options" }
    ];
    
    export default function ComboBox() {
      const handleNotFound = () => {
        alert("Not found");
      }
    
      const onChange = (_, o) => {
        if (o.isMovie !== moviesGroup) {
          handleNotFound();
        }
      }
    
      return (
        <Autocomplete
          disablePortal
          id="combo-box-demo"
          options={options}
          groupBy={(option) => option.isMovie}
          onChange={onChange}
          sx={{ width: 300 }}
          renderInput={(params) => <TextField {...params} label="Movie" />}
        />
      );
    }