Skip to the code
cf.

Star rating

MediumDOM

The page has five stars in a radio group. Each star is a role="radio" element; the first star is worth 1, and the last is worth 5:

<span id="rating-label">Rate this recipe</span>
<div id="rating" role="radiogroup" aria-labelledby="rating-label">
  <span role="radio" aria-checked="false" aria-label="1 star">★</span>
  <span role="radio" aria-checked="false" aria-label="2 stars">★</span>
  <!-- 3 stars and 4 stars -->
  <span role="radio" aria-checked="false" aria-label="5 stars">★</span>
</div>

Write setupRating(group, onChange). Checking a star sets its aria-checked to "true" and every other star's to "false". It gives the class filled to the checked star and every star before it, and takes filled off the stars after it.

Use a roving tabindex: the checked star gets tabindex="0" and the others tabindex="-1". While no star is checked, the first star gets 0, so Tab can still reach the group.

On setup, keep the star that already has aria-checked="true", if there is one; otherwise no star is checked. Clicking a star checks it.

When a star has focus, ArrowRight and ArrowDown check and focus the next star, and ArrowLeft and ArrowUp the previous one, wrapping around at the ends. Space checks the focused star. Handle the keys on keydown, reading event.key (Space is " "), and call preventDefault() for these five keys only.

Call onChange(value) with the new rating, from 1 to 5, each time it changes. Checking the star that's already checked doesn't change it.

setupRating(document.getElementById("rating"), (value) => console.log("rated", value));
// click the third star: stars 1 to 3 are filled; logs "rated 3"
// press ArrowRight: the fourth star is checked and focused; logs "rated 4"
// click the fourth star again: nothing changes, and nothing is logged

