Skip to the code
cf.

Star rating

Customers can rate the teas they've bought. Build App: a five-star rating that takes defaultValue (the starting rating, 0 to 5, default 0), readOnly (default false), and onChange. When onChange isn't given, log the new rating.

Render a <div role="radiogroup" aria-label="Rating"> with five <button role="radio"> stars. The stars' names are 1 star, 2 stars, up to 5 stars (use aria-label). The star equal to the rating has aria-checked="true", the others "false"; with a rating of 0, none is checked.

Each star shows ★ when it's at or below the rating and ☆ above it, so a rating of 3 reads ★★★☆☆. Clicking a star sets the rating to it and calls onChange with the new number.

Hovering over a star previews that rating: the stars show it as if it were set, but nothing is checked and onChange isn't called. When the pointer leaves the group, the stars show the real rating again.

Only the checked star is in the Tab order (tabIndex={0}, the others -1); with no rating, the first star is. With a star focused, ArrowRight and ArrowUp raise the rating by one, ArrowLeft and ArrowDown lower it by one, calling onChange each time and moving the focus to the newly checked star. The rating never goes below 1 or above 5 this way: an arrow at the end does nothing.

With readOnly, render no buttons: just a <span role="img"> with the stars as text and aria-label like 3 out of 5 stars.

<App defaultValue={3} onChange={(rating) => save(rating)} />
// ★★★☆☆, and the third star (named "3 stars") is checked
 
<App defaultValue={4} readOnly />
// <span role="img" aria-label="4 out of 5 stars">★★★★☆</span>

What the tests check

  1. renders five named stars in a Rating radio group
    render(<App />);
    const group = screen.getByRole("radiogroup", { name: "Rating" });
    const names = within(group).getAllByRole("radio").map((star) => star.getAttribute("aria-label"));
    expect(names).toEqual(["1 star", "2 stars", "3 stars", "4 stars", "5 stars"]);
    expect(group).toHaveTextContent("☆☆☆☆☆");
    
  2. starts at defaultValue, with that star checked
    const stars = () => screen.getByRole("radiogroup", { name: "Rating" }).textContent;
    render(<App defaultValue={3} />);
    expect(stars()).toBe("★★★☆☆");
    expect(screen.getByRole("radio", { name: "3 stars" })).toHaveAttribute("aria-checked", "true");
    for (const name of ["1 star", "2 stars", "4 stars", "5 stars"]) {
      expect(screen.getByRole("radio", { name })).toHaveAttribute("aria-checked", "false");
    }
    
  3. clicking a star sets the rating and calls onChange
    const stars = () => screen.getByRole("radiogroup", { name: "Rating" }).textContent;
    const onChange = spy();
    render(<App defaultValue={1} onChange={onChange} />);
    await user.click(screen.getByRole("radio", { name: "4 stars" }));
    expect(stars()).toBe("★★★★☆");
    expect(screen.getByRole("radio", { name: "4 stars" })).toHaveAttribute("aria-checked", "true");
    expect(screen.getByRole("radio", { name: "1 star" })).toHaveAttribute("aria-checked", "false");
    expect(onChange).toHaveBeenCalledWith(4);
    
  4. hovering previews a rating, and leaving shows the real one again
    const stars = () => screen.getByRole("radiogroup", { name: "Rating" }).textContent;
    const onChange = spy();
    render(<App defaultValue={2} onChange={onChange} />);
    await user.hover(screen.getByRole("radio", { name: "5 stars" }));
    expect(stars()).toBe("★★★★★");
    expect(screen.getByRole("radio", { name: "2 stars" })).toHaveAttribute("aria-checked", "true");
    expect(screen.getByRole("radio", { name: "5 stars" })).toHaveAttribute("aria-checked", "false");
    await user.unhover(screen.getByRole("radio", { name: "5 stars" }));
    expect(stars()).toBe("★★☆☆☆");
    expect(onChange).not.toHaveBeenCalled();
    
  5. only the checked star is in the Tab order, or the first with no rating
    const { unmount } = render(<App defaultValue={3} />);
    expect(screen.getAllByRole("radio").map((star) => star.getAttribute("tabindex"))).toEqual(["-1", "-1", "0", "-1", "-1"]);
    unmount();
    render(<App />);
    expect(screen.getAllByRole("radio").map((star) => star.getAttribute("tabindex"))).toEqual(["0", "-1", "-1", "-1", "-1"]);
    
  6. the arrow keys change the rating and move the focus with it
    const stars = () => screen.getByRole("radiogroup", { name: "Rating" }).textContent;
    const onChange = spy();
    render(<App defaultValue={3} onChange={onChange} />);
    await user.tab();
    expect(screen.getByRole("radio", { name: "3 stars" })).toHaveFocus();
    await user.keyboard("{ArrowRight}");
    expect(stars()).toBe("★★★★☆");
    expect(screen.getByRole("radio", { name: "4 stars" })).toHaveFocus();
    expect(onChange).toHaveBeenCalledWith(4);
    await user.keyboard("{ArrowLeft}{ArrowDown}");
    expect(stars()).toBe("★★☆☆☆");
    expect(screen.getByRole("radio", { name: "2 stars" })).toHaveFocus();
    expect(screen.getByRole("radio", { name: "2 stars" })).toHaveAttribute("aria-checked", "true");
    expect(onChange).toHaveBeenCalledTimes(3);
    
  7. the arrow keys stop at 1 and 5
    const stars = () => screen.getByRole("radiogroup", { name: "Rating" }).textContent;
    const onChange = spy();
    render(<App defaultValue={5} onChange={onChange} />);
    await user.tab();
    await user.keyboard("{ArrowRight}{ArrowUp}");
    expect(stars()).toBe("★★★★★");
    expect(onChange).not.toHaveBeenCalled();
    await user.click(screen.getByRole("radio", { name: "1 star" }));
    await user.keyboard("{ArrowLeft}");
    expect(stars()).toBe("★☆☆☆☆");
    expect(screen.getByRole("radio", { name: "1 star" })).toHaveFocus();
    expect(onChange).toHaveBeenCalledTimes(1);
    
  8. read-only shows an image named by the rating, with no buttons
    render(<App defaultValue={3} readOnly />);
    const image = screen.getByRole("img", { name: "3 out of 5 stars" });
    expect(image).toHaveTextContent("★★★☆☆");
    expect(screen.queryAllByRole("radio")).toHaveLength(0);
    expect(screen.queryAllByRole("button")).toHaveLength(0);
    

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).