diff --git a/bases/rsptx/interactives/runestone/parsons/css/parsons.css b/bases/rsptx/interactives/runestone/parsons/css/parsons.css
index 94522f4d4..883e7a5ba 100755
--- a/bases/rsptx/interactives/runestone/parsons/css/parsons.css
+++ b/bases/rsptx/interactives/runestone/parsons/css/parsons.css
@@ -3,8 +3,15 @@
}
.parsons .sortable-code-container {
+ position: relative;
text-align: center;
}
+
+.parsons .parsons-keyboard-application {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+}
.parsons .sortable-code-container:not(.block) {
/* Remove text highlighting in widget */
-webkit-user-select: none;
@@ -253,12 +260,16 @@
.parsons .block.down {
border: 2px solid var(--boxBorderColor, #000);
padding: 4px 9px;
+ outline: 3px solid #3498db;
+ outline-offset: 1px;
}
.parsons .block.up {
border: 2px solid var(--boxBorderColor, #000);
padding: 4px 9px;
background-color: var(--background, #fff);
box-shadow: 3px 3px 4px rgba(0, 0, 0, 0.6);
+ outline: 3px dashed #27ae60;
+ outline-offset: 1px;
}
.parsons {
display: block;
diff --git a/bases/rsptx/interactives/runestone/parsons/js/parsons-i18n.en.js b/bases/rsptx/interactives/runestone/parsons/js/parsons-i18n.en.js
index 67007104a..52a09a547 100644
--- a/bases/rsptx/interactives/runestone/parsons/js/parsons-i18n.en.js
+++ b/bases/rsptx/interactives/runestone/parsons/js/parsons-i18n.en.js
@@ -8,6 +8,21 @@ load({
msg_parson_too_short: "Your answer is too short. Add more blocks.",
msg_parson_drag_from_here: "Drag from here",
msg_parson_drag_to_here: "Drop blocks here",
+ msg_parson_enter_activate: "Enter to activate",
+ msg_parson_keyboard_entry_label:
+ "Parsons problem. Press Enter or Space to arrange blocks.",
+ msg_parson_keyboard_instructions:
+ "Use arrow keys to navigate blocks, then Enter or Space to move it.",
+ msg_parson_keyboard_selected: "Selected $1",
+ msg_parson_incorrect: "Incorrect",
+ msg_parson_block: "block",
+ msg_parson_keyboard_unplaced_position:
+ "In the unplaced list, position $1 of $2",
+ msg_parson_keyboard_answer_position:
+ "In the answer area, position $1 of $2",
+ msg_parson_keyboard_tab_in: "$1 tab in",
+ msg_parson_keyboard_tabs_in: "$1 tabs in",
+ msg_parson_keyboard_options: "$1 of $2 options",
msg_parson_correct_first_try:
"Perfect! It took you only one try to solve this. Great job!",
msg_parson_correct:
diff --git a/bases/rsptx/interactives/runestone/parsons/js/parsons-i18n.pt-br.js b/bases/rsptx/interactives/runestone/parsons/js/parsons-i18n.pt-br.js
index 13cf3552f..0ba2d6703 100644
--- a/bases/rsptx/interactives/runestone/parsons/js/parsons-i18n.pt-br.js
+++ b/bases/rsptx/interactives/runestone/parsons/js/parsons-i18n.pt-br.js
@@ -8,6 +8,21 @@ load({
msg_parson_too_short: "Seu programa é muito curto. Adicione mais blocos.",
msg_parson_drag_from_here: "Arraste daqui",
msg_parson_drag_to_here: "Largue os blocos aqui",
+ msg_parson_enter_activate: "Pressione Enter para ativar",
+ msg_parson_keyboard_entry_label:
+ "Problema Parsons. Pressione Enter ou Espaço para organizar os blocos.",
+ msg_parson_keyboard_instructions:
+ "Use as teclas de seta para navegar pelos blocos e pressione Enter ou Espaço para movê-los.",
+ msg_parson_keyboard_selected: "Selecionado $1",
+ msg_parson_incorrect: "Incorreto",
+ msg_parson_block: "bloco",
+ msg_parson_keyboard_unplaced_position:
+ "Na lista não posicionada, posição $1 de $2",
+ msg_parson_keyboard_answer_position:
+ "Na área de resposta, posição $1 de $2",
+ msg_parson_keyboard_tab_in: "$1 nível de indentação",
+ msg_parson_keyboard_tabs_in: "$1 níveis de indentação",
+ msg_parson_keyboard_options: "$1 de $2 opções",
msg_parson_correct_first_try:
"Perfeito! Você levou apenas uma tentativa para resolver. Bom trabalho!",
msg_parson_correct:
diff --git a/bases/rsptx/interactives/runestone/parsons/js/parsons-i18n.sr-Cyrl.js b/bases/rsptx/interactives/runestone/parsons/js/parsons-i18n.sr-Cyrl.js
index d9748c5af..c324b0ba4 100644
--- a/bases/rsptx/interactives/runestone/parsons/js/parsons-i18n.sr-Cyrl.js
+++ b/bases/rsptx/interactives/runestone/parsons/js/parsons-i18n.sr-Cyrl.js
@@ -8,6 +8,21 @@ load({
msg_parson_too_short: "Искористи све блокове у решењу",
msg_parson_drag_from_here: "Превуци одавде",
msg_parson_drag_to_here: "Превуци овде",
+ msg_parson_enter_activate: "Притисни Enter да активираш",
+ msg_parson_keyboard_entry_label:
+ "Парсонов задатак. Притисни Enter или размак да распоредиш блокове.",
+ msg_parson_keyboard_instructions:
+ "Користи стрелице за кретање по блоковима, а затим Enter или размак да помериш блок.",
+ msg_parson_keyboard_selected: "Изабран је $1",
+ msg_parson_incorrect: "Нетачно",
+ msg_parson_block: "блок",
+ msg_parson_keyboard_unplaced_position:
+ "На листи непостављених, позиција $1 од $2",
+ msg_parson_keyboard_answer_position:
+ "У области одговора, позиција $1 од $2",
+ msg_parson_keyboard_tab_in: "$1 ниво увлачења",
+ msg_parson_keyboard_tabs_in: "$1 нивоа увлачења",
+ msg_parson_keyboard_options: "$1 од $2 опције",
msg_parson_correct_first_try: "Одлично! Успео си из првог покушаја!",
msg_parson_correct:
"Одлично! Решио си у само $1 покушаја. Одабери Поништи ако желиш да решиш из првог покушаја.",
diff --git a/bases/rsptx/interactives/runestone/parsons/js/parsons.js b/bases/rsptx/interactives/runestone/parsons/js/parsons.js
index d1e87a320..b222d98bb 100644
--- a/bases/rsptx/interactives/runestone/parsons/js/parsons.js
+++ b/bases/rsptx/interactives/runestone/parsons/js/parsons.js
@@ -22,6 +22,10 @@
"use strict";
import RunestoneBase from "../../common/js/runestonebase.js";
+import {
+ disableMathJaxTabStops,
+ getAccessibleElementText,
+} from "../../common/js/mathjax-a11y.js";
import { t } from "../../common/js/rsi18n.js";
import {
getDataValue,
@@ -188,11 +192,149 @@ export default class Parsons extends RunestoneBase {
}
this.options = options;
}
+ getBlockLabel(block) {
+ return getAccessibleElementText(block.view) || t("msg_parson_block");
+ }
+
+ isIncorrectBlock(block) {
+ return (
+ block.view.classList.contains("incorrectPosition") ||
+ block.view.classList.contains("indentLeft") ||
+ block.view.classList.contains("indentRight")
+ );
+ }
+
+ getKeyboardBlockLocation(block) {
+ const inSource = block.inSourceArea();
+ const blocks = inSource
+ ? this.enabledSourceBlocks()
+ : this.enabledAnswerBlocks();
+ const position = blocks.indexOf(block) + 1;
+ const location = [
+ t(
+ inSource
+ ? "msg_parson_keyboard_unplaced_position"
+ : "msg_parson_keyboard_answer_position",
+ position,
+ blocks.length,
+ ),
+ ];
+
+ if (!inSource && this.usesIndentation()) {
+ location.push(
+ t(
+ block.indent === 1
+ ? "msg_parson_keyboard_tab_in"
+ : "msg_parson_keyboard_tabs_in",
+ block.indent,
+ ),
+ );
+ }
+
+ const pairedBin = block.pairedBin();
+ if (pairedBin !== -1) {
+ const options = this.blocks.filter(
+ (candidate) =>
+ candidate.enabled() && candidate.pairedBin() === pairedBin,
+ );
+ if (options.length > 1) {
+ location.push(
+ t(
+ "msg_parson_keyboard_options",
+ options.indexOf(block) + 1,
+ options.length,
+ ),
+ );
+ }
+ }
+
+ return location.join(". ") + ".";
+ }
+
+ updateBlockAriaLabel(block) {
+ const label = [this.getBlockLabel(block)];
+ if (this.isIncorrectBlock(block)) {
+ label.push(t("msg_parson_incorrect"));
+ }
+ block.view.setAttribute("aria-label", label.join(". "));
+ }
+
+ updateBlockAriaLabels() {
+ this.blocks.forEach((block) => this.updateBlockAriaLabel(block));
+ }
+
+ showMessage(message, className = "alert alert-info") {
+ this.messageDiv.style.visibility = "visible";
+ this.messageDiv.setAttribute("class", className);
+ this.messageDiv.replaceChildren();
+ const messageVersion = (this.messageVersion || 0) + 1;
+ this.messageVersion = messageVersion;
+ setTimeout(() => {
+ if (this.messageVersion === messageVersion) {
+ this.messageDiv.innerHTML = message;
+ }
+ }, 10);
+ }
+
+ showKeyboardEntryHint() {
+ this.keyboardTip.innerHTML = t("msg_parson_enter_activate");
+ this.keyboardTip.style.display = "";
+ this.sourceLabel.style.display = "none";
+ this.answerLabel.style.display = "none";
+ }
+
+ hideKeyboardEntryHint() {
+ this.keyboardTip.innerHTML = t("msg_parson_arrow_navigate");
+ this.keyboardTip.style.display = "none";
+ this.sourceLabel.style.display = "";
+ this.answerLabel.style.display = "";
+ }
+
+ disableBlockMathTabStops() {
+ disableMathJaxTabStops(this.outerDiv, [".block"]);
+ }
+
+ observeMathJaxSpeech() {
+ if (
+ this.mathJaxSpeechObserver ||
+ typeof MutationObserver === "undefined"
+ ) {
+ return;
+ }
+
+ this.mathJaxSpeechObserver = new MutationObserver((mutations) => {
+ const changedBlocks = new Set();
+ for (const mutation of mutations) {
+ const block = mutation.target.closest?.(".block");
+ if (block) {
+ changedBlocks.add(block);
+ }
+ }
+ if (changedBlocks.size === 0) return;
+
+ this.disableBlockMathTabStops();
+ changedBlocks.forEach((view) => {
+ const block = this.blocks.find((item) => item.view === view);
+ if (block) this.updateBlockAriaLabel(block);
+ });
+ });
+ this.mathJaxSpeechObserver.observe(this.outerDiv, {
+ subtree: true,
+ attributes: true,
+ attributeFilter: ["data-semantic-speech-none"],
+ });
+ }
+
// Based on what is specified in the original HTML, create the HTML view
initializeView() {
this.outerDiv = document.createElement("div");
this.outerDiv.classList.add("parsons");
this.outerDiv.id = this.counterId;
+ this.outerDiv.addEventListener(
+ "keydown",
+ (event) => this.handleKeyboardMovementKeydown(event),
+ true,
+ );
// parsons-text exists in source, make sure it has exercise-statement class
let parsonsTextDiv = this.containerDiv.querySelector(".parsons-text");
if (
@@ -212,6 +354,55 @@ export default class Parsons extends RunestoneBase {
"aria-describedby",
this.counterId + "-tip",
);
+ this.sortContainerDiv.tabIndex = 0;
+ this.sortContainerDiv.setAttribute("role", "button");
+ this.sortContainerDiv.setAttribute(
+ "aria-label",
+ t("msg_parson_keyboard_entry_label"),
+ );
+ this.sortContainerDiv.addEventListener("focus", () => {
+ if (
+ !this.keyboardInputActive &&
+ this.sortContainerDiv.matches(":focus-visible")
+ ) {
+ this.showKeyboardEntryHint();
+ }
+ });
+ this.sortContainerDiv.addEventListener("keydown", (event) => {
+ if (
+ event.target === this.sortContainerDiv &&
+ (event.key === "Enter" || event.key === " ")
+ ) {
+ this.startKeyboardInteraction();
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ });
+ this.sortContainerDiv.addEventListener("click", (event) => {
+ if (event.target === this.sortContainerDiv) {
+ this.startKeyboardInteraction();
+ }
+ });
+ this.keyboardApplication = document.createElement("div");
+ this.keyboardApplication.classList.add("parsons-keyboard-application");
+ this.keyboardApplication.setAttribute("role", "application");
+ this.keyboardApplication.tabIndex = -1;
+ this.keyboardApplication.addEventListener("blur", () => {
+ if (this.keyboardInputActive) {
+ this.cancelKeyboardMovement();
+ }
+ });
+ this.sortContainerDiv.addEventListener("blur", (event) => {
+ if (
+ this.keyboardInputActive &&
+ event.relatedTarget !== this.keyboardApplication
+ ) {
+ this.cancelKeyboardMovement();
+ } else if (!this.keyboardInputActive) {
+ this.hideKeyboardEntryHint();
+ }
+ });
+ this.sortContainerDiv.appendChild(this.keyboardApplication);
this.outerDiv.appendChild(this.sortContainerDiv);
this.sourceRegionDiv = document.createElement("div");
this.sourceRegionDiv.id = this.counterId + "-sourceRegion";
@@ -675,6 +866,7 @@ export default class Parsons extends RunestoneBase {
this.answerArea.appendChild(block.view);
}
this.blocks = blocks;
+ this.observeMathJaxSpeech();
// If present, disable some blocks
var disabled = options.disabled;
if (disabled !== undefined) {
@@ -688,7 +880,11 @@ export default class Parsons extends RunestoneBase {
// Determine how much indent should be possible in the answer area
var indent = 0;
if (!this.noindent) {
- if (this.options.language == "natural" || this.options.language == "math" || this.options.language == "text") {
+ if (
+ this.options.language == "natural" ||
+ this.options.language == "math" ||
+ this.options.language == "text"
+ ) {
indent = this.solutionIndent();
} else {
indent = Math.max(0, this.solutionIndent());
@@ -782,6 +978,8 @@ export default class Parsons extends RunestoneBase {
}
areaWidth = Math.max(areaWidth, item.getBoundingClientRect().width);
}
+ this.disableBlockMathTabStops();
+ this.updateBlockAriaLabels();
// Pass 2: apply uniform width to all blocks, then measure heights
for (i = 0; i < blocks.length; i++) {
const item = blocks[i].view;
@@ -1020,7 +1218,8 @@ export default class Parsons extends RunestoneBase {
div.style.overflow = "visible";
div.style.fontSize = "43px";
div.style.verticalAlign = "middle";
- div.style.color = "var(--parsonsLabelColor, #5858e0)";
+ div.style.color =
+ "var(--parsonsLabelColor, #5858e0)";
div.innerHTML =
"or{";
}
@@ -1047,15 +1246,17 @@ export default class Parsons extends RunestoneBase {
}
}
}
- // Make one block be keyboard accessible
+ // Make the problem, rather than an individual block, keyboard accessible.
initializeTabIndex() {
- for (var i = 0; i < this.blocks.length; i++) {
- var block = this.blocks[i];
- if (block.enabled()) {
- block.makeTabIndex();
- return this;
+ this.sortContainerDiv.tabIndex = 0;
+ for (const block of this.blocks) {
+ if (block.view.classList.contains("disabled")) {
+ block.view.removeAttribute("tabindex");
+ } else {
+ block.view.setAttribute("tabindex", "-1");
}
}
+ return this;
}
/* =====================================================================
==== SERVER COMMUNICATION ==============================================
@@ -2026,7 +2227,7 @@ export default class Parsons extends RunestoneBase {
}
// if time to offer help
if (this.numDistinct == 3 && !this.gotHelp) {
- alert(t("msg_parson_help_info"));
+ this.pendingHelpMessage = t("msg_parson_help_info");
} // end if
} // end if can help
} // end if not solved
@@ -2077,10 +2278,12 @@ export default class Parsons extends RunestoneBase {
var feedbackArea = this.showfeedback === true ? this.messageDiv : null;
var setFeedback = (className, message) => {
if (feedbackArea) {
- feedbackArea.setAttribute("class", className);
- setTimeout(() => {
- feedbackArea.innerHTML = message;
- }, 10);
+ const helpMessage = this.pendingHelpMessage;
+ this.pendingHelpMessage = undefined;
+ this.showMessage(
+ helpMessage ? `${message} ${helpMessage}` : message,
+ className,
+ );
}
};
@@ -2161,6 +2364,8 @@ export default class Parsons extends RunestoneBase {
}
setFeedback("alert alert-danger", t("msg_parson_wrong_order"));
}
+
+ this.updateBlockAriaLabels();
}
// Show explanations for blocks after the student solves the exercise
@@ -2303,13 +2508,7 @@ export default class Parsons extends RunestoneBase {
}
// Remove this distractors to make the problem easier
removeDistractor(block) {
- // Alert the user to what is happening
- var feedbackArea = this.messageDiv;
- this.messageDiv.style.visibility = "visible";
- feedbackArea.setAttribute("class", "alert alert-info");
- setTimeout(() => {
- feedbackArea.innerHTML = t("msg_parson_not_solution");
- }, 10);
+ this.showMessage(t("msg_parson_not_solution"));
// Stop ability to select
if (block.lines[0].distractHelptext) {
block.view.setAttribute("data-toggle", "tooltip");
@@ -2408,13 +2607,7 @@ export default class Parsons extends RunestoneBase {
}
// Give the user the indentation
removeIndentation() {
- // Alert the user to what is happening
- var feedbackArea = this.messageDiv;
- this.messageDiv.style.visibility = "visible";
- feedbackArea.setAttribute("class", "alert alert-info");
- setTimeout(() => {
- feedbackArea.innerHTML = t("msg_parson_provided_indent");
- }, 10);
+ this.showMessage(t("msg_parson_provided_indent"));
// Move and resize blocks
var blockWidth = 200;
for (var i = 0; i < this.lines.length; i++) {
@@ -2594,8 +2787,6 @@ export default class Parsons extends RunestoneBase {
if (indexSol > 0) {
prevBlock = solutionBlocks[indexSol - 1];
indexPrev = answerBlocks.indexOf(prevBlock);
- //alert("my index " + i + " index prev " + indexPrev);
-
// calculate the distance in the answer
dist = Math.abs(i - indexPrev);
if (dist > maxDist) {
@@ -2613,13 +2804,7 @@ export default class Parsons extends RunestoneBase {
var answerBlocks = this.answerBlocks();
var sourceBlocks = this.sourceBlocks();
- // Alert the user to what is happening
- var feedbackArea = this.messageDiv;
- this.messageDiv.style.visibility = "visible";
- feedbackArea.setAttribute("class", "alert alert-info");
- setTimeout(() => {
- feedbackArea.innerHTML = t("msg_parson_combined_blocks");
- }, 10);
+ this.showMessage(t("msg_parson_combined_blocks"));
var block1 = null;
var block2 = null;
@@ -2686,10 +2871,14 @@ export default class Parsons extends RunestoneBase {
{
duration: 1000, // 1 seccond
start: function () {
- block1.view.style.borderColor = "var(--boxBorderColor, #000)";
- block1.view.style.backgroundColor = "var(--background, #fff)";
- block2.view.style.borderColor = "var(--boxBorderColor, #000)";
- block2.view.style.backgroundColor = "var(--background, #fff)";
+ block1.view.style.borderColor =
+ "var(--boxBorderColor, #000)";
+ block1.view.style.backgroundColor =
+ "var(--background, #fff)";
+ block2.view.style.borderColor =
+ "var(--boxBorderColor, #000)";
+ block2.view.style.backgroundColor =
+ "var(--background, #fff)";
block2.lines[0].index += 1000;
that.moving = block2;
that.movingX = startX;
@@ -2734,10 +2923,14 @@ export default class Parsons extends RunestoneBase {
{
duration: 1000,
start: function () {
- block1.view.style.borderColor = "var(--boxBorderColor, #000)";
- block1.view.style.backgroundColor = "var(--background, #fff)";
- block2.view.style.borderColor = "var(--boxBorderColor, #000)";
- block2.view.style.backgroundColor = "var(--background, #fff)";
+ block1.view.style.borderColor =
+ "var(--boxBorderColor, #000)";
+ block1.view.style.backgroundColor =
+ "var(--background, #fff)";
+ block2.view.style.borderColor =
+ "var(--boxBorderColor, #000)";
+ block2.view.style.backgroundColor =
+ "var(--background, #fff)";
},
complete: function () {
block1.consumeBlock(block2);
@@ -2769,22 +2962,15 @@ export default class Parsons extends RunestoneBase {
distractorToRemove !== undefined &&
!distractorToRemove.inSourceArea()
) {
- alert(t("msg_parson_remove_incorrect"));
this.removeDistractor(distractorToRemove);
this.logMove("removedDistractor-" + distractorToRemove.hash());
} else {
var numberOfBlocks = this.numberOfBlocks(false);
if (numberOfBlocks > 3) {
- alert(t("msg_parson_will_combine"));
this.combineBlocks();
this.logMove("combinedBlocks");
} else {
- /*else if(this.numberOfBlocks(true) > 3 && distractorToRemove !== undefined) {
- alert("Will remove an incorrect code block from source area");
- this.removeDistractor(distractorToRemove);
- this.logMove("removedDistractor-" + distractorToRemove.hash());
- } */
- alert(t("msg_parson_three_blocks_left"));
+ this.showMessage(t("msg_parson_three_blocks_left"));
this.canHelp = false;
}
//if (numberOfBlocks < 5) {
@@ -2803,7 +2989,7 @@ export default class Parsons extends RunestoneBase {
//}
// if less than 3 attempts
if (this.numDistinct < 3) {
- alert(t("msg_parson_atleast_three_attempts"));
+ this.showMessage(t("msg_parson_atleast_three_attempts"));
}
// otherwise give help
else {
@@ -2845,16 +3031,174 @@ export default class Parsons extends RunestoneBase {
===================================================================== */
// When the user has entered the Parsons problem via keyboard mode
enterKeyboardMode() {
+ this.keyboardTip.innerHTML = t("msg_parson_arrow_navigate");
this.keyboardTip.style.display = "";
this.sourceLabel.style.display = "none";
this.answerLabel.style.display = "none";
- this.clearFeedback();
}
+ startKeyboardInteraction() {
+ if (this.keyboardInputActive) {
+ return;
+ }
+ const block =
+ this.enabledAnswerBlocks().find((item) =>
+ this.isIncorrectBlock(item),
+ ) ||
+ this.enabledSourceBlocks()[0] ||
+ this.enabledAnswerBlocks()[0];
+ if (!block) {
+ return;
+ }
+ this.enterKeyboardMode();
+ this.textFocus = block;
+ this.textMove = false;
+ block.makeTabIndex();
+ block.view.classList.add("down");
+ this.enterKeyboardNavigationMode(block, true);
+ }
+
+ enterKeyboardNavigationMode(block, includeInstructions = false) {
+ this.keyboardInputActive = true;
+ const label = [
+ t("msg_parson_keyboard_selected", this.getBlockLabel(block)),
+ ];
+ if (this.isIncorrectBlock(block)) {
+ label.push(t("msg_parson_incorrect"));
+ }
+ label.push(this.getKeyboardBlockLocation(block));
+ this.keyboardApplication.setAttribute(
+ "aria-label",
+ includeInstructions
+ ? t("msg_parson_keyboard_instructions") + " " + label.join(". ")
+ : label.join(". "),
+ );
+ this.keyboardApplication.focus();
+ }
+
+ enterKeyboardMovementMode(block) {
+ this.keyboardMovementMode = true;
+ this.keyboardInputActive = true;
+ this.keyboardApplication.setAttribute(
+ "aria-label",
+ this.getKeyboardBlockLocation(block),
+ );
+ this.keyboardApplication.focus();
+ }
+
+ exitKeyboardMovementMode() {
+ this.keyboardMovementMode = false;
+ if (this.textFocus) {
+ this.enterKeyboardNavigationMode(this.textFocus);
+ }
+ }
+
+ cancelKeyboardMovement(returnFocusToEntry = false) {
+ const block = this.textFocus;
+ this.keyboardMovementMode = false;
+ this.keyboardInputActive = false;
+ this.textMove = false;
+ this.textMoving = false;
+ if (block) {
+ block.view.classList.remove("down", "up");
+ }
+ this.textFocus = undefined;
+ this.exitKeyboardMode();
+ if (returnFocusToEntry) {
+ this.sortContainerDiv.focus();
+ }
+ }
+
+ selectKeyboardBlock(block) {
+ const previousBlock = this.textFocus;
+ if (previousBlock && previousBlock !== block) {
+ previousBlock.view.classList.remove("down", "up");
+ }
+ this.textFocus = block;
+ this.textMove = false;
+ block.makeTabIndex();
+ block.view.classList.add("down");
+ this.enterKeyboardNavigationMode(block);
+ }
+
+ exitKeyboardModeForTab() {
+ this.cancelKeyboardMovement();
+ }
+
+ handleKeyboardMovementKeydown(event) {
+ if (!this.keyboardInputActive || !this.textFocus) {
+ return;
+ }
+ const block = this.textFocus;
+ const wasMoving = this.textMove;
+ const previousParent = block.view.parentElement;
+ const previousIndex = Array.prototype.indexOf.call(
+ previousParent.children,
+ block.view,
+ );
+ const previousIndent = block.indent;
+ switch (event.key) {
+ case "Tab":
+ this.exitKeyboardModeForTab();
+ return;
+ case "ArrowLeft":
+ this.textMove ? block.moveLeft() : block.selectLeft();
+ break;
+ case "ArrowUp":
+ this.textMove ? block.moveUp() : block.selectUp();
+ break;
+ case "ArrowRight":
+ this.textMove ? block.moveRight() : block.selectRight();
+ break;
+ case "ArrowDown":
+ this.textMove ? block.moveDown() : block.selectDown();
+ break;
+ case "Enter":
+ case " ":
+ block.toggleMove();
+ if (!this.textMove) {
+ this.exitKeyboardMovementMode();
+ }
+ event.preventDefault();
+ event.stopPropagation?.();
+ return;
+ case "Escape":
+ case "Esc":
+ this.cancelKeyboardMovement(true);
+ event.preventDefault();
+ event.stopPropagation?.();
+ return;
+ default:
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation?.();
+ this.textMoving = false;
+ const placementChanged =
+ wasMoving &&
+ (block.view.parentElement !== previousParent ||
+ block.indent !== previousIndent ||
+ Array.prototype.indexOf.call(
+ block.view.parentElement.children,
+ block.view,
+ ) !== previousIndex);
+ if (placementChanged) {
+ this.clearFeedback();
+ }
+ if (this.textMove) {
+ this.enterKeyboardMovementMode(block);
+ } else {
+ this.keyboardApplication.focus();
+ }
+ }
+
// When the user leaves the Parsons problem via keyboard mode
exitKeyboardMode() {
- this.keyboardTip.style.display = "none";
- this.sourceLabel.style.display = "";
- this.answerLabel.style.display = "";
+ this.keyboardApplication.removeAttribute("aria-label");
+ this.sortContainerDiv.setAttribute(
+ "aria-label",
+ t("msg_parson_keyboard_entry_label"),
+ );
+ this.hideKeyboardEntryHint();
}
/* =====================================================================
==== VIEW ==============================================================
@@ -2872,7 +3216,9 @@ export default class Parsons extends RunestoneBase {
);
}
this.messageDiv.style.visibility = "hidden";
+ this.pendingHelpMessage = undefined;
this.hideBlockExplanations();
+ this.updateBlockAriaLabels();
}
// Disable the interface
async disableInteraction() {
diff --git a/bases/rsptx/interactives/runestone/parsons/js/parsonsBlock.js b/bases/rsptx/interactives/runestone/parsons/js/parsonsBlock.js
index d0f554189..6165c5d03 100644
--- a/bases/rsptx/interactives/runestone/parsons/js/parsonsBlock.js
+++ b/bases/rsptx/interactives/runestone/parsons/js/parsonsBlock.js
@@ -33,6 +33,7 @@ export default class ParsonsBlock {
view.id = problem.counterId + "-block-" + problem.blockIndex;
problem.blockIndex += 1;
view.classList.add("block");
+ view.setAttribute("role", "button");
var sharedIndent = lines[0].indent;
for (let i = 1; i < lines.length; i++) {
sharedIndent = Math.min(sharedIndent, lines[i].indent);
@@ -174,10 +175,26 @@ export default class ParsonsBlock {
}
// Initialize Interactivity
initializeInteractivity() {
+ // Always set up a click handler, even if not currently enabled, so that it can be re-enabled later
+ this.clickHandler = (event) => {
+ if (!this.enabled()) {
+ return;
+ }
+ if (
+ !this.problem.keyboardInputActive ||
+ this.problem.textFocus !== this
+ ) {
+ return;
+ }
+ event.preventDefault();
+ this.toggleMove();
+ };
+ // If the block is disabled, don't add the event listeners yet.
if (this.view.classList.contains("disabled")) {
return this;
}
this.view.setAttribute("tabindex", "-1");
+ this.view.addEventListener("click", this.clickHandler);
this.hammer = new Hammer.Manager(this.view, {
recognizers: [
[
@@ -203,7 +220,10 @@ export default class ParsonsBlock {
}
// Return a boolean as to whether this block is able to be selected
enabled() {
- return this.view.getAttribute("tabindex") !== null;
+ return (
+ !this.view.classList.contains("disabled") &&
+ this.view.getAttribute("tabindex") !== null
+ );
}
// Return a boolean as to whether this block is a distractor
isDistractor() {
@@ -341,7 +361,10 @@ export default class ParsonsBlock {
return verticalOffset;
}
// This block just gained textual focus
- newFocus() {
+ newFocus(event) {
+ if (!this.problem.keyboardInputActive) {
+ return;
+ }
if (this.problem.textFocus == undefined) {
this.problem.enterKeyboardMode();
this.problem.textFocus = this;
@@ -360,12 +383,24 @@ export default class ParsonsBlock {
this.view.classList.add("down");
}
this.problem.textMoving = false;
+ if (!this.problem.keyboardInputActive) {
+ this.problem.enterKeyboardNavigationMode(this);
+ }
}
// This block just lost textual focus
- releaseFocus() {
- this.view.classList.remove("down", "up");
+ releaseFocus(event) {
+ if (this.problem.keyboardInputActive) {
+ this.problem.cancelKeyboardMovement();
+ return;
+ }
+ if (!this.problem.keyboardMovementMode) {
+ this.view.classList.remove("down", "up");
+ }
if (this.problem.textFocus == this) {
- if (!this.problem.textMoving) {
+ if (
+ !this.problem.textMoving &&
+ !this.problem.keyboardMovementMode
+ ) {
// exit out of problem but stay way into problem
this.problem.textFocus = undefined;
if (this.problem.textMove) {
@@ -386,10 +421,10 @@ export default class ParsonsBlock {
}
// Make this block into the keyboard entry point
makeTabIndex() {
- this.view.setAttribute("tabindex", "0");
+ this.view.setAttribute("tabindex", "-1");
if (this.focusHandler === undefined) {
- this.focusHandler = () => this.newFocus();
- this.blurHandler = () => this.releaseFocus();
+ this.focusHandler = (event) => this.newFocus(event);
+ this.blurHandler = (event) => this.releaseFocus(event);
this.keydownHandler = (event) => this.keyDown(event);
}
// re-adding an identical listener is a no-op, so repeated calls are safe
@@ -402,6 +437,7 @@ export default class ParsonsBlock {
if (this.hammer !== undefined) {
this.hammer.set({ enable: false });
}
+ this.view.removeEventListener("click", this.clickHandler);
if (this.view.getAttribute("tabindex") == "0") {
this.releaseFocus();
this.view.removeAttribute("tabindex");
@@ -418,6 +454,7 @@ export default class ParsonsBlock {
if (!this.view.hasAttribute("tabindex")) {
this.view.setAttribute("tabindex", "-1");
}
+ this.view.addEventListener("click", this.clickHandler);
this.view.style.opacity = "";
}
// Called to destroy interaction for the future
@@ -426,6 +463,7 @@ export default class ParsonsBlock {
this.hammer.destroy();
delete this.hammer;
}
+ this.view.removeEventListener("click", this.clickHandler);
if (this.view.getAttribute("tabindex") == "0") {
this.releaseFocus();
}
@@ -529,7 +567,9 @@ export default class ParsonsBlock {
if (index == blocks.length) {
this.problem.textMoving = true;
this.problem.sourceArea.appendChild(this.view);
- this.view.focus();
+ if (!this.problem.keyboardMovementMode) {
+ this.view.focus();
+ }
this.problem.state = undefined;
this.problem.updateView();
return this;
@@ -542,7 +582,9 @@ export default class ParsonsBlock {
}
this.problem.textMoving = true;
this.problem.sourceArea.insertBefore(this.view, block.view);
- this.view.focus();
+ if (!this.problem.keyboardMovementMode) {
+ this.view.focus();
+ }
} else {
// reduce indent
this.indent = this.indent - 1;
@@ -568,7 +610,9 @@ export default class ParsonsBlock {
this.view,
block.view,
);
- this.view.focus();
+ if (!this.problem.keyboardMovementMode) {
+ this.view.focus();
+ }
this.problem.state = undefined;
this.problem.updateView();
return this;
@@ -587,7 +631,9 @@ export default class ParsonsBlock {
this.view,
blocks[i - 1].view,
);
- this.view.focus();
+ if (!this.problem.keyboardMovementMode) {
+ this.view.focus();
+ }
this.problem.state = undefined;
this.problem.updateView();
}
@@ -607,7 +653,9 @@ export default class ParsonsBlock {
if (itemOffset >= offset) {
this.problem.textMoving = true;
this.problem.answerArea.insertBefore(this.view, item.view);
- this.view.focus();
+ if (!this.problem.keyboardMovementMode) {
+ this.view.focus();
+ }
this.problem.state = undefined;
this.problem.updateView();
return this;
@@ -615,7 +663,9 @@ export default class ParsonsBlock {
}
this.problem.textMoving = true;
this.problem.answerArea.appendChild(this.view);
- this.view.focus();
+ if (!this.problem.keyboardMovementMode) {
+ this.view.focus();
+ }
this.problem.state = undefined;
this.problem.updateView();
} else {
@@ -642,7 +692,9 @@ export default class ParsonsBlock {
if (index == blocks.length) {
this.problem.textMoving = true;
this.problem.sourceArea.appendChild(this.view);
- this.view.focus();
+ if (!this.problem.keyboardMovementMode) {
+ this.view.focus();
+ }
this.problem.state = undefined;
this.problem.updateView();
return this;
@@ -652,7 +704,9 @@ export default class ParsonsBlock {
this.view,
blocks[index].view,
);
- this.view.focus();
+ if (!this.problem.keyboardMovementMode) {
+ this.view.focus();
+ }
this.problem.state = undefined;
this.problem.updateView();
return this;
@@ -674,7 +728,9 @@ export default class ParsonsBlock {
blocks[i + 2].view,
);
}
- this.view.focus();
+ if (!this.problem.keyboardMovementMode) {
+ this.view.focus();
+ }
this.problem.state = undefined;
this.problem.updateView();
}
@@ -699,9 +755,7 @@ export default class ParsonsBlock {
chooseOffset = itemOffset;
}
}
- this.problem.textFocus = chooseNext;
- chooseNext.makeTabIndex();
- chooseNext.view.focus();
+ this.problem.selectKeyboardBlock(chooseNext);
}
}
// Move selection up
@@ -716,9 +770,7 @@ export default class ParsonsBlock {
for (var i = blocks.length - 1; i >= 0; i--) {
var item = blocks[i];
if (chooseNext) {
- this.problem.textFocus = item;
- item.makeTabIndex();
- item.view.focus();
+ this.problem.selectKeyboardBlock(item);
return this;
} else {
if (item.view.id == this.view.id) {
@@ -745,9 +797,7 @@ export default class ParsonsBlock {
chooseOffset = itemOffset;
}
}
- this.problem.textFocus = chooseNext;
- chooseNext.makeTabIndex();
- chooseNext.view.focus();
+ this.problem.selectKeyboardBlock(chooseNext);
}
}
// Move selection down
@@ -762,9 +812,7 @@ export default class ParsonsBlock {
for (var i = 0; i < blocks.length; i++) {
var item = blocks[i];
if (chooseNext) {
- this.problem.textFocus = item;
- item.makeTabIndex();
- item.view.focus();
+ this.problem.selectKeyboardBlock(item);
return this;
} else {
if (item.view.id == this.view.id) {
@@ -787,6 +835,7 @@ export default class ParsonsBlock {
this.view.classList.remove("down");
this.view.classList.add("up");
this.problem.textMove = true;
+ this.problem.enterKeyboardMovementMode(this);
}
}
// Answer a string that represents this codeblock for saving
diff --git a/bases/rsptx/interactives/runestone/parsons/test/parsons.test.js b/bases/rsptx/interactives/runestone/parsons/test/parsons.test.js
index b71e60d96..015d48242 100644
--- a/bases/rsptx/interactives/runestone/parsons/test/parsons.test.js
+++ b/bases/rsptx/interactives/runestone/parsons/test/parsons.test.js
@@ -5,6 +5,7 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import Parsons from "../js/parsons.js";
import "../js/timedparsons.js";
+import { setLocale } from "../../common/js/rsi18n.js";
// Build the same DOM a book page provides (see parsonsPreview.tsx and the
// Sphinx/PreTeXt templates): div.runestone > [data-component=parsons] with a
@@ -59,6 +60,7 @@ function answer(parsons, sourceIndexes) {
}
beforeEach(() => {
+ setLocale("en");
document.body.innerHTML = "";
window.componentMap = {};
window.allComponents = [];
@@ -108,6 +110,35 @@ describe("construction", () => {
expect(p.keyboardTip.style.display).toBe("none");
});
+ it("shows the keyboard entry hint only for focus-visible focus", async () => {
+ const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS });
+ vi.spyOn(p.sortContainerDiv, "matches").mockImplementation(
+ (selector) => selector === ":focus-visible",
+ );
+
+ p.sortContainerDiv.focus();
+ expect(p.sourceLabel.style.display).toBe("none");
+ expect(p.answerLabel.style.display).toBe("none");
+ expect(p.keyboardTip.textContent).toBe("Enter to activate");
+ expect(p.keyboardTip.style.display).not.toBe("none");
+
+ p.sortContainerDiv.blur();
+ expect(p.sourceLabel.style.display).not.toBe("none");
+ expect(p.answerLabel.style.display).not.toBe("none");
+ expect(p.keyboardTip.textContent).toContain("Arrow keys to navigate");
+ expect(p.keyboardTip.style.display).toBe("none");
+ });
+
+ it("does not show the keyboard entry hint for mouse focus", async () => {
+ const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS });
+ vi.spyOn(p.sortContainerDiv, "matches").mockReturnValue(false);
+
+ p.sortContainerDiv.focus();
+ expect(p.sourceLabel.style.display).not.toBe("none");
+ expect(p.answerLabel.style.display).not.toBe("none");
+ expect(p.keyboardTip.style.display).toBe("none");
+ });
+
it("moves the question above the problem; drops a blank question", async () => {
const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS });
expect(p.outerDiv.firstElementChild).toBe(p.question);
@@ -141,15 +172,33 @@ describe("construction", () => {
expect(cap.textContent).toContain("Parsons");
});
- it("makes exactly one block the keyboard entry point", async () => {
+ it("uses MathJax speech for block labels and removes nested tab stops", async () => {
+ const p = await makeParsons({
+ blocks: '\(x^2\)',
+ attrs: 'data-language="natural"',
+ });
+ const block = p.blocks[0];
+ const lines = block.view.querySelector(".lines");
+ const math = document.createElement("mjx-container");
+
+ lines.replaceChildren(math);
+ math.tabIndex = 0;
+ math.setAttribute("data-semantic-speech-none", "x squared");
+ await tick();
+
+ expect(block.view.getAttribute("role")).toBe("button");
+ expect(block.view.getAttribute("aria-label")).toBe("x squared");
+ expect(math.getAttribute("tabindex")).toBe("-1");
+ });
+
+ it("makes the sortable container the keyboard entry point", async () => {
const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS });
- const tabZero = p.blocks.filter(
- (b) => b.view.getAttribute("tabindex") === "0",
- );
- expect(tabZero).toHaveLength(1);
- for (const block of p.blocks) {
- expect(block.enabled()).toBe(true);
- }
+
+ expect(p.sortContainerDiv.getAttribute("tabindex")).toBe("0");
+ expect(p.sortContainerDiv.getAttribute("role")).toBe("button");
+ expect(
+ p.blocks.map((block) => block.view.getAttribute("tabindex")),
+ ).toEqual(["-1", "-1", "-1"]);
});
});
@@ -557,47 +606,364 @@ describe("keyboard movement model", () => {
expect(p.answerBlocks()).toEqual([a, b, c]);
});
- it("focusing a block enters keyboard mode; blur leaves it", async () => {
+ it("Enter on the container begins keyboard mode; blur leaves it", async () => {
const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS });
- const block = p.blocks.find(
- (b) => b.view.getAttribute("tabindex") === "0",
+ const block = p.sourceBlocks()[0];
+
+ p.sortContainerDiv.focus();
+ p.sortContainerDiv.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ bubbles: true,
+ cancelable: true,
+ key: "Enter",
+ }),
);
- block.view.focus();
+
expect(p.textFocus).toBe(block);
+ expect(p.keyboardApplication.getAttribute("aria-label")).toBe(
+ `Use arrow keys to navigate blocks, then Enter or Space to move it. Selected ${p.getBlockLabel(block)}. In the unplaced list, position 1 of 3.`,
+ );
expect(block.view.classList.contains("down")).toBe(true);
+ expect(p.keyboardTip.textContent).toContain("Arrow keys to navigate");
expect(p.keyboardTip.style.display).not.toBe("none");
expect(p.sourceLabel.style.display).toBe("none");
- block.view.blur();
+ p.keyboardApplication.blur();
expect(p.textFocus).toBeUndefined();
expect(p.keyboardTip.style.display).toBe("none");
expect(p.sourceLabel.style.display).not.toBe("none");
});
- it("space toggles a focused block between select and move", async () => {
+ it("space toggles a selected block between select and move", async () => {
const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS });
- const block = p.blocks.find(
- (b) => b.view.getAttribute("tabindex") === "0",
- );
- block.view.focus();
- block.keyDown({ keyCode: 32, preventDefault() {} });
+ p.startKeyboardInteraction();
+ const block = p.textFocus;
+
+ p.handleKeyboardMovementKeydown({
+ key: " ",
+ preventDefault() {},
+ });
expect(p.textMove).toBe(true);
expect(block.view.classList.contains("up")).toBe(true);
- block.keyDown({ keyCode: 32, preventDefault() {} });
+ p.handleKeyboardMovementKeydown({
+ key: " ",
+ preventDefault() {},
+ });
expect(p.textMove).toBe(false);
expect(block.view.classList.contains("down")).toBe(true);
});
+ it("returns focus to the entry button when Escape leaves keyboard navigation", async () => {
+ const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS });
+
+ p.startKeyboardInteraction();
+ p.keyboardApplication.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ bubbles: true,
+ cancelable: true,
+ key: "Escape",
+ }),
+ );
+
+ expect(p.textFocus).toBeUndefined();
+ expect(document.activeElement).toBe(p.sortContainerDiv);
+ });
+
+ it("lets Tab leave native keyboard navigation", async () => {
+ const p = await makeParsons({
+ blocks: FLAT_BLOCKS,
+ attrs: FLAT_ATTRS,
+ });
+ p.startKeyboardInteraction();
+ const tabEvent = new KeyboardEvent("keydown", {
+ bubbles: true,
+ cancelable: true,
+ key: "Tab",
+ });
+ p.keyboardApplication.dispatchEvent(tabEvent);
+
+ expect(tabEvent.defaultPrevented).toBe(false);
+ expect(p.textFocus).toBeUndefined();
+ expect(p.sortContainerDiv.getAttribute("tabindex")).toBe("0");
+ expect(
+ p.blocks.map((block) => block.view.getAttribute("tabindex")),
+ ).toEqual(["-1", "-1", "-1"]);
+ });
+
+ it("uses the application container to navigate yellow selections", async () => {
+ const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS });
+ const [first, second] = p.sourceBlocks();
+
+ p.startKeyboardInteraction();
+ p.keyboardApplication.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ bubbles: true,
+ cancelable: true,
+ key: "ArrowDown",
+ }),
+ );
+
+ expect(p.textFocus).toBe(second);
+ expect(p.keyboardApplication.getAttribute("aria-label")).toBe(
+ `Selected ${p.getBlockLabel(second)}. In the unplaced list, position 2 of 3.`,
+ );
+ expect(document.activeElement).toBe(p.keyboardApplication);
+ expect(first.view.classList.contains("down")).toBe(false);
+ expect(second.view.classList.contains("down")).toBe(true);
+ });
+
+ it("retains and announces incorrect feedback until a keyboard move", async () => {
+ const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS });
+ answer(p, [1, 0, 2]);
+ p.checkCurrentAnswer();
+ p.renderFeedback();
+
+ const incorrect = p
+ .answerBlocks()
+ .find((block) => p.isIncorrectBlock(block));
+ expect(incorrect).toBeDefined();
+ expect(incorrect.view.getAttribute("aria-label")).toBe(
+ `${p.getBlockLabel(incorrect)}. Incorrect`,
+ );
+
+ p.startKeyboardInteraction();
+
+ expect(p.textFocus).toBe(incorrect);
+ expect(incorrect.view.classList.contains("incorrectPosition")).toBe(
+ true,
+ );
+ expect(p.keyboardApplication.getAttribute("aria-label")).toBe(
+ `Use arrow keys to navigate blocks, then Enter or Space to move it. Selected ${p.getBlockLabel(incorrect)}. Incorrect. ${p.getKeyboardBlockLocation(incorrect)}`,
+ );
+
+ const movable = p.answerBlocks()[0];
+ p.selectKeyboardBlock(movable);
+ p.handleKeyboardMovementKeydown({
+ key: " ",
+ preventDefault() {},
+ });
+ p.handleKeyboardMovementKeydown({
+ key: "ArrowDown",
+ preventDefault() {},
+ });
+
+ expect(p.answerBlocks()[1]).toBe(movable);
+ expect(incorrect.view.classList.contains("incorrectPosition")).toBe(
+ false,
+ );
+ expect(incorrect.view.getAttribute("aria-label")).toBe(
+ p.getBlockLabel(incorrect),
+ );
+ });
+
+ it("localizes keyboard announcements", async () => {
+ setLocale("pt-br");
+ const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS });
+
+ p.startKeyboardInteraction();
+
+ expect(p.sortContainerDiv.getAttribute("aria-label")).toBe(
+ "Problema Parsons. Pressione Enter ou Espaço para organizar os blocos.",
+ );
+ expect(p.keyboardApplication.getAttribute("aria-label")).toBe(
+ "Use as teclas de seta para navegar pelos blocos e pressione Enter ou Espaço para movê-los. Selecionado line_a = 1. Na lista não posicionada, posição 1 de 3.",
+ );
+ });
+
+ it("uses the application proxy only during keyboard interaction", async () => {
+ const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS });
+
+ p.startKeyboardInteraction();
+
+ expect(p.sortContainerDiv.getAttribute("role")).toBe("button");
+ expect(p.keyboardApplication.getAttribute("role")).toBe("application");
+ expect(document.activeElement).toBe(p.keyboardApplication);
+
+ p.cancelKeyboardMovement();
+
+ expect(p.sortContainerDiv.getAttribute("role")).toBe("button");
+ });
+
+ it("announces a block's current position, indentation, and alternate group", async () => {
+ const p = await makeParsons({
+ blocks: `root
+---
+ choice_a
+---
+ choice_b #paired
+---
+ finish`,
+ attrs: 'data-language="python" data-order="0,1,2,3"',
+ });
+ const first = p.sourceBlocks()[0];
+ const alternate = p.sourceBlocks()[1];
+
+ expect(p.getKeyboardBlockLocation(first)).toBe(
+ "In the unplaced list, position 1 of 4.",
+ );
+ expect(p.getKeyboardBlockLocation(alternate)).toBe(
+ "In the unplaced list, position 2 of 4. 1 of 2 options.",
+ );
+
+ alternate.moveRight();
+ alternate.indent = 1;
+
+ expect(p.getKeyboardBlockLocation(alternate)).toBe(
+ "In the answer area, position 1 of 1. 1 tab in. 1 of 2 options.",
+ );
+ });
+
+ it("uses a click to pick up a block", async () => {
+ const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS });
+ const block = p.sourceBlocks()[0];
+ p.startKeyboardInteraction();
+ block.view.dispatchEvent(
+ new MouseEvent("click", { bubbles: true, detail: 1 }),
+ );
+
+ expect(p.textFocus).toBe(block);
+ expect(p.textMove).toBe(true);
+ expect(document.activeElement).toBe(p.keyboardApplication);
+ });
+
+ it("ignores a click before a block enters keyboard mode", async () => {
+ const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS });
+ const block = p.sourceBlocks()[0];
+
+ block.view.dispatchEvent(
+ new MouseEvent("click", { bubbles: true, detail: 0 }),
+ );
+
+ expect(p.textFocus).toBeUndefined();
+ expect(p.textMove).toBeUndefined();
+ expect(block.view.classList.contains("up")).toBe(false);
+ });
+
+ it("moves a picked-up block from the component's keydown capture", async () => {
+ const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS });
+ const block = p.sourceBlocks()[0];
+
+ p.startKeyboardInteraction();
+ p.keyboardApplication.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ bubbles: true,
+ cancelable: true,
+ key: " ",
+ }),
+ );
+ const moveEvent = new KeyboardEvent("keydown", {
+ bubbles: true,
+ cancelable: true,
+ key: "ArrowRight",
+ });
+ block.view.dispatchEvent(moveEvent);
+
+ expect(moveEvent.defaultPrevented).toBe(true);
+ expect(block.inSourceArea()).toBe(false);
+ expect(p.keyboardApplication.getAttribute("aria-label")).toBe(
+ "In the answer area, position 1 of 1.",
+ );
+ expect(document.activeElement).toBe(p.keyboardApplication);
+
+ const dropEvent = new KeyboardEvent("keydown", {
+ bubbles: true,
+ cancelable: true,
+ key: "Enter",
+ });
+ p.keyboardApplication.dispatchEvent(dropEvent);
+
+ expect(dropEvent.defaultPrevented).toBe(true);
+ expect(p.textMove).toBe(false);
+ expect(document.activeElement).toBe(p.keyboardApplication);
+ });
+
+ it("cleans up the first block before moving a second block", async () => {
+ const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS });
+ const [first, second] = p.sourceBlocks();
+
+ p.startKeyboardInteraction();
+ p.keyboardApplication.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ bubbles: true,
+ cancelable: true,
+ key: " ",
+ }),
+ );
+ p.keyboardApplication.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ bubbles: true,
+ cancelable: true,
+ key: "Enter",
+ }),
+ );
+
+ p.keyboardApplication.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ bubbles: true,
+ cancelable: true,
+ key: "ArrowDown",
+ }),
+ );
+ expect(p.textFocus).toBe(second);
+ p.keyboardApplication.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ bubbles: true,
+ cancelable: true,
+ key: " ",
+ }),
+ );
+ second.view.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ bubbles: true,
+ cancelable: true,
+ key: "ArrowRight",
+ }),
+ );
+
+ expect([...p.outerDiv.querySelectorAll(".block.up")]).toEqual([
+ second.view,
+ ]);
+ expect(p.outerDiv.querySelectorAll(".block.down")).toHaveLength(0);
+
+ p.keyboardApplication.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ bubbles: true,
+ cancelable: true,
+ key: "Enter",
+ }),
+ );
+
+ expect(p.outerDiv.querySelectorAll(".block.up")).toHaveLength(0);
+ expect([...p.outerDiv.querySelectorAll(".block.down")]).toEqual([
+ second.view,
+ ]);
+ });
+
+ it("keeps initially disabled blocks out of keyboard rotation", async () => {
+ const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS });
+ const disabled = p.sourceBlocks()[0];
+ const enabled = p.sourceBlocks()[1];
+
+ disabled.view.classList.add("disabled");
+ p.initializeTabIndex();
+
+ expect(disabled.enabled()).toBe(false);
+ expect(disabled.view.hasAttribute("tabindex")).toBe(false);
+ expect(p.enabledSourceBlocks()).not.toContain(disabled);
+
+ p.startKeyboardInteraction();
+ expect(p.textFocus).toBe(enabled);
+ });
+
it("disable() removes a block from keyboard rotation", async () => {
const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS });
const block = p.sourceBlocks()[0];
block.disable();
expect(block.enabled()).toBe(false);
- // Another block takes over as the keyboard entry point.
- const tabZero = p.blocks.filter(
- (b) => b.view.getAttribute("tabindex") === "0",
- );
- expect(tabZero).toHaveLength(1);
- expect(tabZero[0]).not.toBe(block);
+ expect(p.sortContainerDiv.getAttribute("tabindex")).toBe("0");
+ expect(block.view.hasAttribute("tabindex")).toBe(false);
+ expect(
+ p.blocks.slice(1).map((item) => item.view.getAttribute("tabindex")),
+ ).toEqual(["-1", "-1"]);
});
});
@@ -613,18 +979,57 @@ describe("adaptive problems", () => {
expect(localStorage.getItem(p.adaptiveId + "Solved")).toBe("false");
});
- it("helpMe demands three distinct attempts first", async () => {
+ it("reports help eligibility through the live message instead of an alert", async () => {
const p = await makeParsons({
blocks: FLAT_BLOCKS,
attrs: FLAT_ATTRS + ' data-adaptive="true"',
});
p.helpMe();
- expect(alert).toHaveBeenCalledWith(
- expect.stringContaining("three distinct full attempts"),
+
+ await tick(30);
+ expect(p.messageDiv.getAttribute("role")).toBe("status");
+ expect(p.messageDiv.style.visibility).toBe("visible");
+ expect(p.messageDiv.textContent).toContain(
+ "three distinct full attempts",
);
expect(p.gotHelp).toBe(false);
});
+ it("refreshes repeated Help Me announcements", async () => {
+ const p = await makeParsons({
+ blocks: FLAT_BLOCKS,
+ attrs: FLAT_ATTRS + ' data-adaptive="true"',
+ });
+ p.helpMe();
+ await tick(30);
+
+ p.helpMe();
+ expect(p.messageDiv.textContent).toBe("");
+
+ await tick(30);
+ expect(p.messageDiv.textContent).toContain(
+ "three distinct full attempts",
+ );
+ });
+
+ it("includes Help Me availability in the next feedback message", async () => {
+ const p = await makeParsons({
+ blocks: FLAT_BLOCKS,
+ attrs: FLAT_ATTRS + ' data-adaptive="true"',
+ });
+ p.numDistinct = 2;
+ p.lastAnswerHash = "different";
+ answer(p, [1, 0, 2]);
+
+ p.checkCurrentAnswer();
+ p.renderFeedback();
+
+ await tick(30);
+ expect(p.messageDiv.textContent).toContain(
+ "Click on the Help Me button",
+ );
+ });
+
it("removeDistractor disables the block and reveals its help text", async () => {
const p = await makeParsons({
blocks: INDENTED_BLOCKS,