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,