Keyboard users move around a page with the Tab key: each press moves focus, the place your key presses go, to the next control, and Shift+Tab moves it back. The tabindex attribute decides whether an element is a stop on that route. tabindex="0" puts it in the Tab order, at its place in the page. tabindex="-1" leaves it out, though a script can still focus it with element.focus().
A roving tabindex makes a group of controls, such as tabs, a toolbar, or a menu, a single stop. Only one item in the group has tabindex="0"; the others have tabindex="-1". Tab enters the group on that item, the arrow keys move focus inside the group, and the 0 moves, or roves, along with focus. The next Tab leaves the group. The WAI-ARIA Authoring Practices, the W3C's guide to accessible widgets, take this one-stop convention from desktop apps, and a roving tabindex is one of the two techniques they describe for it. Native radio buttons that share a name already work this way.
ARIA (Accessible Rich Internet Applications) is a set of attributes that tell assistive technology, such as screen readers, what a custom control is and what state it's in. In the tabs pattern, the container has role="tablist". Each tab has role="tab", aria-selected set to "true" on the selected tab and "false" on the others, and aria-controls holding its panel's id. Each panel has role="tabpanel" and aria-labelledby pointing back to its tab. A screen reader then announces the focused tab's name, that it's a tab, and whether it's selected. ARIA changes only what's announced: role="tab" adds no key handling, so the arrow keys are up to your code.
Here's a tab list with three tabs, Overview, Specs, and Reviews. Overview starts selected, and moving to a tab selects it:
| Key press | Focus moves to | tabindex of Overview, Specs, Reviews |
|---|---|---|
| None yet | Focus is on a link above the tabs | 0, -1, -1 |
| Tab | Overview, the one tab with 0 | 0, -1, -1 |
| ArrowRight | Specs, which is now selected | -1, 0, -1 |
| ArrowRight | Reviews | -1, -1, 0 |
| ArrowRight | Overview: it wraps around | 0, -1, -1 |
| End | Reviews, the last tab | -1, -1, 0 |
| Tab | The Reviews panel: one Tab leaves the group | -1, -1, 0 |
| Shift+Tab | Reviews, the tab with 0, not Overview | -1, -1, 0 |
ArrowLeft goes the other way, and Home goes to the first tab. When showing a panel is slow, for example because it loads data, the arrows can move focus without selecting, and Enter or Space selects the focused tab. Either way, a keyboard user crosses the whole tab list with one press of Tab, not one per tab, and lands on the tab whose panel is showing. A screen reader user who hears "tab" knows the arrow keys will work, as they do for tabs in desktop apps.
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
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]);
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]);
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"]);
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();
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]);
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"]);
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");
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]]);
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);
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.