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>
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("☆☆☆☆☆");
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");
}
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);
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();
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"]);
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);
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);
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);
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).
I'm here to help with Star rating. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.