From 9ef81beae6d8c15d6e3d8b0fc4ba9746b4f8e01d Mon Sep 17 00:00:00 2001 From: Andrew Scholer Date: Tue, 21 Jul 2026 06:21:25 -0700 Subject: [PATCH 1/8] Dragndrop: fix issues in keyboard navigation --- .../runestone/dragndrop/css/dragndrop.css | 9 - .../runestone/dragndrop/js/dragndrop.js | 284 ++++++++++++++++-- .../dragndrop/test/dragndrop.test.js | 271 ++++++++++++++++- 3 files changed, 512 insertions(+), 52 deletions(-) diff --git a/bases/rsptx/interactives/runestone/dragndrop/css/dragndrop.css b/bases/rsptx/interactives/runestone/dragndrop/css/dragndrop.css index dad83a427..e2044a205 100644 --- a/bases/rsptx/interactives/runestone/dragndrop/css/dragndrop.css +++ b/bases/rsptx/interactives/runestone/dragndrop/css/dragndrop.css @@ -95,15 +95,6 @@ background-color: #cccccc; } -/* - * This is a hack to allow us to drop on the drop target when - * the drop area contains mathjax or or other child elements. -*/ -.draggable-drop .process-math, -.draggable-drop .MathJax { - pointer-events: none; - cursor: not-allowed; -} /* Hide the error message from people but let screen readers see it */ .vh-dnd-error { diff --git a/bases/rsptx/interactives/runestone/dragndrop/js/dragndrop.js b/bases/rsptx/interactives/runestone/dragndrop/js/dragndrop.js index aa3c24517..0a43d1c4a 100644 --- a/bases/rsptx/interactives/runestone/dragndrop/js/dragndrop.js +++ b/bases/rsptx/interactives/runestone/dragndrop/js/dragndrop.js @@ -57,6 +57,7 @@ export default class DragNDrop extends RunestoneBase { this.feedback = ""; this.question = ""; this.selectedPremise = null; + this.activeResponse = null; // Number of times the student has submitted a gradeable attempt (one // where they have placed enough blocks). Misplaced blocks are only // colored red once this reaches MIN_TRIES_FOR_COLOR. @@ -257,6 +258,11 @@ export default class DragNDrop extends RunestoneBase { this.deselectPremise({ restoreFocus: true }); } }); + this.keyboardInstructionDiv = document.createElement("div"); + this.keyboardInstructionDiv.classList.add("visuallyhidden"); + this.keyboardInstructionDiv.setAttribute("aria-live", "polite"); + this.keyboardInstructionDiv.setAttribute("aria-atomic", "true"); + this.containerDiv.appendChild(this.keyboardInstructionDiv); this.statementDiv = document.createElement("div"); this.statementDiv.classList.add("cardsort-statement"); this.statementDiv.classList.add("exercise-statement"); @@ -268,6 +274,11 @@ export default class DragNDrop extends RunestoneBase { this.containerDiv.appendChild(this.statementDiv); this.dragDropWrapDiv = document.createElement("div"); // Holds the draggables/dropzones, prevents feedback from bleeding in this.dragDropWrapDiv.style.display = "block"; + this.dragDropWrapDiv.classList.add("dragndrop-keyboard-surface"); + this.dragDropWrapDiv.tabIndex = -1; + this.dragDropWrapDiv.addEventListener("keydown", (ev) => { + this.handleKeyboardApplicationKeydown(ev); + }); this.containerDiv.appendChild(this.dragDropWrapDiv); this.draggableDiv = document.createElement("div"); this.draggableDiv.classList.add("rsdraggable", "dragzone"); @@ -282,7 +293,9 @@ export default class DragNDrop extends RunestoneBase { this.finishSettingUp(); } this.ivp = this.isValidPremise.bind(this); - this.queueMathJax(this.containerDiv); + this.queueMathJax(this.containerDiv).then(() => { + this.disablePremiseMathTabStops(); + }); } finishSettingUp() { @@ -294,6 +307,7 @@ export default class DragNDrop extends RunestoneBase { this.minheight = this.draggableDiv.offsetHeight; // Ensure MathJax has completed before adjusting the zone widths this.queueMathJax(this.containerDiv).then(() => { + this.disablePremiseMathTabStops(); this.adjustDragDropWidths(); }); } @@ -485,10 +499,22 @@ export default class DragNDrop extends RunestoneBase { }.bind(this), ); + dgSpan.addEventListener("click", (ev) => { + ev.preventDefault(); + if (this.selectedPremise === dgSpan) { + this.deselectPremise(); + } else { + this.selectPremise(dgSpan); + } + }); + // Enter or Space picks up a premise. Pressing the same key again puts // it down; Escape is handled at the component level so it also works // after focus has moved to a response or button. dgSpan.addEventListener("keydown", (ev) => { + if (ev.target !== dgSpan) { + return; + } if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); if (this.selectedPremise === dgSpan) { @@ -502,20 +528,60 @@ export default class DragNDrop extends RunestoneBase { ) { ev.preventDefault(); this.movePremiseFocus(dgSpan, ev.key === "ArrowDown"); + } else if ( + !this.selectedPremise && + (ev.key === "ArrowLeft" || ev.key === "ArrowRight") + ) { + ev.preventDefault(); + this.focusFirstPremiseInColumn(ev.key === "ArrowRight"); } }); } + disablePremiseMathTabStops(root = this.containerDiv) { + const mathSelector = [ + ".premise .MathJax", + ".premise mjx-container", + ".premise .process-math", + ".premise .MathJax [tabindex]", + ".premise mjx-container [tabindex]", + ".premise .process-math [tabindex]", + ".response .MathJax", + ".response mjx-container", + ".response .process-math", + ".response .MathJax [tabindex]", + ".response mjx-container [tabindex]", + ".response .process-math [tabindex]", + ].join(", "); + for (const mathElement of root.querySelectorAll(mathSelector)) { + mathElement.setAttribute("tabindex", "-1"); + } + } + movePremiseFocus(premise, moveDown) { - const currentIndex = this.premiseArray.indexOf(premise); + const premiseTabOrder = Array.from( + this.containerDiv.querySelectorAll(".premise"), + ).filter((item) => this.premiseArray.includes(item)); + const currentIndex = premiseTabOrder.indexOf(premise); + if (currentIndex === -1) { + return; + } const targetIndex = Math.max( 0, Math.min( currentIndex + (moveDown ? 1 : -1), - this.premiseArray.length - 1, + premiseTabOrder.length - 1, ), ); - this.premiseArray[targetIndex]?.focus(); + premiseTabOrder[targetIndex]?.focus(); + } + + focusFirstPremiseInColumn(rightColumn) { + const column = rightColumn ? this.dropZoneDiv : this.draggableDiv; + const firstPremise = Array.from( + column.querySelectorAll(".premise"), + ).find((item) => this.premiseArray.includes(item)); + firstPremise?.focus(); } setDropListeners(dpSpan) { @@ -524,53 +590,67 @@ export default class DragNDrop extends RunestoneBase { function (ev) { this.isAnswered = true; ev.preventDefault(); - if (ev.target.classList.contains("possibleDrop")) { + const dropTarget = ev.currentTarget; + if (dropTarget.classList.contains("possibleDrop")) { return; } - if (ev.target.classList.contains("draggable-drop")) { - ev.target.classList.add("possibleDrop"); + if (dropTarget.classList.contains("draggable-drop")) { + dropTarget.classList.add("possibleDrop"); } }.bind(this), ); - dpSpan.addEventListener("dragleave", function (ev) { - this.isAnswered = true; - ev.preventDefault(); - if (!ev.target.classList.contains("possibleDrop")) { - return; - } - ev.target.classList.remove("possibleDrop"); - }.bind(this)); + dpSpan.addEventListener( + "dragleave", + function (ev) { + this.isAnswered = true; + ev.preventDefault(); + const dropTarget = ev.currentTarget; + if (!dropTarget.classList.contains("possibleDrop")) { + return; + } + dropTarget.classList.remove("possibleDrop"); + }.bind(this), + ); dpSpan.addEventListener( "drop", function (ev) { this.isAnswered = true; ev.preventDefault(); this.setPointerDragActive(false); - if (ev.target.classList.contains("possibleDrop")) { - ev.target.classList.remove("possibleDrop"); + const dropTarget = ev.currentTarget; + if (dropTarget.classList.contains("possibleDrop")) { + dropTarget.classList.remove("possibleDrop"); } var data = ev.dataTransfer.getData("draggableID"); var draggedSpan = document.getElementById(data); if ( - ev.target.classList.contains("draggable-drop") && - !this.strangerDanger(draggedSpan) && - !this.premiseArray.includes(ev.target) // don't drop on another premise! + dropTarget.classList.contains("draggable-drop") && + !this.strangerDanger(draggedSpan) ) { // Make sure element isn't already there--prevents errors w/appending child - ev.target.appendChild(draggedSpan); + dropTarget.appendChild(draggedSpan); // log a drop event this.logBookEvent({ event: "dragNdrop-drop", div_id: this.divid, - act: `${data} -> ${ev.target.id}`, + act: `${data} -> ${dropTarget.id}`, }); } this.queueMathJax(this.containerDiv).then(() => { + this.disablePremiseMathTabStops(); this.adjustDragDropWidths(); }); }.bind(this), ); + dpSpan.addEventListener("click", (ev) => { + if (!this.selectedPremise || ev.target.closest(".premise")) { + return; + } + ev.preventDefault(); + this.placeSelectedPremise(dpSpan); + }); + // Add keyboard navigation for dropping premises dpSpan.addEventListener("keydown", (ev) => { if (ev.target !== dpSpan || !this.selectedPremise) { @@ -596,11 +676,8 @@ export default class DragNDrop extends RunestoneBase { // Moving focus with Tab or Shift+Tab previews the selected premise in // the newly focused response, just as the vertical arrow keys do. dpSpan.addEventListener("focus", () => { - if ( - this.selectedPremise && - this.selectedPremise.parentElement !== dpSpan - ) { - this.moveSelectedPremise(dpSpan, dpSpan.id); + if (this.selectedPremise) { + this.setActiveResponse(dpSpan); } }); } @@ -609,6 +686,137 @@ export default class DragNDrop extends RunestoneBase { this.containerDiv.classList.toggle("pointer-drag-active", active); } + announceKeyboardInstruction(message) { + if (this.keyboardInstructionDiv) { + this.keyboardInstructionDiv.textContent = message; + } + } + + enterKeyboardApplicationMode(premise) { + if (!this.dragDropWrapDiv) { + return; + } + const premiseLabel = premise.textContent.trim(); + this.dragDropWrapDiv.tabIndex = 0; + this.dragDropWrapDiv.setAttribute("role", "application"); + this.dragDropWrapDiv.setAttribute( + "aria-label", + `Drag and drop placement for ${premiseLabel}`, + ); + if (this.activeResponse) { + this.dragDropWrapDiv.setAttribute( + "aria-activedescendant", + this.activeResponse.id, + ); + } + this.announceKeyboardInstruction( + `Moving ${premiseLabel}. Use arrow keys to choose a target, Enter to place, or Escape to cancel.`, + ); + this.dragDropWrapDiv.focus(); + } + + exitKeyboardApplicationMode() { + if (!this.dragDropWrapDiv) { + return; + } + this.dragDropWrapDiv.removeAttribute("role"); + this.dragDropWrapDiv.removeAttribute("aria-label"); + this.dragDropWrapDiv.removeAttribute("aria-activedescendant"); + this.dragDropWrapDiv.tabIndex = -1; + } + + focusKeyboardApplicationSurface() { + if (this.selectedPremise && this.dragDropWrapDiv) { + this.dragDropWrapDiv.focus(); + } + } + + setActiveResponse(response, { movePremise = true } = {}) { + if (!response) { + return; + } + this.activeResponse = response; + if (this.dragDropWrapDiv && this.selectedPremise) { + this.dragDropWrapDiv.setAttribute( + "aria-activedescendant", + response.id, + ); + } + if ( + movePremise && + this.selectedPremise && + this.selectedPremise.parentElement !== response + ) { + this.moveSelectedPremise(response, response.id); + } + } + + moveActiveResponse({ moveBackward = false, wrap = false } = {}) { + if (!this.selectedPremise || this.responseArray.length === 0) { + return; + } + const offset = moveBackward ? -1 : 1; + const currentIndex = this.responseArray.indexOf(this.activeResponse); + let targetIndex; + if (currentIndex === -1) { + targetIndex = moveBackward ? this.responseArray.length - 1 : 0; + } else { + targetIndex = currentIndex + offset; + } + if (wrap) { + targetIndex = + (targetIndex + this.responseArray.length) % + this.responseArray.length; + } else { + targetIndex = Math.max( + 0, + Math.min(targetIndex, this.responseArray.length - 1), + ); + } + this.setActiveResponse(this.responseArray[targetIndex]); + this.focusKeyboardApplicationSurface(); + } + + handleKeyboardApplicationKeydown(ev) { + if (ev.target !== this.dragDropWrapDiv || !this.selectedPremise) { + return; + } + if (ev.key === "Enter" || ev.key === " ") { + ev.preventDefault(); + ev.stopPropagation(); + this.placeSelectedPremise( + this.activeResponse || this.responseArray[0], + ); + } else if (ev.key === "Escape" || ev.key === "Esc") { + ev.preventDefault(); + ev.stopPropagation(); + this.deselectPremise({ restoreFocus: true }); + } else if (ev.key === "Tab") { + ev.preventDefault(); + ev.stopPropagation(); + this.moveActiveResponse({ moveBackward: ev.shiftKey, wrap: true }); + } else if (ev.key === "ArrowUp" || ev.key === "ArrowDown") { + ev.preventDefault(); + ev.stopPropagation(); + this.moveActiveResponse({ + moveBackward: ev.key === "ArrowUp", + wrap: false, + }); + } else if (ev.key === "ArrowLeft") { + ev.preventDefault(); + ev.stopPropagation(); + this.returnSelectedPremise(); + this.focusKeyboardApplicationSurface(); + } else if (ev.key === "ArrowRight") { + ev.preventDefault(); + ev.stopPropagation(); + this.moveSelectedPremiseRight( + this.activeResponse || this.responseArray[0], + ); + this.focusKeyboardApplicationSurface(); + } + } + moveResponseFocus(response, moveBackward) { const currentIndex = this.responseArray.indexOf(response); const offset = moveBackward ? -1 : 1; @@ -637,6 +845,7 @@ export default class DragNDrop extends RunestoneBase { } const response = this.responseArray[targetIndex]; + this.activeResponse = response; if (response !== premise.parentElement) { this.moveSelectedPremise(response, response.id); } @@ -649,6 +858,7 @@ export default class DragNDrop extends RunestoneBase { return; } this.moveSelectedPremise(this.draggableDiv, "dragzone"); + this.announceKeyboardInstruction("Returned to unplaced list."); } moveSelectedPremiseRight(response) { @@ -664,6 +874,9 @@ export default class DragNDrop extends RunestoneBase { if (!premise || premise.parentElement === destination) { return; } + if (this.responseArray.includes(destination)) { + this.activeResponse = destination; + } destination.appendChild(premise); this.isAnswered = true; this.logBookEvent({ @@ -672,6 +885,7 @@ export default class DragNDrop extends RunestoneBase { act: `${premise.id} -> ${destinationName}`, }); this.queueMathJax(this.containerDiv).then(() => { + this.disablePremiseMathTabStops(); this.adjustDragDropWidths(); }); } @@ -682,6 +896,12 @@ export default class DragNDrop extends RunestoneBase { return; } + if (premise.parentElement === this.draggableDiv) { + this.deselectPremise(); + premise.focus(); + return; + } + this.moveSelectedPremise(response, response.id); this.deselectPremise(); premise.focus(); @@ -692,13 +912,17 @@ export default class DragNDrop extends RunestoneBase { this.selectedPremise = premise; premise.classList.add("selected"); premise.setAttribute("aria-pressed", "true"); - this.updateKeyboardNavigation(); const currentResponse = this.responseArray.includes( premise.parentElement, ) ? premise.parentElement : this.responseArray[0]; - currentResponse?.focus(); + this.activeResponse = currentResponse || null; + this.updateKeyboardNavigation(); + if (currentResponse && premise.parentElement !== currentResponse) { + this.moveSelectedPremise(currentResponse, currentResponse.id); + } + this.enterKeyboardApplicationMode(premise); } deselectPremise({ restoreFocus = false } = {}) { @@ -709,6 +933,8 @@ export default class DragNDrop extends RunestoneBase { premise.classList.remove("selected"); premise.setAttribute("aria-pressed", "false"); this.selectedPremise = null; + this.activeResponse = null; + this.exitKeyboardApplicationMode(); this.updateKeyboardNavigation(); if (restoreFocus) { premise.focus(); diff --git a/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js b/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js index 1ac3b9c66..239ec8327 100644 --- a/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js +++ b/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js @@ -348,6 +348,124 @@ describe("keyboard controls", () => { expect(document.activeElement).toBe(firstPremise); }); + it("moves focus between unselected premises in tab order", async () => { + const dnd = await makeDnd(); + const p1 = dnd.premiseArray.find((p) => p.id === "p1"); + const p2 = dnd.premiseArray.find((p) => p.id === "p2"); + const p3 = dnd.premiseArray.find((p) => p.id === "p3"); + place(dnd, "p2", "r1"); + place(dnd, "p1", "r2"); + + p3.focus(); + p3.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowDown", + bubbles: true, + }), + ); + expect(document.activeElement).toBe(p2); + + p2.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowDown", + bubbles: true, + }), + ); + expect(document.activeElement).toBe(p1); + + p1.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowUp", + bubbles: true, + }), + ); + expect(document.activeElement).toBe(p2); + expect(dnd.selectedPremise).toBe(null); + }); + + it("moves focus to the first premise in the left or right column", async () => { + const dnd = await makeDnd(); + const p1 = dnd.premiseArray.find((p) => p.id === "p1"); + const p2 = dnd.premiseArray.find((p) => p.id === "p2"); + const p3 = dnd.premiseArray.find((p) => p.id === "p3"); + place(dnd, "p2", "r1"); + place(dnd, "p1", "r2"); + + p3.focus(); + p3.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowRight", + bubbles: true, + }), + ); + expect(document.activeElement).toBe(p2); + + p1.focus(); + p1.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowLeft", + bubbles: true, + }), + ); + expect(document.activeElement).toBe(p3); + expect(dnd.selectedPremise).toBe(null); + }); + + it("selects and places a premise with click events", async () => { + const dnd = await makeDnd(); + const premise = dnd.premiseArray.find((p) => p.id === "p1"); + const response = dnd.responseArray.find((r) => r.id === "r2"); + + premise.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }), + ); + expect(dnd.selectedPremise).toBe(premise); + expect(premise.classList.contains("selected")).toBe(true); + expect(premise.getAttribute("aria-pressed")).toBe("true"); + + response.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }), + ); + expect(response.contains(premise)).toBe(true); + expect(dnd.selectedPremise).toBe(null); + expect(premise.classList.contains("selected")).toBe(false); + expect(premise.getAttribute("aria-pressed")).toBe("false"); + expect(dnd.isAnswered).toBe(true); + }); + + it("selects a premise from a nested click", async () => { + const dnd = await makeDnd(); + const premise = dnd.premiseArray[0]; + const nested = document.createElement("span"); + premise.appendChild(nested); + + nested.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }), + ); + + expect(dnd.selectedPremise).toBe(premise); + expect(premise.classList.contains("selected")).toBe(true); + }); + + it("places a selected premise from a nested response click", async () => { + const dnd = await makeDnd(); + const premise = dnd.premiseArray[0]; + const response = dnd.responseArray[1]; + const nested = document.createElement("span"); + response.appendChild(nested); + + premise.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }), + ); + nested.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }), + ); + + expect(response.contains(premise)).toBe(true); + expect(dnd.selectedPremise).toBe(null); + expect(premise.classList.contains("selected")).toBe(false); + }); + it.each(["Enter", " "])( "places the selected premise in a response with %j", async (key) => { @@ -361,7 +479,16 @@ describe("keyboard controls", () => { expect(dnd.selectedPremise).toBe(premise); expect(premise.classList.contains("selected")).toBe(true); expect(premise.getAttribute("aria-pressed")).toBe("true"); - expect(document.activeElement).toBe(dnd.responseArray[0]); + expect(document.activeElement).toBe(dnd.dragDropWrapDiv); + expect(dnd.dragDropWrapDiv.getAttribute("role")).toBe( + "application", + ); + expect( + dnd.dragDropWrapDiv.getAttribute("aria-activedescendant"), + ).toBe(response.id); + expect(dnd.keyboardInstructionDiv.textContent).toBe( + "Moving Dog. Use arrow keys to choose a target, Enter to place, or Escape to cancel.", + ); expect(dnd.responseArray[0].contains(premise)).toBe(true); expect(dnd.premiseArray.every((item) => item.tabIndex === -1)).toBe( true, @@ -370,7 +497,7 @@ describe("keyboard controls", () => { true, ); - response.dispatchEvent( + dnd.dragDropWrapDiv.dispatchEvent( new KeyboardEvent("keydown", { key, bubbles: true }), ); expect(response.contains(premise)).toBe(true); @@ -379,6 +506,8 @@ describe("keyboard controls", () => { expect(premise.getAttribute("aria-pressed")).toBe("false"); expect(dnd.isAnswered).toBe(true); expect(document.activeElement).toBe(premise); + expect(dnd.dragDropWrapDiv.getAttribute("role")).toBe(null); + expect(dnd.dragDropWrapDiv.tabIndex).toBe(-1); expect(dnd.premiseArray.every((item) => item.tabIndex === 0)).toBe( true, ); @@ -395,7 +524,7 @@ describe("keyboard controls", () => { premise.dispatchEvent( new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), ); - dnd.responseArray[0].dispatchEvent( + dnd.dragDropWrapDiv.dispatchEvent( new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), ); @@ -409,6 +538,59 @@ describe("keyboard controls", () => { ); }); + it("removes premise math content from the tab order", async () => { + const dnd = await makeDnd(); + const premise = dnd.premiseArray[0]; + const nestedMath = document.createElement("span"); + const nestedMathChild = document.createElement("span"); + nestedMath.className = "MathJax"; + nestedMath.tabIndex = 0; + nestedMathChild.tabIndex = 0; + nestedMath.appendChild(nestedMathChild); + premise.appendChild(nestedMath); + + dnd.disablePremiseMathTabStops(); + + expect(nestedMath.tabIndex).toBe(-1); + expect(nestedMathChild.tabIndex).toBe(-1); + }); + + it("captures Space on the premise when it contains math content", async () => { + const dnd = await makeDnd(); + const premise = dnd.premiseArray[0]; + const nestedMath = document.createElement("span"); + nestedMath.className = "MathJax"; + premise.appendChild(nestedMath); + + const event = new KeyboardEvent("keydown", { + key: " ", + bubbles: true, + cancelable: true, + }); + premise.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(dnd.selectedPremise).toBe(premise); + expect(premise.classList.contains("selected")).toBe(true); + }); + it("does not capture Space from nested premise content", async () => { + const dnd = await makeDnd(); + const premise = dnd.premiseArray[0]; + const nestedMath = document.createElement("span"); + nestedMath.className = "MathJax"; + premise.appendChild(nestedMath); + + const event = new KeyboardEvent("keydown", { + key: " ", + bubbles: true, + cancelable: true, + }); + nestedMath.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(false); + expect(dnd.selectedPremise).toBe(null); + expect(premise.classList.contains("selected")).toBe(false); + }); it("does not treat a key event from a nested premise as response activation", async () => { const dnd = await makeDnd(); const premise = dnd.premiseArray.find((p) => p.id === "p1"); @@ -433,7 +615,11 @@ describe("keyboard controls", () => { ); expect(secondResponse.contains(premise)).toBe(true); - expect(document.activeElement).toBe(secondResponse); + expect(document.activeElement).toBe(dnd.dragDropWrapDiv); + expect(dnd.activeResponse).toBe(secondResponse); + expect(dnd.dragDropWrapDiv.getAttribute("aria-activedescendant")).toBe( + secondResponse.id, + ); expect(dnd.selectedPremise).toBe(premise); }); @@ -489,7 +675,7 @@ describe("keyboard controls", () => { expect(dnd.selectedPremise).toBe(premise); }); - it("traps Tab and Shift+Tab within responses while a premise is selected", async () => { + it("traps Tab and Shift+Tab within the application surface while a premise is selected", async () => { const dnd = await makeDnd(); const premise = dnd.premiseArray[0]; const [firstResponse, secondResponse] = dnd.responseArray; @@ -497,28 +683,32 @@ describe("keyboard controls", () => { premise.dispatchEvent( new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), ); - expect(document.activeElement).toBe(firstResponse); + expect(document.activeElement).toBe(dnd.dragDropWrapDiv); + expect(firstResponse.contains(premise)).toBe(true); - firstResponse.dispatchEvent( + dnd.dragDropWrapDiv.dispatchEvent( new KeyboardEvent("keydown", { key: "Tab", bubbles: true }), ); - expect(document.activeElement).toBe(secondResponse); + expect(document.activeElement).toBe(dnd.dragDropWrapDiv); + expect(dnd.activeResponse).toBe(secondResponse); expect(secondResponse.contains(premise)).toBe(true); - secondResponse.dispatchEvent( + dnd.dragDropWrapDiv.dispatchEvent( new KeyboardEvent("keydown", { key: "Tab", bubbles: true }), ); - expect(document.activeElement).toBe(firstResponse); + expect(document.activeElement).toBe(dnd.dragDropWrapDiv); + expect(dnd.activeResponse).toBe(firstResponse); expect(firstResponse.contains(premise)).toBe(true); - firstResponse.dispatchEvent( + dnd.dragDropWrapDiv.dispatchEvent( new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, }), ); - expect(document.activeElement).toBe(secondResponse); + expect(document.activeElement).toBe(dnd.dragDropWrapDiv); + expect(dnd.activeResponse).toBe(secondResponse); expect(secondResponse.contains(premise)).toBe(true); }); @@ -539,6 +729,9 @@ describe("keyboard controls", () => { ); expect(premise.parentElement).toBe(dnd.draggableDiv); + expect(dnd.keyboardInstructionDiv.textContent).toBe( + "Returned to unplaced list.", + ); expect(dnd.selectedPremise).toBe(premise); expect(premise.classList.contains("selected")).toBe(true); expect(dnd.responseArray.every((item) => item.tabIndex === 0)).toBe( @@ -546,6 +739,38 @@ describe("keyboard controls", () => { ); }); + it("drops a selected premise in the dragzone with Enter after moving left", async () => { + const dnd = await makeDnd(); + const premise = dnd.premiseArray[0]; + const response = dnd.responseArray[0]; + + premise.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + expect(response.contains(premise)).toBe(true); + + response.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowLeft", + bubbles: true, + }), + ); + expect(premise.parentElement).toBe(dnd.draggableDiv); + + response.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + expect(premise.parentElement).toBe(dnd.draggableDiv); + expect(dnd.selectedPremise).toBe(null); + expect(premise.classList.contains("selected")).toBe(false); + expect(document.activeElement).toBe(premise); + expect(dnd.premiseArray.every((item) => item.tabIndex === 0)).toBe( + true, + ); + expect(dnd.responseArray.every((item) => item.tabIndex === -1)).toBe( + true, + ); + }); it("moves a selected premise right into the focused response", async () => { const dnd = await makeDnd(); const premise = dnd.premiseArray[0]; @@ -561,9 +786,10 @@ describe("keyboard controls", () => { }), ); expect(premise.parentElement).toBe(dnd.draggableDiv); - expect(document.activeElement).toBe(firstResponse); + expect(document.activeElement).toBe(dnd.dragDropWrapDiv); + expect(dnd.activeResponse).toBe(firstResponse); - firstResponse.dispatchEvent( + dnd.dragDropWrapDiv.dispatchEvent( new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, @@ -575,6 +801,23 @@ describe("keyboard controls", () => { }); describe("pointer controls", () => { + it("drops on the response when the pointer event starts on nested content", async () => { + const dnd = await makeDnd(); + const premise = dnd.premiseArray[0]; + const response = dnd.responseArray[0]; + const nestedMath = document.createElement("span"); + nestedMath.className = "MathJax"; + response.appendChild(nestedMath); + const drop = new Event("drop", { bubbles: true, cancelable: true }); + Object.defineProperty(drop, "dataTransfer", { + value: { getData: () => premise.id }, + }); + + nestedMath.dispatchEvent(drop); + + expect(response.contains(premise)).toBe(true); + expect(drop.defaultPrevented).toBe(true); + }); it("highlights responses only while a premise is being dragged", async () => { const dnd = await makeDnd(); const premise = dnd.premiseArray[0]; From 4c032288ccf86858f3249c054fa6d5126fac71ba Mon Sep 17 00:00:00 2001 From: Andrew Scholer Date: Tue, 21 Jul 2026 11:21:11 -0700 Subject: [PATCH 2/8] Matching: improve keyboard navigation --- .../runestone/matching/css/matching.css | 1 + .../runestone/matching/js/matching.js | 223 ++++++++++++--- .../runestone/matching/test/matching.test.js | 253 ++++++++++++++++++ 3 files changed, 439 insertions(+), 38 deletions(-) create mode 100644 bases/rsptx/interactives/runestone/matching/test/matching.test.js diff --git a/bases/rsptx/interactives/runestone/matching/css/matching.css b/bases/rsptx/interactives/runestone/matching/css/matching.css index 470353cf5..ec54ba1f5 100644 --- a/bases/rsptx/interactives/runestone/matching/css/matching.css +++ b/bases/rsptx/interactives/runestone/matching/css/matching.css @@ -189,6 +189,7 @@ stroke-opacity: 0.15; } +.line.selected, .line:focus, .line:hover { stroke: #3498db; diff --git a/bases/rsptx/interactives/runestone/matching/js/matching.js b/bases/rsptx/interactives/runestone/matching/js/matching.js index 62a09bbf5..d75bc16fc 100644 --- a/bases/rsptx/interactives/runestone/matching/js/matching.js +++ b/bases/rsptx/interactives/runestone/matching/js/matching.js @@ -41,6 +41,8 @@ export class MatchingProblem extends RunestoneBase { this.connections = []; this.allBoxes = []; this.selectedBox = null; + this.activeBoxRole = null; + this.selectedLine = null; this.startBox = null; this.tempLine = null; this.useRunestoneServices = eBookConfig.useRunestoneServices; @@ -61,7 +63,9 @@ export class MatchingProblem extends RunestoneBase { this.renderBoxes(); this.attachEvents(); - this.queueMathJax(this.containerDiv); + this.queueMathJax(this.containerDiv).then(() => { + this.disableBoxMathTabStops(); + }); } // required elements for a Runestone component @@ -322,7 +326,7 @@ export class MatchingProblem extends RunestoneBase { this.helpModal.className = "help-modal"; const text = `

