Skip to the code
cf.

Dropdown menu

The tea shop's list has a Sort teas button that opens a short menu of ways to sort. Build App: a menu button that takes label (the button's text), items (a list of strings), and onSelect. When they aren't given, as in the Preview, use Sort teas, a few items of your own, and an onSelect that logs the choice.

The button has aria-haspopup="menu" and aria-expanded ("false" while closed, "true" while open). The menu is a <ul role="menu">, labelled by the button through aria-labelledby, with one <li role="menuitem" tabIndex={-1}> per item. While closed, the menu isn't in the page.

Clicking the button opens the menu, and so do Enter, Space, and ArrowDown while the button has the focus. Opening it moves the focus to the first item. Clicking the button again closes it.

In the open menu, ArrowDown and ArrowUp move the focus to the next and previous item, wrapping around at the ends; Home and End go to the first and last.

Clicking an item, or pressing Enter on it, calls onSelect with the item's string, closes the menu, and puts the focus back on the button. Escape closes the menu and puts the focus back on the button too. Clicking anywhere outside the menu and its button closes it.

<App label="Sort teas" items={["Name", "Price", "Newest"]} onSelect={(item) => setSort(item)} />
 
// renders, while open:
<button id="b" aria-haspopup="menu" aria-expanded="true">Sort teas</button>
<ul role="menu" aria-labelledby="b">
  <li role="menuitem" tabindex="-1">Name</li>
  <li role="menuitem" tabindex="-1">Price</li>
  <li role="menuitem" tabindex="-1">Newest</li>
</ul>

What the tests check

  1. starts closed, with aria-haspopup and aria-expanded=false
    const onSelect = spy();
    const items = ["Name", "Price", "Newest"];
    render(<App label="Sort teas" items={items} onSelect={onSelect} />);
    const button = screen.getByRole("button", { name: "Sort teas" });
    expect(button).toHaveAttribute("aria-haspopup", "menu");
    expect(button).toHaveAttribute("aria-expanded", "false");
    expect(screen.queryByRole("menu")).not.toBeInTheDocument();
    
  2. clicking the button opens a labelled menu and focuses the first item
    const onSelect = spy();
    const items = ["Name", "Price", "Newest"];
    render(<App label="Sort teas" items={items} onSelect={onSelect} />);
    await user.click(screen.getByRole("button", { name: "Sort teas" }));
    expect(screen.getByRole("button", { name: "Sort teas" })).toHaveAttribute("aria-expanded", "true");
    const menu = screen.getByRole("menu", { name: "Sort teas" });
    expect(within(menu).getAllByRole("menuitem").map((item) => item.textContent)).toEqual(items);
    expect(screen.getByRole("menuitem", { name: "Name" })).toHaveFocus();
    
  3. ArrowDown, Enter, and Space on the button open the menu
    const onSelect = spy();
    const items = ["Name", "Price", "Newest"];
    render(<App label="Sort teas" items={items} onSelect={onSelect} />);
    await user.tab();
    await user.keyboard("{ArrowDown}");
    expect(screen.getByRole("menuitem", { name: "Name" })).toHaveFocus();
    await user.keyboard("{Escape}");
    await user.keyboard("{Enter}");
    expect(screen.getByRole("menu")).toBeInTheDocument();
    expect(screen.getByRole("menuitem", { name: "Name" })).toHaveFocus();
    await user.keyboard("{Escape}");
    await user.keyboard(" ");
    expect(screen.getByRole("menu")).toBeInTheDocument();
    expect(screen.getByRole("menuitem", { name: "Name" })).toHaveFocus();
    
  4. the arrow keys move between items and wrap around
    const onSelect = spy();
    const items = ["Name", "Price", "Newest"];
    render(<App label="Sort teas" items={items} onSelect={onSelect} />);
    await user.click(screen.getByRole("button", { name: "Sort teas" }));
    await user.keyboard("{ArrowDown}");
    expect(screen.getByRole("menuitem", { name: "Price" })).toHaveFocus();
    await user.keyboard("{ArrowDown}{ArrowDown}");
    expect(screen.getByRole("menuitem", { name: "Name" })).toHaveFocus();
    await user.keyboard("{ArrowUp}");
    expect(screen.getByRole("menuitem", { name: "Newest" })).toHaveFocus();
    
  5. Home and End go to the first and last item
    const onSelect = spy();
    const items = ["Name", "Price", "Newest"];
    render(<App label="Sort teas" items={items} onSelect={onSelect} />);
    await user.click(screen.getByRole("button", { name: "Sort teas" }));
    await user.keyboard("{End}");
    expect(screen.getByRole("menuitem", { name: "Newest" })).toHaveFocus();
    await user.keyboard("{Home}");
    expect(screen.getByRole("menuitem", { name: "Name" })).toHaveFocus();
    
  6. Enter on an item calls onSelect, closes the menu, and refocuses the button
    const onSelect = spy();
    const items = ["Name", "Price", "Newest"];
    render(<App label="Sort teas" items={items} onSelect={onSelect} />);
    await user.click(screen.getByRole("button", { name: "Sort teas" }));
    await user.keyboard("{ArrowDown}{Enter}");
    expect(onSelect).toHaveBeenCalledTimes(1);
    expect(onSelect).toHaveBeenCalledWith("Price");
    expect(screen.queryByRole("menu")).not.toBeInTheDocument();
    expect(screen.getByRole("button", { name: "Sort teas" })).toHaveFocus();
    expect(screen.getByRole("button", { name: "Sort teas" })).toHaveAttribute("aria-expanded", "false");
    
  7. clicking an item calls onSelect with it and closes the menu
    const onSelect = spy();
    const items = ["Name", "Price", "Newest"];
    render(<App label="Sort teas" items={items} onSelect={onSelect} />);
    await user.click(screen.getByRole("button", { name: "Sort teas" }));
    await user.click(screen.getByRole("menuitem", { name: "Newest" }));
    expect(onSelect).toHaveBeenCalledWith("Newest");
    expect(screen.queryByRole("menu")).not.toBeInTheDocument();
    expect(screen.getByRole("button", { name: "Sort teas" })).toHaveFocus();
    
  8. Escape closes the menu and puts the focus back on the button
    const onSelect = spy();
    const items = ["Name", "Price", "Newest"];
    render(<App label="Sort teas" items={items} onSelect={onSelect} />);
    await user.click(screen.getByRole("button", { name: "Sort teas" }));
    await user.keyboard("{ArrowDown}{Escape}");
    expect(screen.queryByRole("menu")).not.toBeInTheDocument();
    expect(screen.getByRole("button", { name: "Sort teas" })).toHaveFocus();
    expect(onSelect).not.toHaveBeenCalled();
    
  9. clicking the button again or clicking outside closes it
    const onSelect = spy();
    const items = ["Name", "Price", "Newest"];
    render(
      <div>
        <App label="Sort teas" items={items} onSelect={onSelect} />
        <p>Our teas</p>
      </div>,
    );
    const button = screen.getByRole("button", { name: "Sort teas" });
    await user.click(button);
    await user.click(button);
    expect(screen.queryByRole("menu")).not.toBeInTheDocument();
    await user.click(button);
    await user.click(screen.getByText("Our teas"));
    expect(screen.queryByRole("menu")).not.toBeInTheDocument();
    expect(button).toHaveAttribute("aria-expanded", "false");
    expect(onSelect).not.toHaveBeenCalled();
    

Learn more

App.jsxSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).