diff --git a/bases/rsptx/interactives/runestone/matching/css/matching.css b/bases/rsptx/interactives/runestone/matching/css/matching.css index a232bfa41..748938cfc 100644 --- a/bases/rsptx/interactives/runestone/matching/css/matching.css +++ b/bases/rsptx/interactives/runestone/matching/css/matching.css @@ -1,31 +1,26 @@ -* { - box-sizing: border-box; -} - -body { - font-family: sans-serif; - padding: 20px; - margin: 0; -} +/* Matching component styles. Everything here is scoped under + .ptx-runestone-container / .runestone-sphinx by matching.less, so avoid + bare element selectors that would leak into the page. Theme variables + come from common/css/variables.less. */ -h2 { - margin-bottom: 0.5em; -} - -/* Main wrapper for each matching component */ [data-component="matching"] { margin: 2rem 0; - font-family: sans-serif; +} + +[data-component="matching"] * { + box-sizing: border-box; } .matching-workspace { display: flex; justify-content: space-between; position: relative; - max-width: 1000px; + max-width: 780px; width: 100%; margin: 0 auto 1rem; - background: var(--componentBgColor); + padding: 1rem 8px; + user-select: none; + -webkit-user-select: none; } .left-column, @@ -33,48 +28,82 @@ h2 { display: flex; flex-direction: column; justify-content: center; - align-items: center; + align-items: stretch; gap: 12px; - padding: 10px; position: relative; z-index: 1; - max-width: 25vw; + flex: 0 1 38%; + min-width: 0; } - .left-column img, .right-column img { - max-width: 25vw; - /* Ensure images do not exceed 25% of the viewport width */ + max-width: 100%; + height: auto; } .box { - display: inline-flex; + position: relative; + display: flex; align-items: center; justify-content: center; - min-width: 120px; padding: 10px 14px; - min-height: 50px; - max-width: 100%; - background: var(--dropableBgColor, #f9f9f9); - border: 2px solid var(--boxBorderColor); + min-height: 48px; + background: var(--background, #ffffff); + border: 1px solid var(--componentBorderColor, #555555); border-radius: 8px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); text-align: center; - font-weight: bold; + font-weight: 500; user-select: none; - cursor: grab; - text-align: center; + -webkit-user-select: none; + cursor: pointer; touch-action: none; - /* Prevents scrolling during tap+drag */ + /* touch-action: none prevents scrolling during tap+drag */ + transition: + border-color 0.15s ease, + background-color 0.15s ease, + box-shadow 0.15s ease; } -.matching-workspace, -.left-column, -.right-column, -.box, -body { - user-select: none; - -webkit-user-select: none; /* for iOS/Safari */ +/* Premises (left column) tend to be sentences; ragged-right reads better. */ +.box[data-role="drag"] { + justify-content: flex-start; + text-align: left; +} + +/* Connection ports: a small dot on the edge where lines attach, so the + affordance of "draw a line from here to there" is visible. */ +.box::after { + content: ""; + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--background, #ffffff); + border: 2px solid var(--dragLineColor, #555555); + transition: + background-color 0.15s ease, + border-color 0.15s ease; +} + +.box[data-role="drag"]::after { + right: -6px; +} + +.box[data-role="drop"]::after { + left: -6px; +} + +.box:hover { + border-color: var(--dragLineColor, #000000); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); +} + +.box:hover::after { + background: var(--dragLineColor, #555555); } .box mjx-container { @@ -83,10 +112,29 @@ body { .box:focus { outline: 3px solid #3498db; + outline-offset: 1px; } .box.selected { outline: 3px dashed #27ae60; + outline-offset: 1px; +} + +/* Flash shown when the user tries to connect two boxes in the same column. */ +.box.invalid { + border-color: var(--draggableIncorrectBorder, red); + background: var(--draggableIncorrectBg, #f2dede); +} + +/* Graded state, applied to the boxes at each end of a connection. */ +.box.match-correct { + border-color: var(--parsonsCorrectBorderColor, #ade595); + background: var(--parsonsCorrectBgColor, #dff0d8); +} + +.box.match-incorrect { + border-color: var(--parsonsIncorrectBorderColor, #f2b6b6); + background: var(--parsonsIncorrectBgColor, #f2dede); } .connector-svg { @@ -97,76 +145,95 @@ body { height: 100%; pointer-events: none; z-index: 0; - background: var(--componentBgColor); } .line { - stroke: var(--dragLineColor); - ; - stroke-width: 3; + stroke: var(--dragLineColor, #333333); + stroke-opacity: 0.75; + stroke-width: 2.5; + fill: none; + stroke-linecap: round; cursor: pointer; - pointer-events: auto; - transition: stroke 0.2s ease, stroke-width 0.2s ease; + pointer-events: stroke; + transition: + stroke 0.15s ease, + stroke-width 0.15s ease, + stroke-opacity 0.15s ease; +} + +/* The in-progress line that follows the pointer during a drag. */ +.line.temp { + stroke-dasharray: 5 5; + stroke-opacity: 0.5; + pointer-events: none; } .line.correct { - stroke: green; + stroke: #2e8540; + stroke-opacity: 1; stroke-width: 3; } .line.incorrect { - stroke: #bf1521; - stroke-width: 2; - stroke-dasharray: 5 5; + stroke: #c62828; + stroke-opacity: 1; + stroke-dasharray: 6 5; } .line.highlighted { - stroke: #3cd6e7; - stroke-width: 3; + stroke-opacity: 1; + stroke-width: 4; } .line.faded { - stroke: #ccc; - stroke-width: 1; + stroke-opacity: 0.15; } .line:focus, .line:hover { stroke: #3498db; - stroke-dasharray: 6 4; - /* 6px dash, 4px gap */ - stroke-width: 5; + stroke-opacity: 1; + stroke-width: 4; outline: none; } .conn-list { - max-width: 800px; + max-width: 780px; margin: 0 auto 1rem; - padding: 10px; - background: var(--componentBgColor); - border: 1px solid #ccc; - border-radius: 6px; - font-size: 14px; + padding: 10px 14px; + background: var(--background, #ffffff); + border: 1px solid var(--componentBorderColor, #cccccc); + border-radius: 8px; + font-size: 0.9rem; } .conn-entry { margin: 4px 0; - font-family: monospace; } -button { - display: inline-block; - margin: 0.5rem; - font-size: 1rem; - padding: 8px 16px; - border: none; - border-radius: 6px; - cursor: grab; - transition: background 0.2s ease-in-out; +.conn-empty { + font-style: italic; + opacity: 0.75; } -button:hover { - background: #2980b9; +.match-results { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + +.match-score-badge { + font-weight: bold; + padding: 2px 12px; + border-radius: 999px; + border: 1px solid var(--parsonsIncorrectBorderColor, #f2b6b6); + background: var(--parsonsIncorrectBgColor, #f2dede); +} + +.match-score-badge.match-score-perfect { + border-color: var(--parsonsCorrectBorderColor, #ade595); + background: var(--parsonsCorrectBgColor, #dff0d8); } .aria-live { @@ -186,17 +253,27 @@ button:hover { margin-top: 1rem; } +.control-div button { + display: inline-block; + margin: 0; + font-size: 1rem; + padding: 8px 16px; + border: none; + border-radius: 6px; + cursor: pointer; + transition: background 0.2s ease-in-out; +} + .control-div .help-button { background: #2c3e50; color: #ecf0f1; - width: 32px; - height: 32px; + width: 36px; + height: 36px; padding: 0; border-radius: 50%; font-size: 1.2rem; - line-height: 32px; + line-height: 36px; text-align: center; - cursor: pointer; } .control-div .help-button:hover { @@ -219,10 +296,9 @@ button:hover { /* modal content box */ .help-modal-content { - background-color: var(--questionBgColor); padding: 1rem 1.5rem; - border-radius: 6px; + border-radius: 8px; max-width: 80%; max-height: 80%; overflow: auto; @@ -260,4 +336,11 @@ button:hover { margin-top: 10px; margin-bottom: 10px; border-radius: 5px; -} \ No newline at end of file +} + +@media (max-width: 600px) { + .left-column, + .right-column { + flex-basis: 42%; + } +} diff --git a/bases/rsptx/interactives/runestone/matching/js/matching.js b/bases/rsptx/interactives/runestone/matching/js/matching.js index 04fe34ced..62a09bbf5 100644 --- a/bases/rsptx/interactives/runestone/matching/js/matching.js +++ b/bases/rsptx/interactives/runestone/matching/js/matching.js @@ -130,6 +130,9 @@ export class MatchingProblem extends RunestoneBase { } renderFeedback() { + this.allBoxes.forEach((box) => + box.classList.remove("match-correct", "match-incorrect"), + ); this.connections.forEach((conn) => { const idPair = [conn.fromBox.dataset.id, conn.toBox.dataset.id]; const isCorrect = this.boxData.correctAnswers.some( @@ -138,13 +141,24 @@ export class MatchingProblem extends RunestoneBase { ); conn.line.classList.remove("correct", "incorrect"); conn.line.classList.add(isCorrect ? "correct" : "incorrect"); + [conn.fromBox, conn.toBox].forEach((box) => { + if (!isCorrect) { + box.classList.remove("match-correct"); + box.classList.add("match-incorrect"); + } else if (!box.classList.contains("match-incorrect")) { + box.classList.add("match-correct"); + } + }); }); - this.connList.innerHTML = `Score: ${this.scorePercent}%
`; - this.connList.innerHTML += `
Correct: ${this.correctCount}`; - this.connList.innerHTML += `
Incorrect: ${this.incorrectCount}`; - this.connList.innerHTML += `
Missing: ${this.missingCount}`; - if (this.scorePercent !== 100) { + const badgeClass = + this.scorePercent === 100 ? " match-score-perfect" : ""; + this.connList.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.queueMathJax(this.connList); @@ -175,7 +189,7 @@ export class MatchingProblem extends RunestoneBase { this.connections.forEach((conn) => { const from = this.getRightBoxCenter(conn.fromBox); const to = this.getLeftBoxCenter(conn.toBox); - const line = this.createLineElement(from.x, from.y, to.x, to.y); + const line = this.createLineElement(from, to); line.fromBox = conn.fromBox; line.toBox = conn.toBox; this.svg.appendChild(line); @@ -257,7 +271,8 @@ export class MatchingProblem extends RunestoneBase { createConnList(container) { const connList = document.createElement("div"); connList.className = "conn-list"; - connList.innerHTML = "Connections:
"; + connList.innerHTML = + 'Connections:
No connections yet. Drag between boxes to connect them.
'; container.appendChild(connList); return connList; } @@ -306,7 +321,7 @@ export class MatchingProblem extends RunestoneBase { this.helpModal = document.createElement("div"); 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 the box. Then tab to the connecting box and press Enter to create a connection between the two selected boxes.

+

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 the "Check Me" button to check your connections, and save your work.

Click the "Reset" button to clear all connections.

`; @@ -416,15 +431,38 @@ export class MatchingProblem extends RunestoneBase { }; } - createLineElement(x1, y1, x2, y2) { + // The port (where lines attach) is on the inner edge of the box: the + // right edge for left-column (drag) boxes, the left edge for + // right-column (drop) boxes. + getPortCenter(el) { + return el.dataset.role === "drop" + ? this.getLeftBoxCenter(el) + : this.getRightBoxCenter(el); + } + + /* + * Connection lines are drawn as gentle S-curves (cubic beziers) whose + * control points pull horizontally out of each port, so lines leave and + * enter boxes perpendicular to the column edges. + */ + setLineEndpoints(line, from, to) { + const pull = Math.max(30, Math.abs(to.x - from.x) / 2); + // Pull the control points toward the other endpoint so the curve + // leaves the port heading in the right direction; sign handles a + // temp line dragged leftward from a right-column port. + const sign = to.x >= from.x ? 1 : -1; + line.setAttribute( + "d", + `M ${from.x} ${from.y} C ${from.x + sign * pull} ${from.y}, ${to.x - sign * pull} ${to.y}, ${to.x} ${to.y}`, + ); + } + + createLineElement(from, to) { const line = document.createElementNS( "http://www.w3.org/2000/svg", - "line", + "path", ); - line.setAttribute("x1", x1); - line.setAttribute("y1", y1); - line.setAttribute("x2", x2); - line.setAttribute("y2", y2); + this.setLineEndpoints(line, from, to); line.setAttribute("class", "line"); line.setAttribute("tabindex", "0"); // Make the line focusable line.setAttribute("focusable", "true"); // Make the line focusable @@ -472,8 +510,12 @@ export class MatchingProblem extends RunestoneBase { const toRole = toBox.dataset.role; if (fromRole === toRole) { - alert("You can only connect a draggable to a droppable."); - return; + this.flashInvalid(fromBox, toBox); + if (this.ariaLive) { + this.ariaLive.textContent = + "Connections must go between the left column and the right column."; + } + return false; } // we should always store connections as drag to drop @@ -481,11 +523,11 @@ export class MatchingProblem extends RunestoneBase { if (fromBox.dataset.role === "drop") { [fromBox, toBox] = [toBox, fromBox]; } - if (this.isConnected(fromBox, toBox)) return; + if (this.isConnected(fromBox, toBox)) return false; const from = this.getRightBoxCenter(fromBox); const to = this.getLeftBoxCenter(toBox); - const line = this.createLineElement(from.x, from.y, to.x, to.y); + const line = this.createLineElement(from, to); line.fromBox = fromBox; line.toBox = toBox; @@ -498,11 +540,35 @@ export class MatchingProblem extends RunestoneBase { if (this.ariaLive) { this.ariaLive.textContent = `Connected ${fromBox.textContent} to ${toBox.textContent}`; } + return true; + } + + flashInvalid(...boxes) { + boxes.forEach((box) => { + box.classList.add("invalid"); + setTimeout(() => box.classList.remove("invalid"), 500); + }); } updateConnectionModel() { - this.connList.innerHTML = "Connections:
"; + // Any change to the connections invalidates previously rendered + // grading marks, so clear them along with rebuilding the list. + this.allBoxes.forEach((box) => + box.classList.remove("match-correct", "match-incorrect"), + ); + this.connList.innerHTML = "Connections:"; + if (this.connections.length === 0) { + const empty = document.createElement("div"); + empty.className = "conn-entry conn-empty"; + empty.textContent = + "No connections yet. Drag between boxes to connect them."; + this.connList.appendChild(empty); + return; + } this.connections.forEach((conn) => { + if (conn.line) { + conn.line.classList.remove("correct", "incorrect"); + } const fromLabel = conn.fromBox.textContent; let toLabel = conn.toBox.textContent; if (!toLabel) { @@ -549,29 +615,15 @@ export class MatchingProblem extends RunestoneBase { attachEvents() { this.allBoxes.forEach((box) => { box.addEventListener("pointerdown", (e) => { - if (e.ctrlKey || e.metaKey || true) { - e.preventDefault(); - this.startBox = box; - const from = this.getRightBoxCenter(this.startBox); - this.tempLine = this.createLineElement( - from.x, - from.y, - from.x, - from.y, - ); - this.tempLine.setAttribute("stroke", "gray"); - this.tempLine.setAttribute("stroke-dasharray", "4"); - this.svg.appendChild(this.tempLine); - - document.addEventListener( - "pointermove", - this.updateTempLine, - ); - document.addEventListener( - "pointerup", - this.finishConnection, - ); - } + e.preventDefault(); + this.startBox = box; + const from = this.getPortCenter(this.startBox); + this.tempLine = this.createLineElement(from, from); + this.tempLine.classList.add("temp"); + this.svg.appendChild(this.tempLine); + + document.addEventListener("pointermove", this.updateTempLine); + document.addEventListener("pointerup", this.finishConnection); }); box.addEventListener("keydown", (e) => { @@ -580,6 +632,18 @@ export class MatchingProblem extends RunestoneBase { 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); @@ -590,6 +654,15 @@ export class MatchingProblem extends RunestoneBase { if (next) next.focus(); else this.allBoxes[0].focus(); } + } 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."; + } } }); @@ -616,10 +689,7 @@ export class MatchingProblem extends RunestoneBase { this.connections.forEach((conn) => { const from = this.getRightBoxCenter(conn.fromBox); const to = this.getLeftBoxCenter(conn.toBox); - conn.line.setAttribute("x1", from.x); - conn.line.setAttribute("y1", from.y); - conn.line.setAttribute("x2", to.x); - conn.line.setAttribute("y2", to.y); + this.setLineEndpoints(conn.line, from, to); }); }); } @@ -627,15 +697,13 @@ export class MatchingProblem extends RunestoneBase { updateTempLine = (e) => { e.preventDefault(); if (!this.startBox || !this.tempLine) return; - const from = this.getRightBoxCenter(this.startBox); - this.tempLine.setAttribute("x1", from.x); - this.tempLine.setAttribute("y1", from.y); + const from = this.getPortCenter(this.startBox); const containerRect = this.workspace.getBoundingClientRect(); - const x = e.clientX - containerRect.left; - const y = e.clientY - containerRect.top; - - this.tempLine.setAttribute("x2", x); - this.tempLine.setAttribute("y2", y); + const to = { + x: e.clientX - containerRect.left, + y: e.clientY - containerRect.top, + }; + this.setLineEndpoints(this.tempLine, from, to); }; finishConnection = (e) => { @@ -660,17 +728,17 @@ export class MatchingProblem extends RunestoneBase { (box) => box.contains(targetElement) && box !== this.startBox, ); - if (this.startBox && endBox) - this.createPermanentLine(this.startBox, endBox); - // - console.log( - `connected ${this.startBox.dataset.id ? this.startBox.dataset.id : "null"} to ${endBox.dataset.id ? endBox.dataset.id : "null"}`, - ); - this.logBookEvent({ - event: "matching_connection", - div_id: this.divid, - act: `connected ${this.startBox.dataset.id ? this.startBox.dataset.id : "null"} to ${endBox.dataset.id ? endBox.dataset.id : "null"}`, - }); + if ( + this.startBox && + endBox && + this.createPermanentLine(this.startBox, endBox) + ) { + this.logBookEvent({ + event: "matching_connection", + div_id: this.divid, + act: `connected ${this.startBox.dataset.id} to ${endBox.dataset.id}`, + }); + } this.startBox = null; document.removeEventListener("pointermove", this.updateTempLine); document.removeEventListener("pointerup", this.finishConnection);