What the tests check

  1. clicking a star checks it and fills the stars up to it
    const stars = [...document.querySelectorAll('[role="radio"]')];
    const checked = () => stars.map((star) => star.getAttribute("aria-checked"));
    const filled = () => stars.map((star) => star.classList.contains("filled"));
    setupRating(document.getElementById("rating"), () => {});
    fire(stars[2], "click");
    expect(checked()).toEqual(["false", "false", "true", "false", "false"]);
    expect(filled()).toEqual([true, true, true, false, false]);
    
  2. checking a lower star unfills the stars after it
    const stars = [...document.querySelectorAll('[role="radio"]')];
    const checked = () => stars.map((star) => star.getAttribute("aria-checked"));
    const filled = () => stars.map((star) => star.classList.contains("filled"));
    setupRating(document.getElementById("rating"), () => {});
    fire(stars[3], "click");
    fire(stars[0], "click");
    expect(checked()).toEqual(["true", "false", "false", "false", "false"]);
    expect(filled()).toEqual([true, false, false, false, false]);
    
  3. gives tabindex 0 to the checked star, or to the first while none is
    const stars = [...document.querySelectorAll('[role="radio"]')];
    const tabindexes = () => stars.map((star) => star.getAttribute("tabindex"));
    setupRating(document.getElementById("rating"), () => {});
    expect(tabindexes()).toEqual(["0", "-1", "-1", "-1", "-1"]);
    fire(stars[3], "click");
    expect(tabindexes()).toEqual(["-1", "-1", "-1", "0", "-1"]);
    
  4. starts from a star already checked in the HTML, without calling onChange
    const stars = [...document.querySelectorAll('[role="radio"]')];
    const onChange = spy();
    stars[3].setAttribute("aria-checked", "true");
    setupRating(document.getElementById("rating"), onChange);
    expect(stars.map((star) => star.getAttribute("aria-checked"))).toEqual(["false", "false", "false", "true", "false"]);
    expect(stars.map((star) => star.classList.contains("filled"))).toEqual([true, true, true, true, false]);
    expect(stars.map((star) => star.getAttribute("tabindex"))).toEqual(["-1", "-1", "-1", "0", "-1"]);
    expect(onChange).not.toHaveBeenCalled();
    
  5. ArrowRight and ArrowDown check and focus the next star, wrapping
    const stars = [...document.querySelectorAll('[role="radio"]')];
    const checked = () => stars.map((star) => star.getAttribute("aria-checked"));
    const press = (key) => fire(document.activeElement, "keydown", { key });
    const focused = () => document.activeElement.getAttribute("aria-label") || "the " + document.activeElement.tagName.toLowerCase();
    setupRating(document.getElementById("rating"), () => {});
    fire(stars[3], "click");
    stars[3].focus();
    press("ArrowRight");
    expect(focused()).toBe("5 stars");
    expect(checked()).toEqual(["false", "false", "false", "false", "true"]);
    press("ArrowDown");
    expect(focused()).toBe("1 star");
    expect(checked()).toEqual(["true", "false", "false", "false", "false"]);
    expect(stars.map((star) => star.classList.contains("filled"))).toEqual([true, false, false, false, false]);
    
  6. ArrowLeft and ArrowUp check and focus the previous star, wrapping
    const stars = [...document.querySelectorAll('[role="radio"]')];
    const checked = () => stars.map((star) => star.getAttribute("aria-checked"));
    const press = (key) => fire(document.activeElement, "keydown", { key });
    const focused = () => document.activeElement.getAttribute("aria-label") || "the " + document.activeElement.tagName.toLowerCase();
    setupRating(document.getElementById("rating"), () => {});
    stars[0].focus();
    press("ArrowLeft");
    expect(focused()).toBe("5 stars");
    expect(checked()).toEqual(["false", "false", "false", "false", "true"]);
    press("ArrowUp");
    expect(focused()).toBe("4 stars");
    expect(checked()).toEqual(["false", "false", "false", "true", "false"]);
    expect(stars.map((star) => star.getAttribute("tabindex"))).toEqual(["-1", "-1", "-1", "0", "-1"]);
    
  7. Space checks the focused star
    const stars = [...document.querySelectorAll('[role="radio"]')];
    const focused = () => document.activeElement.getAttribute("aria-label") || "the " + document.activeElement.tagName.toLowerCase();
    setupRating(document.getElementById("rating"), () => {});
    stars[0].focus();
    const event = fire(stars[0], "keydown", { key: " " });
    expect(event.defaultPrevented).toBe(true);
    expect(stars.map((star) => star.getAttribute("aria-checked"))).toEqual(["true", "false", "false", "false", "false"]);
    expect(focused()).toBe("1 star");
    
  8. calls onChange with the new rating, only when it changes
    const stars = [...document.querySelectorAll('[role="radio"]')];
    const onChange = spy();
    setupRating(document.getElementById("rating"), onChange);
    fire(stars[2], "click");
    fire(stars[2], "click");
    stars[2].focus();
    fire(stars[2], "keydown", { key: " " });
    expect(onChange.calls.map((call) => call.args)).toEqual([[3]]);
    fire(stars[2], "keydown", { key: "ArrowLeft" });
    expect(onChange.calls.map((call) => call.args)).toEqual([[3], [2]]);
    
  9. prevents the default only for the keys it handles
    const stars = [...document.querySelectorAll('[role="radio"]')];
    setupRating(document.getElementById("rating"), () => {});
    stars[0].focus();
    expect(fire(stars[0], "keydown", { key: "Tab" }).defaultPrevented).toBe(false);
    expect(fire(stars[0], "keydown", { key: "Enter" }).defaultPrevented).toBe(false);
    expect(fire(stars[0], "keydown", { key: "Home" }).defaultPrevented).toBe(false);
    expect(stars.map((star) => star.getAttribute("aria-checked"))).toEqual(["false", "false", "false", "false", "false"]);
    expect(fire(stars[0], "keydown", { key: "ArrowRight" }).defaultPrevented).toBe(true);
    

Learn more

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