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");
+ }
+}
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..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";
@@ -57,6 +61,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.
@@ -196,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");
@@ -257,6 +263,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 +279,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 +298,10 @@ 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();
+ this.updatePremiseAriaLabels();
+ });
}
finishSettingUp() {
@@ -294,6 +313,8 @@ 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.updatePremiseAriaLabels();
this.adjustDragDropWidths();
});
}
@@ -337,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 =
@@ -427,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) {
@@ -481,14 +535,27 @@ 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),
);
+ 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 +569,44 @@ 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) {
+ disableMathJaxTabStops(root, [".premise", ".response"]);
+ }
+
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 +615,69 @@ 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);
+ this.updatePremiseAriaLabel(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.updatePremiseAriaLabels();
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 +703,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 +713,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 = getAccessibleElementText(premise);
+ 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 +872,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 +885,7 @@ export default class DragNDrop extends RunestoneBase {
return;
}
this.moveSelectedPremise(this.draggableDiv, "dragzone");
+ this.announceKeyboardInstruction("Returned to unplaced list.");
}
moveSelectedPremiseRight(response) {
@@ -664,7 +901,11 @@ export default class DragNDrop extends RunestoneBase {
if (!premise || premise.parentElement === destination) {
return;
}
+ if (this.responseArray.includes(destination)) {
+ this.activeResponse = destination;
+ }
destination.appendChild(premise);
+ this.updatePremiseAriaLabel(premise);
this.isAnswered = true;
this.logBookEvent({
event: "dragNdrop-drop",
@@ -672,6 +913,8 @@ export default class DragNDrop extends RunestoneBase {
act: `${premise.id} -> ${destinationName}`,
});
this.queueMathJax(this.containerDiv).then(() => {
+ this.disablePremiseMathTabStops();
+ this.updatePremiseAriaLabels();
this.adjustDragDropWidths();
});
}
@@ -682,6 +925,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 +941,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 +962,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();
@@ -788,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 1ac3b9c66..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 =
+ '
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.
`; - this.helpModal.innerHTML = ` - `; + this.helpModal.setAttribute("aria-labelledby", titleId); + this.helpModal.innerHTML = + '"; 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 @@ -398,13 +442,163 @@ 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"; + const gradingState = box.classList.contains("match-incorrect") + ? ", incorrect" + : box.classList.contains("match-correct") + ? ", correct" + : ""; + box.setAttribute( "aria-label", - `${role === "drag" ? "Draggable" : "Droppable"}: ${label}`, + labelPrefix + ": " + this.getBoxLabel(box) + gradingState, ); - return div; } + 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) => + 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 ${this.getBoxLabel(box)}. 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 + ? 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.`; + } + } + } + + 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 +663,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); } @@ -486,7 +696,36 @@ 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 + ? this.getBoxLabel(line.fromBox) + : "one box"; + const toLabel = line.toBox + ? this.getBoxLabel(line.toBox) + : "another box"; + if (this.selectedLine === line) { + this.setSelectedLine(null, false); + } this.svg.removeChild(line); const index = this.connections.findIndex( (conn) => @@ -495,6 +734,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) { @@ -531,6 +773,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 }); @@ -538,7 +781,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; } @@ -550,12 +793,19 @@ 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"), ); + this.allBoxes.forEach((box) => this.updateBoxAriaLabel(box)); this.connList.innerHTML = "Connections:"; if (this.connections.length === 0) { const empty = document.createElement("div"); @@ -569,14 +819,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); }); } @@ -627,45 +874,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..751810a1a --- /dev/null +++ b/bases/rsptx/interactives/runestone/matching/test/matching.test.js @@ -0,0 +1,411 @@ +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 = ` +