Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions bases/rsptx/interactives/runestone/clickableArea/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,12 @@ Option spec:

<h3>Accessibility</h3>

Each clickable area is rendered as a checkbox: it is reachable with the Tab key
and is selected or unselected with Enter or the space bar. The set of clickable
areas is a group named by the <code>data-question</code> text, and a screen
reader announces the selected state of each area, the running selection count,
and the result of Check Me.
Each clickable area is rendered as a checkbox. The set of clickable areas has
one stop in the page Tab order: arrow keys move between choices, Home and End
move to the first and last choices, and Enter or the space bar selects or
unselects the focused choice. Tab leaves the group. The group is named by the
<code>data-question</code> text, and a screen reader announces the selected
state of each area, the running selection count, and the result of Check Me.

A clickable area is named by its own contents, so authors should make sure that
content stands on its own. In particular, an image used as a clickable area
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export default class ClickableArea extends RunestoneBase {
this.instructions.id = `${this.divid}_instructions`;
this.instructions.className = "clickable-sr-only";
this.instructions.textContent =
"Select all that apply. Move between the choices with the Tab key and press Enter or the space bar to select or unselect a choice.";
"Select all that apply. Press Tab to enter or leave the choices. Use the arrow keys to move between choices, Home or End to move to the first or last choice, and press Enter or the space bar to select or unselect a choice.";
this.containerDiv.appendChild(this.instructions);
this.newDiv.setAttribute("role", "group");
if (this.question && this.question.id) {
Expand Down Expand Up @@ -419,27 +419,79 @@ export default class ClickableArea extends RunestoneBase {
}
}
}
// Expose each clickable as a checkbox so that it can be reached with the
// Tab key and so that a screen reader announces its selected state.
// Expose each clickable as a checkbox so that a screen reader announces
// its selected state. Only one checkbox is in the page tab order; arrow
// keys move that tab stop through the group.
clickable.setAttribute("role", "checkbox");
clickable.setAttribute("tabindex", "0");
clickable.setAttribute(
"tabindex",
this.clickableArray.length === 0 ? "0" : "-1",
);
clickable.setAttribute(
"aria-checked",
clickable.classList.contains("clickable-clicked")
? "true"
: "false",
);
clickable.onclick = () => this.toggleClickable(clickable);
clickable.onclick = () => {
this.focusClickable(clickable);
this.toggleClickable(clickable);
};
clickable.addEventListener("focus", () => {
this.setActiveClickable(clickable);
});
clickable.addEventListener("keydown", (ev) => {
// Enter and the space bar are the standard checkbox activation keys.
if (ev.key === "Enter" || ev.key === " " || ev.key === "Spacebar") {
ev.preventDefault(); // keep the space bar from scrolling the page
this.toggleClickable(clickable);
} else if (ev.key === "ArrowRight" || ev.key === "ArrowDown") {
ev.preventDefault();
this.moveClickableFocus(clickable, 1);
} else if (ev.key === "ArrowLeft" || ev.key === "ArrowUp") {
ev.preventDefault();
this.moveClickableFocus(clickable, -1);
} else if (ev.key === "Home") {
ev.preventDefault();
this.focusClickable(this.clickableArray[0]);
} else if (ev.key === "End") {
ev.preventDefault();
this.focusClickable(
this.clickableArray[this.clickableArray.length - 1],
);
}
});
this.clickableArray.push(clickable);
this.clickableCounter++;
}
setActiveClickable(clickable) {
if (
this.interactionDisabled ||
!this.clickableArray.includes(clickable)
) {
return;
}
for (const choice of this.clickableArray) {
choice.setAttribute("tabindex", choice === clickable ? "0" : "-1");
}
}
focusClickable(clickable) {
if (this.interactionDisabled || !clickable) {
return;
}
this.setActiveClickable(clickable);
clickable.focus();
}
moveClickableFocus(clickable, offset) {
const currentIndex = this.clickableArray.indexOf(clickable);
if (currentIndex === -1) {
return;
}
const nextIndex = Math.min(
this.clickableArray.length - 1,
Math.max(0, currentIndex + offset),
);
this.focusClickable(this.clickableArray[nextIndex]);
}
toggleClickable(clickable) {
if (this.interactionDisabled) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,13 @@ async function makeClickable(fixture = makeFixture) {
}

function press(el, key) {
el.dispatchEvent(
new window.KeyboardEvent("keydown", { key: key, bubbles: true }),
);
const event = new window.KeyboardEvent("keydown", {
key: key,
bubbles: true,
cancelable: true,
});
el.dispatchEvent(event);
return event;
}

describe("ClickableArea accessibility", () => {
Expand All @@ -68,14 +72,16 @@ describe("ClickableArea accessibility", () => {
vi.restoreAllMocks();
});

it("exposes every clickable as a focusable checkbox", async () => {
it("exposes the clickables as checkboxes with one group tab stop", async () => {
const ca = await makeClickable();
expect(ca.clickableArray).toHaveLength(4);
for (const el of ca.clickableArray) {
expect(el.getAttribute("role")).toBe("checkbox");
expect(el.getAttribute("tabindex")).toBe("0");
expect(el.getAttribute("aria-checked")).toBe("false");
}
expect(
ca.clickableArray.map((el) => el.getAttribute("tabindex")),
).toEqual(["0", "-1", "-1", "-1"]);
});

it("names the group of choices with the question", async () => {
Expand All @@ -85,7 +91,7 @@ describe("ClickableArea accessibility", () => {
expect(
document.getElementById(ca.newDiv.getAttribute("aria-describedby"))
.textContent,
).toMatch(/Tab key/);
).toMatch(/arrow keys/);
});

it.each(["Enter", " "])("toggles a choice with the %s key", async (key) => {
Expand All @@ -106,8 +112,57 @@ describe("ClickableArea accessibility", () => {
const ca = await makeClickable();
const first = ca.clickableArray[0];
press(first, "a");
press(first, "Tab");
const tabEvent = press(first, "Tab");
expect(first.getAttribute("aria-checked")).toBe("false");
expect(tabEvent.defaultPrevented).toBe(false);
});

it.each([
["ArrowRight", 1],
["ArrowDown", 1],
["ArrowLeft", -1],
["ArrowUp", -1],
])("moves focus with %s", async (key, offset) => {
const ca = await makeClickable();
const startIndex = offset > 0 ? 1 : 2;
ca.clickableArray[startIndex].focus();
const targetIndex = startIndex + offset;
const event = press(ca.clickableArray[startIndex], key);

expect(event.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(ca.clickableArray[targetIndex]);
expect(ca.clickableArray[targetIndex].getAttribute("tabindex")).toBe(
"0",
);
expect(ca.clickableArray[startIndex].getAttribute("tabindex")).toBe(
"-1",
);
});

it("keeps arrow focus at the ends of the group", async () => {
const ca = await makeClickable();
const first = ca.clickableArray[0];
const last = ca.clickableArray[ca.clickableArray.length - 1];

first.focus();
press(first, "ArrowLeft");
expect(document.activeElement).toBe(first);

last.focus();
press(last, "ArrowRight");
expect(document.activeElement).toBe(last);
});

it("moves focus to the first or last choice with Home and End", async () => {
const ca = await makeClickable();
const middle = ca.clickableArray[1];
middle.focus();

press(middle, "End");
expect(document.activeElement).toBe(ca.clickableArray[3]);

press(ca.clickableArray[3], "Home");
expect(document.activeElement).toBe(ca.clickableArray[0]);
});

it("keeps the space bar from scrolling the page", async () => {
Expand All @@ -123,11 +178,14 @@ describe("ClickableArea accessibility", () => {

it("keeps aria-checked in sync when toggled by mouse", async () => {
const ca = await makeClickable();
const first = ca.clickableArray[0];
first.click();
expect(first.getAttribute("aria-checked")).toBe("true");
first.click();
expect(first.getAttribute("aria-checked")).toBe("false");
const second = ca.clickableArray[1];
second.click();
expect(second.getAttribute("aria-checked")).toBe("true");
expect(document.activeElement).toBe(second);
expect(second.getAttribute("tabindex")).toBe("0");
expect(ca.clickableArray[0].getAttribute("tabindex")).toBe("-1");
second.click();
expect(second.getAttribute("aria-checked")).toBe("false");
});

it("announces the running selection count", async () => {
Expand Down Expand Up @@ -203,8 +261,12 @@ describe("ClickableArea accessibility", () => {
expect(ca.clickableArray.map((el) => el.nodeName)).toContain("TR");
for (const el of ca.clickableArray) {
expect(el.getAttribute("role")).toBe("checkbox");
expect(el.getAttribute("tabindex")).toBe("0");
}
expect(
ca.clickableArray.filter(
(el) => el.getAttribute("tabindex") === "0",
),
).toHaveLength(1);
press(ca.clickableArray[0], " ");
expect(ca.clickableArray[0].getAttribute("aria-checked")).toBe("true");
});
Expand Down