From ca50920a72e53dab2abd5a8296603ddf92728af0 Mon Sep 17 00:00:00 2001 From: Andrew Scholer Date: Wed, 26 Aug 2026 14:01:31 -0700 Subject: [PATCH] Clickable: keyboard navigation improvements --- .../runestone/clickableArea/README.md | 11 +-- .../runestone/clickableArea/js/clickable.js | 64 ++++++++++++-- .../clickableArea/test/clickable.test.js | 88 ++++++++++++++++--- 3 files changed, 139 insertions(+), 24 deletions(-) diff --git a/bases/rsptx/interactives/runestone/clickableArea/README.md b/bases/rsptx/interactives/runestone/clickableArea/README.md index ac10a70e9..f18e78464 100644 --- a/bases/rsptx/interactives/runestone/clickableArea/README.md +++ b/bases/rsptx/interactives/runestone/clickableArea/README.md @@ -47,11 +47,12 @@ Option spec:

Accessibility

-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 data-question 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 +data-question 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 diff --git a/bases/rsptx/interactives/runestone/clickableArea/js/clickable.js b/bases/rsptx/interactives/runestone/clickableArea/js/clickable.js index b06f0baf4..fd9f19ac9 100644 --- a/bases/rsptx/interactives/runestone/clickableArea/js/clickable.js +++ b/bases/rsptx/interactives/runestone/clickableArea/js/clickable.js @@ -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) { @@ -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; diff --git a/bases/rsptx/interactives/runestone/clickableArea/test/clickable.test.js b/bases/rsptx/interactives/runestone/clickableArea/test/clickable.test.js index b7b370abd..5f12a4b3a 100644 --- a/bases/rsptx/interactives/runestone/clickableArea/test/clickable.test.js +++ b/bases/rsptx/interactives/runestone/clickableArea/test/clickable.test.js @@ -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", () => { @@ -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 () => { @@ -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) => { @@ -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 () => { @@ -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 () => { @@ -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"); });