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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 81 additions & 14 deletions bases/rsptx/interactives/runestone/matching/js/matching.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export class MatchingProblem extends RunestoneBase {

this.renderBoxes();
this.attachEvents();
this.observeMathJaxSpeech();

this.queueMathJax(this.containerDiv).then(() => {
this.disableBoxMathTabStops();
Expand Down Expand Up @@ -477,6 +478,38 @@ export class MatchingProblem extends RunestoneBase {
disableMathJaxTabStops(root, [".box"]);
}

observeMathJaxSpeech() {
if (typeof MutationObserver === "undefined") return;

this.mathJaxSpeechObserver = new MutationObserver((mutations) => {
const changedBoxes = new Set();
for (const mutation of mutations) {
const box = mutation.target.closest?.(".box");
if (box && this.allBoxes.includes(box)) {
changedBoxes.add(box);
}
}
if (changedBoxes.size === 0) return;

changedBoxes.forEach((box) => this.updateBoxAriaLabel(box));
const changedConnections = this.connections.filter(
({ fromBox, toBox }) =>
changedBoxes.has(fromBox) || changedBoxes.has(toBox),
);
changedConnections.forEach(({ line }) =>
this.updateLineAriaLabel(line),
);
if (changedConnections.length > 0) {
this.renderConnectionList();
}
});
this.mathJaxSpeechObserver.observe(this.containerDiv, {
subtree: true,
attributes: true,
attributeFilter: ["data-semantic-speech-none"],
});
}

getColumnBoxes(role) {
const column = role === "drag" ? this.leftColumn : this.rightColumn;
return Array.from(column.querySelectorAll(".box")).filter((box) =>
Expand Down Expand Up @@ -798,14 +831,29 @@ export class MatchingProblem extends RunestoneBase {
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));
appendConnectionBoxContent(entry, box) {
const speech = document.createElement("span");
speech.className = "visuallyhidden";
speech.textContent = this.getBoxLabel(box);

const visual = document.createElement("span");
visual.className = "conn-box-visual";
visual.setAttribute("aria-hidden", "true");
for (const child of box.childNodes) {
visual.appendChild(child.cloneNode(true));
}
visual
.querySelectorAll(
"a[href], button, input, select, textarea, [tabindex]",
)
.forEach((element) => {
element.tabIndex = -1;
});

entry.append(speech, visual);
}

renderConnectionList() {
this.connList.innerHTML = "<strong>Connections:</strong>";
if (this.connections.length === 0) {
const empty = document.createElement("div");
Expand All @@ -816,18 +864,37 @@ export class MatchingProblem extends RunestoneBase {
return;
}
this.connections.forEach((conn) => {
if (conn.line) {
conn.line.classList.remove("correct", "incorrect");
}
const fromLabel = this.getBoxLabel(conn.fromBox);
const toLabel = this.getBoxLabel(conn.toBox);
const line = document.createElement("div");
line.className = "conn-entry";
line.innerHTML = `${fromLabel} <span aria-hidden="true">→</span><span class="visuallyhidden">connected to</span> ${toLabel}`;
this.appendConnectionBoxContent(line, conn.fromBox);

const arrow = document.createElement("span");
arrow.setAttribute("aria-hidden", "true");
arrow.textContent = "→";
const connectionText = document.createElement("span");
connectionText.className = "visuallyhidden";
connectionText.textContent = "connected to";
line.append(arrow, connectionText);

this.appendConnectionBoxContent(line, conn.toBox);
this.connList.appendChild(line);
});
}

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.connections.forEach(({ line }) => {
line?.classList.remove("correct", "incorrect");
});
this.renderConnectionList();
}

/*
* This method grades the connections made by the user.
* It checks the current answer against the correct answers,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,66 @@ describe("matching keyboard controls", () => {
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",
const connectionEntry = matching.connList.querySelector(".conn-entry");
expect(
[...connectionEntry.querySelectorAll(".visuallyhidden")].map(
(element) => element.textContent,
),
).toEqual(["x squared", "connected to", "Derivative two x"]);
const visualMath = connectionEntry.querySelectorAll(
'[aria-hidden="true"] mjx-container',
);
expect(visualMath).toHaveLength(2);
expect(visualMath[0].getAttribute("data-semantic-speech-none")).toBe(
"x squared",
);
expect(visualMath[1].getAttribute("data-semantic-speech-none")).toBe(
"two x",
);
});

it("refreshes connection labels when MathJax speech becomes available", async () => {
const matching = await makeMatching({
question: {
statement: "Match the function.",
left: [
{
id: "p1",
label: '<span class="process-math"></span>',
},
],
right: [{ id: "r1", label: "Derivative" }],
correctAnswers: [["p1", "r1"]],
},
});
const leftBox = matching.leftColumn.querySelector(".box");
const rightBox = matching.rightColumn.querySelector(".box");
const math = leftBox.querySelector(".process-math");
const mathContainer = document.createElement("mjx-container");

keydown(leftBox, "Enter");
keydown(rightBox, "Enter");
matching.gradeConnections();

math.appendChild(mathContainer);
mathContainer.setAttribute("data-semantic-speech-none", "x squared");
await tick();

expect(leftBox.getAttribute("aria-label")).toBe(
"Draggable: x squared, correct",
);
expect(matching.connections[0].line.getAttribute("aria-label")).toBe(
"Connection from x squared to Derivative. Press Enter, Delete, or Backspace to remove.",
);
expect(
[...matching.connList.querySelectorAll(".visuallyhidden")].map(
(element) => element.textContent,
),
).toEqual(["x squared", "connected to", "Derivative"]);
expect(matching.feedbackDiv.hidden).toBe(false);
expect(leftBox.classList.contains("match-correct")).toBe(true);
expect(matching.connections[0].line.classList.contains("correct")).toBe(
true,
);
});

Expand Down