Click and drag between boxes to create connections.

Use the tab key to navigate to a box and press Enter to select it. Focus then jumps to the other column; tab to the box you want to connect and press Enter. Press Escape to cancel a selection.

-

Click on a connection line to remove it. You can also use the tab key to select lines. Press the delete key to remove a selected line.

+

Click on a connection line or use the tab key to select it. Press Enter, Delete, or Backspace to remove a selected line.

Click the "Check Me" button to check your connections, and save your work.

Click the "Reset" button to clear all connections.

`; @@ -405,6 +409,138 @@ export class MatchingProblem extends RunestoneBase { return div; } + disableBoxMathTabStops(root = this.containerDiv) { + const mathSelector = [ + ".box .MathJax", + ".box mjx-container", + ".box .process-math", + ".box .MathJax [tabindex]", + ".box mjx-container [tabindex]", + ".box .process-math [tabindex]", + ].join(", "); + for (const mathElement of root.querySelectorAll(mathSelector)) { + mathElement.setAttribute("tabindex", "-1"); + } + } + + getColumnBoxes(role) { + const column = role === "drag" ? this.leftColumn : this.rightColumn; + return Array.from(column.querySelectorAll(".box")).filter((box) => + this.allBoxes.includes(box), + ); + } + + getTabbableBoxes() { + if (!this.selectedBox) { + return this.allBoxes; + } + return this.getColumnBoxes( + this.selectedBox.dataset.role === "drag" ? "drop" : "drag", + ); + } + + updateBoxTabStops() { + const tabbableBoxes = new Set(this.getTabbableBoxes()); + for (const box of this.allBoxes) { + box.tabIndex = tabbableBoxes.has(box) ? 0 : -1; + } + } + + setSelectedBox(box) { + this.setSelectedLine(null, false); + if (this.selectedBox) { + this.selectedBox.classList.remove("selected"); + } + this.selectedBox = box; + this.activeBoxRole = box ? box.dataset.role : null; + if (box) { + box.classList.add("selected"); + } + this.updateBoxTabStops(); + } + + activateBox(box) { + if (!this.selectedBox) { + this.setSelectedBox(box); + const firstOppositeBox = this.getTabbableBoxes()[0]; + firstOppositeBox?.focus(); + if (this.ariaLive) { + this.ariaLive.textContent = `Selected ${box.textContent}. Tab to a box in the other column and press Enter to connect, or press Escape to cancel.`; + } + return; + } + + if (box !== this.selectedBox) { + this.createPermanentLine(this.selectedBox, box); + } + this.setSelectedBox(null); + box.focus(); + } + + setSelectedLine(line, announce = true) { + if (this.selectedLine && this.selectedLine !== line) { + this.selectedLine.classList.remove("selected"); + } + this.selectedLine = line; + if (line) { + if (this.selectedBox) { + this.selectedBox.classList.remove("selected"); + this.selectedBox = null; + this.activeBoxRole = null; + this.updateBoxTabStops(); + } + line.classList.add("selected"); + if (announce && this.ariaLive) { + const fromLabel = line.fromBox?.textContent || "one box"; + const toLabel = line.toBox?.textContent || "another box"; + this.ariaLive.textContent = `Selected connection from ${fromLabel} to ${toLabel}. Press Enter to delete it.`; + } + } + } + + cancelSelectedBox() { + const selected = this.selectedBox; + if (!selected) { + return; + } + this.setSelectedBox(null); + selected.focus(); + if (this.ariaLive) { + this.ariaLive.textContent = "Selection cancelled."; + } + } + + moveBoxFocus(box, moveDown) { + const boxOrder = this.selectedBox + ? this.getTabbableBoxes() + : this.getColumnBoxes(box.dataset.role); + const currentIndex = boxOrder.indexOf(box); + if (currentIndex === -1) { + return; + } + const targetIndex = Math.max( + 0, + Math.min(currentIndex + (moveDown ? 1 : -1), boxOrder.length - 1), + ); + boxOrder[targetIndex]?.focus(); + } + + moveBoxFocusAcrossColumns(rightColumn) { + const targetRole = rightColumn ? "drop" : "drag"; + this.getColumnBoxes(targetRole)[0]?.focus(); + } + + moveTabFocus(box, moveBackward) { + const boxOrder = this.getTabbableBoxes(); + const currentIndex = boxOrder.indexOf(box); + if (currentIndex === -1 || boxOrder.length === 0) { + return; + } + const offset = moveBackward ? -1 : 1; + const targetIndex = + (currentIndex + offset + boxOrder.length) % boxOrder.length; + boxOrder[targetIndex]?.focus(); + } getCenter(el) { const elRect = el.getBoundingClientRect(); const containerRect = this.workspace.getBoundingClientRect(); @@ -469,15 +605,31 @@ export class MatchingProblem extends RunestoneBase { line.setAttribute("role", "button"); // Add ARIA role for accessibility line.setAttribute( "aria-label", - "Connection line. Press Delete to remove.", + "Connection line. Press Enter, Delete, or Backspace to remove.", ); // Add ARIA label - line.addEventListener("click", () => { - this.removeLine(line); + line.addEventListener("click", (e) => { + e.preventDefault(); + line.focus(); + this.setSelectedLine(line); + }); + + line.addEventListener("focus", () => { + this.setSelectedLine(line); + }); + + line.addEventListener("blur", () => { + if (this.selectedLine === line) { + this.setSelectedLine(null, false); + } }); line.addEventListener("keydown", (e) => { - if (e.key === "Delete" || e.key === "Backspace") { + if ( + e.key === "Enter" || + e.key === "Delete" || + e.key === "Backspace" + ) { e.preventDefault(); this.removeLine(line); } @@ -487,6 +639,11 @@ export class MatchingProblem extends RunestoneBase { } removeLine(line) { + const fromLabel = line.fromBox?.textContent || "one box"; + const toLabel = line.toBox?.textContent || "another box"; + if (this.selectedLine === line) { + this.setSelectedLine(null, false); + } this.svg.removeChild(line); const index = this.connections.findIndex( (conn) => @@ -495,6 +652,9 @@ export class MatchingProblem extends RunestoneBase { ); if (index !== -1) this.connections.splice(index, 1); this.updateConnectionModel(); + if (this.ariaLive) { + this.ariaLive.textContent = `Removed connection from ${fromLabel} to ${toLabel}.`; + } } isConnected(a, b) { @@ -627,45 +787,32 @@ export class MatchingProblem extends RunestoneBase { }); box.addEventListener("keydown", (e) => { + if (e.target !== box) { + return; + } if (e.key === "Enter") { e.preventDefault(); - if (!this.selectedBox) { - this.selectedBox = box; - box.classList.add("selected"); - // Jump focus to the top of the opposite column so - // the user doesn't have to tab through the rest of - // this column and every connection line to get - // there. (With nothing selected, natural tab order - // still visits the lines so they can be deleted.) - const opposite = this.allBoxes.find( - (b) => b.dataset.role !== box.dataset.role, - ); - if (opposite) opposite.focus(); - if (this.ariaLive) { - this.ariaLive.textContent = `Selected ${box.textContent}. Tab to a box in the other column and press Enter to connect, or press Escape to cancel.`; - } - } else { - if (box !== this.selectedBox) - this.createPermanentLine(this.selectedBox, box); - this.selectedBox.classList.remove("selected"); - this.selectedBox = null; - const currentIndex = this.allBoxes.indexOf(box); - const next = this.allBoxes[currentIndex + 1]; - if (next) next.focus(); - else this.allBoxes[0].focus(); - } + this.activateBox(box); } else if (e.key === "Escape" && this.selectedBox) { e.preventDefault(); - const selected = this.selectedBox; - selected.classList.remove("selected"); - this.selectedBox = null; - selected.focus(); - if (this.ariaLive) { - this.ariaLive.textContent = "Selection cancelled."; - } + this.cancelSelectedBox(); + } else if (this.selectedBox && e.key === "Tab") { + e.preventDefault(); + this.moveTabFocus(box, e.shiftKey); + } else if (e.key === "ArrowUp" || e.key === "ArrowDown") { + e.preventDefault(); + this.moveBoxFocus(box, e.key === "ArrowDown"); + } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") { + e.preventDefault(); + this.moveBoxFocusAcrossColumns(e.key === "ArrowRight"); } }); + box.addEventListener("click", (e) => { + e.preventDefault(); + this.activateBox(box); + }); + box.addEventListener("mouseenter", () => { this.connections.forEach((conn) => { if (conn.fromBox === box || conn.toBox === box) { diff --git a/bases/rsptx/interactives/runestone/matching/test/matching.test.js b/bases/rsptx/interactives/runestone/matching/test/matching.test.js new file mode 100644 index 000000000..f1ce89fec --- /dev/null +++ b/bases/rsptx/interactives/runestone/matching/test/matching.test.js @@ -0,0 +1,253 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { MatchingProblem } from "../js/matching.js"; + +const MATCHING_QUESTION = { + statement: "Match each animal to its sound.", + feedback: "Think about pets.", + left: [ + { id: "p1", label: "Dog" }, + { id: "p2", label: "Cat" }, + { id: "p3", label: "Rock" }, + ], + right: [ + { id: "r1", label: "Barks" }, + { id: "r2", label: "Meows" }, + { id: "r3", label: "Silence" }, + ], + correctAnswers: [ + ["p1", "r1"], + ["p2", "r2"], + ], +}; + +function makeFixture({ + id = "test_matching_1", + question = MATCHING_QUESTION, +} = {}) { + document.body.innerHTML = ` +
+
+ +
+
`; + return document.getElementById(id); +} + +const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function makeMatching(fixtureOpts = {}) { + const orig = makeFixture(fixtureOpts); + const matching = new MatchingProblem({ orig }); + await matching.boxesRenderedPromise; + await tick(); + return matching; +} + +function keydown(target, key, extra = {}) { + target.dispatchEvent( + new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...extra, + }), + ); +} + +beforeEach(() => { + document.body.innerHTML = ""; + window.componentMap = {}; + window.allComponents = []; + localStorage.clear(); + vi.restoreAllMocks(); + vi.spyOn(Math, "random").mockReturnValue(0.99); + vi.stubGlobal("alert", vi.fn()); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("matching keyboard controls", () => { + it("removes math content inside boxes from the tab order", async () => { + const matching = await makeMatching(); + const box = matching.leftColumn.querySelector(".box"); + const nestedMath = document.createElement("span"); + const nestedMathChild = document.createElement("span"); + nestedMath.className = "MathJax"; + nestedMath.tabIndex = 0; + nestedMathChild.tabIndex = 0; + nestedMath.appendChild(nestedMathChild); + box.appendChild(nestedMath); + + matching.disableBoxMathTabStops(); + + expect(nestedMath.tabIndex).toBe(-1); + expect(nestedMathChild.tabIndex).toBe(-1); + }); + + it("only keeps right boxes tabbable while a left box is active", async () => { + const matching = await makeMatching(); + const leftBoxes = [...matching.leftColumn.querySelectorAll(".box")]; + const rightBoxes = [...matching.rightColumn.querySelectorAll(".box")]; + + keydown(leftBoxes[0], "Enter"); + + expect(matching.selectedBox).toBe(leftBoxes[0]); + expect(matching.activeBoxRole).toBe("drag"); + expect(matching.ariaLive.textContent).toBe( + "Selected Dog. Tab to a box in the other column and press Enter to connect, or press Escape to cancel.", + ); + expect(leftBoxes.every((box) => box.tabIndex === -1)).toBe(true); + expect(rightBoxes.every((box) => box.tabIndex === 0)).toBe(true); + expect(document.activeElement).toBe(rightBoxes[0]); + }); + + it("only keeps left boxes tabbable while a right box is active", async () => { + const matching = await makeMatching(); + const leftBoxes = [...matching.leftColumn.querySelectorAll(".box")]; + const rightBoxes = [...matching.rightColumn.querySelectorAll(".box")]; + + keydown(rightBoxes[1], "Enter"); + + expect(matching.selectedBox).toBe(rightBoxes[1]); + expect(matching.activeBoxRole).toBe("drop"); + expect(leftBoxes.every((box) => box.tabIndex === 0)).toBe(true); + expect(rightBoxes.every((box) => box.tabIndex === -1)).toBe(true); + expect(document.activeElement).toBe(leftBoxes[0]); + }); + + it("traps Tab within the opposite column while a box is active", async () => { + const matching = await makeMatching(); + const leftBoxes = [...matching.leftColumn.querySelectorAll(".box")]; + const rightBoxes = [...matching.rightColumn.querySelectorAll(".box")]; + + keydown(leftBoxes[0], "Enter"); + expect(document.activeElement).toBe(rightBoxes[0]); + + keydown(rightBoxes[0], "Tab"); + expect(document.activeElement).toBe(rightBoxes[1]); + + keydown(rightBoxes[1], "Tab", { shiftKey: true }); + expect(document.activeElement).toBe(rightBoxes[0]); + }); + + it("cancels an active box with Escape and announces the cancellation", async () => { + const matching = await makeMatching(); + const leftBoxes = [...matching.leftColumn.querySelectorAll(".box")]; + + keydown(leftBoxes[0], "Enter"); + keydown(document.activeElement, "Escape"); + + expect(matching.selectedBox).toBe(null); + expect(matching.activeBoxRole).toBe(null); + expect(matching.allBoxes.every((box) => box.tabIndex === 0)).toBe(true); + expect(document.activeElement).toBe(leftBoxes[0]); + expect(matching.ariaLive.textContent).toBe("Selection cancelled."); + }); + + it("moves focus vertically within the current column", async () => { + const matching = await makeMatching(); + const leftBoxes = [...matching.leftColumn.querySelectorAll(".box")]; + + leftBoxes[0].focus(); + keydown(leftBoxes[0], "ArrowDown"); + expect(document.activeElement).toBe(leftBoxes[1]); + + keydown(leftBoxes[1], "ArrowUp"); + expect(document.activeElement).toBe(leftBoxes[0]); + }); + + it("moves focus to the first box in the left or right column", async () => { + const matching = await makeMatching(); + const leftBoxes = [...matching.leftColumn.querySelectorAll(".box")]; + const rightBoxes = [...matching.rightColumn.querySelectorAll(".box")]; + + leftBoxes[1].focus(); + keydown(leftBoxes[1], "ArrowRight"); + expect(document.activeElement).toBe(rightBoxes[0]); + + keydown(rightBoxes[0], "ArrowLeft"); + expect(document.activeElement).toBe(leftBoxes[0]); + }); + + it("creates a connection and restores all box tab stops", async () => { + const matching = await makeMatching(); + const leftBoxes = [...matching.leftColumn.querySelectorAll(".box")]; + const rightBoxes = [...matching.rightColumn.querySelectorAll(".box")]; + + keydown(leftBoxes[0], "Enter"); + keydown(rightBoxes[1], "Enter"); + + expect(matching.connections).toHaveLength(1); + expect(matching.connections[0].fromBox).toBe(leftBoxes[0]); + expect(matching.connections[0].toBox).toBe(rightBoxes[1]); + expect(matching.selectedBox).toBe(null); + expect(matching.activeBoxRole).toBe(null); + expect(matching.allBoxes.every((box) => box.tabIndex === 0)).toBe(true); + expect(document.activeElement).toBe(rightBoxes[1]); + }); + + it("selects a formed connection and removes it with Enter", async () => { + const matching = await makeMatching(); + const leftBoxes = [...matching.leftColumn.querySelectorAll(".box")]; + const rightBoxes = [...matching.rightColumn.querySelectorAll(".box")]; + + keydown(leftBoxes[0], "Enter"); + keydown(rightBoxes[1], "Enter"); + const line = matching.connections[0].line; + + line.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }), + ); + expect(matching.connections).toHaveLength(1); + expect(matching.selectedLine).toBe(line); + expect(line.classList.contains("selected")).toBe(true); + expect(matching.ariaLive.textContent).toBe( + "Selected connection from Dog to Meows. Press Enter to delete it.", + ); + + keydown(line, "Enter"); + expect(matching.connections).toHaveLength(0); + expect(matching.selectedLine).toBe(null); + expect(matching.svg.contains(line)).toBe(false); + expect(matching.ariaLive.textContent).toBe( + "Removed connection from Dog to Meows.", + ); + }); + + it("selects and connects boxes with click events", async () => { + const matching = await makeMatching(); + const leftBoxes = [...matching.leftColumn.querySelectorAll(".box")]; + const rightBoxes = [...matching.rightColumn.querySelectorAll(".box")]; + + leftBoxes[0].click(); + expect(matching.selectedBox).toBe(leftBoxes[0]); + expect(matching.activeBoxRole).toBe("drag"); + expect(document.activeElement).toBe(rightBoxes[0]); + + rightBoxes[1].click(); + expect(matching.connections).toHaveLength(1); + expect(matching.connections[0].fromBox).toBe(leftBoxes[0]); + expect(matching.connections[0].toBox).toBe(rightBoxes[1]); + expect(matching.selectedBox).toBe(null); + expect(matching.allBoxes.every((box) => box.tabIndex === 0)).toBe(true); + }); + + it("activates the box when clicking nested content", async () => { + const matching = await makeMatching(); + const leftBoxes = [...matching.leftColumn.querySelectorAll(".box")]; + const rightBoxes = [...matching.rightColumn.querySelectorAll(".box")]; + const nestedContent = document.createElement("span"); + nestedContent.textContent = " nested"; + leftBoxes[0].appendChild(nestedContent); + + nestedContent.click(); + expect(matching.selectedBox).toBe(leftBoxes[0]); + + rightBoxes[0].click(); + expect(matching.connections).toHaveLength(1); + expect(matching.connections[0].fromBox).toBe(leftBoxes[0]); + expect(matching.connections[0].toBox).toBe(rightBoxes[0]); + }); +}); From c205ae9424d5144a2fe3c85a2e1daee99e3da64f Mon Sep 17 00:00:00 2001 From: Andrew Scholer Date: Mon, 24 Aug 2026 11:16:23 -0700 Subject: [PATCH 3/8] Interactives common: Add mathjax aria extraction helper --- .../runestone/common/js/mathjax-a11y.js | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 bases/rsptx/interactives/runestone/common/js/mathjax-a11y.js diff --git a/bases/rsptx/interactives/runestone/common/js/mathjax-a11y.js b/bases/rsptx/interactives/runestone/common/js/mathjax-a11y.js new file mode 100644 index 000000000..e18fea464 --- /dev/null +++ b/bases/rsptx/interactives/runestone/common/js/mathjax-a11y.js @@ -0,0 +1,106 @@ +/* *********************************** + * |docname| - MathJax accessibility helpers + * *********************************** + * Utilities for interactive elements that contain MathJax-rendered content. + */ + +const mathSpeechSelector = + "mjx-container, .MathJax, [data-semantic-speech-none]"; +const mathSpeechDescendantSelector = + "mjx-container[aria-label], .MathJax[aria-label], [data-semantic-speech-none]"; + +/** + * Return MathJax's speech text for a rendered math element. + */ +export function getMathJaxSpeechText(element) { + if (!element || element.nodeType !== Node.ELEMENT_NODE) { + return ""; + } + if (!element.matches(mathSpeechSelector)) { + return ""; + } + return ( + element.getAttribute("data-semantic-speech-none") || + element.getAttribute("aria-label") || + "" + ) + .replace(/\s+/g, " ") + .trim(); +} + +/** + * Return the speech text from the first MathJax descendant of an element. + */ +export function getMathJaxSpeechDescendantText(element) { + return getMathJaxSpeechText( + element?.querySelector?.(mathSpeechDescendantSelector), + ); +} + +/** + * Return an accessible text equivalent for content that can include MathJax. + */ +export function getAccessibleElementText(element) { + const parts = []; + const visit = (node) => { + if (node.nodeType === Node.TEXT_NODE) { + parts.push(node.textContent); + return; + } + if (node.nodeType !== Node.ELEMENT_NODE) { + return; + } + const nodeElement = node; + const speechText = getMathJaxSpeechText(nodeElement); + if (speechText) { + parts.push(speechText); + return; + } + if (nodeElement.matches(".process-math")) { + const descendantSpeech = + getMathJaxSpeechDescendantText(nodeElement); + if (descendantSpeech) { + parts.push(descendantSpeech); + return; + } + } + if (nodeElement.getAttribute("aria-hidden") === "true") { + return; + } + if (nodeElement.tagName === "IMG") { + parts.push(nodeElement.getAttribute("alt") || ""); + return; + } + for (const child of nodeElement.childNodes) { + visit(child); + } + }; + if (element) { + visit(element); + } + return parts.join(" ").replace(/\s+/g, " ").trim(); +} + +/** + * Remove rendered MathJax content from the tab order within each container. + */ +export function disableMathJaxTabStops(root, containerSelectors) { + const mathSelectors = [ + ".MathJax", + "mjx-container", + ".process-math", + ".MathJax [tabindex]", + "mjx-container [tabindex]", + ".process-math [tabindex]", + ]; + const selector = containerSelectors + .flatMap((containerSelector) => + mathSelectors.map( + (mathSelector) => containerSelector + " " + mathSelector, + ), + ) + .join(", "); + for (const mathElement of root.querySelectorAll(selector)) { + mathElement.setAttribute("tabindex", "-1"); + } +} From 1ffa1670801f63b1d7bb3ecc31392fa134c7249b Mon Sep 17 00:00:00 2001 From: Andrew Scholer Date: Mon, 24 Aug 2026 10:38:31 -0700 Subject: [PATCH 4/8] Dragndrop: add mathjax rendered speech to aria-labels --- .../runestone/dragndrop/js/dragndrop.js | 66 +++++++++++----- .../dragndrop/test/dragndrop.test.js | 75 +++++++++++++++++++ 2 files changed, 123 insertions(+), 18 deletions(-) diff --git a/bases/rsptx/interactives/runestone/dragndrop/js/dragndrop.js b/bases/rsptx/interactives/runestone/dragndrop/js/dragndrop.js index 0a43d1c4a..6f3dd8c91 100644 --- a/bases/rsptx/interactives/runestone/dragndrop/js/dragndrop.js +++ b/bases/rsptx/interactives/runestone/dragndrop/js/dragndrop.js @@ -31,6 +31,10 @@ import RunestoneBase from "../../common/js/runestonebase.js"; import { t } from "../../common/js/rsi18n.js"; +import { + disableMathJaxTabStops, + getAccessibleElementText, +} from "../../common/js/mathjax-a11y.js"; import { DragndropXmlConverter } from "./xmlconversion.js"; import "../css/dragndrop.less"; import "./dragndrop-i18n.en.js"; @@ -197,6 +201,7 @@ export default class DragNDrop extends RunestoneBase { replaceSpan.dataset.category = category; replaceSpan.dataset.parent_id = this.divid; this.premiseArray.push(replaceSpan); + this.updatePremiseAriaLabel(replaceSpan); this.setDragListeners(replaceSpan); // now create an error message for when the premise is dropped in the wrong place let errorMessage = document.createElement("div"); @@ -295,6 +300,7 @@ export default class DragNDrop extends RunestoneBase { this.ivp = this.isValidPremise.bind(this); this.queueMathJax(this.containerDiv).then(() => { this.disablePremiseMathTabStops(); + this.updatePremiseAriaLabels(); }); } @@ -308,6 +314,7 @@ export default class DragNDrop extends RunestoneBase { // Ensure MathJax has completed before adjusting the zone widths this.queueMathJax(this.containerDiv).then(() => { this.disablePremiseMathTabStops(); + this.updatePremiseAriaLabels(); this.adjustDragDropWidths(); }); } @@ -351,6 +358,7 @@ export default class DragNDrop extends RunestoneBase { ) { // Make sure element isn't already there--prevents erros w/appending child this.draggableDiv.appendChild(draggedSpan); + this.updatePremiseAriaLabel(draggedSpan); this.adjustDragDropWidths(); this.minheight = this.draggableDiv.offsetHeight; this.dragDropWrapDiv.style.minHeight = @@ -441,6 +449,38 @@ export default class DragNDrop extends RunestoneBase { } } } + this.updatePremiseAriaLabels(); + } + + getResponseLabel(response) { + if (!response) { + return ""; + } + const responseClone = response.cloneNode(true); + for (const premise of responseClone.querySelectorAll(".premise")) { + premise.remove(); + } + return getAccessibleElementText(responseClone); + } + + updatePremiseAriaLabel(premise) { + const premiseLabel = getAccessibleElementText(premise); + const response = this.responseArray.includes(premise.parentElement) + ? premise.parentElement + : null; + const placementLabel = response + ? "placed in " + this.getResponseLabel(response) + : "unplaced"; + premise.setAttribute( + "aria-label", + premiseLabel + " matching premise, " + placementLabel, + ); + } + + updatePremiseAriaLabels() { + for (const premise of this.premiseArray) { + this.updatePremiseAriaLabel(premise); + } } findPremise(id) { @@ -495,6 +535,7 @@ export default class DragNDrop extends RunestoneBase { ) { // Make sure element isn't already there--prevents errors w/appending child this.draggableDiv.appendChild(draggedSpan); + this.updatePremiseAriaLabel(draggedSpan); } }.bind(this), ); @@ -539,23 +580,7 @@ export default class DragNDrop extends RunestoneBase { } disablePremiseMathTabStops(root = this.containerDiv) { - const mathSelector = [ - ".premise .MathJax", - ".premise mjx-container", - ".premise .process-math", - ".premise .MathJax [tabindex]", - ".premise mjx-container [tabindex]", - ".premise .process-math [tabindex]", - ".response .MathJax", - ".response mjx-container", - ".response .process-math", - ".response .MathJax [tabindex]", - ".response mjx-container [tabindex]", - ".response .process-math [tabindex]", - ].join(", "); - for (const mathElement of root.querySelectorAll(mathSelector)) { - mathElement.setAttribute("tabindex", "-1"); - } + disableMathJaxTabStops(root, [".premise", ".response"]); } movePremiseFocus(premise, moveDown) { @@ -629,6 +654,7 @@ export default class DragNDrop extends RunestoneBase { ) { // Make sure element isn't already there--prevents errors w/appending child dropTarget.appendChild(draggedSpan); + this.updatePremiseAriaLabel(draggedSpan); // log a drop event this.logBookEvent({ event: "dragNdrop-drop", @@ -638,6 +664,7 @@ export default class DragNDrop extends RunestoneBase { } this.queueMathJax(this.containerDiv).then(() => { this.disablePremiseMathTabStops(); + this.updatePremiseAriaLabels(); this.adjustDragDropWidths(); }); }.bind(this), @@ -696,7 +723,7 @@ export default class DragNDrop extends RunestoneBase { if (!this.dragDropWrapDiv) { return; } - const premiseLabel = premise.textContent.trim(); + const premiseLabel = getAccessibleElementText(premise); this.dragDropWrapDiv.tabIndex = 0; this.dragDropWrapDiv.setAttribute("role", "application"); this.dragDropWrapDiv.setAttribute( @@ -878,6 +905,7 @@ export default class DragNDrop extends RunestoneBase { this.activeResponse = destination; } destination.appendChild(premise); + this.updatePremiseAriaLabel(premise); this.isAnswered = true; this.logBookEvent({ event: "dragNdrop-drop", @@ -886,6 +914,7 @@ export default class DragNDrop extends RunestoneBase { }); this.queueMathJax(this.containerDiv).then(() => { this.disablePremiseMathTabStops(); + this.updatePremiseAriaLabels(); this.adjustDragDropWidths(); }); } @@ -1014,6 +1043,7 @@ export default class DragNDrop extends RunestoneBase { premise.removeAttribute("aria-errormessage"); this.draggableDiv.appendChild(premise); } + this.updatePremiseAriaLabels(); this.answerState = {}; // Start the "3 tries before red" cycle over after a reset this.tries = 0; diff --git a/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js b/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js index 239ec8327..da5750577 100644 --- a/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js +++ b/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js @@ -95,6 +95,14 @@ function place(dnd, premiseId, responseId) { response.appendChild(premise); } +function renderMathSpeech(element, speech) { + const math = element.querySelector(".process-math") || element; + math.innerHTML = + ''; +} + // Feedback text is written inside a setTimeout(…, 10). const feedbackSettles = () => tick(20); @@ -466,6 +474,73 @@ describe("keyboard controls", () => { expect(premise.classList.contains("selected")).toBe(false); }); + it("updates premise aria labels as premises are placed and returned", async () => { + const dnd = await makeDnd(); + const premise = dnd.premiseArray.find((p) => p.id === "p1"); + const response = dnd.responseArray.find((r) => r.id === "r1"); + + expect(premise.getAttribute("aria-label")).toBe( + "Dog matching premise, unplaced", + ); + + premise.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + expect(response.contains(premise)).toBe(true); + expect(premise.getAttribute("aria-label")).toBe( + "Dog matching premise, placed in Barks", + ); + + dnd.dragDropWrapDiv.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }), + ); + expect(premise.parentElement).toBe(dnd.draggableDiv); + expect(premise.getAttribute("aria-label")).toBe( + "Dog matching premise, unplaced", + ); + }); + + it("uses MathJax speech for premise aria labels", async () => { + const dnd = await makeDnd({ + question: { + statement: "Match each function to its derivative.", + left: [ + { + id: "p1", + label: '\(x^2\)', + }, + ], + right: [ + { + id: "r1", + label: '\(2x\)', + }, + ], + correctAnswers: [["p1", "r1"]], + }, + }); + const premise = dnd.premiseArray[0]; + const response = dnd.responseArray[0]; + + renderMathSpeech(premise, "x squared"); + renderMathSpeech(response, "two x"); + dnd.updatePremiseAriaLabels(); + + expect(premise.getAttribute("aria-label")).toBe( + "x squared matching premise, unplaced", + ); + + premise.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + + expect(premise.getAttribute("aria-label")).toBe( + "x squared matching premise, placed in two x", + ); + expect(dnd.keyboardInstructionDiv.textContent).toBe( + "Moving x squared. Use arrow keys to choose a target, Enter to place, or Escape to cancel.", + ); + }); it.each(["Enter", " "])( "places the selected premise in a response with %j", async (key) => { From d88c71588c39512f6b64008c2ee73eb3da8a7063 Mon Sep 17 00:00:00 2001 From: Andrew Scholer Date: Mon, 24 Aug 2026 10:49:15 -0700 Subject: [PATCH 5/8] Matching: use rendered mathjax in aria labels --- .../runestone/matching/js/matching.js | 92 +++++++++++++------ .../runestone/matching/test/matching.test.js | 57 ++++++++++++ 2 files changed, 123 insertions(+), 26 deletions(-) diff --git a/bases/rsptx/interactives/runestone/matching/js/matching.js b/bases/rsptx/interactives/runestone/matching/js/matching.js index d75bc16fc..d16a504eb 100644 --- a/bases/rsptx/interactives/runestone/matching/js/matching.js +++ b/bases/rsptx/interactives/runestone/matching/js/matching.js @@ -1,4 +1,8 @@ import RunestoneBase from "../../common/js/runestonebase.js"; +import { + disableMathJaxTabStops, + getAccessibleElementText, +} from "../../common/js/mathjax-a11y.js"; import "../css/matching.less"; import { MatchingXmlConverter } from "./xmlconversion.js"; export class MatchingProblem extends RunestoneBase { @@ -65,6 +69,7 @@ export class MatchingProblem extends RunestoneBase { this.queueMathJax(this.containerDiv).then(() => { this.disableBoxMathTabStops(); + this.updateBoxAriaLabels(); }); } @@ -402,27 +407,36 @@ export class MatchingProblem extends RunestoneBase { div.innerHTML = label; div.tabIndex = 0; div.setAttribute("role", "button"); - div.setAttribute( + this.updateBoxAriaLabel(div); + return div; + } + + getBoxLabel(box) { + return getAccessibleElementText(box) || "box"; + } + + updateBoxAriaLabel(box) { + const labelPrefix = + box.dataset.role === "drag" ? "Draggable" : "Droppable"; + box.setAttribute( "aria-label", - `${role === "drag" ? "Draggable" : "Droppable"}: ${label}`, + labelPrefix + ": " + this.getBoxLabel(box), ); - return div; } - disableBoxMathTabStops(root = this.containerDiv) { - const mathSelector = [ - ".box .MathJax", - ".box mjx-container", - ".box .process-math", - ".box .MathJax [tabindex]", - ".box mjx-container [tabindex]", - ".box .process-math [tabindex]", - ].join(", "); - for (const mathElement of root.querySelectorAll(mathSelector)) { - mathElement.setAttribute("tabindex", "-1"); + updateBoxAriaLabels() { + for (const box of this.allBoxes || []) { + this.updateBoxAriaLabel(box); + } + for (const connection of this.connections || []) { + this.updateLineAriaLabel(connection.line); } } + disableBoxMathTabStops(root = this.containerDiv) { + disableMathJaxTabStops(root, [".box"]); + } + getColumnBoxes(role) { const column = role === "drag" ? this.leftColumn : this.rightColumn; return Array.from(column.querySelectorAll(".box")).filter((box) => @@ -465,7 +479,7 @@ export class MatchingProblem extends RunestoneBase { const firstOppositeBox = this.getTabbableBoxes()[0]; firstOppositeBox?.focus(); if (this.ariaLive) { - this.ariaLive.textContent = `Selected ${box.textContent}. Tab to a box in the other column and press Enter to connect, or press Escape to cancel.`; + this.ariaLive.textContent = `Selected ${this.getBoxLabel(box)}. Tab to a box in the other column and press Enter to connect, or press Escape to cancel.`; } return; } @@ -491,8 +505,12 @@ export class MatchingProblem extends RunestoneBase { } line.classList.add("selected"); if (announce && this.ariaLive) { - const fromLabel = line.fromBox?.textContent || "one box"; - const toLabel = line.toBox?.textContent || "another box"; + const fromLabel = line.fromBox + ? this.getBoxLabel(line.fromBox) + : "one box"; + const toLabel = line.toBox + ? this.getBoxLabel(line.toBox) + : "another box"; this.ariaLive.textContent = `Selected connection from ${fromLabel} to ${toLabel}. Press Enter to delete it.`; } } @@ -638,9 +656,33 @@ export class MatchingProblem extends RunestoneBase { return line; } + updateLineAriaLabel(line) { + if (!line) { + return; + } + const fromLabel = line.fromBox + ? this.getBoxLabel(line.fromBox) + : "one box"; + const toLabel = line.toBox + ? this.getBoxLabel(line.toBox) + : "another box"; + line.setAttribute( + "aria-label", + "Connection from " + + fromLabel + + " to " + + toLabel + + ". Press Enter, Delete, or Backspace to remove.", + ); + } + removeLine(line) { - const fromLabel = line.fromBox?.textContent || "one box"; - const toLabel = line.toBox?.textContent || "another box"; + const fromLabel = line.fromBox + ? this.getBoxLabel(line.fromBox) + : "one box"; + const toLabel = line.toBox + ? this.getBoxLabel(line.toBox) + : "another box"; if (this.selectedLine === line) { this.setSelectedLine(null, false); } @@ -691,6 +733,7 @@ export class MatchingProblem extends RunestoneBase { line.fromBox = fromBox; line.toBox = toBox; + this.updateLineAriaLabel(line); this.svg.appendChild(line); this.connections.push({ fromBox, toBox, line }); @@ -698,7 +741,7 @@ export class MatchingProblem extends RunestoneBase { this.isAnswered = true; if (this.ariaLive) { - this.ariaLive.textContent = `Connected ${fromBox.textContent} to ${toBox.textContent}`; + this.ariaLive.textContent = `Connected ${this.getBoxLabel(fromBox)} to ${this.getBoxLabel(toBox)}`; } return true; } @@ -729,14 +772,11 @@ export class MatchingProblem extends RunestoneBase { if (conn.line) { conn.line.classList.remove("correct", "incorrect"); } - const fromLabel = conn.fromBox.textContent; - let toLabel = conn.toBox.textContent; - if (!toLabel) { - toLabel = conn.toBox.querySelector("img").alt; // innerHTML preserves everything inside - } + const fromLabel = this.getBoxLabel(conn.fromBox); + const toLabel = this.getBoxLabel(conn.toBox); const line = document.createElement("div"); line.className = "conn-entry"; - line.textContent = `${fromLabel} → ${toLabel}`; + line.innerHTML = `${fromLabel} connected to ${toLabel}`; this.connList.appendChild(line); }); } diff --git a/bases/rsptx/interactives/runestone/matching/test/matching.test.js b/bases/rsptx/interactives/runestone/matching/test/matching.test.js index f1ce89fec..d8c062b4b 100644 --- a/bases/rsptx/interactives/runestone/matching/test/matching.test.js +++ b/bases/rsptx/interactives/runestone/matching/test/matching.test.js @@ -54,6 +54,14 @@ function keydown(target, key, extra = {}) { ); } +function renderMathSpeech(element, speech) { + const math = element.querySelector(".process-math") || element; + math.innerHTML = + ''; +} + beforeEach(() => { document.body.innerHTML = ""; window.componentMap = {}; @@ -86,6 +94,55 @@ describe("matching keyboard controls", () => { expect(nestedMathChild.tabIndex).toBe(-1); }); + it("uses MathJax speech for box and connection labels", async () => { + const matching = await makeMatching({ + question: { + statement: "Match each function to its derivative.", + left: [ + { + id: "p1", + label: '\(x^2\)', + }, + ], + right: [ + { + id: "r1", + label: 'Derivative \(2x\)', + }, + ], + correctAnswers: [["p1", "r1"]], + }, + }); + const leftBox = matching.leftColumn.querySelector(".box"); + const rightBox = matching.rightColumn.querySelector(".box"); + + renderMathSpeech(leftBox, "x squared"); + renderMathSpeech(rightBox, "two x"); + matching.updateBoxAriaLabels(); + + expect(leftBox.getAttribute("aria-label")).toBe("Draggable: x squared"); + expect(rightBox.getAttribute("aria-label")).toBe( + "Droppable: Derivative two x", + ); + + keydown(leftBox, "Enter"); + expect(matching.ariaLive.textContent).toBe( + "Selected x squared. Tab to a box in the other column and press Enter to connect, or press Escape to cancel.", + ); + + keydown(rightBox, "Enter"); + const line = matching.connections[0].line; + expect(line.getAttribute("aria-label")).toBe( + "Connection from x squared to Derivative two x. Press Enter, Delete, or Backspace to remove.", + ); + expect(matching.ariaLive.textContent).toBe( + "Connected x squared to Derivative two x", + ); + expect(matching.connList.querySelector(".conn-entry").textContent).toBe( + "x squared → Derivative two x", + ); + }); + it("only keeps right boxes tabbable while a left box is active", async () => { const matching = await makeMatching(); const leftBoxes = [...matching.leftColumn.querySelectorAll(".box")]; From 979d90d8439916daf8f24ba2dc233799daf16a15 Mon Sep 17 00:00:00 2001 From: Andrew Scholer Date: Mon, 24 Aug 2026 11:36:55 -0700 Subject: [PATCH 6/8] Matching: results announced by screen readers --- .../runestone/matching/css/matching.css | 14 +++++++++ .../runestone/matching/js/matching.js | 26 ++++++++++++++--- .../runestone/matching/test/matching.test.js | 29 +++++++++++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/bases/rsptx/interactives/runestone/matching/css/matching.css b/bases/rsptx/interactives/runestone/matching/css/matching.css index ec54ba1f5..800a8e0b2 100644 --- a/bases/rsptx/interactives/runestone/matching/css/matching.css +++ b/bases/rsptx/interactives/runestone/matching/css/matching.css @@ -209,6 +209,20 @@ font-size: 0.9rem; } +.match-feedback { + max-width: 780px; + margin: 0 auto 1rem; + padding: 10px 14px; + background: var(--background, #ffffff); + border: 1px solid var(--componentBorderColor, #cccccc); + border-radius: 8px; + font-size: 0.9rem; +} + +.match-feedback[hidden] { + display: none; +} + .conn-entry { margin: 4px 0; } diff --git a/bases/rsptx/interactives/runestone/matching/js/matching.js b/bases/rsptx/interactives/runestone/matching/js/matching.js index d16a504eb..e543b9080 100644 --- a/bases/rsptx/interactives/runestone/matching/js/matching.js +++ b/bases/rsptx/interactives/runestone/matching/js/matching.js @@ -38,6 +38,7 @@ export class MatchingProblem extends RunestoneBase { console.error("Error setting statement:", error); } this.connList = this.createConnList(container); + this.feedbackDiv = this.createFeedbackDiv(container); this.ariaLive = this.createAriaLive(container); this.controlDiv = this.createControlDiv(container); this.createHelpModal(); @@ -162,15 +163,16 @@ export class MatchingProblem extends RunestoneBase { const badgeClass = this.scorePercent === 100 ? " match-score-perfect" : ""; - this.connList.innerHTML = `
Score: ${this.scorePercent}%${this.correctCount} correct · ${this.incorrectCount} incorrect · ${this.missingCount} missing
`; + this.feedbackDiv.hidden = false; + this.feedbackDiv.innerHTML = `
Score: ${this.scorePercent}%${this.correctCount} correct · ${this.incorrectCount} incorrect · ${this.missingCount} missing
`; if ( this.scorePercent !== 100 && this.boxData.feedback && this.boxData.feedback.trim() ) { - this.connList.innerHTML += `
Feedback: ${this.boxData.feedback}
`; + this.feedbackDiv.innerHTML += `
Feedback: ${this.boxData.feedback}
`; } - this.queueMathJax(this.connList); + this.queueMathJax(this.feedbackDiv); } createStatement(container) { @@ -231,7 +233,6 @@ export class MatchingProblem extends RunestoneBase { this.missingCount = parsedData.missingCount; this.scorePercent = parsedData.score; this.restoreAnswers(); - this.renderFeedback(); } } setLocalStorage() { @@ -286,6 +287,17 @@ export class MatchingProblem extends RunestoneBase { return connList; } + createFeedbackDiv(container) { + const feedbackDiv = document.createElement("div"); + feedbackDiv.className = "match-feedback"; + feedbackDiv.hidden = true; + feedbackDiv.setAttribute("role", "status"); + feedbackDiv.setAttribute("aria-live", "polite"); + feedbackDiv.setAttribute("aria-atomic", "true"); + container.appendChild(feedbackDiv); + return feedbackDiv; + } + createAriaLive(container) { const ariaLive = document.createElement("div"); ariaLive.className = "aria-live"; @@ -753,9 +765,15 @@ export class MatchingProblem extends RunestoneBase { }); } + hideFeedback() { + this.feedbackDiv.hidden = true; + this.feedbackDiv.replaceChildren(); + } + updateConnectionModel() { // Any change to the connections invalidates previously rendered // grading marks, so clear them along with rebuilding the list. + this.hideFeedback(); this.allBoxes.forEach((box) => box.classList.remove("match-correct", "match-incorrect"), ); diff --git a/bases/rsptx/interactives/runestone/matching/test/matching.test.js b/bases/rsptx/interactives/runestone/matching/test/matching.test.js index d8c062b4b..a0dc719c2 100644 --- a/bases/rsptx/interactives/runestone/matching/test/matching.test.js +++ b/bases/rsptx/interactives/runestone/matching/test/matching.test.js @@ -307,4 +307,33 @@ describe("matching keyboard controls", () => { expect(matching.connections[0].fromBox).toBe(leftBoxes[0]); expect(matching.connections[0].toBox).toBe(rightBoxes[0]); }); + + it("shows live feedback after checking and hides it after an edit", async () => { + const matching = await makeMatching(); + const leftBoxes = [...matching.leftColumn.querySelectorAll(".box")]; + const rightBoxes = [...matching.rightColumn.querySelectorAll(".box")]; + + expect(matching.feedbackDiv.hidden).toBe(true); + expect(matching.feedbackDiv.getAttribute("role")).toBe("status"); + expect(matching.feedbackDiv.getAttribute("aria-live")).toBe("polite"); + expect(matching.feedbackDiv.previousElementSibling).toBe( + matching.connList, + ); + + leftBoxes[0].click(); + rightBoxes[0].click(); + expect(matching.feedbackDiv.hidden).toBe(true); + + matching.gradeConnections(); + expect(matching.feedbackDiv.hidden).toBe(false); + expect(matching.feedbackDiv.textContent).toContain("Score:"); + expect(matching.connList.querySelector(".conn-entry")).not.toBe(null); + + const restored = await makeMatching(); + expect(restored.feedbackDiv.hidden).toBe(true); + + keydown(matching.connections[0].line, "Enter"); + expect(matching.feedbackDiv.hidden).toBe(true); + expect(matching.feedbackDiv.textContent).toBe(""); + }); }); From 3a27001517cfdb68406881e3ad19c9b06c6efd21 Mon Sep 17 00:00:00 2001 From: Andrew Scholer Date: Mon, 24 Aug 2026 11:42:49 -0700 Subject: [PATCH 7/8] Matching: include box status (correct/incorrect) in aria label --- .../runestone/matching/js/matching.js | 9 ++++- .../runestone/matching/test/matching.test.js | 40 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/bases/rsptx/interactives/runestone/matching/js/matching.js b/bases/rsptx/interactives/runestone/matching/js/matching.js index e543b9080..4255ca802 100644 --- a/bases/rsptx/interactives/runestone/matching/js/matching.js +++ b/bases/rsptx/interactives/runestone/matching/js/matching.js @@ -160,6 +160,7 @@ export class MatchingProblem extends RunestoneBase { } }); }); + this.allBoxes.forEach((box) => this.updateBoxAriaLabel(box)); const badgeClass = this.scorePercent === 100 ? " match-score-perfect" : ""; @@ -430,9 +431,14 @@ export class MatchingProblem extends RunestoneBase { updateBoxAriaLabel(box) { const labelPrefix = box.dataset.role === "drag" ? "Draggable" : "Droppable"; + const gradingState = box.classList.contains("match-incorrect") + ? ", incorrect" + : box.classList.contains("match-correct") + ? ", correct" + : ""; box.setAttribute( "aria-label", - labelPrefix + ": " + this.getBoxLabel(box), + labelPrefix + ": " + this.getBoxLabel(box) + gradingState, ); } @@ -777,6 +783,7 @@ export class MatchingProblem extends RunestoneBase { this.allBoxes.forEach((box) => box.classList.remove("match-correct", "match-incorrect"), ); + this.allBoxes.forEach((box) => this.updateBoxAriaLabel(box)); this.connList.innerHTML = "Connections:"; if (this.connections.length === 0) { const empty = document.createElement("div"); diff --git a/bases/rsptx/interactives/runestone/matching/test/matching.test.js b/bases/rsptx/interactives/runestone/matching/test/matching.test.js index a0dc719c2..686a8f04f 100644 --- a/bases/rsptx/interactives/runestone/matching/test/matching.test.js +++ b/bases/rsptx/interactives/runestone/matching/test/matching.test.js @@ -336,4 +336,44 @@ describe("matching keyboard controls", () => { expect(matching.feedbackDiv.hidden).toBe(true); expect(matching.feedbackDiv.textContent).toBe(""); }); + + it("updates box labels for grading states and connection edits", async () => { + const matching = await makeMatching(); + const boxes = [...matching.allBoxes]; + const premise = boxes.find((box) => box.dataset.id === "p1"); + const correctResponse = boxes.find((box) => box.dataset.id === "r1"); + const incorrectResponse = boxes.find((box) => box.dataset.id === "r2"); + + premise.click(); + correctResponse.click(); + matching.gradeConnections(); + expect(premise.getAttribute("aria-label")).toBe( + "Draggable: Dog, correct", + ); + expect(correctResponse.getAttribute("aria-label")).toBe( + "Droppable: Barks, correct", + ); + + keydown(matching.connections[0].line, "Enter"); + expect(premise.getAttribute("aria-label")).toBe("Draggable: Dog"); + expect(correctResponse.getAttribute("aria-label")).toBe( + "Droppable: Barks", + ); + + premise.click(); + incorrectResponse.click(); + matching.gradeConnections(); + expect(premise.getAttribute("aria-label")).toBe( + "Draggable: Dog, incorrect", + ); + expect(incorrectResponse.getAttribute("aria-label")).toBe( + "Droppable: Meows, incorrect", + ); + + matching.resetConnections(); + expect(premise.getAttribute("aria-label")).toBe("Draggable: Dog"); + expect(incorrectResponse.getAttribute("aria-label")).toBe( + "Droppable: Meows", + ); + }); }); From 2527b6c5412f8e86d027a5d7afacf0a2520cf964 Mon Sep 17 00:00:00 2001 From: Andrew Scholer Date: Mon, 24 Aug 2026 11:49:31 -0700 Subject: [PATCH 8/8] Matching: convert help to accessible native dialog --- .../runestone/matching/css/matching.css | 29 +++++++------- .../runestone/matching/js/matching.js | 38 +++++++++++++++---- .../runestone/matching/test/matching.test.js | 32 ++++++++++++++++ 3 files changed, 78 insertions(+), 21 deletions(-) diff --git a/bases/rsptx/interactives/runestone/matching/css/matching.css b/bases/rsptx/interactives/runestone/matching/css/matching.css index 800a8e0b2..9859dced2 100644 --- a/bases/rsptx/interactives/runestone/matching/css/matching.css +++ b/bases/rsptx/interactives/runestone/matching/css/matching.css @@ -296,31 +296,34 @@ background: #34495e; } -/* modal overlay */ +/* native help dialog */ .help-modal { - display: none; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; + width: min(640px, calc(100vw - 2rem)); + max-width: calc(100vw - 2rem); + padding: 0; + border: none; + background: transparent; +} + +.help-modal::backdrop { background: rgba(0, 0, 0, 0.5); - align-items: center; - justify-content: center; - z-index: 10; } -/* modal content box */ .help-modal-content { background-color: var(--questionBgColor); padding: 1rem 1.5rem; border-radius: 8px; - max-width: 80%; - max-height: 80%; + max-height: calc(100vh - 4rem); overflow: auto; position: relative; } +.help-modal-content h2 { + margin: 0; + padding-right: 2rem; + font-size: 1.2rem; +} + /* close button */ .help-modal-content .help-close { position: absolute; diff --git a/bases/rsptx/interactives/runestone/matching/js/matching.js b/bases/rsptx/interactives/runestone/matching/js/matching.js index 4255ca802..5a9e80a33 100644 --- a/bases/rsptx/interactives/runestone/matching/js/matching.js +++ b/bases/rsptx/interactives/runestone/matching/js/matching.js @@ -340,31 +340,53 @@ export class MatchingProblem extends RunestoneBase { } createHelpModal() { - this.helpModal = document.createElement("div"); + this.helpModal = document.createElement("dialog"); this.helpModal.className = "help-modal"; + this.helpModal.setAttribute("aria-live", "polite"); + this.helpModal.setAttribute("aria-atomic", "true"); + const titleId = this.divid + "-help-title"; const text = `

