diff --git a/bases/rsptx/interactives/runestone/common/js/runestonebase.js b/bases/rsptx/interactives/runestone/common/js/runestonebase.js index 01234e2b3..2fbf2f388 100644 --- a/bases/rsptx/interactives/runestone/common/js/runestonebase.js +++ b/bases/rsptx/interactives/runestone/common/js/runestonebase.js @@ -620,16 +620,18 @@ export default class RunestoneBase { if (MathJax.typesetPromise) { if (typeof window.runestoneMathReady !== "undefined") { return window.runestoneMathReady.then(() => - this.mjresolver(this.aQueue.enqueue(component)), + this.aQueue.enqueue(component), ); } else { - return this.mjresolver(this.aQueue.enqueue(component)); + return this.aQueue.enqueue(component); } } else { console.log(`Waiting on MathJax!! ${MathJax.typesetPromise}`); - setTimeout(() => this.queueMathJax(component), 200); - console.log(`Returning mjready promise: ${this.mjReady}`); - return this.mjReady; + return new Promise((resolve, reject) => { + setTimeout(() => { + this.queueMathJax(component).then(resolve, reject); + }, 200); + }); } } } diff --git a/bases/rsptx/interactives/runestone/hparsons/js/BlockFeedback.js b/bases/rsptx/interactives/runestone/hparsons/js/BlockFeedback.js index e66c6d44b..09c1d5003 100644 --- a/bases/rsptx/interactives/runestone/hparsons/js/BlockFeedback.js +++ b/bases/rsptx/interactives/runestone/hparsons/js/BlockFeedback.js @@ -9,7 +9,13 @@ export default class BlockFeedback extends HParsonsFeedback { createOutput() { // Block based grading output this.messageDiv = document.createElement("div"); + this.feedbackLiveRegion = document.createElement("div"); + this.feedbackLiveRegion.setAttribute("role", "status"); + this.feedbackLiveRegion.setAttribute("aria-live", "polite"); + this.feedbackLiveRegion.setAttribute("aria-atomic", "true"); + this.feedbackLiveRegion.classList.add("sr-only"); this.hparsons.outerDiv.appendChild(this.messageDiv); + this.hparsons.outerDiv.appendChild(this.feedbackLiveRegion); } customizeUI() { @@ -158,6 +164,19 @@ export default class BlockFeedback extends HParsonsFeedback { } feedbackArea.innerHTML = t("msg_parson_wrong_order"); } + this.announceFeedback(); + this.hparsons.hparsonsInput.refreshBlockAria(); + } + + announceFeedback(message = this.messageDiv.textContent.trim()) { + if (this.feedbackAnnouncementTimeout) { + clearTimeout(this.feedbackAnnouncementTimeout); + } + this.feedbackLiveRegion.textContent = ""; + this.feedbackAnnouncementTimeout = setTimeout(() => { + this.feedbackLiveRegion.textContent = message; + this.feedbackAnnouncementTimeout = null; + }, 10); } // Feedback UI for Block-based Feedback @@ -171,6 +190,12 @@ export default class BlockFeedback extends HParsonsFeedback { ); } this.messageDiv.style.display = "none"; + if (this.feedbackAnnouncementTimeout) { + clearTimeout(this.feedbackAnnouncementTimeout); + this.feedbackAnnouncementTimeout = null; + } + this.feedbackLiveRegion.textContent = ""; + this.hparsons.hparsonsInput.refreshBlockAria(); } reset() { @@ -180,5 +205,6 @@ export default class BlockFeedback extends HParsonsFeedback { this.solved = false; } this.clearFeedback(); + this.announceFeedback("Blocks reset."); } } diff --git a/bases/rsptx/interactives/runestone/hparsons/js/hparsons.js b/bases/rsptx/interactives/runestone/hparsons/js/hparsons.js index 212c761b7..a69554349 100644 --- a/bases/rsptx/interactives/runestone/hparsons/js/hparsons.js +++ b/bases/rsptx/interactives/runestone/hparsons/js/hparsons.js @@ -1,4 +1,6 @@ import RunestoneBase from "../../common/js/runestonebase.js"; +import { disableMathJaxTabStops } from "../../common/js/mathjax-a11y.js"; + import "../css/hljs-xcode.css"; import BlockFeedback from "./BlockFeedback.js"; import SQLFeedback from "./SQLFeedback.js"; @@ -193,25 +195,63 @@ export default class HParsons extends RunestoneBase { return textarea.value; } - renderMathInBlocks() { - if (this.language !== "math") return; - setTimeout(() => { - const blocks = document.querySelectorAll( - `#${this.divid}-container .parsons-block`, + observeMathJaxTabStops() { + if (this.mathTabStopObserver || typeof MutationObserver === "undefined") { + return; + } + this.mathTabStopObserver = new MutationObserver((mutations) => { + const needsCleanup = mutations.some( + (mutation) => + mutation.type === "childList" || + mutation.target.getAttribute("tabindex") !== "-1", ); - blocks.forEach((block) => { - block.innerHTML = this.decodeHTMLEntities(block.innerHTML); - if (block.innerHTML.indexOf("process-math") !== -1) { - // remove the span tag with process-math class - block.innerHTML = block.innerHTML.replace( - /|<\/span>/g, - "", + if (needsCleanup) { + disableMathJaxTabStops(this.hparsonsInput, [ + ".parsons-block", + ]); + } + }); + this.mathTabStopObserver.observe(this.hparsonsInput, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ["tabindex"], + }); + } + + renderMathInBlocks() { + if (this.language !== "math") return Promise.resolve(); + this.observeMathJaxTabStops(); + return new Promise((resolve, reject) => { + // MathJax may load just after the component; preserve the + // established deferral before submitting these block renders. + setTimeout(() => { + try { + const blocks = this.hparsonsInput.querySelectorAll( + ".parsons-block", + ); + const renderPromises = Array.from(blocks, (block) => { + block.innerHTML = this.decodeHTMLEntities(block.innerHTML); + if (block.innerHTML.indexOf("process-math") !== -1) { + block.innerHTML = block.innerHTML.replace( + /|<\/span>/g, + "", + ); + } + return this.queueMathJax(block); + }); + Promise.all(renderPromises).then( + () => { + disableMathJaxTabStops(this.hparsonsInput, [".parsons-block"]); + resolve(); + }, + reject, ); + } catch (err) { + reject(err); } - - this.queueMathJax(block); - }); - }, 10); + }, 10); + }); } // Return previous answers in local storage diff --git a/bases/rsptx/interactives/runestone/hparsons/js/micro-parsons/ParsonsInput.ts b/bases/rsptx/interactives/runestone/hparsons/js/micro-parsons/ParsonsInput.ts index d2f0dc3dc..cf802169e 100644 --- a/bases/rsptx/interactives/runestone/hparsons/js/micro-parsons/ParsonsInput.ts +++ b/bases/rsptx/interactives/runestone/hparsons/js/micro-parsons/ParsonsInput.ts @@ -27,6 +27,9 @@ export class ParsonsInput implements IParsonsInput { private hljsLanguage: string | undefined; private _liveRegion: HTMLDivElement; + private _activeBlock: HTMLDivElement | null; + private _keyboardInstructions: HTMLSpanElement; + private _nextBlockId: number; // if the input has been initialized once private initialized: boolean; constructor( @@ -72,6 +75,20 @@ export class ParsonsInput implements IParsonsInput { this._liveRegion.classList.add("sr-only"); this.el.appendChild(this._liveRegion); + + this._activeBlock = null; + this._nextBlockId = 0; + this._keyboardInstructions = document.createElement("span"); + this._keyboardInstructions.id = `${this.el.id}-keyboard-instructions`; + this._keyboardInstructions.classList.add("sr-only"); + this._keyboardInstructions.textContent = + "Press Enter to move blocks with the keyboard."; + this.el.appendChild(this._keyboardInstructions); + this.el.tabIndex = 0; + this.el.setAttribute("role", "button"); + this.el.setAttribute("aria-pressed", "false"); + this.el.setAttribute("aria-label", "Parsons block arrangement"); + this.el.setAttribute("aria-describedby", this._keyboardInstructions.id); this.storedSourceBlocks = []; this.blockOrder = []; this.storedSourceBlockExplanations = null; @@ -214,6 +231,7 @@ export class ParsonsInput implements IParsonsInput { private _onBlockClicked = (block: Node, ev: Event): void => { const blockText = this._getTextFromBlock(block as HTMLDivElement).trim(); + let focusedBlock = block as HTMLDivElement; if (block.parentElement == this._dragArea) { let endPosition; if (this.reusable) { @@ -221,6 +239,7 @@ export class ParsonsInput implements IParsonsInput { blockCopy.onclick = (ev) => this._onBlockClicked(blockCopy, ev); this._dropArea.appendChild(blockCopy); endPosition = this._getBlockPosition(blockCopy); + focusedBlock = blockCopy; } else { this._dropArea.appendChild(block); endPosition = this._getBlockPosition(block); @@ -236,6 +255,7 @@ export class ParsonsInput implements IParsonsInput { this.parentElement.logEvent(inputEvent); } this._updateBlockAria(); + this._refocusMovedBlock(focusedBlock); this._announce(`${blockText} moved to answer area`); } else { const startPosition = this._getBlockPosition(block); @@ -244,6 +264,11 @@ export class ParsonsInput implements IParsonsInput { } else { this._dragArea.appendChild(block); } + if (this.reusable) { + focusedBlock = this._allBlocks().find( + (sourceBlock) => sourceBlock.dataset.index === block.dataset.index, + ) as HTMLDivElement; + } const inputEvent: MicroParsonsEvent.Input = { type: "input", action: MicroParsonsEvent.InputAction.REMOVE, @@ -252,6 +277,7 @@ export class ParsonsInput implements IParsonsInput { }; this.parentElement.logEvent(inputEvent); this._updateBlockAria(); + this._refocusMovedBlock(focusedBlock); this._announce(`${blockText} moved to available blocks`); } }; @@ -388,29 +414,102 @@ export class ParsonsInput implements IParsonsInput { // Accessibility helpers // ----------------------------------------------------------------------- - /** Sync role, aria-selected, and roving tabindex for all blocks. */ + /** Sync block semantics while the combined keyboard surface owns focus. */ private _updateBlockAria = (): void => { - const applyToArea = (area: HTMLDivElement, isAnswer: boolean) => { - const blocks = area.querySelectorAll(".parsons-block"); + if (this._activeBlock && !this.el.contains(this._activeBlock)) { + this._activeBlock = null; + } + const applyToArea = (area: HTMLDivElement, areaName: string) => { + const blocks = Array.from( + area.querySelectorAll(".parsons-block"), + ); blocks.forEach((block, i) => { + if (!block.id) { + this._nextBlockId += 1; + block.id = `${this.el.id}-block-${this._nextBlockId}`; + } block.setAttribute("role", "option"); - block.setAttribute("aria-selected", isAnswer ? "true" : "false"); - block.setAttribute("tabindex", i === 0 ? "0" : "-1"); + block.setAttribute( + "aria-label", + `${this._getTextFromBlock(block).trim()}, ${areaName}, item ${i + 1} of ${blocks.length}${block.classList.contains("incorrectPosition") ? ", incorrect" : ""}`, + ); + block.setAttribute( + "aria-selected", + String(block === this._activeBlock), + ); + block.setAttribute("tabindex", "-1"); }); }; - applyToArea(this._dragArea, false); - applyToArea(this._dropArea, true); + applyToArea(this._dragArea, "available blocks"); + applyToArea(this._dropArea, "answer area"); + if ( + this.el.getAttribute("role") === "application" && + this._activeBlock + ) { + this.el.setAttribute("aria-activedescendant", this._activeBlock.id); + } }; - /** Move focus to a specific block, updating roving tabindex within its area. */ - private _focusBlock = (block: HTMLDivElement, area: HTMLDivElement): void => { - area.querySelectorAll(".parsons-block").forEach((b) => { - b.setAttribute("tabindex", "-1"); - }); - block.setAttribute("tabindex", "0"); + private _allBlocks = (): HTMLDivElement[] => [ + ...this._dragArea.querySelectorAll(".parsons-block"), + ...this._dropArea.querySelectorAll(".parsons-block"), + ]; + + public refreshBlockAria = (): void => { + this._updateBlockAria(); + }; + + private _setActiveBlock = (block: HTMLDivElement): void => { + this._activeBlock = block; + this._updateBlockAria(); + if (this.el.getAttribute("role") === "application") { + block.focus(); + } + }; + + /** Restore the keyboard surface after a pointer click moves a block. */ + private _refocusMovedBlock = (block: HTMLDivElement): void => { + if (this.el.getAttribute("role") !== "application") return; + this._setActiveBlock(block); block.focus(); }; + private _enterKeyboardMovement = (): void => { + const blocks = this._allBlocks(); + if (blocks.length === 0) return; + this.el.setAttribute("role", "application"); + this.el.removeAttribute("aria-pressed"); + this.el.setAttribute("aria-label", "Parsons block movement"); + this._keyboardInstructions.textContent = + "Use Left and Right Arrow to choose a block in this area. Use Up and Down Arrow to switch between available blocks and the answer area. Press Enter to move the current block. Press Escape or Tab to finish."; + this._setActiveBlock(blocks[0]); + this._announce(`Moving ${this._getTextFromBlock(blocks[0]).trim()}`); + }; + + private _exitKeyboardMovement = (): void => { + this._activeBlock = null; + this.el.setAttribute("role", "button"); + this.el.setAttribute("aria-pressed", "false"); + this.el.setAttribute("aria-label", "Parsons block arrangement"); + this.el.removeAttribute("aria-activedescendant"); + this._keyboardInstructions.textContent = + "Press Enter to move blocks with the keyboard."; + this._updateBlockAria(); + this._announce("Keyboard block movement finished."); + }; + + private _moveActiveBlock = (ev: KeyboardEvent): void => { + const block = this._activeBlock; + if (!block) return; + const wasInDragArea = block.parentElement === this._dragArea; + this._onBlockClicked(block, ev); + const movedBlock = + wasInDragArea && this.reusable + ? (this._dropArea.lastElementChild as HTMLDivElement) + : block; + this._setActiveBlock(movedBlock); + }; + /** Announce a message to screen readers via the live region. */ private _announce = (message: string): void => { // Clear first so repeated identical messages still trigger re-announcement @@ -420,64 +519,65 @@ export class ParsonsInput implements IParsonsInput { }, 10); }; - /** Wire up keyboard navigation for both block areas. */ + /** Use one Tab stop for both block areas and scope arrows to movement mode. */ private _setupKeyboardNav = (): void => { - this.el.addEventListener("keydown", (ev: KeyboardEvent) => { - const block = (ev.target as HTMLElement).closest( - ".parsons-block", - ); - if (!block) return; - - const area = block.parentElement as HTMLDivElement; - if (area !== this._dragArea && area !== this._dropArea) return; - - const blocks = Array.from( - area.querySelectorAll(".parsons-block"), - ); - const idx = blocks.indexOf(block); + this.el.addEventListener("click", (ev: MouseEvent) => { + if ( + ev.target === this.el && + this.el.getAttribute("role") === "button" + ) { + this._enterKeyboardMovement(); + } + }); - switch (ev.key) { - case "ArrowRight": - case "ArrowDown": - ev.preventDefault(); - if (idx < blocks.length - 1) { - this._focusBlock(blocks[idx + 1], area); - } - break; - case "ArrowLeft": - case "ArrowUp": - ev.preventDefault(); - if (idx > 0) { - this._focusBlock(blocks[idx - 1], area); - } - break; - case " ": - case "Enter": { + this.el.addEventListener("keydown", (ev: KeyboardEvent) => { + if (this.el.getAttribute("role") !== "application") { + if (ev.target !== this.el) return; + if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); - const wasInDragArea = area === this._dragArea; - this._onBlockClicked(block, ev); - // Focus the moved block in its new location - let focusTarget: HTMLDivElement | null = null; - if (wasInDragArea) { - // Block (or its clone) is now in drop-area - focusTarget = this.reusable - ? (this._dropArea.lastElementChild as HTMLDivElement) - : block; - } else { - // Block was removed from drop-area - focusTarget = this.reusable - ? ((this._dropArea.firstElementChild || - this._dragArea.firstElementChild) as HTMLDivElement | null) - : block; - } - if (focusTarget) { - this._focusBlock( - focusTarget, - focusTarget.parentElement as HTMLDivElement, - ); - } - break; + this._enterKeyboardMovement(); + } + return; + } + if (!this.el.contains(ev.target as Node)) return; + if (ev.key === "Escape") { + ev.preventDefault(); + this._exitKeyboardMovement(); + return; + } + if (ev.key === "Tab") { + this._exitKeyboardMovement(); + return; + } + const activeBlock = this._activeBlock as HTMLDivElement; + const currentArea = + activeBlock.parentElement === this._dragArea + ? this._dragArea + : this._dropArea; + const otherArea = + currentArea === this._dragArea ? this._dropArea : this._dragArea; + const currentBlocks = Array.from( + currentArea.querySelectorAll(".parsons-block"), + ); + const index = currentBlocks.indexOf(activeBlock); + if (ev.key === "ArrowRight") { + ev.preventDefault(); + if (index < currentBlocks.length - 1) + this._setActiveBlock(currentBlocks[index + 1]); + } else if (ev.key === "ArrowLeft") { + ev.preventDefault(); + if (index > 0) this._setActiveBlock(currentBlocks[index - 1]); + } else if (ev.key === "ArrowUp" || ev.key === "ArrowDown") { + ev.preventDefault(); + const otherBlocks = Array.from( + otherArea.querySelectorAll(".parsons-block"), + ); + if (otherBlocks.length > 0) { + this._setActiveBlock(otherBlocks[Math.min(index, otherBlocks.length - 1)]); } + } else if (ev.key === "Enter") { + ev.preventDefault(); + this._moveActiveBlock(ev); } }); }; diff --git a/bases/rsptx/interactives/runestone/hparsons/js/micro-parsons/micro-parsons.ts b/bases/rsptx/interactives/runestone/hparsons/js/micro-parsons/micro-parsons.ts index 9c220eaf4..aece3f106 100644 --- a/bases/rsptx/interactives/runestone/hparsons/js/micro-parsons/micro-parsons.ts +++ b/bases/rsptx/interactives/runestone/hparsons/js/micro-parsons/micro-parsons.ts @@ -135,6 +135,10 @@ export class MicroParsonsElement extends HTMLElement { return (this.hparsonsInput as ParsonsInput).getBlockIndices(); } + public refreshBlockAria(): void { + (this.hparsonsInput as ParsonsInput).refreshBlockAria(); + } + public setCodeContext(props: { before: string | null; after: string | null; diff --git a/bases/rsptx/interactives/runestone/hparsons/test/hparsons.test.js b/bases/rsptx/interactives/runestone/hparsons/test/hparsons.test.js index b6fc3759d..e889ef2bc 100644 --- a/bases/rsptx/interactives/runestone/hparsons/test/hparsons.test.js +++ b/bases/rsptx/interactives/runestone/hparsons/test/hparsons.test.js @@ -150,6 +150,111 @@ describe("HParsons block grading", () => { expect(hp.feedbackController.grade).toBe("correct"); }); + it("pre-renders math blocks without MathJax tab stops", async () => { + const hp = makeComponent({ + blocks: MATH_BLOCKS, + blockAnswer: "0 1 2", + }); + // Allow the initial deferred MathJax render to finish first. + await new Promise((resolve) => setTimeout(resolve, 20)); + const block = hp.hparsonsInput.querySelector(".parsons-block"); + hp.queueMathJax.mockImplementationOnce((mathBlock) => { + mathBlock.innerHTML = ` + + visual math internals + `; + return Promise.resolve(); + }); + + await hp.renderMathInBlocks(); + + const math = block.querySelector("mjx-container"); + expect(math.tabIndex).toBe(-1); + expect(math.querySelector("span").tabIndex).toBe(-1); + + const lateMath = document.createElement("mjx-container"); + lateMath.tabIndex = 0; + block.appendChild(lateMath); + await new Promise((resolve) => setTimeout(resolve)); + + expect(lateMath.tabIndex).toBe(-1); + }); + + it("waits for queued MathJax block renders", async () => { + const hp = makeComponent({ + blocks: MATH_BLOCKS, + blockAnswer: "0 1 2", + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + let completeRender; + hp.queueMathJax.mockImplementationOnce( + () => + new Promise((resolve) => { + completeRender = resolve; + }), + ); + const render = hp.renderMathInBlocks(); + let resolved = false; + render.then(() => { + resolved = true; + }); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(resolved).toBe(false); + completeRender(); + await render; + expect(resolved).toBe(true); + }); + + it("announces block-grading feedback through a persistent status region", async () => { + const hp = makeComponent({ + blocks: ["first", "second"].join("\n"), + blockAnswer: "0 1", + }); + + const liveRegion = hp.feedbackController.feedbackLiveRegion; + expect(liveRegion.getAttribute("role")).toBe( + "status", + ); + expect(liveRegion.getAttribute("aria-live")).toBe("polite"); + expect(liveRegion.getAttribute("aria-atomic")).toBe("true"); + + await hp.feedbackController.runButtonHandler(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(liveRegion.textContent).toBe( + hp.feedbackController.messageDiv.textContent, + ); + }); + + it("cancels feedback that is cleared before it can be announced", async () => { + const hp = makeComponent({ + blocks: ["first", "second"].join("\n"), + blockAnswer: "0 1", + }); + + hp.feedbackController.announceFeedback("Incorrect order."); + hp.feedbackController.clearFeedback(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(hp.feedbackController.feedbackLiveRegion.textContent).toBe(""); + }); + + it("announces when the block arrangement is reset", async () => { + const hp = makeComponent({ + blocks: ["first", "second"].join("\n"), + blockAnswer: "0 1", + }); + + hp.feedbackController.reset(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(hp.feedbackController.feedbackLiveRegion.textContent).toBe( + "Blocks reset.", + ); + }); + it("ignores stray whitespace in data-blockanswer", () => { const hp = makeComponent({ blocks: ["a", "b", "c"].join("\n"), @@ -160,6 +265,100 @@ describe("HParsons block grading", () => { }); }); +describe("HParsons keyboard movement surface", () => { + beforeEach(() => { + document.body.innerHTML = ""; + vi.spyOn(HParsons.prototype, "queueMathJax").mockResolvedValue({}); + vi.spyOn(HParsons.prototype, "logBookEvent").mockResolvedValue({}); + vi.spyOn(HParsons.prototype, "checkServer").mockImplementation( + () => {}, + ); + }); + + it("uses one Tab stop and moves blocks only after activation", () => { + const hp = makeComponent({ + blocks: ["first", "second", "third"].join("\n"), + blockAnswer: "0 1 2", + }); + const input = hp.hparsonsInput.querySelector(".hparsons-input"); + const blocks = input.querySelectorAll(".parsons-block"); + + expect(input.tabIndex).toBe(0); + expect(input.getAttribute("role")).toBe("button"); + expect(input.getAttribute("aria-pressed")).toBe("false"); + expect(Array.from(blocks).every((block) => block.tabIndex === -1)).toBe( + true, + ); + + input.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + expect(input.getAttribute("role")).toBe("application"); + expect(input.getAttribute("aria-activedescendant")).toBe(blocks[0].id); + expect(document.activeElement).toBe(blocks[0]); + + blocks[0].dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }), + ); + expect(input.getAttribute("aria-activedescendant")).toBe(blocks[1].id); + expect(document.activeElement).toBe(blocks[1]); + + input.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }), + ); + expect(input.getAttribute("aria-activedescendant")).toBe(blocks[1].id); + + input.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }), + ); + input.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + expect(hp.hparsonsInput.getBlockIndices()).toEqual([0]); + + input.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }), + ); + expect(input.getAttribute("aria-activedescendant")).toBe(blocks[1].id); + + input.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }), + ); + expect(input.getAttribute("aria-activedescendant")).toBe(blocks[2].id); + + input.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }), + ); + expect(input.getAttribute("aria-activedescendant")).toBe(blocks[0].id); + + input.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + expect(input.getAttribute("role")).toBe("button"); + expect(input.getAttribute("aria-pressed")).toBe("false"); + expect(input.hasAttribute("aria-activedescendant")).toBe(false); + }); + + it("refocuses the movement surface on the block clicked in movement mode", () => { + const hp = makeComponent({ + blocks: ["first", "second"].join("\n"), + blockAnswer: "0 1", + }); + const input = hp.hparsonsInput.querySelector(".hparsons-input"); + const firstBlock = input.querySelector(".parsons-block"); + + input.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + firstBlock.click(); + + expect(document.activeElement).toBe(firstBlock); + expect(input.getAttribute("aria-activedescendant")).toBe(firstBlock.id); + expect(firstBlock.parentElement.classList.contains("drop-area")).toBe( + true, + ); + }); +}); describe("HParsons wrong-order feedback", () => { beforeEach(() => { document.body.innerHTML = ""; @@ -190,5 +389,10 @@ describe("HParsons wrong-order feedback", () => { ), ).map((block) => block.dataset.index); expect(flagged).toEqual(["0"]); + expect( + hp.hparsonsInput.querySelector( + ".drop-area .parsons-block.incorrectPosition", + ).getAttribute("aria-label"), + ).toContain("incorrect"); }); });