From 1add912047740ecf089bf94118b55f5bb5b4059a Mon Sep 17 00:00:00 2001 From: Andrew Scholer Date: Wed, 15 Jul 2026 03:23:00 -0700 Subject: [PATCH 1/4] Add keyboard controls for dragndrop --- .../runestone/dragndrop/css/dragndrop.css | 13 +- .../runestone/dragndrop/js/dragndrop.js | 218 +++++++++++++--- .../dragndrop/test/dragndrop.test.js | 245 ++++++++++++++++++ 3 files changed, 442 insertions(+), 34 deletions(-) diff --git a/bases/rsptx/interactives/runestone/dragndrop/css/dragndrop.css b/bases/rsptx/interactives/runestone/dragndrop/css/dragndrop.css index 96ac228b4..e95a52281 100644 --- a/bases/rsptx/interactives/runestone/dragndrop/css/dragndrop.css +++ b/bases/rsptx/interactives/runestone/dragndrop/css/dragndrop.css @@ -54,6 +54,15 @@ cursor: grab; } +/* Keep keyboard focus and the picked-up state visually distinct. */ +.draggable-drag:focus { + outline: 3px solid #3498db; +} + +.draggable-drag.selected { + outline: 3px dashed #27ae60; +} + .draggable-feedback { text-align: left !important; clear: both; @@ -77,7 +86,7 @@ .possibleDrop { border-color: #000000; - background-color: #CCCCCC; + background-color: #cccccc; } /* @@ -107,4 +116,4 @@ background-color: var(--questionBgColor); padding: 10px; margin-bottom: 10px; -} \ No newline at end of file +} diff --git a/bases/rsptx/interactives/runestone/dragndrop/js/dragndrop.js b/bases/rsptx/interactives/runestone/dragndrop/js/dragndrop.js index 41662ab97..b35f38ef6 100644 --- a/bases/rsptx/interactives/runestone/dragndrop/js/dragndrop.js +++ b/bases/rsptx/interactives/runestone/dragndrop/js/dragndrop.js @@ -56,6 +56,7 @@ export default class DragNDrop extends RunestoneBase { } this.feedback = ""; this.question = ""; + this.selectedPremise = 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. @@ -216,7 +217,8 @@ export default class DragNDrop extends RunestoneBase { replaceSpan.innerHTML = label; replaceSpan.id = id; replaceSpan.classList.add("draggable-drop", "drop-label", "response"); - replaceSpan.tabIndex = 0; + // Responses enter the tab order only while a premise is selected. + replaceSpan.tabIndex = -1; replaceSpan.setAttribute("role", "button"); replaceSpan.dataset.category = category; replaceSpan.dataset.parent_id = this.divid; @@ -246,6 +248,15 @@ export default class DragNDrop extends RunestoneBase { this.containerDiv = document.createElement("div"); this.containerDiv.id = this.divid; this.containerDiv.classList.add("draggable-container"); + this.containerDiv.addEventListener("keydown", (ev) => { + if ( + (ev.key === "Escape" || ev.key === "Esc") && + this.selectedPremise + ) { + ev.preventDefault(); + this.deselectPremise({ restoreFocus: true }); + } + }); this.statementDiv = document.createElement("div"); this.statementDiv.classList.add("cardsort-statement"); this.statementDiv.classList.add("exercise-statement"); @@ -270,9 +281,8 @@ export default class DragNDrop extends RunestoneBase { if (eBookConfig.practice_mode) { this.finishSettingUp(); } - self = this; this.ivp = this.isValidPremise.bind(this); - self.queueMathJax(self.containerDiv); + this.queueMathJax(this.containerDiv); } finishSettingUp() { @@ -313,7 +323,7 @@ export default class DragNDrop extends RunestoneBase { this.draggableDiv.addEventListener( "drop", function (ev) { - self.isAnswered = true; + this.isAnswered = true; ev.preventDefault(); if (this.draggableDiv.classList.contains("possibleDrop")) { this.draggableDiv.classList.remove("possibleDrop"); @@ -444,8 +454,9 @@ export default class DragNDrop extends RunestoneBase { } setDragListeners(dgSpan) { - let self = this; - dgSpan.addEventListener("dragstart", function (ev) { + dgSpan.setAttribute("aria-pressed", "false"); + dgSpan.addEventListener("dragstart", (ev) => { + this.deselectPremise(); ev.dataTransfer.setData("draggableID", ev.target.id); }); dgSpan.addEventListener("dragover", function (ev) { @@ -454,7 +465,7 @@ export default class DragNDrop extends RunestoneBase { dgSpan.addEventListener( "drop", function (ev) { - self.isAnswered = true; + this.isAnswered = true; ev.preventDefault(); var data = ev.dataTransfer.getData("draggableID"); var draggedSpan = document.getElementById(data); @@ -468,26 +479,44 @@ export default class DragNDrop extends RunestoneBase { }.bind(this), ); - // Add keyboard navigation for selecting premises - dgSpan.addEventListener("keydown", function (ev) { + // 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.key === "Enter" || ev.key === " ") { ev.preventDefault(); - if (!self.selectedPremise) { - self.selectedPremise = dgSpan; - dgSpan.classList.add("selected"); + if (this.selectedPremise === dgSpan) { + this.deselectPremise(); } else { - self.selectedPremise.classList.remove("selected"); - self.selectedPremise = null; + this.selectPremise(dgSpan); } + } else if ( + !this.selectedPremise && + (ev.key === "ArrowUp" || ev.key === "ArrowDown") + ) { + ev.preventDefault(); + this.movePremiseFocus(dgSpan, ev.key === "ArrowDown"); } }); } + movePremiseFocus(premise, moveDown) { + const currentIndex = this.premiseArray.indexOf(premise); + const targetIndex = Math.max( + 0, + Math.min( + currentIndex + (moveDown ? 1 : -1), + this.premiseArray.length - 1, + ), + ); + this.premiseArray[targetIndex]?.focus(); + } + setDropListeners(dpSpan) { dpSpan.addEventListener( "dragover", function (ev) { - self.isAnswered = true; + this.isAnswered = true; ev.preventDefault(); if (ev.target.classList.contains("possibleDrop")) { return; @@ -498,7 +527,7 @@ export default class DragNDrop extends RunestoneBase { }.bind(this), ); dpSpan.addEventListener("dragleave", function (ev) { - self.isAnswered = true; + this.isAnswered = true; ev.preventDefault(); if (!ev.target.classList.contains("possibleDrop")) { return; @@ -508,7 +537,7 @@ export default class DragNDrop extends RunestoneBase { dpSpan.addEventListener( "drop", function (ev) { - self.isAnswered = true; + this.isAnswered = true; ev.preventDefault(); if (ev.target.classList.contains("possibleDrop")) { ev.target.classList.remove("possibleDrop"); @@ -536,27 +565,151 @@ export default class DragNDrop extends RunestoneBase { ); // Add keyboard navigation for dropping premises - dpSpan.addEventListener("keydown", function (ev) { + dpSpan.addEventListener("keydown", (ev) => { + if (ev.target !== dpSpan || !this.selectedPremise) { + return; + } + if (ev.key === "Enter" || ev.key === " ") { + ev.preventDefault(); + this.placeSelectedPremise(dpSpan); + } else if (ev.key === "Tab") { + ev.preventDefault(); + this.moveResponseFocus(dpSpan, ev.shiftKey); + } else if (ev.key === "ArrowUp" || ev.key === "ArrowDown") { + ev.preventDefault(); + this.moveSelectedPremiseVertically(ev.key === "ArrowDown"); + } else if (ev.key === "ArrowLeft") { + ev.preventDefault(); + this.returnSelectedPremise(); + } else if (ev.key === "ArrowRight") { + ev.preventDefault(); + this.moveSelectedPremiseRight(dpSpan); + } + }); + // 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 ( - (ev.key === "Enter" || ev.key === " ") && - self.selectedPremise + this.selectedPremise && + this.selectedPremise.parentElement !== dpSpan ) { - ev.preventDefault(); - if ( - !self.strangerDanger(self.selectedPremise) && - !self.premiseArray.includes(dpSpan) // don't drop on another premise! - ) { - dpSpan.appendChild(self.selectedPremise); - self.selectedPremise.classList.remove("selected"); - self.selectedPremise = null; - self.queueMathJax(self.containerDiv).then(() => { - self.adjustDragDropWidths(); - }); - } + this.moveSelectedPremise(dpSpan, dpSpan.id); } }); } + moveResponseFocus(response, moveBackward) { + const currentIndex = this.responseArray.indexOf(response); + const offset = moveBackward ? -1 : 1; + const targetIndex = + (currentIndex + offset + this.responseArray.length) % + this.responseArray.length; + this.responseArray[targetIndex]?.focus(); + } + + moveSelectedPremiseVertically(moveDown) { + const premise = this.selectedPremise; + if (!premise || this.responseArray.length === 0) { + return; + } + + const currentIndex = this.responseArray.indexOf(premise.parentElement); + let targetIndex; + if (currentIndex === -1) { + targetIndex = moveDown ? 0 : this.responseArray.length - 1; + } else { + targetIndex = currentIndex + (moveDown ? 1 : -1); + targetIndex = Math.max( + 0, + Math.min(targetIndex, this.responseArray.length - 1), + ); + } + + const response = this.responseArray[targetIndex]; + if (response !== premise.parentElement) { + this.moveSelectedPremise(response, response.id); + } + response.focus(); + } + + returnSelectedPremise() { + const premise = this.selectedPremise; + if (!premise || premise.parentElement === this.draggableDiv) { + return; + } + this.moveSelectedPremise(this.draggableDiv, "dragzone"); + } + + moveSelectedPremiseRight(response) { + const premise = this.selectedPremise; + if (!premise || premise.parentElement !== this.draggableDiv) { + return; + } + this.moveSelectedPremise(response, response.id); + } + + moveSelectedPremise(destination, destinationName) { + const premise = this.selectedPremise; + if (!premise || premise.parentElement === destination) { + return; + } + destination.appendChild(premise); + this.isAnswered = true; + this.logBookEvent({ + event: "dragNdrop-drop", + div_id: this.divid, + act: `${premise.id} -> ${destinationName}`, + }); + this.queueMathJax(this.containerDiv).then(() => { + this.adjustDragDropWidths(); + }); + } + + placeSelectedPremise(response) { + const premise = this.selectedPremise; + if (!premise || this.strangerDanger(premise)) { + return; + } + + this.moveSelectedPremise(response, response.id); + this.deselectPremise(); + premise.focus(); + } + + selectPremise(premise) { + this.deselectPremise(); + this.selectedPremise = premise; + premise.classList.add("selected"); + premise.setAttribute("aria-pressed", "true"); + this.updateKeyboardNavigation(); + this.responseArray[0]?.focus(); + } + + deselectPremise({ restoreFocus = false } = {}) { + const premise = this.selectedPremise; + if (!premise) { + return; + } + premise.classList.remove("selected"); + premise.setAttribute("aria-pressed", "false"); + this.selectedPremise = null; + this.updateKeyboardNavigation(); + if (restoreFocus) { + premise.focus(); + } + } + + updateKeyboardNavigation() { + const premiseTabIndex = this.selectedPremise ? -1 : 0; + const responseTabIndex = this.selectedPremise ? 0 : -1; + for (const premise of this.premiseArray) { + premise.tabIndex = premiseTabIndex; + } + for (const response of this.responseArray) { + response.tabIndex = responseTabIndex; + } + } + adjustDragDropWidths() { // Temporarily minimize the dragzone width to the content this.draggableDiv.style.width = "fit-content"; @@ -601,6 +754,7 @@ export default class DragNDrop extends RunestoneBase { == Reset button functionality == ==============================*/ resetDraggables() { + this.deselectPremise(); this.dropZoneDiv.innerHTML = ""; for (let response of this.responseArray) { response.classList.remove("drop-incorrect"); diff --git a/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js b/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js index ef5fa2f32..9273bb582 100644 --- a/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js +++ b/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js @@ -283,6 +283,251 @@ describe("reset", () => { }); }); +describe("keyboard controls", () => { + it("starts with only premises in the tab order", async () => { + const dnd = await makeDnd(); + expect( + dnd.premiseArray.every((premise) => premise.tabIndex === 0), + ).toBe(true); + expect( + dnd.responseArray.every((response) => response.tabIndex === -1), + ).toBe(true); + }); + + it("moves focus between unselected premises with ArrowUp and ArrowDown", async () => { + const dnd = await makeDnd(); + const [firstPremise, secondPremise] = dnd.premiseArray; + firstPremise.focus(); + + firstPremise.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowDown", + bubbles: true, + }), + ); + expect(document.activeElement).toBe(secondPremise); + expect(dnd.selectedPremise).toBe(null); + + secondPremise.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowUp", + bubbles: true, + }), + ); + expect(document.activeElement).toBe(firstPremise); + }); + + it.each(["Enter", " "])( + "places the selected premise in a response with %j", + async (key) => { + const dnd = await makeDnd(); + const premise = dnd.premiseArray.find((p) => p.id === "p1"); + const response = dnd.responseArray.find((r) => r.id === "r1"); + + premise.dispatchEvent( + new KeyboardEvent("keydown", { key, bubbles: true }), + ); + 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(dnd.responseArray[0].contains(premise)).toBe(true); + expect(dnd.premiseArray.every((item) => item.tabIndex === -1)).toBe( + true, + ); + expect(dnd.responseArray.every((item) => item.tabIndex === 0)).toBe( + true, + ); + + response.dispatchEvent( + new KeyboardEvent("keydown", { key, bubbles: 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); + 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("Escape restores premise navigation and focus", async () => { + const dnd = await makeDnd(); + const premise = dnd.premiseArray.find((p) => p.id === "p2"); + + premise.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + dnd.responseArray[0].dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + + expect(dnd.selectedPremise).toBe(null); + 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("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"); + const response = dnd.responseArray.find((r) => r.id === "r1"); + response.appendChild(premise); + + premise.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + expect(dnd.selectedPremise).toBe(premise); + expect(premise.classList.contains("selected")).toBe(true); + }); + + it("moves the selected premise through responses with the arrow keys", async () => { + const dnd = await makeDnd(); + const premise = dnd.premiseArray.find((p) => p.id === "p1"); + const [firstResponse, secondResponse] = dnd.responseArray; + + premise.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + expect(firstResponse.contains(premise)).toBe(true); + expect(dnd.selectedPremise).toBe(premise); + + firstResponse.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowDown", + bubbles: true, + }), + ); + expect(secondResponse.contains(premise)).toBe(true); + expect(document.activeElement).toBe(secondResponse); + + secondResponse.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowUp", + bubbles: true, + }), + ); + expect(firstResponse.contains(premise)).toBe(true); + expect(document.activeElement).toBe(firstResponse); + expect(premise.classList.contains("selected")).toBe(true); + }); + + it("previews the selected premise in each response focused with Tab", async () => { + const dnd = await makeDnd(); + const premise = dnd.premiseArray[0]; + const [firstResponse, secondResponse] = dnd.responseArray; + + premise.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + expect(firstResponse.contains(premise)).toBe(true); + + // Calling focus mirrors the focus change produced by Tab in the + // browser without relying on jsdom to implement Tab navigation. + secondResponse.focus(); + expect(secondResponse.contains(premise)).toBe(true); + expect(dnd.selectedPremise).toBe(premise); + + firstResponse.focus(); + expect(firstResponse.contains(premise)).toBe(true); + expect(dnd.selectedPremise).toBe(premise); + }); + + it("traps Tab and Shift+Tab within responses while a premise is selected", async () => { + const dnd = await makeDnd(); + const premise = dnd.premiseArray[0]; + const [firstResponse, secondResponse] = dnd.responseArray; + + premise.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + expect(document.activeElement).toBe(firstResponse); + + firstResponse.dispatchEvent( + new KeyboardEvent("keydown", { key: "Tab", bubbles: true }), + ); + expect(document.activeElement).toBe(secondResponse); + expect(secondResponse.contains(premise)).toBe(true); + + secondResponse.dispatchEvent( + new KeyboardEvent("keydown", { key: "Tab", bubbles: true }), + ); + expect(document.activeElement).toBe(firstResponse); + expect(firstResponse.contains(premise)).toBe(true); + + firstResponse.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Tab", + shiftKey: true, + bubbles: true, + }), + ); + expect(document.activeElement).toBe(secondResponse); + expect(secondResponse.contains(premise)).toBe(true); + }); + + it("returns a placed selected premise to the dragzone with ArrowLeft", async () => { + const dnd = await makeDnd(); + const premise = dnd.premiseArray.find((p) => p.id === "p1"); + const response = dnd.responseArray[0]; + response.appendChild(premise); + + premise.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + response.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowLeft", + bubbles: true, + }), + ); + + expect(premise.parentElement).toBe(dnd.draggableDiv); + expect(dnd.selectedPremise).toBe(premise); + expect(premise.classList.contains("selected")).toBe(true); + expect(dnd.responseArray.every((item) => item.tabIndex === 0)).toBe( + true, + ); + }); + + it("moves a selected premise right into the focused response", async () => { + const dnd = await makeDnd(); + const premise = dnd.premiseArray[0]; + const firstResponse = dnd.responseArray[0]; + + premise.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + firstResponse.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowLeft", + bubbles: true, + }), + ); + expect(premise.parentElement).toBe(dnd.draggableDiv); + expect(document.activeElement).toBe(firstResponse); + + firstResponse.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowRight", + bubbles: true, + }), + ); + expect(firstResponse.contains(premise)).toBe(true); + expect(dnd.selectedPremise).toBe(premise); + }); +}); + describe("persistence", () => { it("restores placements from localStorage on a fresh render", async () => { const first = await makeDnd(); From 294734c45743bb831c7efe8b126c49e1b22e3aee Mon Sep 17 00:00:00 2001 From: Andrew Scholer Date: Wed, 15 Jul 2026 03:45:28 -0700 Subject: [PATCH 2/4] Dragndrop: highlight targets while dragging premise --- .../runestone/dragndrop/css/dragndrop.css | 6 +++ .../runestone/dragndrop/css/dragndrop.less | 19 ++++++- .../runestone/dragndrop/js/dragndrop.js | 11 ++++ .../dragndrop/test/dragndrop.test.js | 51 ++++++++++++------- 4 files changed, 68 insertions(+), 19 deletions(-) diff --git a/bases/rsptx/interactives/runestone/dragndrop/css/dragndrop.css b/bases/rsptx/interactives/runestone/dragndrop/css/dragndrop.css index e95a52281..dad83a427 100644 --- a/bases/rsptx/interactives/runestone/dragndrop/css/dragndrop.css +++ b/bases/rsptx/interactives/runestone/dragndrop/css/dragndrop.css @@ -80,6 +80,12 @@ cursor: grab; } +/* Show every available response while a premise is being pointer-dragged. */ +.draggable-container.pointer-drag-active .draggable-drop { + border-color: var(--dnd-active-drop-border, #2b6ea6); + box-shadow: 0 0 0 2px var(--dnd-active-drop-glow, rgba(43, 110, 166, 0.45)); +} + .draggable-container { text-align: center; } diff --git a/bases/rsptx/interactives/runestone/dragndrop/css/dragndrop.less b/bases/rsptx/interactives/runestone/dragndrop/css/dragndrop.less index 498e30882..22ef5ed10 100644 --- a/bases/rsptx/interactives/runestone/dragndrop/css/dragndrop.less +++ b/bases/rsptx/interactives/runestone/dragndrop/css/dragndrop.less @@ -1,5 +1,20 @@ -.ptx-runestone-container,.runestone-sphinx { +.ptx-runestone-container, +.runestone-sphinx { + --dnd-active-drop-border: #2b6ea6; + --dnd-active-drop-glow: rgba(43, 110, 166, 0.45); +} + +:root.dark-mode { + .ptx-runestone-container, + .runestone-sphinx { + --dnd-active-drop-border: #8cc8ff; + --dnd-active-drop-glow: rgba(140, 200, 255, 0.55); + } +} + +.ptx-runestone-container, +.runestone-sphinx { @import (less) url("./dragndrop.css"); } -@import (less) url("../../common/css/variables.less"); \ No newline at end of file +@import (less) url("../../common/css/variables.less"); diff --git a/bases/rsptx/interactives/runestone/dragndrop/js/dragndrop.js b/bases/rsptx/interactives/runestone/dragndrop/js/dragndrop.js index b35f38ef6..cf1b23195 100644 --- a/bases/rsptx/interactives/runestone/dragndrop/js/dragndrop.js +++ b/bases/rsptx/interactives/runestone/dragndrop/js/dragndrop.js @@ -325,6 +325,7 @@ export default class DragNDrop extends RunestoneBase { function (ev) { this.isAnswered = true; ev.preventDefault(); + this.setPointerDragActive(false); if (this.draggableDiv.classList.contains("possibleDrop")) { this.draggableDiv.classList.remove("possibleDrop"); } @@ -457,8 +458,12 @@ export default class DragNDrop extends RunestoneBase { dgSpan.setAttribute("aria-pressed", "false"); dgSpan.addEventListener("dragstart", (ev) => { this.deselectPremise(); + this.setPointerDragActive(true); ev.dataTransfer.setData("draggableID", ev.target.id); }); + dgSpan.addEventListener("dragend", () => { + this.setPointerDragActive(false); + }); dgSpan.addEventListener("dragover", function (ev) { ev.preventDefault(); }); @@ -467,6 +472,7 @@ export default class DragNDrop extends RunestoneBase { function (ev) { this.isAnswered = true; ev.preventDefault(); + this.setPointerDragActive(false); var data = ev.dataTransfer.getData("draggableID"); var draggedSpan = document.getElementById(data); if ( @@ -539,6 +545,7 @@ export default class DragNDrop extends RunestoneBase { function (ev) { this.isAnswered = true; ev.preventDefault(); + this.setPointerDragActive(false); if (ev.target.classList.contains("possibleDrop")) { ev.target.classList.remove("possibleDrop"); } @@ -598,6 +605,10 @@ export default class DragNDrop extends RunestoneBase { }); } + setPointerDragActive(active) { + this.containerDiv.classList.toggle("pointer-drag-active", active); + } + moveResponseFocus(response, moveBackward) { const currentIndex = this.responseArray.indexOf(response); const offset = moveBackward ? -1 : 1; diff --git a/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js b/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js index 9273bb582..2e9ec174d 100644 --- a/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js +++ b/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js @@ -94,9 +94,9 @@ describe("construction from a JSON script block", () => { const dnd = await makeDnd(); const container = document.getElementById("test_dnd_1"); expect(container).toBe(dnd.containerDiv); - expect( - container.querySelector(".cardsort-statement").textContent, - ).toBe("Match each animal to its sound."); + expect(container.querySelector(".cardsort-statement").textContent).toBe( + "Match each animal to its sound.", + ); const dragIds = [...dnd.draggableDiv.querySelectorAll(".premise")].map( (el) => el.id, ); @@ -146,10 +146,7 @@ describe("construction from legacy data-subcomponent markup", () => { await tick(); expect(dnd.question).toBe("Match the terms."); expect(dnd.feedback).toBe("A hint."); - expect(dnd.premiseArray.map((p) => p.id)).toEqual([ - "drag_a", - "drag_b", - ]); + expect(dnd.premiseArray.map((p) => p.id)).toEqual(["drag_a", "drag_b"]); // dropzone ids are derived from the for attribute expect(dnd.responseArray.map((r) => r.id)).toEqual([ "drop_a", @@ -528,6 +525,27 @@ describe("keyboard controls", () => { }); }); +describe("pointer controls", () => { + it("highlights responses only while a premise is being dragged", async () => { + const dnd = await makeDnd(); + const premise = dnd.premiseArray[0]; + const dragStart = new Event("dragstart", { bubbles: true }); + Object.defineProperty(dragStart, "dataTransfer", { + value: { setData: vi.fn() }, + }); + + premise.dispatchEvent(dragStart); + expect(dnd.containerDiv.classList.contains("pointer-drag-active")).toBe( + true, + ); + + premise.dispatchEvent(new Event("dragend", { bubbles: true })); + expect(dnd.containerDiv.classList.contains("pointer-drag-active")).toBe( + false, + ); + }); +}); + describe("persistence", () => { it("restores placements from localStorage on a fresh render", async () => { const first = await makeDnd(); @@ -535,9 +553,9 @@ describe("persistence", () => { first.checkCurrentAnswer(); const second = await makeDnd(); const r1 = second.responseArray.find((r) => r.id === "r1"); - expect( - [...r1.querySelectorAll(".premise")].map((p) => p.id), - ).toEqual(["p1"]); + expect([...r1.querySelectorAll(".premise")].map((p) => p.id)).toEqual([ + "p1", + ]); expect( [...second.draggableDiv.querySelectorAll(".premise")].map( (p) => p.id, @@ -563,9 +581,9 @@ describe("persistence", () => { correct: true, }); const r2 = dnd.responseArray.find((r) => r.id === "r2"); - expect( - [...r2.querySelectorAll(".premise")].map((p) => p.id), - ).toEqual(["p2"]); + expect([...r2.querySelectorAll(".premise")].map((p) => p.id)).toEqual([ + "p2", + ]); expect(dnd.correct).toBe(true); }); @@ -632,10 +650,9 @@ describe("TimedDragNDrop", () => { } beforeEach(() => { - vi.spyOn( - RunestoneBase.prototype, - "logBookEvent", - ).mockResolvedValue(undefined); + vi.spyOn(RunestoneBase.prototype, "logBookEvent").mockResolvedValue( + undefined, + ); }); afterEach(() => { From 93946fc8720ae90e21bdf53e095004fd70a1c668 Mon Sep 17 00:00:00 2001 From: Andrew Scholer Date: Wed, 15 Jul 2026 03:50:05 -0700 Subject: [PATCH 3/4] Fix: selecting dragndrop premise does not reset location if already placed --- .../runestone/dragndrop/js/dragndrop.js | 7 ++++++- .../runestone/dragndrop/test/dragndrop.test.js | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/bases/rsptx/interactives/runestone/dragndrop/js/dragndrop.js b/bases/rsptx/interactives/runestone/dragndrop/js/dragndrop.js index cf1b23195..c052fb4b9 100644 --- a/bases/rsptx/interactives/runestone/dragndrop/js/dragndrop.js +++ b/bases/rsptx/interactives/runestone/dragndrop/js/dragndrop.js @@ -693,7 +693,12 @@ export default class DragNDrop extends RunestoneBase { premise.classList.add("selected"); premise.setAttribute("aria-pressed", "true"); this.updateKeyboardNavigation(); - this.responseArray[0]?.focus(); + const currentResponse = this.responseArray.includes( + premise.parentElement, + ) + ? premise.parentElement + : this.responseArray[0]; + currentResponse?.focus(); } deselectPremise({ restoreFocus = false } = {}) { diff --git a/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js b/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js index 2e9ec174d..c8957997c 100644 --- a/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js +++ b/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js @@ -388,6 +388,21 @@ describe("keyboard controls", () => { expect(premise.classList.contains("selected")).toBe(true); }); + it("keeps an already placed premise in its response when selected", async () => { + const dnd = await makeDnd(); + const premise = dnd.premiseArray[0]; + const [, secondResponse] = dnd.responseArray; + secondResponse.appendChild(premise); + + premise.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + + expect(secondResponse.contains(premise)).toBe(true); + expect(document.activeElement).toBe(secondResponse); + expect(dnd.selectedPremise).toBe(premise); + }); + it("moves the selected premise through responses with the arrow keys", async () => { const dnd = await makeDnd(); const premise = dnd.premiseArray.find((p) => p.id === "p1"); From 7501ef2c8cc43843a1d08e3a1bd87c6224fbf86b Mon Sep 17 00:00:00 2001 From: Andrew Scholer Date: Wed, 15 Jul 2026 04:22:17 -0700 Subject: [PATCH 4/4] Only log dragndrop once placement made, not while placing --- .../runestone/dragndrop/js/dragndrop.js | 50 +++++++++++++----- .../dragndrop/test/dragndrop.test.js | 51 +++++++++++++++++++ 2 files changed, 88 insertions(+), 13 deletions(-) diff --git a/bases/rsptx/interactives/runestone/dragndrop/js/dragndrop.js b/bases/rsptx/interactives/runestone/dragndrop/js/dragndrop.js index c052fb4b9..f366854a8 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.selectedPremiseOrigin = 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. @@ -254,7 +255,7 @@ export default class DragNDrop extends RunestoneBase { this.selectedPremise ) { ev.preventDefault(); - this.deselectPremise({ restoreFocus: true }); + this.cancelPremiseSelection(); } }); this.statementDiv = document.createElement("div"); @@ -600,7 +601,7 @@ export default class DragNDrop extends RunestoneBase { this.selectedPremise && this.selectedPremise.parentElement !== dpSpan ) { - this.moveSelectedPremise(dpSpan, dpSpan.id); + this.moveSelectedPremise(dpSpan); } }); } @@ -638,7 +639,7 @@ export default class DragNDrop extends RunestoneBase { const response = this.responseArray[targetIndex]; if (response !== premise.parentElement) { - this.moveSelectedPremise(response, response.id); + this.moveSelectedPremise(response); } response.focus(); } @@ -648,7 +649,7 @@ export default class DragNDrop extends RunestoneBase { if (!premise || premise.parentElement === this.draggableDiv) { return; } - this.moveSelectedPremise(this.draggableDiv, "dragzone"); + this.moveSelectedPremise(this.draggableDiv); } moveSelectedPremiseRight(response) { @@ -656,21 +657,15 @@ export default class DragNDrop extends RunestoneBase { if (!premise || premise.parentElement !== this.draggableDiv) { return; } - this.moveSelectedPremise(response, response.id); + this.moveSelectedPremise(response); } - moveSelectedPremise(destination, destinationName) { + moveSelectedPremise(destination) { const premise = this.selectedPremise; if (!premise || premise.parentElement === destination) { return; } destination.appendChild(premise); - this.isAnswered = true; - this.logBookEvent({ - event: "dragNdrop-drop", - div_id: this.divid, - act: `${premise.id} -> ${destinationName}`, - }); this.queueMathJax(this.containerDiv).then(() => { this.adjustDragDropWidths(); }); @@ -682,7 +677,19 @@ export default class DragNDrop extends RunestoneBase { return; } - this.moveSelectedPremise(response, response.id); + const destination = + premise.parentElement === this.draggableDiv + ? this.draggableDiv + : response; + const destinationName = + destination === this.draggableDiv ? "dragzone" : response.id; + this.moveSelectedPremise(destination); + this.isAnswered = true; + this.logBookEvent({ + event: "dragNdrop-drop", + div_id: this.divid, + act: `${premise.id} -> ${destinationName}`, + }); this.deselectPremise(); premise.focus(); } @@ -690,6 +697,7 @@ export default class DragNDrop extends RunestoneBase { selectPremise(premise) { this.deselectPremise(); this.selectedPremise = premise; + this.selectedPremiseOrigin = premise.parentElement; premise.classList.add("selected"); premise.setAttribute("aria-pressed", "true"); this.updateKeyboardNavigation(); @@ -709,12 +717,28 @@ export default class DragNDrop extends RunestoneBase { premise.classList.remove("selected"); premise.setAttribute("aria-pressed", "false"); this.selectedPremise = null; + this.selectedPremiseOrigin = null; this.updateKeyboardNavigation(); if (restoreFocus) { premise.focus(); } } + cancelPremiseSelection() { + const premise = this.selectedPremise; + const origin = this.selectedPremiseOrigin; + if (!premise) { + return; + } + if (origin && premise.parentElement !== origin) { + origin.appendChild(premise); + this.queueMathJax(this.containerDiv).then(() => { + this.adjustDragDropWidths(); + }); + } + this.deselectPremise({ restoreFocus: true }); + } + updateKeyboardNavigation() { const premiseTabIndex = this.selectedPremise ? -1 : 0; const responseTabIndex = this.selectedPremise ? 0 : -1; diff --git a/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js b/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js index c8957997c..a46b47edc 100644 --- a/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js +++ b/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js @@ -367,6 +367,7 @@ describe("keyboard controls", () => { expect(dnd.selectedPremise).toBe(null); expect(document.activeElement).toBe(premise); + expect(premise.parentElement).toBe(dnd.draggableDiv); expect(dnd.premiseArray.every((item) => item.tabIndex === 0)).toBe( true, ); @@ -488,6 +489,56 @@ describe("keyboard controls", () => { expect(secondResponse.contains(premise)).toBe(true); }); + it("logs only the confirmed keyboard placement, not preview moves", async () => { + const dnd = await makeDnd(); + const premise = dnd.premiseArray[0]; + const [firstResponse, secondResponse] = dnd.responseArray; + const logSpy = vi.spyOn(dnd, "logBookEvent").mockResolvedValue(); + + premise.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + secondResponse.focus(); + secondResponse.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowUp", + bubbles: true, + }), + ); + expect(logSpy).not.toHaveBeenCalled(); + + firstResponse.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + expect(logSpy).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith({ + event: "dragNdrop-drop", + div_id: dnd.divid, + act: `${premise.id} -> ${firstResponse.id}`, + }); + }); + + it("Escape restores the starting response without logging", async () => { + const dnd = await makeDnd(); + const premise = dnd.premiseArray[0]; + const [firstResponse, secondResponse] = dnd.responseArray; + secondResponse.appendChild(premise); + const logSpy = vi.spyOn(dnd, "logBookEvent").mockResolvedValue(); + + premise.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + firstResponse.focus(); + expect(firstResponse.contains(premise)).toBe(true); + + firstResponse.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + expect(secondResponse.contains(premise)).toBe(true); + expect(dnd.selectedPremise).toBe(null); + expect(logSpy).not.toHaveBeenCalled(); + }); + it("returns a placed selected premise to the dragzone with ArrowLeft", async () => { const dnd = await makeDnd(); const premise = dnd.premiseArray.find((p) => p.id === "p1");