Click and drag between boxes to create connections.

Use the tab key to navigate to a box and press Enter to select it. Focus then jumps to the other column; tab to the box you want to connect and press Enter. Press Escape to cancel a selection.

Click on a connection line or use the tab key to select it. Press Enter, Delete, or Backspace to remove a selected line.

Click the "Check Me" button to check your connections, and save your work.

Click the "Reset" button to clear all connections.

`; - this.helpModal.innerHTML = ` -
- -
${text}
-
`; + this.helpModal.setAttribute("aria-labelledby", titleId); + this.helpModal.innerHTML = + '
' + + '' + + '

Matching help

' + + '
' + + text + + "
"; this.containerDiv.appendChild(this.helpModal); this.helpModal .querySelector(".help-close") .addEventListener("click", () => this.hideHelp()); + this.helpModal.addEventListener("cancel", (event) => { + event.preventDefault(); + this.hideHelp(); + }); + this.helpModal.addEventListener("click", (event) => { + if (event.target === this.helpModal) { + this.hideHelp(); + } + }); + this.helpModal.addEventListener("close", () => this.helpBtn?.focus()); } showHelp() { - this.helpModal.style.display = "flex"; + if (!this.helpModal.open) { + this.helpModal.showModal(); + } } hideHelp() { - this.helpModal.style.display = "none"; + if (this.helpModal.open) { + this.helpModal.close(); + } } // Utility functions diff --git a/bases/rsptx/interactives/runestone/matching/test/matching.test.js b/bases/rsptx/interactives/runestone/matching/test/matching.test.js index 686a8f04f..751810a1a 100644 --- a/bases/rsptx/interactives/runestone/matching/test/matching.test.js +++ b/bases/rsptx/interactives/runestone/matching/test/matching.test.js @@ -376,4 +376,36 @@ describe("matching keyboard controls", () => { "Droppable: Meows", ); }); + + it("opens help in a dismissible live dialog", async () => { + const matching = await makeMatching(); + const dialog = matching.helpModal; + const closeButton = dialog.querySelector(".help-close"); + dialog.showModal = vi.fn(function () { + this.setAttribute("open", ""); + }); + dialog.close = vi.fn(function () { + this.removeAttribute("open"); + this.dispatchEvent(new Event("close")); + }); + + expect(dialog.tagName).toBe("DIALOG"); + expect(dialog.getAttribute("aria-live")).toBe("polite"); + expect(closeButton.getAttribute("aria-label")).toBe( + "Close matching help", + ); + + matching.helpBtn.click(); + expect(dialog.showModal).toHaveBeenCalledOnce(); + expect(dialog.open).toBe(true); + + const cancel = new Event("cancel", { cancelable: true }); + dialog.dispatchEvent(cancel); + expect(cancel.defaultPrevented).toBe(true); + expect(dialog.close).toHaveBeenCalledOnce(); + + matching.showHelp(); + dialog.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(dialog.close).toHaveBeenCalledTimes(2); + }); });