diff --git a/ActiveLogic.css b/ActiveLogic.css index 1fc6885..64f9e50 100644 --- a/ActiveLogic.css +++ b/ActiveLogic.css @@ -30,6 +30,12 @@ font-weight: bold; } +/* Preserve Bootstrap's legend clearance when Quest inserts its hidden + focus target between the legend and the first response. */ +.question fieldset > legend + .screen-reader-focus + * { + clear: left; +} + /* this is an answer with a text area...*/ .freeresponse { display: flex; @@ -163,7 +169,7 @@ input[type="text"] { cursor: pointer; border: none; outline: none; - overflow: hidden; + overflow: visible; padding: 1em; } @@ -206,6 +212,13 @@ input[type="text"] { box-shadow: 0 0 .75vw #b3b3b3; } + .quest-grid.table-layout input[type="checkbox"]:focus-visible + .custom-label::before, + .quest-grid.table-layout input[type="radio"]:focus-visible + .custom-label::before { + border-color: #1c5d86; + outline: 3px solid #1c5d86; + outline-offset: 3px; + } + .quest-grid.table-layout input[type="checkbox"]:checked + .custom-label, .quest-grid.table-layout input[type="radio"]:checked + .custom-label { border: none; @@ -223,8 +236,8 @@ input[type="text"] { } } -/* 576px matches the default breakpoint for bootstrap xs */ -@media screen and (max-width: 576px) { +/* Bootstrap's small breakpoint starts at 576px; xs ends immediately below it. */ +@media screen and (max-width: 575.98px) { .quest-grid.table-layout { display: block; } @@ -279,5 +292,46 @@ input[type="text"] { width: 100%; height: 100%; } + + input[type="checkbox"]:focus-visible + .custom-label, + input[type="radio"]:focus-visible + .custom-label { + outline: 3px solid #1c5d86; + outline-offset: -6px; + } + + input[type="checkbox"]:checked:focus-visible + .custom-label, + input[type="radio"]:checked:focus-visible + .custom-label { + outline-color: #fff; + } + } +} + +@media (forced-colors: active) { + .quest-grid.table-layout input[type="checkbox"]:focus-visible + .custom-label::before, + .quest-grid.table-layout input[type="radio"]:focus-visible + .custom-label::before, + .quest-grid.table-layout input[type="checkbox"]:focus-visible + .custom-label, + .quest-grid.table-layout input[type="radio"]:focus-visible + .custom-label { + outline-color: CanvasText; } -} \ No newline at end of file + + .quest-grid.table-layout input[type="checkbox"]:checked + .custom-label::after, + .quest-grid.table-layout input[type="radio"]:checked + .custom-label::after { + forced-color-adjust: none; + background-color: Highlight; + } +} + +@media (forced-colors: active) and (max-width: 575.98px) { + .quest-grid.table-layout input[type="checkbox"]:checked + .custom-label, + .quest-grid.table-layout input[type="radio"]:checked + .custom-label { + forced-color-adjust: none; + background-color: Highlight; + border-color: Highlight; + color: HighlightText; + } + + .quest-grid.table-layout input[type="checkbox"]:checked:focus-visible + .custom-label, + .quest-grid.table-layout input[type="radio"]:checked:focus-visible + .custom-label { + outline-color: HighlightText; + } +} diff --git a/Default.css b/Default.css index 77dec1e..7793a7d 100644 --- a/Default.css +++ b/Default.css @@ -9,6 +9,12 @@ input[type="checkbox"] + label { white-space: pre-line; } +/* Preserve Bootstrap's legend clearance when Quest inserts its hidden + focus target between the legend and the first response. */ +.question fieldset > legend + .screen-reader-focus + * { + clear: left; +} + /* CSS for grids */ .quest-grid.table-layout { width: 100%; @@ -96,7 +102,7 @@ input[type="checkbox"] + label { cursor: pointer; border: none; outline: none; - overflow: hidden; + overflow: visible; padding: 1em; } @@ -139,6 +145,13 @@ input[type="checkbox"] + label { box-shadow: 0 0 .75vw #b3b3b3; } + .quest-grid.table-layout input[type="checkbox"]:focus-visible + .custom-label::before, + .quest-grid.table-layout input[type="radio"]:focus-visible + .custom-label::before { + border-color: #1c5d86; + outline: 3px solid #1c5d86; + outline-offset: 3px; + } + .quest-grid.table-layout input[type="checkbox"]:checked+.custom-label, .quest-grid.table-layout input[type="radio"]:checked+.custom-label { border: none; @@ -156,8 +169,8 @@ input[type="checkbox"] + label { } } -/* 576px matches the default breakpoint for bootstrap xs */ -@media screen and (max-width: 576px) { +/* Bootstrap's small breakpoint starts at 576px; xs ends immediately below it. */ +@media screen and (max-width: 575.98px) { .quest-grid.table-layout { display: block; } @@ -212,5 +225,46 @@ input[type="checkbox"] + label { width: 100%; height: 100%; } + + input[type="checkbox"]:focus-visible + .custom-label, + input[type="radio"]:focus-visible + .custom-label { + outline: 3px solid #1c5d86; + outline-offset: -6px; + } + + input[type="checkbox"]:checked:focus-visible + .custom-label, + input[type="radio"]:checked:focus-visible + .custom-label { + outline-color: #fff; + } + } +} + +@media (forced-colors: active) { + .quest-grid.table-layout input[type="checkbox"]:focus-visible + .custom-label::before, + .quest-grid.table-layout input[type="radio"]:focus-visible + .custom-label::before, + .quest-grid.table-layout input[type="checkbox"]:focus-visible + .custom-label, + .quest-grid.table-layout input[type="radio"]:focus-visible + .custom-label { + outline-color: CanvasText; + } + + .quest-grid.table-layout input[type="checkbox"]:checked + .custom-label::after, + .quest-grid.table-layout input[type="radio"]:checked + .custom-label::after { + forced-color-adjust: none; + background-color: Highlight; + } +} + +@media (forced-colors: active) and (max-width: 575.98px) { + .quest-grid.table-layout input[type="checkbox"]:checked + .custom-label, + .quest-grid.table-layout input[type="radio"]:checked + .custom-label { + forced-color-adjust: none; + background-color: Highlight; + border-color: Highlight; + color: HighlightText; } -} \ No newline at end of file + + .quest-grid.table-layout input[type="checkbox"]:checked:focus-visible + .custom-label, + .quest-grid.table-layout input[type="radio"]:checked:focus-visible + .custom-label { + outline-color: HighlightText; + } +} diff --git a/Quest.css b/Quest.css index c352617..884a925 100644 --- a/Quest.css +++ b/Quest.css @@ -81,6 +81,17 @@ input[type="checkbox"].form-check-input{ text-decoration: none; } +.question .popover-dismiss:focus-visible { + outline: 3px solid #1c5d86; + outline-offset: 2px; +} + +@media (forced-colors: active) { + .question .popover-dismiss:focus-visible { + outline-color: CanvasText; + } +} + .popover-header { background-color: #f8f9fa; border-bottom: 1px solid #e9ecef; diff --git a/Style1.css b/Style1.css index 0f3d3d5..df50b08 100644 --- a/Style1.css +++ b/Style1.css @@ -38,12 +38,39 @@ input.styling[type="radio"] { display: inline; } -input[type="radio"] { - display: none; -} - -input[type="checkbox"] { - display: none; +.question input[type="radio"], +.question input[type="checkbox"] { + position: absolute; + opacity: 0; + width: 1px; + height: 1px; +} + +.question .response > input[type="radio"]:focus-visible + label:not(.custom-label), +.question .response > input[type="checkbox"]:focus-visible + label:not(.custom-label) { + outline: 3px solid #1c5d86; + outline-offset: 2px; +} + +.question .popover-dismiss:focus-visible { + outline: 3px solid #1c5d86; + outline-offset: 2px; +} + +@media (forced-colors: active) { + .question .response > input[type="radio"]:focus-visible + label:not(.custom-label), + .question .response > input[type="checkbox"]:focus-visible + label:not(.custom-label), + .question .popover-dismiss:focus-visible { + outline-color: CanvasText; + } + + .question .response > input[type="radio"]:checked + label:not(.custom-label), + .question .response > input[type="checkbox"]:checked + label:not(.custom-label) { + forced-color-adjust: none; + background-color: Highlight; + border-color: Highlight; + color: HighlightText; + } } .show-button { diff --git a/accessibleQuestionTextBuilder.js b/accessibleQuestionTextBuilder.js index 023f40c..935d0fd 100644 --- a/accessibleQuestionTextBuilder.js +++ b/accessibleQuestionTextBuilder.js @@ -1,13 +1,15 @@ import { evaluateCondition } from './evaluateConditions.js'; import { handleForIDAttributes, moduleParams } from './questionnaire.js'; +const QUESTION_TRANSITION_FOCUS_DELAY_MS = 500; +const MODAL_RETURN_FOCUS_DELAY_MS = 100; + /** * Initialize the question text and focus management for screen readers. * This drives the screen reader's question announcement and focus when a question is loaded. * Set the focus after a brief timeout to ensure the screen reader has time to process the new content. * @param {HTMLElement} fieldsetEle - The fieldset element containing the question text. * @param {Boolean} questionFocusSet - The flag to manage screen reader focus. - * @param {Boolean} isModalClose - The flag to reset the questionFocusSet flag on modal close. * @returns {Boolean} - The updated questionFocusSet flag. */ @@ -19,8 +21,8 @@ export function manageAccessibleQuestion(fieldsetEle, questionFocusSet) { // Focus the hidden, focusable element if (!moduleParams.isRenderer) { setTimeout(() => { - focusableEle.focus({ preventScroll: true }); - }, 500); + focusAccessibleQuestionTarget(focusableEle); + }, QUESTION_TRANSITION_FOCUS_DELAY_MS); } questionFocusSet = true; @@ -29,6 +31,16 @@ export function manageAccessibleQuestion(fieldsetEle, questionFocusSet) { return questionFocusSet; } +function focusAccessibleQuestionTarget(focusableEle) { + // A response or submit dialog may open before a scheduled question-focus + // handoff runs. Keep focus in the active modal instead of returning it to + // content behind the dialog. + const openModal = moduleParams.questDiv?.querySelector('.modal.show'); + if (focusableEle?.isConnected && !openModal) { + focusableEle.focus({ preventScroll: true }); + } +} + /** * Build the question text for screen readers. * Calculate the breakpoint between question and responses for accessible focus management. @@ -41,6 +53,7 @@ export function manageAccessibleQuestion(fieldsetEle, questionFocusSet) { function buildQuestionText(fieldsetEle) { let focusNode = null; + let multiQuestionStartIndex = null; // The conditions for building textContent (survey questions) for the screen reader. const textNodeConditional = (node) => @@ -103,9 +116,16 @@ function buildQuestionText(fieldsetEle) { questionElements.push(node.cloneNode(true)); - // Stop collecting for legend if we hit the text node with a question-terminating condition. - // Let the handleMultiQuestionSurveyAccessibility() handle the focus node. + // Stop collecting for the legend at the end of the primary prompt. + // Preserve its next sibling as the boundary before responses (or + // before any subsequent prompts in a multi-question fieldset). if (node.nodeType === Node.TEXT_NODE && isTerminalText(node.textContent)) { + focusNode = node.nextSibling; + // The next sibling is the first node that has not been + // consumed by the primary prompt. It may be either the first + // response or the first fragment of a subsequent prompt, so + // the compound-question scan must include it. + multiQuestionStartIndex = nodeIndex + 1; break; } @@ -131,7 +151,11 @@ function buildQuestionText(fieldsetEle) { if (!focusNode) { focusNode = fieldsetEle.querySelector('legend') || fieldsetEle.lastChild || fieldsetEle; } else { - handleMultiQuestionSurveyAccessibility(childNodes, fieldsetEle, focusNode); + handleMultiQuestionSurveyAccessibility( + childNodes, + fieldsetEle, + multiQuestionStartIndex ?? childNodes.indexOf(focusNode) + 1, + ); } // Create the tag for screen readers and move the question text into it. @@ -141,11 +165,10 @@ function buildQuestionText(fieldsetEle) { } // Find additional questions (e.g. QoL multi-question surveys). -// Start after the focus node since the initial question is handled above for all cases. +// Start at the supplied unconsumed-node index since the initial question is +// handled above for all cases. // Swap those nodes (text, , , , and embedded
) into divs and add a tabindex to make them focusable for screen reader accessibility. -function handleMultiQuestionSurveyAccessibility(childNodes, fieldsetEle, focusNode) { - let startIndex = childNodes.indexOf(focusNode) + 1; - +function handleMultiQuestionSurveyAccessibility(childNodes, fieldsetEle, startIndex) { // Array holds the question objects let questions = []; @@ -500,7 +523,6 @@ function createFocusableElement(fieldsetEle, focusNode) { if (!focusableEle) { focusableEle = document.createElement('span'); focusableEle.classList.add('screen-reader-focus'); - focusableEle.setAttribute('tabindex', '0'); focusableEle.style.cssText = ` position: absolute; width: 1px; @@ -525,156 +547,45 @@ function createFocusableElement(fieldsetEle, focusNode) { } } + // A help control can live inside the generated legend. Keep + // the question target immediately before that interactive + // content so forward Tab navigation does not skip from the target to the + // responses or action buttons after the legend. + const legendEle = fieldsetEle.querySelector(':scope > legend'); + const legendPopover = legendEle?.querySelector('[data-bs-toggle="popover"][tabindex="0"]'); + if (legendPopover && focusableEle.parentElement !== legendEle) { + legendEle.prepend(focusableEle); + } + + // This target receives deliberate focus after question transitions, but + // must not become an extra empty stop in sequential keyboard navigation. + focusableEle.setAttribute('tabindex', '-1'); + return focusableEle; } /** - * Close the modal and focus on the question text. - * Re-build the question text and focus management for screen readers. - * @param {Event} event - The event object. + * Restore question context after an unanswered-response modal closes. + * Focus the question target after Bootstrap finishes hiding the modal. */ -export function closeModalAndFocusQuestion(event) { - const modal = moduleParams.questDiv.querySelector('#softModal'); - const isWindowClick = event.target === modal; - const isButtonClick = event.target.closest('button.btn-close') || - ['modalCloseButton', 'modalContinueButton'].includes(event.target.id); - - if (isWindowClick || isButtonClick) { - modal.style.display = 'none'; - - // Find the active question - const activeQuestion = moduleParams.questDiv.querySelector('.question.active'); - if (activeQuestion) { - const questionFocusSet = false; - setTimeout(() => { - manageAccessibleQuestion(activeQuestion.querySelector('fieldset') || activeQuestion, questionFocusSet); - }, 100); - } - } -} +export function closeModalAndFocusQuestion() { + if (moduleParams.isRenderer) return; -// Custom Accessible handling for up/down arrow keys. -// This ensures focus doesn't trap accessible navigation in lists that have 'Other' text inputs. -// Only active when moduleParams.isRenderer is false (inactive in the renderer because focus() causes issues). -export function handleUpDownArrowKeys(event) { - if (event.key === 'ArrowDown') { - event.preventDefault(); - focusNextElement(event.target); - } else if (event.key === 'ArrowUp') { - event.preventDefault(); - focusPreviousResponse(event.target); - } -} - -// Get the next focusable element. -// Important for JAWS compatibility with text input fields in radio/checkbox groups. -function focusNextElement(currentElement) { - const focusableElements = 'a, button, input:not([type="hidden"]), label, select, textarea, [tabindex]:not([tabindex="-1"])'; - const allFocusable = Array.from(moduleParams.questDiv.querySelectorAll(focusableElements)); + // Retain the short modal-settle buffer. For a soft-modal continuation, the newly + // activated question is already in the DOM when Bootstrap's hidden event runs. + const activeQuestion = moduleParams.questDiv.querySelector('.question.active'); + if (!activeQuestion) return; - const currentIndex = allFocusable.indexOf(currentElement); - if (currentIndex !== -1) { - let newIndex = currentIndex; - let nextElement; + const accessibleQuestion = activeQuestion.querySelector('fieldset') || activeQuestion; + const focusableEle = accessibleQuestion.querySelector('span.screen-reader-focus'); + // An async question can be active while its host content is still loading. + // Its normal prepareQuestionDOM path owns construction and focus once the + // final markup is available. + if (!focusableEle) return; - do { - newIndex++; - nextElement = allFocusable[newIndex]; - } while (nextElement && (nextElement === currentElement || (nextElement.tagName === 'INPUT' && nextElement.type === 'text' && document.activeElement === nextElement))); - - if (nextElement) { - setTimeout(() => { - nextElement.focus({ preventScroll: true }) - }, 0); - } - } -} - -// Get the previous focuasble 'response' div. -// Important for JAWS compatibility with text input fields in radio/checkbox groups. -function focusPreviousResponse(currentElement) { - const currentResponse = currentElement.closest('.response'); - if (currentResponse) { - let previousResponse = currentResponse.previousElementSibling; - while (previousResponse && !previousResponse.classList.contains('response')) { - previousResponse = previousResponse.previousElementSibling; - } - if (previousResponse) { - const focusableElements = previousResponse.querySelectorAll('a, button, input:not([type="hidden"]), label, select, textarea, [tabindex]:not([tabindex="-1"])'); - if (focusableElements.length > 0) { - setTimeout(() => { - focusableElements[0].focus({ preventScroll: true }); - }, 0); - } - } - } - return null; -} - -// Function to handle radio button clicks and changes in lists. -export function handleRadioCheckboxListEvents(event) { - const parentResponseDiv = event.target.closest('.response'); - const eleToFocus = parentResponseDiv.querySelector('input') || parentResponseDiv; - updateAriaLiveSelectionAnnouncer(parentResponseDiv); setTimeout(() => { - eleToFocus.focus({ preventScroll: true }); - }, 100); -} - -// JAWS/Windows function to handle radio button clicks and changes in tables. -// For accessibility. Focus management is seamless in VoiceOver (MAC) but flawed in JAWS (Windows). -// This manages the screen reader's table focus with a hidden element inside a table cell. -// The element moves to the cell when a radio button is clicked. - -export function handleRadioCheckboxTableEvents(event) { - event.preventDefault(); - const radioOrCheckbox = event.target; - const responseCell = radioOrCheckbox.closest('.response'); - - if (responseCell) { - let currentRow = responseCell.closest('tr'); - - switch (radioOrCheckbox.type) { - // If it's a radio click, focus the hidden element on the next question (the first column of the next row). - case 'radio': { - - // Handle hidden rows and the end of the table. - let nextRow = currentRow.nextElementSibling; - do { - if (!nextRow) break; - nextRow = nextRow.getAttribute('data-hidden') === 'true' ? nextRow.nextElementSibling : nextRow; - } while (nextRow && nextRow.getAttribute('data-hidden') === 'true'); - - // If next row exists and it's visible, focus the question (the first cell in the next row). - // Otherwise, focus the next question button so the user can continue. - nextRow - ? focusNextTableRowQuestion(nextRow) - : focusNextQuestionButton(); - - break; - } - - // If it's a checkbox click, focus the hidden element on the selection so the user can continue making selections. - // If middle of row, place focus back on the checkbox. - // If end of last row, focus the next question button so the user can continue. - case 'checkbox': { - updateAriaLiveSelectionAnnouncerTable(responseCell); - const nextCell = responseCell.nextElementSibling; - const isLastCellInRow = !nextCell; - const isLastRow = !currentRow.nextElementSibling; - - if (isLastRow && isLastCellInRow) { - focusNextQuestionButton(); - } else { - focusSelectedCheckbox(responseCell); - } - break; - } - - default: - moduleParams.errorLogger('RadioCheckboxTableEvent: Invalid event type', event.type); - } - } + focusAccessibleQuestionTarget(focusableEle); + }, MODAL_RETURN_FOCUS_DELAY_MS); } // Update the aria-live region with the current selection announcement in a list (for screen readers). @@ -721,68 +632,6 @@ export function updateAriaLiveSelectionAnnouncerTable(responseDiv) { }, 250); } -function focusNextTableRowQuestion(nextRow) { - setTimeout(() => { - const focusHelper = getFocusHelper(); - if (!focusHelper) return; - - const nextQuestionCell = nextRow.querySelector('th'); - if (!nextQuestionCell) { - moduleParams.errorLogger('RadioCheckboxTableEvent: Next question cell not found', nextRow); - return; - } - - nextQuestionCell.appendChild(focusHelper); - focusHelper.focus({ preventScroll: true }); - }, 100); -} - -// JAWS/Windows function to focus the next question button after a selection is made. -// This handles the last row's selection in a radio table and the final selectable cell in a checkbox table. -function focusNextQuestionButton() { - setTimeout(() => { - const focusHelper = getFocusHelper(); - if (!focusHelper) return; - - const activeQuestion = moduleParams.questDiv.querySelector('.question.active'); - if (!activeQuestion) { - moduleParams.errorLogger('Active question not found', document.activeElement); - return; - } - - const nextQuestionButton = activeQuestion.querySelector('button.next'); - if (!nextQuestionButton) { - moduleParams.errorLogger('Next question button not found', activeQuestion); - return; - } - - nextQuestionButton.appendChild(focusHelper); - focusHelper.focus({ preventScroll: true }); - }, 100); -} - -// JAWS/Windows function to re-focus a checkbox in a table after it is selected. -function focusSelectedCheckbox(responseCell) { - setTimeout(() => { - const focusHelper = getFocusHelper(); - if (!focusHelper) return; - - responseCell.appendChild(focusHelper); - focusHelper.focus({ preventScroll: true }); - }, 100); -} - -// JAWS/Windows function for accessible focus management. -function getFocusHelper() { - const focusHelper = moduleParams.questDiv.querySelector('#srFocusHelper'); - if (!focusHelper) { - moduleParams.errorLogger('Focus helper not found'); - return null; - } - - return focusHelper; -} - // Clear the selection accnouncer when a user is navigating between questions (next/back buttons) export function clearSelectionAnnouncement() { const liveRegion = moduleParams.questDiv.querySelector('#ariaLiveSelectionAnnouncer'); @@ -813,4 +662,3 @@ function handleSummaryUIEdgeCases(fieldset, questionID) { } } } - diff --git a/buildGrid.js b/buildGrid.js index c280219..cc0ac44 100644 --- a/buildGrid.js +++ b/buildGrid.js @@ -40,7 +40,6 @@ function buildHtmlTable(grid_obj, gridButtonDiv) { let grid_html = `
${grid_text_displayif(shared_text)}
- `; // Build the table header row with the question text and response headers. Start with a placeholder for the row header. diff --git a/docs/accessibilityManualTest.md b/docs/accessibilityManualTest.md index 2186563..bc98786 100644 --- a/docs/accessibilityManualTest.md +++ b/docs/accessibilityManualTest.md @@ -4,6 +4,9 @@ Scope: **Quest 2 participant runtime only** Browser automation can inspect DOM semantics, focus, state, and live regions, but it does not run VoiceOver or JAWS and cannot prove what either screen reader announces. This matrix describes those manual accessibility testing processes. +For the keyboard-only command guide, see +[`keyboardNavigation.md`](keyboardNavigation.md). + ## Test boundary - Use only non-production participant data and the canonical fixtures under `tests/fixtures/canonical/`. @@ -37,6 +40,31 @@ Browser automation can inspect DOM semantics, focus, state, and live regions, bu “Current” must be replaced with exact version numbers in the test record. Playwright WebKit is not Safari plus VoiceOver, and Chromium with a Windows user-agent string is not JAWS. +## Test record + +Create a separate record for every environment, browser, assistive-technology mode, and fixture run. Do not use one blanket result for a scenario or environment. + +Record these fields before starting: + +| Field | Required value | +| --- | --- | +| Date and tester | Test date and tester name or initials | +| Quest revision | Exact commit SHA and Quest version, if versioned | +| Environment | Environment-matrix ID and exact operating-system version/build | +| Browser | Browser name and exact version | +| Assistive technology | Name and exact version, or `None` for keyboard-only runs | +| Mode | Full Keyboard Access, Quick Nav, Virtual Cursor, or Forms Mode state, as applicable | +| Fixture and viewport | Fixture filename and viewport dimensions | +| Automated prerequisite | Exact command and its pass/fail result | + +Record every numbered or table step separately: + +| Scenario and step | Result | Actual focus, role/name/state, and spoken output | Evidence or defect | +| --- | --- | --- | --- | +| Example: VoiceOver step 2 | `PASS`, `FAIL`, `BLOCKED`, or `NOT RUN` | Record the observed result. Do not write only “as expected” | Link evidence or explain the blocker/omission | + +Use `BLOCKED` only when an external condition prevents the step. Use `NOT RUN` only with a reason. A scenario passes only when every required step has an explicit `PASS`; an aggregate environment-level pass cannot replace the per-step record. + ## Keyboard-only baseline — issue #1587 Run KBD-MAC and KBD-WIN without a screen reader. Repeat in both Chrome and Edge for KBD-WIN. @@ -55,6 +83,8 @@ Run KBD-MAC and KBD-WIN without a screen reader. Repeat in both Chrome and Edge Raw browser key expectations apply only to this keyboard-only baseline. Do not file a failure solely because a screen reader reserves or reroutes one of these keys. +Standards note for issue #1587: a custom element with `role="button"` must activate with both Enter and Space, as specified by the [WAI-ARIA button pattern](https://www.w3.org/WAI/ARIA/apg/patterns/button/). Native ` +(2) No additional details + +[END,end] Complete.`; + +async function tabUntil(page, predicate, maximumPresses = 12) { + const path = []; + + for (let press = 0; press < maximumPresses; press += 1) { + await page.keyboard.press('Tab'); + const focused = await page.evaluate(() => document.activeElement?.id ?? null); + path.push(focused); + if (await predicate(focused)) return path; + } + + throw new Error(`Focus did not reach the expected control after ${maximumPresses} Tab presses: ${JSON.stringify(path)}`); +} + +async function tabUntilAction(page, clickType, maximumPresses = 12) { + const path = []; + + for (let press = 0; press < maximumPresses; press += 1) { + await page.keyboard.press('Tab'); + const focused = await page.evaluate(() => ({ + id: document.activeElement?.id ?? null, + clickType: document.activeElement?.dataset?.clickType ?? null, + })); + path.push(focused); + if (focused.clickType === clickType) return path; + } + + throw new Error(`Focus did not reach the ${clickType} action after ${maximumPresses} Tab presses: ${JSON.stringify(path)}`); +} + +async function pressSpace(page, control) { + await control.focus(); + await expect(control).toBeFocused(); + await page.keyboard.press('Space'); + await waitInHarness(page, 150); +} + +async function expectGridFocusIndicatorUnclipped(label) { + const geometry = await label.evaluate((element) => { + const labelStyle = getComputedStyle(element); + const markerStyle = getComputedStyle(element, '::before'); + const pixels = (value) => Number.parseFloat(value) || 0; + const markerWidth = pixels(markerStyle.width) + + (markerStyle.boxSizing === 'border-box' + ? 0 + : pixels(markerStyle.borderLeftWidth) + pixels(markerStyle.borderRightWidth)); + const markerHeight = pixels(markerStyle.height) + + (markerStyle.boxSizing === 'border-box' + ? 0 + : pixels(markerStyle.borderTopWidth) + pixels(markerStyle.borderBottomWidth)); + const focusExtent = Math.max( + 0, + pixels(markerStyle.outlineWidth) + pixels(markerStyle.outlineOffset), + ); + const labelRect = element.getBoundingClientRect(); + const markerCenterX = labelRect.left + (labelRect.width / 2); + const markerCenterY = labelRect.top + (labelRect.height / 2); + const paintBounds = { + left: markerCenterX - (markerWidth / 2) - focusExtent, + right: markerCenterX + (markerWidth / 2) + focusExtent, + top: markerCenterY - (markerHeight / 2) - focusExtent, + bottom: markerCenterY + (markerHeight / 2) + focusExtent, + }; + const clippingAncestors = []; + let ancestor = element.parentElement; + + while (ancestor) { + const style = getComputedStyle(ancestor); + const clipsX = ['hidden', 'clip', 'scroll', 'auto'].includes(style.overflowX); + const clipsY = ['hidden', 'clip', 'scroll', 'auto'].includes(style.overflowY); + if (clipsX || clipsY) { + const rect = ancestor.getBoundingClientRect(); + clippingAncestors.push({ + tagName: ancestor.tagName, + id: ancestor.id, + className: ancestor.className, + clipsX, + clipsY, + containsX: !clipsX + || (paintBounds.left >= rect.left - 0.5 && paintBounds.right <= rect.right + 0.5), + containsY: !clipsY + || (paintBounds.top >= rect.top - 0.5 && paintBounds.bottom <= rect.bottom + 0.5), + }); + } + ancestor = ancestor.parentElement; + } + + return { + focusPaintWidth: markerWidth + (2 * focusExtent), + focusPaintHeight: markerHeight + (2 * focusExtent), + labelWidth: labelRect.width, + labelHeight: labelRect.height, + overflowX: labelStyle.overflowX, + overflowY: labelStyle.overflowY, + clippingAncestors, + }; + }); + + expect( + geometry.overflowX === 'visible' + || geometry.labelWidth + 0.5 >= geometry.focusPaintWidth, + `Grid focus indicator is horizontally clipped: ${JSON.stringify(geometry)}`, + ).toBe(true); + expect( + geometry.overflowY === 'visible' + || geometry.labelHeight + 0.5 >= geometry.focusPaintHeight, + `Grid focus indicator is vertically clipped: ${JSON.stringify(geometry)}`, + ).toBe(true); + expect( + geometry.clippingAncestors.every(({ containsX, containsY }) => containsX && containsY), + `A grid ancestor clips the focus indicator: ${JSON.stringify(geometry)}`, + ).toBe(true); +} + +async function openPlainTextarea(page) { + await openParticipant(page, { markdown: TEXTAREA_KEYBOARD_MARKDOWN }); + await waitInHarness(page, 550); + const textarea = activeQuestion(page, 'NOTES').locator('#notes'); + await textarea.fill('first\nsecond'); + return textarea; +} + +async function textareaSelectionState(textarea) { + return textarea.evaluate((element) => ({ + focused: document.activeElement === element, + start: element.selectionStart, + end: element.selectionEnd, + })); +} + +test.describe('native participant keyboard navigation @canonical @keyboard @windows-a11y', () => { + test('native textarea ArrowDown retains focus and moves the multiline caret', async ({ page }, testInfo) => { + test.skip(!NATIVE_KEYBOARD_PROJECTS.has(testInfo.project.name), 'The native textarea contract runs in every desktop engine and the Windows browser branch.'); + + const textarea = await openPlainTextarea(page); + await textarea.focus(); + await textarea.evaluate((element) => element.setSelectionRange(0, 0)); + await expect(textarea).toBeFocused(); + await page.keyboard.press('ArrowDown'); + await waitInHarness(page, 25); + + const state = await textareaSelectionState(textarea); + expect(state.focused).toBe(true); + expect(state.start).toBeGreaterThan(0); + expect(state.end).toBe(state.start); + }); + + test('native textarea ArrowUp retains focus and moves the multiline caret', async ({ page }, testInfo) => { + test.skip(!NATIVE_KEYBOARD_PROJECTS.has(testInfo.project.name), 'The native textarea contract runs in every desktop engine and the Windows browser branch.'); + + const textarea = await openPlainTextarea(page); + await textarea.focus(); + await textarea.evaluate((element) => element.setSelectionRange(6, 6)); + await expect(textarea).toBeFocused(); + await page.keyboard.press('ArrowUp'); + await waitInHarness(page, 25); + + const state = await textareaSelectionState(textarea); + expect(state.focused).toBe(true); + expect(state.start).toBe(0); + expect(state.end).toBe(0); + }); + + test('choice-linked textarea arrows remain native and move its multiline caret', async ({ page }, testInfo) => { + test.skip(!NATIVE_KEYBOARD_PROJECTS.has(testInfo.project.name), 'The native textarea contract runs in every desktop engine and the Windows browser branch.'); + + await openParticipant(page, { markdown: CHOICE_LINKED_TEXTAREA_MARKDOWN }); + await waitInHarness(page, 550); + const textarea = activeQuestion(page, 'OTHER').locator('#OTHER_TEXT'); + await textarea.fill('first\nsecond'); + await textarea.focus(); + await textarea.evaluate((element) => element.setSelectionRange(0, 0)); + await page.evaluate(() => { + const root = document.querySelector('#questionnaireRoot'); + root.addEventListener('keydown', (event) => { + if (event.target.id === 'OTHER_TEXT') { + root.dataset.lastOtherArrow = JSON.stringify({ + defaultPrevented: event.defaultPrevented, + activeId: document.activeElement?.id ?? null, + }); + } + }, { once: true }); + }); + + await page.keyboard.press('ArrowDown'); + await waitInHarness(page, 25); + + const state = await textareaSelectionState(textarea); + expect(state.focused).toBe(true); + expect(state.start).toBeGreaterThan(0); + expect(await page.locator('#questionnaireRoot').evaluate((root) => ( + JSON.parse(root.dataset.lastOtherArrow) + ))).toEqual({ defaultPrevented: false, activeId: 'OTHER_TEXT' }); + }); + + test('native links activate with Enter inside the Quest event boundary', async ({ page }, testInfo) => { + test.skip(!NATIVE_KEYBOARD_PROJECTS.has(testInfo.project.name), 'The native link contract runs in every desktop engine and the Windows browser branch.'); + + await openParticipant(page, { markdown: LINK_KEYBOARD_MARKDOWN }); + await waitInHarness(page, 550); + const link = activeQuestion(page, 'LINK').getByRole('link', { name: 'keyboard help' }); + await link.focus(); + await expect(link).toBeFocused(); + await page.keyboard.press('Enter'); + await expect.poll(() => page.evaluate(() => window.location.hash)).toBe('#quest_keyboard_target'); + }); + + test('keyboard radio selection keeps focus, exclusivity, state, storage, and Back restoration', async ({ page }, testInfo) => { + test.skip(!NATIVE_KEYBOARD_PROJECTS.has(testInfo.project.name), 'The native control contract runs in every desktop engine and the Windows browser branch.'); + + await openParticipant(page); + await waitInHarness(page, 550); + const question = activeQuestion(page, 'CHOICE'); + const first = question.locator('#CHOICE_1'); + const second = question.locator('#CHOICE_2'); + + await pressSpace(page, first); + await expect(first).toBeChecked(); + await expect(second).not.toBeChecked(); + await expect(first).toBeFocused(); + + await page.keyboard.press('ArrowRight'); + await waitInHarness(page, 150); + await expect(first).not.toBeChecked(); + await expect(second).toBeChecked(); + await expect(second).toBeFocused(); + + // Both horizontal and vertical native radio arrows are part of #1587. + // Do not lock browser-specific wraparound behavior at the group edges. + await page.keyboard.press('ArrowUp'); + await waitInHarness(page, 150); + await expect(first).toBeChecked(); + await expect(second).not.toBeChecked(); + await expect(first).toBeFocused(); + + expect((await harnessSnapshot(page)).state.active).toMatchObject({ CHOICE: '1' }); + await goNext(page); + await expect(activeQuestion(page, 'CHECKS')).toBeVisible(); + await flushHarness(page); + + const stored = await harnessSnapshot(page); + expect(stored.state.survey).toMatchObject({ CHOICE: '1' }); + expect(stored.logs.storeCalls.some(({ changes }) => ( + changes['TEST_RUNTIME.CHOICE'] === '1' + ))).toBe(true); + + await goBack(page); + await expect(activeQuestion(page, 'CHOICE')).toBeVisible(); + await expect(activeQuestion(page, 'CHOICE').locator('#CHOICE_1')).toBeChecked(); + await expect(activeQuestion(page, 'CHOICE').locator('.screen-reader-focus')).toBeFocused(); + }); + + test('Tab gives a radio group one native stop and preserves its selected member', async ({ page }, testInfo) => { + test.skip(!TAB_PROJECTS.has(testInfo.project.name), 'Safari Full Keyboard Access has a separate manual contract.'); + + await openParticipant(page); + await waitInHarness(page, 550); + const first = activeQuestion(page, 'CHOICE').locator('#CHOICE_1'); + const second = activeQuestion(page, 'CHOICE').locator('#CHOICE_2'); + await page.locator('#host-before').focus(); + await expect(page.locator('#host-before')).toBeFocused(); + + const path = await tabUntil(page, (id) => id === 'CHOICE_1'); + expect(path).toContain('CHOICE_1'); + await expect(first).toBeFocused(); + const firstLabel = activeQuestion(page, 'CHOICE').locator('label[for="CHOICE_1"]'); + const secondLabel = activeQuestion(page, 'CHOICE').locator('label[for="CHOICE_2"]'); + await expect(firstLabel).toHaveCSS('outline-style', 'solid'); + await expect(firstLabel).toHaveCSS('outline-width', '3px'); + await expect(firstLabel).toHaveCSS('outline-color', 'rgb(28, 93, 134)'); + await expect(secondLabel).toHaveCSS('outline-style', 'none'); + await page.keyboard.press('Space'); + await expect(first).toBeChecked(); + await expect(firstLabel).toHaveCSS('outline-style', 'solid'); + + await page.keyboard.press('ArrowRight'); + await waitInHarness(page, 150); + await expect(second).toBeChecked(); + await expect(second).toBeFocused(); + + await page.locator('#host-before').focus(); + const selectedPath = await tabUntil(page, (id) => id === 'CHOICE_2'); + expect(selectedPath.some((id) => id === null)).toBe(false); + await expect(second).toBeFocused(); + + const afterGroup = await tabUntilAction(page, 'next'); + expect(afterGroup.map(({ id }) => id)).not.toContain('CHOICE_1'); + await expect(activeQuestion(page, 'CHOICE').getByRole('button', { name: 'Next question' })).toBeFocused(); + await waitInHarness(page, 150); + await expect(activeQuestion(page, 'CHOICE').getByRole('button', { name: 'Next question' })).toBeFocused(); + + await expect(activeQuestion(page, 'CHOICE').locator('.response[tabindex]')).toHaveCount(0); + await expect(activeQuestion(page, 'CHOICE').locator('#srFocusHelper')).toHaveCount(0); + }); + + test('keeps the production Module 1 marital-status radio group in native post-render keyboard order', async ({ page }, testInfo) => { + test.skip(!TAB_PROJECTS.has(testInfo.project.name), 'Safari Full Keyboard Access has a separate manual contract.'); + + const questionId = 'D_783167257'; + await openParticipant(page, { + markdown: readLockedMarkdown('module1'), + persistedData: { treeJSON: treeAt(questionId, 'SECTION1') }, + }); + await waitInHarness(page, 550); + + const question = activeQuestion(page, questionId); + const focusTarget = question.locator('.screen-reader-focus'); + const first = question.locator(`#${questionId}_514080822`); + const second = question.locator(`#${questionId}_522680498`); + const firstLabel = question.locator(`label[for="${questionId}_514080822"]`); + const next = question.getByRole('button', { name: 'Next question' }); + + await expect(focusTarget).toBeFocused(); + await expect(focusTarget).toHaveAttribute('tabindex', '-1'); + await expect(question.locator('input[type="radio"]')).toHaveCount(7); + await expect(question.locator('input[type="radio"]:checked')).toHaveCount(0); + await expect(question.locator('.response[tabindex]')).toHaveCount(0); + await expect(question.locator('#srFocusHelper')).toHaveCount(0); + expect(await focusTarget.evaluate((helper, firstRadioId) => { + const firstRadio = document.getElementById(firstRadioId); + return Boolean(helper.compareDocumentPosition(firstRadio) & Node.DOCUMENT_POSITION_FOLLOWING); + }, `${questionId}_514080822`)).toBe(true); + + await page.keyboard.press('Tab'); + await expect(first).toBeFocused(); + await expect(firstLabel).toHaveCSS('outline-style', 'solid'); + + await page.keyboard.press('Space'); + await waitInHarness(page, 150); + await expect(first).toBeFocused(); + await expect(first).toBeChecked(); + const afterSpace = await harnessSnapshot(page); + expect(afterSpace.state.active).toMatchObject({ [questionId]: '514080822' }); + expect(afterSpace.logs.events).toContainEqual(expect.objectContaining({ + type: 'keydown', + key: ' ', + defaultPrevented: false, + target: expect.objectContaining({ id: `${questionId}_514080822` }), + })); + expect(afterSpace.logs.events).toContainEqual(expect.objectContaining({ + type: 'change', + target: expect.objectContaining({ id: `${questionId}_514080822` }), + })); + + await page.keyboard.press('ArrowDown'); + await waitInHarness(page, 150); + await expect(second).toBeFocused(); + await expect(second).toBeChecked(); + await expect(first).not.toBeChecked(); + const afterArrow = await harnessSnapshot(page); + expect(afterArrow.state.active).toMatchObject({ [questionId]: '522680498' }); + expect(afterArrow.logs.events).toContainEqual(expect.objectContaining({ + type: 'keydown', + key: 'ArrowDown', + defaultPrevented: false, + target: expect.objectContaining({ id: `${questionId}_514080822` }), + })); + expect(afterArrow.logs.events).toContainEqual(expect.objectContaining({ + type: 'change', + target: expect.objectContaining({ id: `${questionId}_522680498` }), + })); + + await page.keyboard.press('Tab'); + await expect(next).toBeFocused(); + await expect(question.locator('input[type="radio"]:checked')).toHaveCount(1); + await page.keyboard.press('Shift+Tab'); + await expect(second).toBeFocused(); + + await next.focus(); + await page.keyboard.press('Enter'); + await expect(activeQuestion(page, 'RACEETHINTRO')).toBeVisible(); + await flushHarness(page); + expect((await harnessSnapshot(page)).logs.storeCalls.some(({ changes }) => ( + changes['D_726699695_V2.D_783167257'] === '522680498' + ))).toBe(true); + + await goBack(page); + await waitInHarness(page, 550); + await expect(activeQuestion(page, questionId).locator('.screen-reader-focus')).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(activeQuestion(page, questionId).locator(`#${questionId}_522680498`)).toBeFocused(); + await expect(activeQuestion(page, questionId).locator(`#${questionId}_522680498`)).toBeChecked(); + await expectHealthyHarness(page); + }); + + test('keeps the production Module 1 race multi-select native through linked text, XOR, storage, and Back', async ({ page }, testInfo) => { + test.skip(!TAB_PROJECTS.has(testInfo.project.name), 'Safari Full Keyboard Access has a separate manual contract.'); + + const questionId = 'D_384191091'; + const firstId = `${questionId}_583826374`; + const secondId = `${questionId}_636411467`; + const otherId = `${questionId}_807835037`; + const otherTextId = 'D_747350323'; + const exclusiveId = `${questionId}_178420302`; + await openParticipant(page, { + markdown: readLockedMarkdown('module1'), + persistedData: { treeJSON: treeAt(questionId, 'RACEETHINTRO') }, + }); + await waitInHarness(page, 550); + + const question = activeQuestion(page, questionId); + const first = question.locator(`#${firstId}`); + const second = question.locator(`#${secondId}`); + const other = question.locator(`#${otherId}`); + const otherText = question.locator(`#${otherTextId}`); + const exclusive = question.locator(`#${exclusiveId}`); + + await expect(question.locator('.screen-reader-focus')).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(first).toBeFocused(); + await page.keyboard.press('Space'); + await expect(first).toBeChecked(); + await expect(first).toBeFocused(); + await page.keyboard.press('Space'); + await expect(first).not.toBeChecked(); + await expect(first).toBeFocused(); + + await page.keyboard.press('Space'); + await page.keyboard.press('Tab'); + await expect(second).toBeFocused(); + await page.keyboard.press('Space'); + await expect(second).toBeChecked(); + await expect(second).toBeFocused(); + + await tabUntil(page, (id) => id === otherId); + await page.keyboard.press('Space'); + await waitInHarness(page, 25); + await expect(other).toBeChecked(); + await expect(other).toBeFocused(); + + const afterOther = await harnessSnapshot(page); + expect(afterOther.logs.events).toContainEqual(expect.objectContaining({ + type: 'keydown', + key: ' ', + defaultPrevented: false, + target: expect.objectContaining({ id: otherId }), + })); + + await page.keyboard.press('Tab'); + await expect(otherText).toBeFocused(); + await page.keyboard.type('keyboard-test'); + const caretBeforeLeft = await otherText.evaluate((element) => element.selectionStart); + await page.keyboard.press('ArrowLeft'); + await expect(otherText).toBeFocused(); + expect(await otherText.evaluate((element) => element.selectionStart)).toBe(caretBeforeLeft - 1); + await page.keyboard.press('Shift+Tab'); + await expect(other).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(otherText).toBeFocused(); + + await tabUntilAction(page, 'next'); + await page.keyboard.press('Enter'); + await expect(activeQuestion(page, 'D_362270886')).toBeVisible(); + await flushHarness(page); + const stored = await harnessSnapshot(page); + expect(stored.logs.storeCalls.some(({ changes }) => { + const response = changes['D_726699695_V2.D_384191091']; + return response?.D_747350323 === 'keyboard-test' + && response?.D_384191091?.join(',') === '583826374,636411467,807835037'; + })).toBe(true); + + await goBack(page); + await waitInHarness(page, 550); + const restored = activeQuestion(page, questionId); + await expect(restored.locator(`#${firstId}`)).toBeChecked(); + await expect(restored.locator(`#${secondId}`)).toBeChecked(); + await expect(restored.locator(`#${otherId}`)).toBeChecked(); + await expect(restored.locator(`#${otherTextId}`)).toHaveValue('keyboard-test'); + await page.keyboard.press('Tab'); + await expect(restored.locator(`#${firstId}`)).toBeFocused(); + + await tabUntil(page, (id) => id === exclusiveId); + await page.keyboard.press('Space'); + await waitInHarness(page, 25); + await expect(exclusive).toBeFocused(); + await expect(exclusive).toBeChecked(); + await expect(restored.locator(`#${firstId}`)).not.toBeChecked(); + await expect(restored.locator(`#${secondId}`)).not.toBeChecked(); + await expect(restored.locator(`#${otherId}`)).not.toBeChecked(); + await expect(restored.locator(`#${otherTextId}`)).toHaveValue(''); + + await tabUntilAction(page, 'next'); + await page.keyboard.press('Enter'); + await expect(activeQuestion(page, 'D_588212264')).toBeVisible(); + await flushHarness(page); + const afterExclusive = await harnessSnapshot(page); + expect(afterExclusive.logs.storeCalls.some(({ changes }) => ( + changes['D_726699695_V2.D_384191091']?.D_384191091?.join(',') === '178420302' + ))).toBe(true); + + await goBack(page); + await waitInHarness(page, 550); + await expect(activeQuestion(page, questionId).locator(`#${exclusiveId}`)).toBeChecked(); + await expectHealthyHarness(page); + }); + + test('keeps pointer-only focus convenience for the production Module 1 linked Other response', async ({ page }, testInfo) => { + test.skip(!NATIVE_KEYBOARD_PROJECTS.has(testInfo.project.name), 'The pointer regression runs in each desktop browser engine and the Windows browser branch.'); + + const questionId = 'D_384191091'; + await openParticipant(page, { + markdown: readLockedMarkdown('module1'), + persistedData: { treeJSON: treeAt(questionId, 'RACEETHINTRO') }, + }); + await waitInHarness(page, 550); + + const question = activeQuestion(page, questionId); + const other = question.locator(`#${questionId}_807835037`); + const otherText = question.locator('#D_747350323'); + await question.locator(`label[for="${questionId}_807835037"]`).click({ position: { x: 8, y: 8 } }); + await expect(other).toBeChecked(); + await expect(otherText).toBeFocused(); + }); + + test('keeps the production Module 1 textarea arrows, storage, and Back restoration native', async ({ page }, testInfo) => { + test.skip(!TAB_PROJECTS.has(testInfo.project.name), 'Safari Full Keyboard Access has a separate manual contract.'); + + const questionId = 'D_868232409'; + const textareaId = `${questionId}_ta`; + await openParticipant(page, { + markdown: readLockedMarkdown('module1'), + previousResults: { age: '45' }, + persistedData: { treeJSON: treeAt(questionId, 'D_700374192') }, + }); + await waitInHarness(page, 550); + + const question = activeQuestion(page, questionId); + const textarea = question.locator(`#${textareaId}`); + const next = question.getByRole('button', { name: 'Next question' }); + const back = question.getByRole('button', { name: 'Back to the previous question' }); + await expect(question.locator('.screen-reader-focus')).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(textarea).toBeFocused(); + await page.keyboard.type('first line'); + await page.keyboard.press('Enter'); + await page.keyboard.type('second line'); + + await textarea.evaluate((element) => element.setSelectionRange(0, 0)); + await page.keyboard.press('ArrowDown'); + const afterDown = await textarea.evaluate((element) => ({ + focused: document.activeElement === element, + caret: element.selectionStart, + })); + expect(afterDown.focused).toBe(true); + expect(afterDown.caret).toBeGreaterThan(0); + await page.keyboard.press('ArrowUp'); + await expect(textarea).toBeFocused(); + expect(await textarea.evaluate((element) => element.selectionStart)).toBe(0); + + await page.keyboard.press('Tab'); + await expect(next).toBeFocused(); + await tabUntilAction(page, 'previous'); + await expect(back).toBeFocused(); + await next.focus(); + await expect(next).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(activeQuestion(page, 'D_739294356')).toBeVisible(); + await flushHarness(page); + expect((await harnessSnapshot(page)).logs.storeCalls.some(({ changes }) => ( + changes['D_726699695_V2.D_868232409'] === 'first line\nsecond line' + ))).toBe(true); + + await goBack(page); + await waitInHarness(page, 550); + await expect(activeQuestion(page, questionId).locator('.screen-reader-focus')).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(activeQuestion(page, questionId).locator(`#${textareaId}`)).toBeFocused(); + await expect(activeQuestion(page, questionId).locator(`#${textareaId}`)).toHaveValue('first line\nsecond line'); + await expectHealthyHarness(page); + }); + + test('Tab reaches each checkbox, and keyboard toggles persist through Next and Back', async ({ page }, testInfo) => { + test.skip(!TAB_PROJECTS.has(testInfo.project.name), 'Safari Full Keyboard Access has a separate manual contract.'); + + await openParticipant(page); + await waitInHarness(page, 550); + const choice = activeQuestion(page, 'CHOICE').locator('#CHOICE_1'); + await pressSpace(page, choice); + await goNext(page); + await expect(activeQuestion(page, 'CHECKS')).toBeVisible(); + await waitInHarness(page, 550); + + const checks = activeQuestion(page, 'CHECKS'); + const email = checks.locator('#CHECKS_1'); + const text = checks.locator('#CHECKS_2'); + await page.locator('#host-before').focus(); + await tabUntil(page, (id) => id === 'CHECKS_1'); + await expect(email).toBeFocused(); + const emailLabel = checks.locator('label[for="CHECKS_1"]'); + const textLabel = checks.locator('label[for="CHECKS_2"]'); + await expect(emailLabel).toHaveCSS('outline-style', 'solid'); + await expect(emailLabel).toHaveCSS('outline-width', '3px'); + await expect(emailLabel).toHaveCSS('outline-color', 'rgb(28, 93, 134)'); + await expect(textLabel).toHaveCSS('outline-style', 'none'); + await page.keyboard.press('Space'); + await waitInHarness(page, 150); + await expect(email).toBeChecked(); + await expect(email).toBeFocused(); + + await tabUntil(page, (id) => id === 'CHECKS_2'); + await expect(text).toBeFocused(); + await page.keyboard.press('Space'); + await waitInHarness(page, 150); + await expect(text).toBeChecked(); + await expect(text).toBeFocused(); + + expect((await harnessSnapshot(page)).state.active).toMatchObject({ CHECKS: ['1', '2'] }); + await goNext(page); + await expect(activeQuestion(page, 'TEXT')).toBeVisible(); + await flushHarness(page); + + const stored = await harnessSnapshot(page); + expect(stored.state.survey).toMatchObject({ CHECKS: ['1', '2'] }); + expect(stored.logs.storeCalls.some(({ changes }) => ( + Array.isArray(changes['TEST_RUNTIME.CHECKS']) + && changes['TEST_RUNTIME.CHECKS'].join(',') === '1,2' + ))).toBe(true); + + await goBack(page); + await expect(activeQuestion(page, 'CHECKS')).toBeVisible(); + await expect(activeQuestion(page, 'CHECKS').locator('#CHECKS_1')).toBeChecked(); + await expect(activeQuestion(page, 'CHECKS').locator('#CHECKS_2')).toBeChecked(); + }); + + test('native select arrows retain focus without a Quest key trap, and native selection persists on blur', async ({ page }, testInfo) => { + test.skip(!NATIVE_KEYBOARD_PROJECTS.has(testInfo.project.name), 'The native select contract runs in every desktop engine and the Windows browser branch.'); + + await openParticipant(page, { fixture: 'nativeSelect.txt' }); + await waitInHarness(page, 550); + const select = activeQuestion(page, 'STATE').locator('#home_state'); + await select.focus(); + await expect(select).toBeFocused(); + await page.evaluate(() => { + const root = document.querySelector('#questionnaireRoot'); + root.addEventListener('keydown', (event) => { + if (event.target.id === 'home_state') { + root.dataset.lastNativeSelectKeydown = JSON.stringify({ + defaultPrevented: event.defaultPrevented, + activeId: document.activeElement?.id ?? null, + }); + } + }, { once: true }); + }); + await page.keyboard.press('ArrowDown'); + const nativeKeydown = await page.locator('#questionnaireRoot').evaluate((root) => ( + JSON.parse(root.dataset.lastNativeSelectKeydown) + )); + expect(nativeKeydown).toEqual({ defaultPrevented: false, activeId: 'home_state' }); + await expect(select).toBeFocused(); + + // Native popup open/commit/dismiss keys are platform controls. Exercise + // state persistence separately without pretending a single arrow must + // change a disabled placeholder in every browser/OS combination. + await select.selectOption('MD'); + const selectedValue = await select.inputValue(); + await select.blur(); + await waitInHarness(page, 50); + expect((await harnessSnapshot(page)).state.active).toMatchObject({ STATE: selectedValue }); + await goNext(page); + await expect(activeQuestion(page, 'END')).toBeVisible(); + await flushHarness(page); + const stored = await harnessSnapshot(page); + expect(stored.state.survey).toMatchObject({ STATE: selectedValue }); + expect(stored.logs.storeCalls.some(({ changes }) => ( + changes['TEST_NATIVE_SELECT.STATE'] === selectedValue + ))).toBe(true); + }); + + test('native grid radio keyboard selection keeps its visual proxy, state, storage, and Back restoration aligned', async ({ page }, testInfo) => { + test.skip(!new Set(['chromium-desktop', 'chromium-windows-ua']).has(testInfo.project.name), 'The grid keyboard path runs in Chromium and the Windows user-agent parity project.'); + + await page.setViewportSize({ width: 1_000, height: 800 }); + await openParticipant(page, { fixture: 'gridResponsive.txt' }); + await waitInHarness(page, 550); + await goNext(page); + await waitInHarness(page, 550); + const grid = activeQuestion(page, 'GRID_RATE'); + const never = grid.locator('#GRID_WALK_0'); + const neverLabel = never.locator('xpath=following-sibling::label'); + const sometimes = grid.locator('#GRID_WALK_1'); + const sometimesLabel = sometimes.locator('xpath=following-sibling::label'); + + await expect(grid.locator('.screen-reader-focus')).toBeFocused(); + await expect(grid.locator('.screen-reader-focus')).toHaveAttribute('tabindex', '-1'); + await page.keyboard.press('Tab'); + await expect(never).toBeFocused(); + const focusMarkerBeforeSelection = await neverLabel.evaluate((label) => { + const style = getComputedStyle(label, '::before'); + return { + borderColor: style.borderColor, + outlineStyle: style.outlineStyle, + outlineWidth: style.outlineWidth, + outlineColor: style.outlineColor, + }; + }); + expect(focusMarkerBeforeSelection.borderColor).toBe('rgb(28, 93, 134)'); + expect(focusMarkerBeforeSelection).toMatchObject({ + outlineStyle: 'solid', + outlineWidth: '3px', + outlineColor: 'rgb(28, 93, 134)', + }); + await expectGridFocusIndicatorUnclipped(neverLabel); + await page.keyboard.press('ArrowRight'); + await waitInHarness(page, 300); + await expect(sometimes).toBeChecked(); + await expect(sometimes).toBeFocused(); + await expect(grid.locator('#srFocusHelper')).toHaveCount(0); + await expect(sometimesLabel).toBeVisible(); + + const marker = await sometimesLabel.evaluate((label) => { + const selectedStyle = getComputedStyle(label, '::after'); + const focusStyle = getComputedStyle(label, '::before'); + return { + display: selectedStyle.display, + width: Number.parseFloat(selectedStyle.width), + focusOutlineStyle: focusStyle.outlineStyle, + focusOutlineWidth: focusStyle.outlineWidth, + focusOutlineColor: focusStyle.outlineColor, + }; + }); + expect(marker.display).toBe('block'); + expect(marker.width).toBeGreaterThan(0); + expect(marker).toMatchObject({ + focusOutlineStyle: 'solid', + focusOutlineWidth: '3px', + focusOutlineColor: 'rgb(28, 93, 134)', + }); + + await page.keyboard.press('Tab'); + await expect(grid.locator('#GRID_CYCLE_0')).toBeFocused(); + + const selectedValue = await sometimes.getAttribute('value'); + expect((await harnessSnapshot(page)).state.active).toMatchObject({ + GRID_RATE: { GRID_WALK: selectedValue }, + }); + + // Complete the second required row through the existing pointer path so + // the keyboard selection can cross Quest's normal storage boundary. + await grid.locator('tr[data-question-id="GRID_CYCLE"] label', { hasText: 'Sometimes' }).click(); + await expect(grid.locator('#GRID_CYCLE_1')).toBeChecked(); + await goNext(page); + await expect(activeQuestion(page, 'END')).toBeVisible(); + await flushHarness(page); + + const stored = await harnessSnapshot(page); + expect(stored.state.survey).toMatchObject({ + GRID_RATE: { GRID_WALK: selectedValue }, + }); + expect(stored.logs.storeCalls.some(({ changes }) => ( + changes['TEST_GRID.GRID_RATE']?.GRID_WALK === selectedValue + ))).toBe(true); + + await activeQuestion(page, 'END').getByRole('button', { name: 'Back to the previous section' }).click(); + await expect(activeQuestion(page, 'GRID_RATE')).toBeVisible(); + await expect(activeQuestion(page, 'GRID_RATE').locator('#GRID_WALK_1')).toBeChecked(); + }); + + test('native checkbox-grid traversal and selection retain native focus', async ({ page }, testInfo) => { + test.skip(!new Set(['chromium-desktop', 'chromium-windows-ua']).has(testInfo.project.name), 'The checkbox-grid keyboard path runs in Chromium and the Windows user-agent parity project.'); + + await page.setViewportSize({ width: 1_000, height: 800 }); + await openParticipant(page, { fixture: 'gridCheckboxFocus.txt' }); + await waitInHarness(page, 550); + await goNext(page); + await waitInHarness(page, 550); + const grid = activeQuestion(page, 'GRID_CHECK'); + const controls = [ + grid.locator('#GRID_CHECK_ROW_A_0'), + grid.locator('#GRID_CHECK_ROW_A_1'), + grid.locator('#GRID_CHECK_ROW_B_0'), + grid.locator('#GRID_CHECK_ROW_B_1'), + ]; + + await expect(grid.locator('.screen-reader-focus')).toBeFocused(); + await expect(grid.locator('.screen-reader-focus')).toHaveAttribute('tabindex', '-1'); + await expect(grid.locator('#srFocusHelper')).toHaveCount(0); + + for (const [index, control] of controls.entries()) { + await page.keyboard.press('Tab'); + await expect(control).toBeFocused(); + if (index === 0) { + await expectGridFocusIndicatorUnclipped( + control.locator('xpath=following-sibling::label'), + ); + } + if (index === 0 || index === controls.length - 1) { + await page.keyboard.press('Space'); + await waitInHarness(page, 150); + await expect(control).toBeChecked(); + await expect(control).toBeFocused(); + } + } + + await page.keyboard.press('Tab'); + await expect(grid.getByRole('button', { name: 'Next question' })).toBeFocused(); + await waitInHarness(page, 150); + await expect(grid.getByRole('button', { name: 'Next question' })).toBeFocused(); + await expect(grid.locator('.response[tabindex]')).toHaveCount(0); + }); + + test('focus and selected-state indicators survive Windows forced-colors mode', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium-desktop', 'Forced-colors rendering is exercised once in Chromium.'); + await page.emulateMedia({ forcedColors: 'active' }); + await openParticipant(page); + await waitInHarness(page, 550); + const choice = activeQuestion(page, 'CHOICE').locator('#CHOICE_1'); + const label = activeQuestion(page, 'CHOICE').locator('label[for="CHOICE_1"]'); + await choice.focus(); + await page.keyboard.press('Space'); + await expect(choice).toBeChecked(); + await expect(label).toHaveCSS('outline-style', 'solid'); + await expect(label).toHaveCSS('outline-width', '3px'); + expect(await label.evaluate((element) => { + const style = getComputedStyle(element); + return { + forcedColorAdjust: style.forcedColorAdjust, + backgroundColor: style.backgroundColor, + color: style.color, + }; + })).toMatchObject({ forcedColorAdjust: 'none' }); + + await openParticipant(page, { fixture: 'gridResponsive.txt' }); + await waitInHarness(page, 550); + await goNext(page); + await waitInHarness(page, 550); + const gridChoice = activeQuestion(page, 'GRID_RATE').locator('#GRID_WALK_1'); + const gridLabel = gridChoice.locator('xpath=following-sibling::label'); + await gridChoice.focus(); + await page.keyboard.press('Space'); + await expect(gridChoice).toBeChecked(); + const forcedMarker = await gridLabel.evaluate((element) => ({ + focusOutlineStyle: getComputedStyle(element, '::before').outlineStyle, + focusOutlineWidth: getComputedStyle(element, '::before').outlineWidth, + markerBackground: getComputedStyle(element, '::after').backgroundColor, + markerForcedColorAdjust: getComputedStyle(element, '::after').forcedColorAdjust, + })); + expect(forcedMarker).toMatchObject({ + focusOutlineStyle: 'solid', + focusOutlineWidth: '3px', + markerForcedColorAdjust: 'none', + }); + expect(forcedMarker.markerBackground).not.toBe('rgba(0, 0, 0, 0)'); + }); +}); diff --git a/tests/e2e/knownDefects.spec.js b/tests/e2e/knownDefects.spec.js index a556a1a..b6e6d99 100644 --- a/tests/e2e/knownDefects.spec.js +++ b/tests/e2e/knownDefects.spec.js @@ -1,20 +1,13 @@ import { test, expect } from './support/test.js'; -import { activeQuestion, goNext, openParticipant, selectLabeledResponse, waitInHarness } from './support/harness.js'; +import { activeQuestion, goNext, openParticipant, waitInHarness } from './support/harness.js'; import { analyzeQuestAxe, matchesAxeDefect } from './support/axe.js'; -import { accessibilityDefects, axeDefects, runtimeDefects } from '../knownDefects/registry.js'; +import { accessibilityDefects, axeDefects } from '../knownDefects/registry.js'; -const ASSISTIVE_TECH_ENGINES = new Set(['chromium-desktop', 'webkit-desktop']); const CHOICE_SEMANTIC_PROJECTS = new Set([ - ...ASSISTIVE_TECH_ENGINES, - 'chromium-windows-ua', -]); -const NATIVE_KEYBOARD_ENGINES = new Set([ 'chromium-desktop', - 'firefox-desktop', 'webkit-desktop', 'chromium-windows-ua', ]); - async function axeFindingsForDefect(page, defect) { const findings = await analyzeQuestAxe(page); return findings.filter((finding) => matchesAxeDefect(finding, defect)); @@ -64,6 +57,16 @@ test.describe('open accessibility regressions @known-defect', () => { }, ); + characterizeAxeDefect( + 'participant action text retains sufficient contrast while hovered', + axeDefects.actionHoverContrast, + async (page) => { + await openParticipant(page, { fixture: 'validation.txt' }); + await activeQuestion(page, 'BOUNDED').getByRole('button', { name: 'Next question' }).hover(); + }, + 'firefox-desktop', + ); + characterizeAxeDefect( 'bounded numeric input has a non-title label', axeDefects.validationLabel, @@ -75,51 +78,6 @@ test.describe('open accessibility regressions @known-defect', () => { }, ); - test('#1079 exposes named radio roles and their checked state', async ({ page }, testInfo) => { - test.skip(!ASSISTIVE_TECH_ENGINES.has(testInfo.project.name), 'Chromium and WebKit capture the JAWS/VoiceOver semantic baseline.'); - - await openParticipant(page); - await selectLabeledResponse(page, 'Blue'); - const question = activeQuestion(page, 'CHOICE'); - expect(await question.locator('input[type="radio"]').count()).toBe(2); - await expect(question.locator('#CHOICE_1')).toBeChecked(); - test.fail(true, `${accessibilityDefects[1079].issue}: ${accessibilityDefects[1079].automatedContract}`); - expect(await question.getByRole('radio').count()).toBe(2); - await expect(question.getByRole('radio', { name: 'Blue' })).toBeChecked(); - await expect(question.getByRole('radio', { name: 'Green' })).not.toBeChecked(); - }); - - test('#1079 preserves named radio roles and state in the Windows branch', async ({ page }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium-windows-ua', 'This semantic contract targets Quest’s Windows-specific branch.'); - - await openParticipant(page); - await selectLabeledResponse(page, 'Green'); - const question = activeQuestion(page, 'CHOICE'); - expect(await question.locator('input[type="radio"]').count()).toBe(2); - await expect(question.locator('#CHOICE_2')).toBeChecked(); - test.fail(true, `${accessibilityDefects[1079].issue}: ${accessibilityDefects[1079].automatedContract}`); - expect(await question.getByRole('radio').count()).toBe(2); - await expect(question.getByRole('radio', { name: 'Blue' })).not.toBeChecked(); - await expect(question.getByRole('radio', { name: 'Green' })).toBeChecked(); - }); - - test('#1079 exposes named checkbox roles and their checked state', async ({ page }, testInfo) => { - test.skip(!CHOICE_SEMANTIC_PROJECTS.has(testInfo.project.name), 'Chromium, WebKit, and the Windows branch capture the assistive-technology semantic baseline.'); - - await openParticipant(page); - await selectLabeledResponse(page, 'Blue'); - await goNext(page); - await selectLabeledResponse(page, 'Email'); - - const question = activeQuestion(page, 'CHECKS'); - expect(await question.locator('input[type="checkbox"]').count()).toBe(2); - await expect(question.locator('#CHECKS_1')).toBeChecked(); - test.fail(true, `${accessibilityDefects[1079].issue}: native checkboxes must expose role, name, and checked state.`); - expect(await question.getByRole('checkbox').count()).toBe(2); - await expect(question.getByRole('checkbox', { name: 'Email' })).toBeChecked(); - await expect(question.getByRole('checkbox', { name: 'Text message' })).not.toBeChecked(); - }); - test('#1079 exposes each grid choice with its row and column name plus checked state', async ({ page }, testInfo) => { test.skip(!CHOICE_SEMANTIC_PROJECTS.has(testInfo.project.name), 'Chromium, WebKit, and the Windows branch capture the assistive-technology semantic baseline.'); @@ -137,113 +95,4 @@ test.describe('open accessibility regressions @known-defect', () => { await expect(question.getByRole('radio', { name: /Cycling.*Often|Often.*Cycling/ })).not.toBeChecked(); }); - test('#1079 restores focus to a Windows list choice after pointer activation', async ({ page }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium-windows-ua', 'This focus contract targets Quest’s Windows-specific list branch.'); - - await openParticipant(page); - await waitInHarness(page, 550); - await selectLabeledResponse(page, 'Green'); - await waitInHarness(page, 150); - await expect(activeQuestion(page, 'CHOICE').locator('#CHOICE_2')).toBeChecked(); - test.fail(true, `${accessibilityDefects[1079].issue}: list activation must return focus to the operable native choice.`); - await expect(activeQuestion(page, 'CHOICE').locator('#CHOICE_2')).toBeFocused(); - }); - - test('#1587 retains native radio focus and activates it with Space', async ({ page }, testInfo) => { - test.skip(!NATIVE_KEYBOARD_ENGINES.has(testInfo.project.name), 'The native keyboard baseline runs in every desktop engine.'); - - await openParticipant(page); - const first = activeQuestion(page).locator('#CHOICE_1'); - await expect(first).not.toBeChecked(); - await first.focus(); - // Retaining native input focus is the first currently broken part of this - // keyboard contract. Once fixed, the same test advances to Space itself. - test.fail(true, `${accessibilityDefects[1587].issue}: a native radio must retain focus and Space must check it.`); - await expect(first).toBeFocused(); - await page.keyboard.press('Space'); - const checked = await first.isChecked(); - expect(checked).toBe(true); - }); - - test('#1587 preserves native radio arrow movement and group exclusivity', async ({ page }, testInfo) => { - test.skip(!NATIVE_KEYBOARD_ENGINES.has(testInfo.project.name), 'The native keyboard baseline runs in every desktop engine.'); - - await openParticipant(page); - await selectLabeledResponse(page, 'Blue'); - const first = activeQuestion(page).locator('#CHOICE_1'); - const second = activeQuestion(page).locator('#CHOICE_2'); - await expect(first).toBeChecked(); - await expect(second).not.toBeChecked(); - await first.focus(); - test.fail(true, `${accessibilityDefects[1587].issue}: a native radio must retain focus before arrows move its checked group state.`); - await expect(first).toBeFocused(); - await page.keyboard.press('ArrowRight'); - const actual = { - first: await first.isChecked(), - second: await second.isChecked(), - focused: await page.evaluate(() => document.activeElement?.id), - }; - expect(actual).toEqual({ first: false, second: true, focused: 'CHOICE_2' }); - }); - - test('#1587 retains native checkbox focus and toggles it with Space', async ({ page }, testInfo) => { - test.skip(!NATIVE_KEYBOARD_ENGINES.has(testInfo.project.name), 'The native keyboard baseline runs in every desktop engine.'); - - await openParticipant(page); - await activeQuestion(page).locator('label', { hasText: 'Blue' }).click(); - await activeQuestion(page).getByRole('button', { name: 'Next question' }).click(); - const checkbox = activeQuestion(page, 'CHECKS').locator('#CHECKS_1'); - await expect(checkbox).not.toBeChecked(); - await checkbox.focus(); - test.fail(true, `${accessibilityDefects[1587].issue}: a native checkbox must retain focus and Space must toggle it.`); - await expect(checkbox).toBeFocused(); - await page.keyboard.press('Space'); - const selected = await checkbox.isChecked(); - await page.keyboard.press('Space'); - const deselected = await checkbox.isChecked(); - expect([selected, deselected]).toEqual([true, false]); - }); - - test('#1587 preserves the browser-native select keyboard contract', async ({ page }, testInfo) => { - test.skip(!NATIVE_KEYBOARD_ENGINES.has(testInfo.project.name), 'The native keyboard baseline runs in every desktop engine.'); - - await openParticipant(page, { fixture: 'nativeSelect.txt' }); - const stateSelect = activeQuestion(page, 'STATE').locator('#home_state'); - await stateSelect.focus(); - await expect(stateSelect).toBeFocused(); - await page.keyboard.press('ArrowDown'); - const value = await stateSelect.inputValue(); - test.fail(true, `${accessibilityDefects[1587].issue}: Quest must not trap native select keys.`); - expect(value).not.toBe(''); - }); - - test('restores focus to the submit trigger after Escape closes its dialog', async ({ page }, testInfo) => { - test.skip(!NATIVE_KEYBOARD_ENGINES.has(testInfo.project.name), 'The modal focus contract runs in every desktop engine.'); - - await openParticipant(page, { fixture: 'validation.txt' }); - await activeQuestion(page, 'BOUNDED').locator('#bounded').fill('2'); - await goNext(page); - const trigger = activeQuestion(page, 'END').getByRole('button', { name: 'Submit your survey' }); - const modal = page.locator('#submitModal'); - await trigger.click(); - await expect(modal).toHaveClass(/show/); - await expect(page.locator('#submitModalTitle')).toBeFocused(); - await page.keyboard.press('Escape'); - await waitInHarness(page, 700); - await expect(modal).not.toHaveClass(/show/); - test.fail(true, `${runtimeDefects.submitFocusRestore.localDefectId}: ${runtimeDefects.submitFocusRestore.reason}`); - expect(await trigger.evaluate((element) => element === document.activeElement)).toBe(true); - }); - - test('keeps focus on a response dialog when the pending question-focus timer completes', async ({ page }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium-desktop', 'The timer race is characterized once in Chromium.'); - - await openParticipant(page); - await goNext(page); - const title = page.locator('#softModalTitle'); - await expect(title).toBeFocused(); - await waitInHarness(page, 550); - test.fail(true, `${runtimeDefects.modalQuestionFocusRace.localDefectId}: ${runtimeDefects.modalQuestionFocusRace.reason}`); - await expect(title).toBeFocused(); - }); }); diff --git a/tests/e2e/module4AddressPaths.spec.js b/tests/e2e/module4AddressPaths.spec.js index cd85452..806e425 100644 --- a/tests/e2e/module4AddressPaths.spec.js +++ b/tests/e2e/module4AddressPaths.spec.js @@ -40,7 +40,14 @@ function treeThrough(previousQuestionId, currentQuestionId) { }); } +async function waitForQuestionFocus(page, questionId) { + // Let Quest's intentional delayed question-focus handoff finish before + // Playwright begins a multi-field entry sequence. + await expect(activeQuestion(page, questionId).locator('.screen-reader-focus')).toBeFocused(); +} + async function fillCompletePrimaryAddress(page) { + await waitForQuestionFocus(page, 'D_121490150'); const question = activeQuestion(page, 'D_121490150'); await question.locator('#D_255248624').fill('123'); await question.locator('#D_945532934').fill('Main Street'); @@ -112,9 +119,11 @@ test.describe('Module 4 residential-address paths @canonical @corpus', () => { await goNext(page); await continueWithoutAnswering(page); await expect(activeQuestion(page, 'D_920576363')).toBeVisible(); + await waitForQuestionFocus(page, 'D_920576363'); await activeQuestion(page, 'D_920576363').locator('#D_725583683').fill('Lakeview'); await goNext(page); await expect(activeQuestion(page, 'D_804504024')).toBeVisible(); + await waitForQuestionFocus(page, 'D_804504024'); await activeQuestion(page, 'D_804504024').locator('#D_105043152').fill('First Street'); await activeQuestion(page, 'D_804504024').locator('#D_543135391').fill('Second Avenue'); await goNext(page); @@ -133,10 +142,12 @@ test.describe('Module 4 residential-address paths @canonical @corpus', () => { test('uses missing-field backup for a partial street address without offering the cross-street fallback', async ({ page }) => { await openParticipant(page, { markdown: englishAddressFixture }); + await waitForQuestionFocus(page, 'D_121490150'); await activeQuestion(page, 'D_121490150').locator('#D_255248624').fill('44'); await activeQuestion(page, 'D_121490150').locator('#D_945532934').fill('Partial Place'); await goNext(page); await expect(activeQuestion(page, 'D_920576363')).toBeVisible(); + await waitForQuestionFocus(page, 'D_920576363'); await activeQuestion(page, 'D_920576363').locator('#D_725583683').fill('Lakeview'); await goNext(page); diff --git a/tests/e2e/participantAsync.spec.js b/tests/e2e/participantAsync.spec.js index c1b422f..a4f90e5 100644 --- a/tests/e2e/participantAsync.spec.js +++ b/tests/e2e/participantAsync.spec.js @@ -71,4 +71,49 @@ test.describe('host-provided asynchronous questions @core @canonical', () => { const snapshot = await expectHealthyHarness(page, { allowErrors: true }); expect(snapshot.logs.errors.some((entry) => entry.message.includes('Synthetic async failure'))).toBe(true); }); + + test('waits for delayed async markup before focusing after optional-modal continuation', async ({ page }) => { + await openParticipant(page, { + markdown: ` + {"name":"TEST_ASYNC_MODAL"} + + [OPTIONAL?] You may continue without answering. + (1) Optional response + + [ASYNC?] + + [END,end] Async modal focus testing complete. + `, + asyncQuestionsMap: ASYNC_MAP, + asyncQuestionHtml: ASYNC_HTML, + asyncDelayMs: 400, + }); + await expect(activeQuestion(page, 'OPTIONAL').locator('.screen-reader-focus')).toBeFocused(); + + await page.evaluate(() => { + window.__asyncQuestionFocusHistory = []; + document.addEventListener('focusin', (event) => { + if (!event.target.matches?.('.screen-reader-focus')) return; + const question = event.target.closest('form.question'); + window.__asyncQuestionFocusHistory.push({ + questionId: question?.id ?? null, + hasResponseMarkup: Boolean(question?.querySelector('.response')), + }); + }, true); + }); + + await goNext(page); + const dialog = page.getByRole('dialog', { name: 'Response Requested' }); + await expect(dialog).toBeVisible(); + await dialog.getByRole('button', { name: 'Continue Without Answering' }).click(); + + await expect(activeQuestion(page, 'ASYNC').locator('#ASYNC_A')).toHaveCount(1); + await expect(activeQuestion(page, 'ASYNC').locator('.screen-reader-focus')).toBeFocused(); + + const asyncFocusHistory = await page.evaluate(() => window.__asyncQuestionFocusHistory); + const asyncQuestionFocus = asyncFocusHistory.filter(({ questionId }) => questionId === 'ASYNC'); + expect(asyncQuestionFocus.length).toBeGreaterThan(0); + expect(asyncQuestionFocus.every(({ hasResponseMarkup }) => hasResponseMarkup)).toBe(true); + await expectHealthyHarness(page); + }); }); diff --git a/tests/e2e/participantControls.spec.js b/tests/e2e/participantControls.spec.js index 79ae14d..0674f0e 100644 --- a/tests/e2e/participantControls.spec.js +++ b/tests/e2e/participantControls.spec.js @@ -6,6 +6,7 @@ import { harnessSnapshot, openParticipant, selectLabeledResponse, + waitInHarness, } from './support/harness.js'; const CONTROL_PROJECTS = new Set([ @@ -120,6 +121,7 @@ test.describe('participant controls @core @canonical', () => { test('delegated keyboard submits activate Next, Reset, and Back with Enter and Space @windows-a11y', async ({ page }) => { await openParticipant(page, { fixture: 'navigationState.txt' }); + await waitInHarness(page, 550); async function pressAction(name, key, expectedQuestionId) { const button = activeQuestion(page).getByRole('button', { name }); @@ -191,16 +193,14 @@ test.describe('participant controls @core @canonical', () => { expect(snapshot.logs.errors).toEqual([]); }); - test('uses prefetched text while loading the URL-signaled Quest styles from locked local files', async ({ page, diagnostics }) => { + test('uses prefetched text while loading URL-signaled styles beside the local Quest module', async ({ page }) => { const questionnaireUrl = 'https://questionnaire.test/prod/module.txt'; await openParticipant(page, { url: questionnaireUrl, questVersion: '2.0.0-test' }); await expect(activeQuestion(page, 'CHOICE')).toBeVisible(); await expect(page.locator('head link[rel="stylesheet"][href^="blob:"]')).toHaveCount(2); - expect(diagnostics.fulfilledExternalRequests).toEqual(expect.arrayContaining([ - 'https://episphere.github.io/quest-dev/ActiveLogic.css', - 'https://episphere.github.io/quest-dev/Style1.css', - ])); + const questBasePath = (await harnessSnapshot(page)).runtime.basePath; + expect(questBasePath).toBe(new URL('/', page.url()).href); const snapshot = await expectHealthyHarness(page); expect(snapshot.logs.renders[0].config).toMatchObject({ diff --git a/tests/e2e/popoverAccessibility.spec.js b/tests/e2e/popoverAccessibility.spec.js index fed6514..42b6bb1 100644 --- a/tests/e2e/popoverAccessibility.spec.js +++ b/tests/e2e/popoverAccessibility.spec.js @@ -1,5 +1,13 @@ import { test, expect } from './support/test.js'; -import { activeQuestion, expectHealthyHarness, openParticipant } from './support/harness.js'; +import { + activeQuestion, + expectHealthyHarness, + goBack, + goNext, + harnessSnapshot, + openParticipant, + waitInHarness, +} from './support/harness.js'; const DESKTOP_ENGINES = new Set([ 'chromium-desktop', @@ -11,7 +19,24 @@ const DESKTOP_ENGINES = new Set([ const POPOVER_MARKDOWN = `{"name":"TEST_POPOVER_A11Y"} [PLAIN] Please read the following survey help. -|popup|More information|Help title|Synthetic help text|`; +|popup|More information|Help title|Synthetic help text| + +[FOLLOWUP] Follow-up content. + +[END,end] Complete.`; + +const RESPONSE_POPOVER_MARKDOWN = `{"name":"TEST_RESPONSE_POPOVER_A11Y"} + +[EYE_COLOR?] What is the natural color of your eyes? +(214997383) Blue +(696181630) |popup|Hazel|Informational Text|Hazel is a combination of green and brown.| +(941477699) Brown + +[END,end] Complete.`; + +function helpPopover(page, content = 'Synthetic help text') { + return page.locator('.popover').filter({ hasText: content }); +} test.describe('production-shaped popover accessibility @canonical', () => { test.beforeEach(async ({}, testInfo) => { @@ -21,8 +46,36 @@ test.describe('production-shaped popover accessibility @canonical', () => { ); }); - test('reveals help on keyboard focus, exposes its content, and dismisses on focus departure', async ({ page }) => { + test('includes question-text help in the natural post-render Tab order with visible focus', async ({ page }, testInfo) => { + await openParticipant(page, { markdown: POPOVER_MARKDOWN }); + await waitInHarness(page, 550); + + const question = activeQuestion(page, 'PLAIN'); + const focusTarget = question.locator('.screen-reader-focus'); + const trigger = question.getByRole('button', { name: 'More information' }); + const next = question.getByRole('button', { name: 'Next question' }); + + await expect(focusTarget).toBeFocused(); + await expect(focusTarget).toHaveAttribute('tabindex', '-1'); + + await page.keyboard.press('Tab'); + await expect(trigger).toBeFocused(); + await expect(trigger).toHaveCSS('outline-style', 'solid'); + await expect(trigger).toHaveCSS('outline-width', '3px'); + await expect(trigger).toHaveCSS('outline-color', 'rgb(28, 93, 134)'); + + // Playwright WebKit does not model Safari's Full Keyboard Access setting, + // so its default Tab behavior skips native buttons. Safari traversal from + // the help trigger through Next is part of the manual protocol. + if (testInfo.project.name !== 'webkit-desktop') { + await page.keyboard.press('Tab'); + await expect(next).toBeFocused(); + } + }); + + test('supports pointer, Enter, Space, Escape, and focus-departure behavior exactly once', async ({ page }) => { await openParticipant(page, { markdown: POPOVER_MARKDOWN }); + await waitInHarness(page, 550); const question = activeQuestion(page, 'PLAIN'); await expect(question.locator('legend')).toContainText('Please read the following survey help.'); @@ -31,16 +84,131 @@ test.describe('production-shaped popover accessibility @canonical', () => { await expect(trigger).toBeVisible(); await trigger.focus(); await expect(trigger).toBeFocused(); + await expect(helpPopover(page)).toHaveCount(0); + + const closedEscape = await trigger.evaluate((element) => { + const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Escape' }); + const dispatchResult = element.dispatchEvent(event); + return { + defaultPrevented: event.defaultPrevented, + dispatchResult, + }; + }); + expect(closedEscape).toEqual({ defaultPrevented: false, dispatchResult: true }); + await expect(helpPopover(page)).toHaveCount(0); - const popover = page.locator('.popover').filter({ hasText: 'Synthetic help text' }); + await page.keyboard.press('Enter'); + const popover = helpPopover(page); await expect(popover).toBeVisible(); await expect(popover).toHaveAttribute('role', 'tooltip'); await expect(popover).toContainText('Help title'); await expect(popover).toContainText('Synthetic help text'); await expect(trigger).toHaveAttribute('aria-describedby', await popover.getAttribute('id')); - await page.locator('#host-after').focus(); + await page.keyboard.press('Enter'); await expect(popover).not.toBeVisible(); + expect(await trigger.getAttribute('aria-describedby')).toBeNull(); + + const scrollBeforeSpace = await page.evaluate(() => window.scrollY); + await page.keyboard.press('Space'); + await expect(helpPopover(page)).toBeVisible(); + await expect(trigger).toBeFocused(); + expect(await page.evaluate(() => window.scrollY)).toBe(scrollBeforeSpace); + + await page.keyboard.press('Escape'); + await expect(helpPopover(page)).not.toBeVisible(); + await expect(trigger).toBeFocused(); + + await trigger.click(); + await expect(helpPopover(page)).toBeVisible(); + await expect(trigger).toBeFocused(); + await page.locator('#host-after').focus(); + await expect(helpPopover(page)).not.toBeVisible(); + expect(await trigger.getAttribute('aria-describedby')).toBeNull(); + await expectHealthyHarness(page); + }); + + test('does not let a help control nested in a response label answer the question', async ({ page }) => { + await openParticipant(page, { markdown: RESPONSE_POPOVER_MARKDOWN }); + await waitInHarness(page, 550); + const question = activeQuestion(page, 'EYE_COLOR'); + const trigger = question.getByRole('button', { name: 'Hazel' }); + const response = trigger.locator('xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " response ")]'); + const radio = response.locator('input[type="radio"]'); + const label = response.locator('label'); + + await trigger.focus(); + await page.keyboard.press('Space'); + await expect(helpPopover(page, 'Hazel is a combination')).toBeVisible(); + await expect(radio).not.toBeChecked(); + expect((await harnessSnapshot(page)).state.active).not.toHaveProperty('EYE_COLOR'); + await page.keyboard.press('Escape'); + + await trigger.click(); + await expect(helpPopover(page, 'Hazel is a combination')).toBeVisible(); + await expect(radio).not.toBeChecked(); + expect((await harnessSnapshot(page)).state.active).not.toHaveProperty('EYE_COLOR'); + await page.keyboard.press('Escape'); + + // Click the response-card padding, not its nested help control. + await label.click({ position: { x: 8, y: 8 } }); + await expect(radio).toBeChecked(); + expect((await harnessSnapshot(page)).state.active).toMatchObject({ EYE_COLOR: '696181630' }); + await expectHealthyHarness(page); + }); + + test('disposes an open popover during navigation and reinitializes it on Back', async ({ page }) => { + await openParticipant(page, { markdown: POPOVER_MARKDOWN }); + await waitInHarness(page, 550); + const trigger = activeQuestion(page, 'PLAIN').getByRole('button', { name: 'More information' }); + await trigger.click(); + await expect(helpPopover(page)).toBeVisible(); + + await goNext(page); + await expect(activeQuestion(page, 'FOLLOWUP')).toBeVisible(); + await expect(helpPopover(page)).toHaveCount(0); + + await goBack(page); + const restoredTrigger = activeQuestion(page, 'PLAIN').getByRole('button', { name: 'More information' }); + await restoredTrigger.focus(); + await page.keyboard.press('Enter'); + await expect(helpPopover(page)).toBeVisible(); + await expectHealthyHarness(page); + }); + + test('disposes an open popover before a sequential host render', async ({ page }) => { + await openParticipant(page, { markdown: POPOVER_MARKDOWN }); + await waitInHarness(page, 550); + const originalTrigger = activeQuestion(page, 'PLAIN').getByRole('button', { name: 'More information' }); + await originalTrigger.click(); + await expect(helpPopover(page)).toBeVisible(); + + // Intentionally bypass the ordinary participant harness's one-render guard: + // this is the dedicated contract for Quest's stateful public API boundary. + const renderResult = await page.evaluate(async (markdown) => { + const { transform } = await import('/main.js'); + window.sequentialQuestErrors = []; + const result = await transform.render({ + activate: true, + errorLogger: (...args) => window.sequentialQuestErrors.push(args.map(String).join(' ')), + lang: 'en', + questVersion: 'test-local', + showProgressBarInQuest: true, + store: async () => ({ code: 200 }), + text: markdown, + }, 'questionnaireRoot', {}); + return result; + }, POPOVER_MARKDOWN); + expect(renderResult).toBe(true); + await expect(activeQuestion(page, 'PLAIN')).toBeVisible(); + await expect(helpPopover(page)).toHaveCount(0); + await waitInHarness(page, 550); + + const newTrigger = activeQuestion(page, 'PLAIN').getByRole('button', { name: 'More information' }); + await newTrigger.focus(); + await page.keyboard.press('Enter'); + await expect(helpPopover(page)).toBeVisible(); + expect(await page.evaluate(() => window.sequentialQuestErrors)).toEqual([]); await expectHealthyHarness(page); }); }); diff --git a/tests/e2e/productionCompoundForms.spec.js b/tests/e2e/productionCompoundForms.spec.js index d8a2f88..c15a2bd 100644 --- a/tests/e2e/productionCompoundForms.spec.js +++ b/tests/e2e/productionCompoundForms.spec.js @@ -21,6 +21,7 @@ import { const dietQuestion = 'D_916948380'; const dietVegetables = 'D_970075000'; const dietFruit = 'D_724001040'; +const qualityOfLifeQuestion = 'D_284353934'; const module4Name = 'D_716117817'; const commuteModes = 'D_421586693'; const commuteDuration = 'D_733638576'; @@ -33,6 +34,74 @@ async function chooseRadio(question, name, value) { await question.locator(`label[for="${name}_${value}"]`).click(); } +async function compoundChoiceContract(question, { + groupName, + value, + prompt, + answer, +}) { + const control = question.locator(`input[type="radio"][name="${groupName}"][value="${value}"]`); + await expect(control).toHaveCount(1); + + return control.evaluate((input, expected) => { + const normalize = (text) => String(text ?? '').replace(/\s+/g, ' ').trim().toLowerCase(); + const referencedElements = (element, attribute) => String(element.getAttribute(attribute) ?? '') + .split(/\s+/) + .filter(Boolean) + .map((id) => document.getElementById(id)) + .filter(Boolean); + const referencedText = (element, attribute) => referencedElements(element, attribute) + .map(({ textContent }) => textContent ?? '') + .join(' '); + const accessibleNameSource = (element, nativeName = '') => { + const labelledBy = referencedElements(element, 'aria-labelledby'); + if (labelledBy.length > 0) { + return labelledBy.map(({ textContent }) => textContent ?? '').join(' '); + } + if (element.hasAttribute('aria-label')) { + return element.getAttribute('aria-label') ?? ''; + } + return nativeName; + }; + const radioNames = (container) => new Set(Array.from( + container.querySelectorAll('input[type="radio"]'), + ({ name }) => name, + )); + + // Keep ARIA's name-source precedence intact. Combining overridden sources + // could hide an incomplete aria-labelledby/aria-label regression. + const ownName = accessibleNameSource( + input, + Array.from(input.labels, ({ textContent }) => textContent).join(' '), + ); + const describedContext = referencedText(input, 'aria-describedby'); + const groupedContext = []; + const questionRoot = input.closest('form.question'); + + for (let ancestor = input.parentElement; ancestor && ancestor !== questionRoot; ancestor = ancestor.parentElement) { + const names = radioNames(ancestor); + if (names.size !== 1 || !names.has(input.name)) continue; + + if (ancestor.tagName === 'FIELDSET') { + groupedContext.push(ancestor.querySelector(':scope > legend')?.textContent ?? ''); + } + if (ancestor.getAttribute('role') === 'radiogroup') { + groupedContext.push(accessibleNameSource(ancestor)); + groupedContext.push(referencedText(ancestor, 'aria-describedby')); + } + } + + const normalizedName = normalize(ownName); + const normalizedPrompt = normalize(expected.prompt); + return { + answerNamed: normalizedName.includes(normalize(expected.answer)), + promptContext: normalizedName.includes(normalizedPrompt) + || normalize(describedContext).includes(normalizedPrompt) + || normalize(groupedContext.join(' ')).includes(normalizedPrompt), + }; + }, { prompt, answer }); +} + test.describe('locked production compound-response forms @corpus', () => { test.beforeEach(async ({}, testInfo) => { test.skip(testInfo.project.name !== 'chromium-desktop', 'Production compound flows run once in Chromium.'); @@ -155,7 +224,7 @@ test.describe('locked production compound-response forms @corpus', () => { await expectHealthyHarness(page); }); - test('Diet Screener choices expose both the food subgroup and answer in their accessible names @known-defect', async ({ page }) => { + test('Diet Screener choices expose both the food subgroup and answer in accessible context @known-defect', async ({ page }) => { await openParticipant(page, { markdown: readLockedMarkdown('moduleDietScreener'), persistedData: { treeJSON: treeAt(dietQuestion) }, @@ -165,9 +234,41 @@ test.describe('locked production compound-response forms @corpus', () => { await expect(question).toBeVisible(); expect(await question.locator('input[type="radio"]').count()).toBeGreaterThan(0); test.fail(true, `${accessibilityDefects.compoundQuestionContext.localDefectId}: ${accessibilityDefects.compoundQuestionContext.reason}`); - await expect(question.getByRole('radio', { name: /Vegetables.*1 per week|1 per week.*Vegetables/ })) - .toHaveCount(1, { timeout: 1_000 }); - await expect(question.getByRole('radio', { name: /Fruit.*1 per day|1 per day.*Fruit/ })) - .toHaveCount(1, { timeout: 1_000 }); + const intendedChoices = [ + { groupName: dietVegetables, value: '192247286', prompt: 'Vegetables', answer: '1 per week' }, + { groupName: dietFruit, value: '351464854', prompt: 'Fruit', answer: '1 per day' }, + ]; + for (const expected of intendedChoices) { + const contract = await compoundChoiceContract(question, expected); + expect.soft(contract.answerNamed, `${expected.groupName} must retain its response-option name`).toBe(true); + expect.soft(contract.promptContext, `${expected.groupName} must expose its subgroup prompt`).toBe(true); + } + }); + + test('QoL choices expose each subgroup prompt with its answer @known-defect', async ({ page }) => { + await openParticipant(page, { + markdown: readLockedMarkdown('moduleQoL'), + persistedData: { treeJSON: treeAt(qualityOfLifeQuestion) }, + }); + + const question = activeQuestion(page, qualityOfLifeQuestion); + await expect(question).toBeVisible(); + await expect(question.locator('input[type="radio"]')).toHaveCount(20); + expect(await question.locator('input[type="radio"]').evaluateAll((inputs) => ( + new Set(inputs.map((input) => input.name)).size + ))).toBe(4); + + test.fail(true, `${accessibilityDefects.compoundQuestionContext.localDefectId}: ${accessibilityDefects.compoundQuestionContext.reason}`); + const intendedChoices = [ + { groupName: 'D_559540891', value: '367964536', prompt: 'chores', answer: 'Without any difficulty' }, + { groupName: 'D_917425212', value: '367964536', prompt: 'stairs', answer: 'Without any difficulty' }, + { groupName: 'D_783201540', value: '367964536', prompt: 'walk', answer: 'Without any difficulty' }, + { groupName: 'D_780866928', value: '367964536', prompt: 'errands', answer: 'Without any difficulty' }, + ]; + for (const expected of intendedChoices) { + const contract = await compoundChoiceContract(question, expected); + expect.soft(contract.answerNamed, `${expected.groupName} must retain its response-option name`).toBe(true); + expect.soft(contract.promptContext, `${expected.groupName} must expose its subgroup prompt`).toBe(true); + } }); }); diff --git a/tests/e2e/productionMarkupFidelity.spec.js b/tests/e2e/productionMarkupFidelity.spec.js new file mode 100644 index 0000000..a5df96a --- /dev/null +++ b/tests/e2e/productionMarkupFidelity.spec.js @@ -0,0 +1,289 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { test, expect } from './support/test.js'; +import { + activeQuestion, + expectHealthyHarness, + goBack, + goNext, + openParticipant, +} from './support/harness.js'; +import { readLockedMarkdown, repositoryRoot, treeAt } from './support/corpus.js'; + +const DESKTOP_ENGINES = new Set([ + 'chromium-desktop', + 'firefox-desktop', + 'webkit-desktop', +]); +const hostPersonas = JSON.parse(readFileSync(join(repositoryRoot, 'tests', 'corpus', 'hostPersonas.json'), 'utf8')); +const profilePersona = hostPersonas.personas.find(({ id }) => id === 'external-branch-primary'); + +const INTRO_EXPECTATIONS = { + en: { + paragraphs: [ + { start: 'Welcome, Test Participant!', end: 'skip any questions that you do not want to answer.' }, + { start: 'For some questions,', end: 'Here is an example.' }, + { start: 'Let’s get started.', end: 'Let’s get started.' }, + ], + popup: { + text: 'example.', + title: 'example', + content: 'This is an example of how additional information will be displayed.', + }, + }, + es: { + paragraphs: [ + { start: '¡Bienvenido, Test Participant!', end: 'Se puede saltar cualquier pregunta que no desee responder.' }, + { start: 'En algunas preguntas,', end: 'Este es ejemplo.' }, + { start: 'Comencemos.', end: 'Comencemos.' }, + ], + popup: { + text: 'ejemplo.', + title: 'ejemplo', + content: 'Este es un ejemplo de cómo se verá la información adicional', + }, + }, +}; + +const ADDRESS_EXPECTATIONS = { + en: { + popupText: 'filtered or treated', + popupTitle: 'Informational Text', + popupContent: 'Please only include water that has not been filtered or treated through reverse osmosis, distillation, or filters that remove lead, chlorine, pesticides, or other chemicals. You can include water filtered with water softeners, which are filters that remove only sediment from the water.', + fallbackAddress: 'the current address you provided', + }, + es: { + popupText: 'filtrado o potabilizado', + popupTitle: 'Texto de ayuda', + popupContent: 'Incluya solo agua que no se filtró ni que se potabilizó mediante ósmosis inversa, destilación o filtros que eliminan plomo, cloro, plaguicidas u otras sustancias químicas. Puede incluir agua filtrada con descalcificadores, que son filtros que solo eliminan los sedimentos del agua.', + fallbackAddress: 'la dirección actual que nos dio', + }, +}; + +const addressState = { + D_121490150: { + D_255248624: '123', + D_945532934: 'Production Avenue', + D_303500597: 'Baltimore', + D_195068098: 'Maryland', + D_202784871: '21201', + D_831127170: 'United States', + }, + D_958419506: '901693169', + treeJSON: treeAt('D_539909957', 'D_958419506'), +}; + +async function introProjection(question) { + return question.locator('fieldset > legend.question-text').evaluate((legend) => { + const profile = legend.querySelector('span[name="firstName"]'); + const popup = legend.querySelector('[data-bs-toggle="popover"]'); + const renderedText = legend.innerText.replace(/\r\n?/g, '\n'); + return { + legendCount: legend.parentElement.querySelectorAll(':scope > legend.question-text').length, + helperCount: legend.parentElement.querySelectorAll('.screen-reader-focus').length, + helperTabIndex: legend.parentElement.querySelector('.screen-reader-focus')?.tabIndex ?? null, + paragraphs: renderedText.split(/\n[ \t]*\n/).map((text) => text.trim()).filter(Boolean), + paragraphSeparatorCount: (renderedText.match(/\n[ \t]*\n/g) ?? []).length, + hasExcessiveParagraphSpacing: /(?:\n[ \t]*){3,}/.test(renderedText), + whiteSpace: getComputedStyle(legend).whiteSpace, + profile: profile && { + name: profile.getAttribute('name'), + text: profile.textContent, + }, + popup: popup && { + text: popup.textContent, + title: popup.getAttribute('data-bs-original-title') + || popup.getAttribute('data-bs-title') + || popup.getAttribute('title'), + content: popup.getAttribute('data-bs-content')?.trim(), + trigger: popup.getAttribute('data-bs-trigger'), + }, + }; + }); +} + +async function richAddressProjection(question) { + return question.locator('fieldset').evaluate((fieldset) => { + const legend = fieldset.querySelector(':scope > legend.question-text'); + const helper = fieldset.querySelector(':scope > .screen-reader-focus'); + const firstResponse = fieldset.querySelector(':scope > .response'); + const popup = fieldset.querySelector('.response [data-bs-toggle="popover"]'); + const visibleAddress = Array.from(legend.querySelectorAll('.displayif')) + .find((element) => element.style.display !== 'none' && element.querySelector('[forid], [original-forid]')); + const conditionals = Array.from(legend.querySelectorAll('.displayif'), (element) => ({ + condition: element.getAttribute('displayif'), + text: element.textContent.replace(/\s+/g, ' ').trim(), + visible: getComputedStyle(element).display !== 'none', + })); + const radioLabels = Array.from(fieldset.querySelectorAll('.response input[type="radio"]'), (control) => ({ + id: control.id, + labelCount: control.labels.length, + labelFor: control.labels[0]?.htmlFor ?? null, + })); + + return { + legendCount: fieldset.querySelectorAll(':scope > legend.question-text').length, + helperCount: fieldset.querySelectorAll(':scope > .screen-reader-focus').length, + helperTabIndex: helper?.tabIndex ?? null, + helperImmediatelyBeforeResponse: helper?.nextElementSibling === firstResponse, + rawPipeVisible: legend.textContent.includes('{$'), + renderedLegendText: legend.innerText.replace(/\s+/g, ' ').trim(), + conditionals, + visibleAddressText: visibleAddress?.innerText.replace(/\s+/g, ' ').trim() ?? null, + pipedValues: visibleAddress + ? Array.from(visibleAddress.querySelectorAll('[forid], [original-forid]'), (element) => ({ + id: element.getAttribute('original-forid') ?? element.getAttribute('forid'), + text: element.textContent, + parentTag: element.parentElement.tagName, + visible: getComputedStyle(element).display !== 'none', + })) + : [], + radioLabels, + checkedRadio: fieldset.querySelector('input[type="radio"]:checked')?.id ?? null, + popup: popup && { + text: popup.textContent, + title: popup.getAttribute('data-bs-original-title') + || popup.getAttribute('data-bs-title') + || popup.getAttribute('title'), + content: popup.getAttribute('data-bs-content'), + responseControlId: popup.closest('.response')?.querySelector('input[type="radio"]')?.id ?? null, + responseLabelFor: popup.closest('.response')?.querySelector('label')?.htmlFor ?? null, + }, + }; + }); +} + +test.describe('locked Module 1 intro markup fidelity @canonical @corpus', () => { + test.beforeEach(async ({}, testInfo) => { + test.skip( + !DESKTOP_ENGINES.has(testInfo.project.name), + 'The production markup contract runs in the canonical desktop browser matrix.', + ); + }); + + for (const language of ['en', 'es']) { + test(`keeps ${language} raw blank-line paragraphs, profile text, and popup markup through Next then Back`, async ({ page }, testInfo) => { + const expectation = INTRO_EXPECTATIONS[language]; + const participantInputs = { + ...profilePersona.previousResults, + ...profilePersona.userProfile, + }; + testInfo.annotations.push( + { type: 'corpus-path', description: language === 'en' ? 'prod/module1.txt' : 'prod/module1Spanish.txt' }, + { type: 'host-persona', description: profilePersona.id }, + ); + + await openParticipant(page, { + markdown: readLockedMarkdown('module1', language), + lang: language, + previousResults: participantInputs, + questVersion: '4.0', + }); + + const intro = activeQuestion(page, 'INTROM1'); + await expect(intro).toBeVisible(); + const initial = await introProjection(intro); + + expect(initial).toMatchObject({ + legendCount: 1, + helperCount: 1, + helperTabIndex: -1, + paragraphSeparatorCount: 2, + hasExcessiveParagraphSpacing: false, + whiteSpace: 'pre-line', + profile: { name: 'firstName', text: profilePersona.userProfile.firstName }, + popup: { ...expectation.popup, trigger: 'manual' }, + }); + // The real intro uses three raw text blocks and exactly two blank-line + // separators; it does not rely on authored paragraph elements. + expect(initial.paragraphs).toHaveLength(3); + expectation.paragraphs.forEach(({ start, end }, index) => { + expect(initial.paragraphs[index].startsWith(start)).toBe(true); + expect(initial.paragraphs[index].endsWith(end)).toBe(true); + }); + + await goNext(page); + await expect(activeQuestion(page, 'INTROBAC')).toBeVisible(); + await goBack(page); + await expect(activeQuestion(page, 'INTROM1')).toBeVisible(); + + expect(await introProjection(activeQuestion(page, 'INTROM1'))).toEqual(initial); + await expectHealthyHarness(page); + }); + } +}); + +test.describe('locked Module 4 rich response markup fidelity @canonical @corpus', () => { + test.beforeEach(async ({}, testInfo) => { + test.skip( + !DESKTOP_ENGINES.has(testInfo.project.name), + 'The production response-bearing rich-markup contract runs in the canonical desktop browser matrix.', + ); + }); + + for (const language of ['en', 'es']) { + test(`keeps ${language} address piping, rich response text, and labels through Next then Back`, async ({ page }) => { + const expectation = ADDRESS_EXPECTATIONS[language]; + await openParticipant(page, { + markdown: readLockedMarkdown('module4', language), + lang: language, + persistedData: addressState, + }); + + const question = activeQuestion(page, 'D_539909957'); + await expect(question).toBeVisible(); + const initial = await richAddressProjection(question); + + expect(initial).toMatchObject({ + legendCount: 1, + helperCount: 1, + helperTabIndex: -1, + helperImmediatelyBeforeResponse: true, + rawPipeVisible: false, + checkedRadio: null, + popup: { + text: expectation.popupText, + title: expectation.popupTitle, + content: expectation.popupContent, + responseControlId: 'D_539909957_123108471', + responseLabelFor: 'D_539909957_123108471', + }, + }); + expect(initial.conditionals).toHaveLength(2); + expect(initial.conditionals.filter(({ visible }) => visible)).toHaveLength(1); + expect(initial.conditionals.filter(({ visible }) => !visible)).toHaveLength(1); + expect(initial.conditionals.find(({ visible }) => !visible).text).toContain(expectation.fallbackAddress); + expect(initial.renderedLegendText).not.toContain(expectation.fallbackAddress); + expect(initial.visibleAddressText).toContain('123 Production Avenue'); + expect(initial.visibleAddressText).toContain('Baltimore'); + expect(initial.visibleAddressText).toContain('21201'); + expect(initial.pipedValues).toEqual(expect.arrayContaining([ + { id: 'D_255248624', text: '123', parentTag: 'B', visible: true }, + { id: 'D_945532934', text: 'Production Avenue', parentTag: 'B', visible: true }, + { id: 'D_303500597', text: 'Baltimore', parentTag: 'B', visible: true }, + ])); + expect(initial.pipedValues.filter(({ visible }) => visible).map(({ id }) => id)).toEqual([ + 'D_255248624', + 'D_945532934', + 'D_303500597', + 'D_195068098', + 'D_202784871', + 'D_831127170', + ]); + expect(initial.radioLabels).toHaveLength(8); + expect(initial.radioLabels.every(({ id, labelCount, labelFor }) => ( + labelCount === 1 && labelFor === id + ))).toBe(true); + await question.locator('label[for="D_539909957_463122075"]').click(); + const selected = await richAddressProjection(question); + expect(selected.checkedRadio).toBe('D_539909957_463122075'); + await goNext(page); + await expect(activeQuestion(page)).toBeVisible(); + expect(await activeQuestion(page).getAttribute('id')).not.toBe('D_539909957'); + await goBack(page); + await expect(activeQuestion(page, 'D_539909957')).toBeVisible(); + expect(await richAddressProjection(activeQuestion(page, 'D_539909957'))).toEqual(selected); + await expectHealthyHarness(page); + }); + } +}); diff --git a/tests/e2e/responsive.spec.js b/tests/e2e/responsive.spec.js index 57f7684..e305380 100644 --- a/tests/e2e/responsive.spec.js +++ b/tests/e2e/responsive.spec.js @@ -2,9 +2,12 @@ import { test, expect } from './support/test.js'; import { activeQuestion, expectHealthyHarness, + flushHarness, goNext, + harnessSnapshot, openParticipant, selectLabeledResponse, + waitInHarness, } from './support/harness.js'; const RESPONSIVE_PROJECTS = new Set([ @@ -81,11 +84,13 @@ test.describe('participant responsive layout @responsive @canonical', () => { const question = activeQuestion(page, 'GRID_RATE'); const table = question.locator('table.quest-grid'); await expect(table).toBeVisible(); + await expect(table).toHaveCSS('margin-top', '10px'); if (testInfo.project.name === 'chromium-phone') { await expect(table.locator('thead')).toHaveCSS('display', 'none'); await expect(table.locator('td.response').first()).toHaveCSS('display', 'block'); await expect(table.locator('td.response').first()).toHaveAttribute('data-header', /^\s*Never$/); + await expect(table).toHaveCSS('padding-top', '0px'); } else { await expect(table).toHaveCSS('display', 'inline-table'); await expect(table.locator('tr').first()).toHaveCSS('display', 'table-row'); @@ -96,6 +101,23 @@ test.describe('participant responsive layout @responsive @canonical', () => { await expectHealthyHarness(page); }); + test('switches once at the 576px Bootstrap breakpoint without overlapping layouts', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium-desktop', 'The exact CSS breakpoint boundary is exercised once in Chromium.'); + + await page.setViewportSize({ width: 575, height: 900 }); + await openParticipant(page, { fixture: 'gridResponsive.txt' }); + await goNext(page); + const table = activeQuestion(page, 'GRID_RATE').locator('table.quest-grid'); + await expect(table).toHaveCSS('display', 'block'); + await expect(table.locator('thead')).toHaveCSS('display', 'none'); + + await page.setViewportSize({ width: 576, height: 900 }); + await expect(table).toHaveCSS('display', 'inline-table'); + await expect(table.locator('thead')).not.toHaveCSS('display', 'none'); + await expect(table.locator('tr').first()).toHaveCSS('display', 'table-row'); + await expectHealthyHarness(page); + }); + test('keeps phone grid response names visible before and after selection', async ({ page }, testInfo) => { test.skip( testInfo.project.name !== 'chromium-phone', @@ -103,7 +125,9 @@ test.describe('participant responsive layout @responsive @canonical', () => { ); await openParticipant(page, { fixture: 'gridResponsive.txt' }); + await waitInHarness(page, 550); await goNext(page); + await page.mouse.move(0, 0); const row = activeQuestion(page, 'GRID_RATE').locator('tr[data-question-id="GRID_WALK"]'); const expectedLabels = ['Never', 'Sometimes', 'Often']; @@ -128,6 +152,98 @@ test.describe('participant responsive layout @responsive @canonical', () => { await expectHealthyHarness(page); }); + test('keeps phone radio-grid keyboard focus, selection, and visible card state aligned', async ({ page }, testInfo) => { + test.skip( + testInfo.project.name !== 'chromium-phone', + 'This contract targets the stacked phone grid and its keyboard focus proxy.', + ); + + await openParticipant(page, { fixture: 'gridResponsive.txt' }); + await goNext(page); + await waitInHarness(page, 550); + + const question = activeQuestion(page, 'GRID_RATE'); + const row = question.locator('tr[data-question-id="GRID_WALK"]'); + const never = row.locator('#GRID_WALK_0'); + const sometimes = row.locator('#GRID_WALK_1'); + await expect(question.locator('.screen-reader-focus')).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(never).toBeFocused(); + const neverLabel = never.locator('xpath=following-sibling::label'); + await expect(neverLabel).toHaveCSS('outline-style', 'solid'); + await expect(neverLabel).toHaveCSS('outline-width', '3px'); + await expect(neverLabel).toHaveCSS('outline-color', 'rgb(28, 93, 134)'); + + await page.keyboard.press('ArrowRight'); + await waitInHarness(page, 150); + await expect(sometimes).toBeFocused(); + await expect(sometimes).toBeChecked(); + const selectedCell = sometimes.locator('xpath=..'); + const selectedLabel = sometimes.locator('xpath=following-sibling::label'); + await expectMobileResponseLabel(selectedLabel, selectedCell, { + backgroundColor: 'rgb(50, 122, 187)', + text: 'Sometimes', + textColor: 'rgb(255, 255, 255)', + }); + await expect(selectedLabel).toHaveCSS('outline-style', 'solid'); + await expect(selectedLabel).toHaveCSS('outline-width', '3px'); + await expect(selectedLabel).toHaveCSS('outline-color', 'rgb(255, 255, 255)'); + await expectHealthyHarness(page); + }); + + test('keeps phone checkbox-grid keyboard focus, card state, and storage aligned', async ({ page }, testInfo) => { + test.skip( + testInfo.project.name !== 'chromium-phone', + 'This contract targets checkbox cells in the stacked phone grid.', + ); + + await openParticipant(page, { fixture: 'gridCheckboxFocus.txt' }); + await waitInHarness(page, 550); + await goNext(page); + await waitInHarness(page, 550); + + const question = activeQuestion(page, 'GRID_CHECK'); + const firstRow = question.locator('tr[data-question-id="GRID_CHECK_ROW_A"]'); + const firstCell = firstRow.locator('td.response').first(); + const firstCheckbox = firstCell.locator('input[type="checkbox"]'); + const firstLabel = firstCell.locator('label.custom-label'); + await page.mouse.move(0, 0); + await expectMobileResponseLabel(firstLabel, firstCell, { + backgroundColor: 'rgb(240, 240, 240)', + text: 'Phone', + textColor: 'rgb(51, 51, 51)', + }); + + await expect(question.locator('.screen-reader-focus')).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(firstCheckbox).toBeFocused(); + await page.keyboard.press('Space'); + await waitInHarness(page, 150); + await expect(firstCheckbox).toBeChecked(); + await expectMobileResponseLabel(firstLabel, firstCell, { + backgroundColor: 'rgb(50, 122, 187)', + text: 'Phone', + textColor: 'rgb(255, 255, 255)', + }); + await expect(firstLabel).toHaveCSS('outline-style', 'solid'); + await expect(firstLabel).toHaveCSS('outline-width', '3px'); + await expect(firstLabel).toHaveCSS('outline-color', 'rgb(255, 255, 255)'); + + const selectedValue = await firstCheckbox.getAttribute('value'); + const secondRow = question.locator('tr[data-question-id="GRID_CHECK_ROW_B"]'); + await secondRow.locator('td.response').first().locator('label.custom-label').click(); + await goNext(page); + await expect(activeQuestion(page, 'END')).toBeVisible(); + await flushHarness(page); + + const stored = await harnessSnapshot(page); + expect(stored.state.survey.GRID_CHECK.GRID_CHECK_ROW_A).toContain(selectedValue); + expect(stored.logs.storeCalls.some(({ changes }) => ( + changes['TEST_GRID_CHECKBOX_FOCUS.GRID_CHECK']?.GRID_CHECK_ROW_A?.includes(selectedValue) + ))).toBe(true); + await expectHealthyHarness(page); + }); + test('keeps representative responsive grid geometry stable @visual', async ({ page }, testInfo) => { testInfo.snapshotSuffix = ''; await openParticipant(page, { fixture: 'gridResponsive.txt' }); @@ -135,19 +251,9 @@ test.describe('participant responsive layout @responsive @canonical', () => { const question = activeQuestion(page, 'GRID_RATE'); await question.locator('tr[data-question-id="GRID_WALK"] label', { hasText: 'Sometimes' }).click(); await page.mouse.move(0, 0); - // Keep this cross-platform snapshot focused on geometry and selected-state - // fills. The phone-specific test above separately protects visible label - // text and its exact unselected/selected colors. - await page.addStyleTag({ - content: ` - #questionnaireRoot, - #questionnaireRoot * { - -webkit-text-fill-color: transparent !important; - text-shadow: none !important; - } - `, - }); + // Keep response text visible: label rendering is part of the mobile-grid + // regression contract, so masking it would hide a previously observed bug. await expect(question).toHaveScreenshot( `participant-grid-${testInfo.project.name}.png`, { diff --git a/tests/e2e/responsive.spec.js-snapshots/participant-grid-chromium-desktop-chromium-desktop.png b/tests/e2e/responsive.spec.js-snapshots/participant-grid-chromium-desktop-chromium-desktop.png index a2a6da1..b49d693 100644 Binary files a/tests/e2e/responsive.spec.js-snapshots/participant-grid-chromium-desktop-chromium-desktop.png and b/tests/e2e/responsive.spec.js-snapshots/participant-grid-chromium-desktop-chromium-desktop.png differ diff --git a/tests/e2e/responsive.spec.js-snapshots/participant-grid-chromium-phone-chromium-phone.png b/tests/e2e/responsive.spec.js-snapshots/participant-grid-chromium-phone-chromium-phone.png index c6cd130..ae17d41 100644 Binary files a/tests/e2e/responsive.spec.js-snapshots/participant-grid-chromium-phone-chromium-phone.png and b/tests/e2e/responsive.spec.js-snapshots/participant-grid-chromium-phone-chromium-phone.png differ diff --git a/tests/e2e/responsive.spec.js-snapshots/participant-grid-chromium-tablet-chromium-tablet.png b/tests/e2e/responsive.spec.js-snapshots/participant-grid-chromium-tablet-chromium-tablet.png index b7d93b4..97753c3 100644 Binary files a/tests/e2e/responsive.spec.js-snapshots/participant-grid-chromium-tablet-chromium-tablet.png and b/tests/e2e/responsive.spec.js-snapshots/participant-grid-chromium-tablet-chromium-tablet.png differ diff --git a/tests/e2e/support/test.js b/tests/e2e/support/test.js index 1b913e3..27ca902 100644 --- a/tests/e2e/support/test.js +++ b/tests/e2e/support/test.js @@ -8,18 +8,8 @@ const canonicalImageAsset = { url: 'https://episphere.github.io/quest/images/FemaleBaldness1.png', path: join(repositoryRoot, 'tests', 'e2e', 'assets', 'FemaleBaldness1.png'), }; -const questRuntimeAssets = [ - { - url: 'https://episphere.github.io/quest-dev/ActiveLogic.css', - path: join(repositoryRoot, 'ActiveLogic.css'), - }, - { - url: 'https://episphere.github.io/quest-dev/Style1.css', - path: join(repositoryRoot, 'Style1.css'), - }, -]; const versionedQuestRuntimeAsset = /^https:\/\/cdn\.jsdelivr\.net\/gh\/episphere\/quest@v[^/]+\/(ActiveLogic|Style1)\.css$/; -const FIREFOX_INSTALL_TRIGGER_DEPRECATION = /^\[JavaScript Warning: "InstallTrigger is deprecated and will be removed in the future\." \{file: "http:\/\/127\.0\.0\.1:\d+\/main\.js" line: 179\}\] \[source: http:\/\/127\.0\.0\.1:\d+\/main\.js\]$/; +const FIREFOX_INSTALL_TRIGGER_DEPRECATION = /^\[JavaScript Warning: "InstallTrigger is deprecated and will be removed in the future\." \{file: "http:\/\/127\.0\.0\.1:\d+\/main\.js(?:\?[^"\]]+)?" line: \d+\}\] \[source: http:\/\/127\.0\.0\.1:\d+\/main\.js(?:\?[^\]]+)?\]$/; const testServerPort = process.env.QUEST_PLAYWRIGHT_PORT ?? '4173'; const LOCAL_ORIGINS = new Set([ @@ -86,21 +76,6 @@ export async function installOfflineDiagnostics(page) { }); }); - // A Connect render normally supplies prefetched Markdown and retains the - // questionnaire URL. Quest uses the nonempty URL as the signal to fetch - // its two runtime stylesheets. Fulfill that production branch with the - // exact checked-in CSS while keeping the browser deny-by-default. - for (const asset of questRuntimeAssets) { - await page.route(asset.url, async (route) => { - diagnostics.fulfilledExternalRequests.push(asset.url); - await route.fulfill({ - status: 200, - contentType: 'text/css', - path: asset.path, - }); - }); - } - await page.route(versionedQuestRuntimeAsset, async (route) => { const url = route.request().url(); const fileName = new URL(url).pathname.split('/').at(-1); diff --git a/tests/e2e/visual.spec.js b/tests/e2e/visual.spec.js index e2c714c..e015318 100644 --- a/tests/e2e/visual.spec.js +++ b/tests/e2e/visual.spec.js @@ -56,6 +56,37 @@ test.describe('stable participant styling @visual', () => { ); }); + test('keeps keyboard focus visible on list and grid choices', async ({ page }, testInfo) => { + await openParticipant(page); + await waitInHarness(page, 550); + const listChoice = activeQuestion(page, 'CHOICE').locator('#CHOICE_1'); + await listChoice.focus(); + await page.keyboard.press('Space'); + await expect(listChoice).toBeFocused(); + await expect(listChoice).toBeChecked(); + await stabilizeVisual(page, testInfo); + + await expect(activeQuestion(page, 'CHOICE')).toHaveScreenshot( + 'participant-choice-keyboard-focus.png', + screenshotOptions, + ); + + await openParticipant(page, { fixture: 'gridResponsive.txt' }); + await goNext(page); + await waitInHarness(page, 550); + const gridChoice = activeQuestion(page, 'GRID_RATE').locator('#GRID_WALK_1'); + await gridChoice.focus(); + await page.keyboard.press('Space'); + await expect(gridChoice).toBeFocused(); + await expect(gridChoice).toBeChecked(); + await stabilizeVisual(page, testInfo); + + await expect(activeQuestion(page, 'GRID_RATE')).toHaveScreenshot( + 'participant-grid-keyboard-focus.png', + screenshotOptions, + ); + }); + test('keeps validation and modal states visually reviewable', async ({ page }, testInfo) => { await openParticipant(page, { fixture: 'validation.txt' }); await activeQuestion(page, 'BOUNDED').locator('#bounded').fill('9'); diff --git a/tests/e2e/visual.spec.js-snapshots/participant-choice-keyboard-focus-chromium-desktop.png b/tests/e2e/visual.spec.js-snapshots/participant-choice-keyboard-focus-chromium-desktop.png new file mode 100644 index 0000000..2fc0834 Binary files /dev/null and b/tests/e2e/visual.spec.js-snapshots/participant-choice-keyboard-focus-chromium-desktop.png differ diff --git a/tests/e2e/visual.spec.js-snapshots/participant-grid-keyboard-focus-chromium-desktop.png b/tests/e2e/visual.spec.js-snapshots/participant-grid-keyboard-focus-chromium-desktop.png new file mode 100644 index 0000000..abb6f2b Binary files /dev/null and b/tests/e2e/visual.spec.js-snapshots/participant-grid-keyboard-focus-chromium-desktop.png differ diff --git a/tests/e2e/windowsEnvironment.spec.js b/tests/e2e/windowsEnvironment.spec.js deleted file mode 100644 index 98695d1..0000000 --- a/tests/e2e/windowsEnvironment.spec.js +++ /dev/null @@ -1,65 +0,0 @@ -import { test, expect } from './support/test.js'; -import { - activeQuestion, - expectHealthyHarness, - goNext, - openParticipant, - selectLabeledResponse, - waitInHarness, -} from './support/harness.js'; - -test.describe('Windows-specific participant handling @canonical @windows-a11y', () => { - test.beforeEach(async ({}, testInfo) => { - test.skip( - testInfo.project.name !== 'chromium-windows-ua', - 'These checks characterize the Quest Windows user-agent branch used for JAWS support.', - ); - }); - - test('adds response-container tab stops and persists pointer selection', async ({ page }) => { - await openParticipant(page); - await expect(page.locator('.question.active .response')).toHaveCount(2); - await expect(page.locator('.question.active .response').first()).toHaveAttribute('tabindex', '0'); - - await selectLabeledResponse(page, 'Green'); - await expect(activeQuestion(page).locator('#CHOICE_2')).toBeChecked(); - await expectHealthyHarness(page); - }); - - test('moves the grid focus helper through the next row and terminal Next target after radio selections', async ({ page }) => { - await openParticipant(page, { fixture: 'gridResponsive.txt' }); - await goNext(page); - await waitInHarness(page, 550); - - const question = activeQuestion(page, 'GRID_RATE'); - await question.locator('tr[data-question-id="GRID_WALK"] label', { hasText: 'Sometimes' }).click(); - - await expect(question.locator('#srFocusHelper')).toBeFocused(); - await expect(question.locator('#qtextGRID_CYCLE #srFocusHelper')).toHaveCount(1); - await question.locator('tr[data-question-id="GRID_CYCLE"] label', { hasText: 'Often' }).click(); - - await expect(question.locator('#srFocusHelper')).toBeFocused(); - await expect(question.getByRole('button', { name: 'Next question' }).locator('#srFocusHelper')).toHaveCount(1); - await expectHealthyHarness(page); - }); - - test('keeps checkbox-grid focus in an intermediate cell and moves it to Next at the terminal cell', async ({ page }) => { - await openParticipant(page, { fixture: 'gridCheckboxFocus.txt' }); - await goNext(page); - await waitInHarness(page, 550); - const question = activeQuestion(page, 'GRID_CHECK'); - - const firstCell = question.locator('tr[data-question-id="GRID_CHECK_ROW_A"] td.response').first(); - await firstCell.locator('label', { hasText: 'Phone' }).click(); - await expect(firstCell.locator('#srFocusHelper')).toHaveCount(1); - await expect(question.locator('#srFocusHelper')).toBeFocused(); - - const terminalCell = question.locator('tr[data-question-id="GRID_CHECK_ROW_B"] td.response').last(); - await terminalCell.locator('label', { hasText: 'Email' }).click(); - await expect(question.getByRole('button', { name: 'Next question' }).locator('#srFocusHelper')).toHaveCount(1); - await expect(question.locator('#srFocusHelper')).toBeFocused(); - await expect(firstCell.locator('input[type="checkbox"]')).toBeChecked(); - await expect(terminalCell.locator('input[type="checkbox"]')).toBeChecked(); - await expectHealthyHarness(page); - }); -}); diff --git a/tests/fixtures/canonical/unansweredModals.txt b/tests/fixtures/canonical/unansweredModals.txt new file mode 100644 index 0000000..4216ca1 --- /dev/null +++ b/tests/fixtures/canonical/unansweredModals.txt @@ -0,0 +1,10 @@ +// Distinction between a requested response and a required response. +{"name":"TEST_UNANSWERED_MODALS"} + +[SOFT?] You may answer this question or continue without answering. +(1) Optional response + +[HARD!] You must answer this question before continuing. +(1) Required response + +[END,end] Unanswered-response modal testing complete. diff --git a/tests/harness/participant.js b/tests/harness/participant.js index 8911d3b..4445865 100644 --- a/tests/harness/participant.js +++ b/tests/harness/participant.js @@ -5,6 +5,7 @@ import '../../Style1.css'; import * as bootstrap from 'bootstrap'; import { transform } from '../../main.js'; +import { moduleParams } from '../../questionnaire.js'; import { getStateManager } from '../../stateManager.js'; globalThis.bootstrap = bootstrap; @@ -175,10 +176,24 @@ function stateSnapshot() { } : null; } +function runtimeSnapshot() { + const stateManager = getStateManager(true); + const processor = stateManager?.getQuestionProcessor(); + return { + basePath: moduleParams.basePath ?? null, + processor: processor ? { + questionCount: processor.questions.length, + processedQuestionCount: processor.processedQuestions.size, + questionIds: processor.questions.map(({ questionID }) => questionID), + } : null, + }; +} + function snapshot() { return serializable({ activeQuestionId: questRoot.querySelector('form.question.active')?.id ?? null, focusedElement: describeTarget(document.activeElement), + runtime: runtimeSnapshot(), state: stateSnapshot(), logs, }); diff --git a/tests/integration/accessibilityBehavior.spec.js b/tests/integration/accessibilityBehavior.spec.js index cdaea81..27a8d61 100644 --- a/tests/integration/accessibilityBehavior.spec.js +++ b/tests/integration/accessibilityBehavior.spec.js @@ -22,60 +22,58 @@ describe('screen-reader and keyboard behavior', () => { vi.useRealTimers(); }); - it('uses native list controls while adding Windows-specific refocus and announcements', async () => { + it('keeps focus on a native list control while announcing its changed state', async () => { const quest = await renderFreshQuest(); - quest.moduleParams.isWindowsEnvironment = true; const radio = quest.root.querySelector('#Q1_2'); + radio.focus(); radio.click(); radio.dispatchEvent(new Event('change', { bubbles: true, cancelable: true })); - await vi.advanceTimersByTimeAsync(100); + await vi.advanceTimersByTimeAsync(300); expect(document.activeElement).toBe(radio); expect(quest.root.querySelector('#ariaLiveSelectionAnnouncer').textContent).toContain('Second answer Selected.'); }); - it('moves the Windows table focus helper to the next visible row after a radio selection', async () => { + it('keeps focus on a native grid radio after its state changes', async () => { const quest = await renderFreshQuest({ markdown: GRID_SURVEY, persistedData: { treeJSON: treeAt('GRID') }, }); - quest.moduleParams.isWindowsEnvironment = true; const firstRowChoice = quest.root.querySelector('#ROW_ONE_0'); + firstRowChoice.focus(); firstRowChoice.click(); firstRowChoice.dispatchEvent(new Event('change', { bubbles: true, cancelable: true })); - const helper = quest.root.querySelector('#srFocusHelper'); - await vi.advanceTimersByTimeAsync(100); - expect(document.activeElement).toBe(helper); - expect(helper.closest('tr')?.dataset.questionId).toBe('ROW_TWO'); - expect(helper.parentElement.tagName).toBe('TH'); + await vi.advanceTimersByTimeAsync(300); + expect(document.activeElement).toBe(firstRowChoice); + expect(firstRowChoice.checked).toBe(true); + expect(quest.root.querySelector('#srFocusHelper')).toBeNull(); }); - it('moves the Windows table focus helper to Next after the final row selection', async () => { + it('keeps focus on a native final-row grid radio instead of advancing on selection', async () => { const quest = await renderFreshQuest({ markdown: GRID_SURVEY, persistedData: { treeJSON: treeAt('GRID') }, }); - quest.moduleParams.isWindowsEnvironment = true; const finalRowChoice = quest.root.querySelector('#ROW_TWO_1'); + finalRowChoice.focus(); finalRowChoice.click(); finalRowChoice.dispatchEvent(new Event('change', { bubbles: true, cancelable: true })); - const helper = quest.root.querySelector('#srFocusHelper'); - await vi.advanceTimersByTimeAsync(100); - expect(document.activeElement).toBe(helper); - expect(helper.closest('button')).toBe(quest.root.querySelector('#GRID .next')); + await vi.advanceTimersByTimeAsync(300); + expect(document.activeElement).toBe(finalRowChoice); + expect(finalRowChoice.checked).toBe(true); + expect(quest.root.querySelector('#GRID .next')).not.toBe(document.activeElement); }); - it('announces table selections without adding non-native selection commands on macOS paths', async () => { + it('announces table selections without adding non-native selection commands', async () => { const quest = await renderFreshQuest({ markdown: GRID_SURVEY, persistedData: { treeJSON: treeAt('GRID') }, }); - quest.moduleParams.isWindowsEnvironment = false; const choice = quest.root.querySelector('#ROW_ONE_1'); choice.click(); @@ -86,7 +84,7 @@ describe('screen-reader and keyboard behavior', () => { expect(choice.getAttribute('role')).toBeNull(); }); - it('uses Up and Down only to leave nested text fields, without overriding radio native keys', async () => { + it('leaves Up and Down under native text and radio control', async () => { const quest = await renderFreshQuest(); const fieldset = quest.root.querySelector('#Q1 fieldset'); fieldset.innerHTML = ` @@ -99,14 +97,18 @@ describe('screen-reader and keyboard behavior', () => { const secondText = fieldset.querySelector('#SECOND_TEXT'); secondText.focus(); - secondText.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'ArrowUp' })); + const textArrowUp = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'ArrowUp' }); + secondText.dispatchEvent(textArrowUp); await vi.advanceTimersByTimeAsync(0); - expect(document.activeElement).toBe(fieldset.querySelector('#FIRST_RADIO')); + expect(textArrowUp.defaultPrevented).toBe(false); + expect(document.activeElement).toBe(secondText); firstText.focus(); - firstText.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'ArrowDown' })); + const textArrowDown = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'ArrowDown' }); + firstText.dispatchEvent(textArrowDown); await vi.advanceTimersByTimeAsync(0); - expect(document.activeElement).not.toBe(firstText); + expect(textArrowDown.defaultPrevented).toBe(false); + expect(document.activeElement).toBe(firstText); const radio = fieldset.querySelector('#SECOND_RADIO'); radio.focus(); @@ -115,24 +117,36 @@ describe('screen-reader and keyboard behavior', () => { expect(nativeArrow.defaultPrevented).toBe(false); }); - it('reconstructs and restores question focus after closing the soft-response modal', async () => { + it.each(['softModal', 'hardModal'])('restores question focus after closing %s', async (modalId) => { const quest = await renderFreshQuest(); - const modal = quest.root.querySelector('#softModal'); + const modal = quest.root.querySelector(`#${modalId}`); const focusTarget = quest.root.querySelector('#Q1 .screen-reader-focus'); // The initial render already schedules a 500 ms focus. Remove it so a - // passing assertion proves the close handler's 100 ms + 500 ms chain. + // passing assertion proves the hidden-modal handler itself restores focus. vi.clearAllTimers(); expect(document.activeElement).not.toBe(focusTarget); - modal.style.display = 'block'; - modal.dispatchEvent(new MouseEvent('click', { bubbles: true })); + modal.dispatchEvent(new Event('hidden.bs.modal')); - expect(modal.style.display).toBe('none'); - await vi.advanceTimersByTimeAsync(100); + await vi.advanceTimersByTimeAsync(99); expect(document.activeElement).not.toBe(focusTarget); - await vi.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(1); expect(document.activeElement).toBe(focusTarget); expect(quest.root.querySelectorAll('#Q1 .screen-reader-focus')).toHaveLength(1); }); + + it('leaves a missing target for question preparation instead of rebuilding on modal close', async () => { + const quest = await renderFreshQuest(); + const modal = quest.root.querySelector('#softModal'); + const originalFocusTarget = quest.root.querySelector('#Q1 .screen-reader-focus'); + + vi.clearAllTimers(); + originalFocusTarget.remove(); + modal.dispatchEvent(new Event('hidden.bs.modal')); + await vi.advanceTimersByTimeAsync(100); + + expect(quest.root.querySelector('#Q1 .screen-reader-focus')).toBeNull(); + expect(document.activeElement).not.toBe(originalFocusTarget); + }); }); diff --git a/tests/integration/accessibilityBuilder.spec.js b/tests/integration/accessibilityBuilder.spec.js index 43f8803..9193523 100644 --- a/tests/integration/accessibilityBuilder.spec.js +++ b/tests/integration/accessibilityBuilder.spec.js @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { renderFreshQuest } from '../helpers/questRuntime.js'; async function loadAccessibilityFixture(markup) { @@ -24,6 +24,7 @@ describe('accessible question text construction', () => { expect(legend.textContent).toContain('Section heading'); expect(legend.textContent).toContain('Choose an answer.'); expect(fieldset.querySelectorAll('.screen-reader-focus')).toHaveLength(1); + expect(fieldset.querySelector('.screen-reader-focus').tabIndex).toBe(-1); expect([...fieldset.querySelectorAll('br')].every((br) => br.getAttribute('aria-hidden') === 'true')).toBe(true); expect(fieldset.querySelectorAll('br').length).toBeLessThanOrEqual(3); }); @@ -46,6 +47,24 @@ describe('accessible question text construction', () => { expect(fieldset.querySelectorAll('.response')).toHaveLength(2); }); + it('retains the first formatted fragment after a punctuated primary prompt', async () => { + const { quest, accessibility } = await loadAccessibilityFixture(` + +
How severe is your fatigue?Pain level: Are you experiencing pain?
+ + `); + const fieldset = quest.root.querySelector('fieldset'); + + accessibility.manageAccessibleQuestion(fieldset, false); + + const legend = fieldset.querySelector(':scope > legend'); + const followUp = fieldset.querySelector(':scope > div[role="alert"][tabindex="0"]'); + expect(legend.textContent).toBe('How severe is your fatigue?'); + expect(followUp?.innerHTML).toContain('Pain level:'); + expect(followUp?.textContent).toContain('Pain level: Are you experiencing pain?'); + expect(fieldset.querySelectorAll('.response')).toHaveLength(1); + }); + it('creates a fieldset around table questions that do not originally have one', async () => { const { quest, accessibility } = await loadAccessibilityFixture(` @@ -90,7 +109,7 @@ describe('accessible question text construction', () => { it('updates a raw forid in an existing legend and avoids rebuilding focus controls', async () => { const { quest, accessibility } = await loadAccessibilityFixture(` -
Hello old value
+
Hello old value
`); const fieldset = quest.root.querySelector('fieldset'); @@ -119,112 +138,27 @@ describe('accessible question text construction', () => { }); }); -describe('accessible selection focus and defensive paths', () => { - beforeEach(() => vi.useFakeTimers()); +describe('accessible selection announcements and defensive paths', () => { afterEach(() => { vi.clearAllTimers(); vi.useRealTimers(); }); - it('keeps focus on a non-final table checkbox and announces its state', async () => { + it('announces table checkbox state without managing focus', async () => { + vi.useFakeTimers(); const { quest, accessibility } = await loadAccessibilityFixture(` -
-
+
-
Row
`); const input = quest.root.querySelector('#CHECK_1'); - const event = { target: input, type: 'change', preventDefault: vi.fn() }; - - accessibility.handleRadioCheckboxTableEvents(event); + input.focus(); + accessibility.updateAriaLiveSelectionAnnouncerTable(input.closest('.response')); - expect(event.preventDefault).toHaveBeenCalledOnce(); - await vi.advanceTimersByTimeAsync(100); - expect(document.activeElement).toBe(quest.root.querySelector('#srFocusHelper')); - expect(document.activeElement.closest('td')).toBe(input.closest('td')); - await vi.advanceTimersByTimeAsync(150); + await vi.advanceTimersByTimeAsync(250); expect(quest.root.querySelector('#ariaLiveSelectionAnnouncer').textContent).toBe('First Selected.'); - }); - - it('moves focus from the final table checkbox to the active question Next button', async () => { - const { quest, accessibility } = await loadAccessibilityFixture(` -
-
-
Row
- `); - - accessibility.handleRadioCheckboxTableEvents({ - target: quest.root.querySelector('#FINAL'), - type: 'change', - preventDefault: vi.fn(), - }); - - const helper = quest.root.querySelector('#srFocusHelper'); - await vi.advanceTimersByTimeAsync(100); - expect(document.activeElement).toBe(helper); - expect(helper.closest('button')).toBe(quest.root.querySelector('button.next')); - }); - - it('logs invalid table inputs and missing focus targets without throwing', async () => { - const { quest, accessibility } = await loadAccessibilityFixture(` - - - - -
First
- `); - const invalid = quest.root.querySelector('#BAD'); - accessibility.handleRadioCheckboxTableEvents({ target: invalid, type: 'change', preventDefault: vi.fn() }); - expect(quest.errors.some(([message]) => message.includes('Invalid event type'))).toBe(true); - - accessibility.handleRadioCheckboxTableEvents({ - target: quest.root.querySelector('#RADIO'), - type: 'change', - preventDefault: vi.fn(), - }); - await vi.advanceTimersByTimeAsync(100); - expect(quest.errors.some(([message]) => message.includes('Next question cell not found'))).toBe(true); - - quest.root.querySelector('#srFocusHelper').remove(); - accessibility.handleRadioCheckboxTableEvents({ - target: quest.root.querySelector('tr:last-child input'), - type: 'change', - preventDefault: vi.fn(), - }); - await vi.advanceTimersByTimeAsync(100); - expect(quest.errors.some(([message]) => message.includes('Focus helper not found'))).toBe(true); - }); - - it('logs missing active-question and Next-button targets for final selections', async () => { - const { quest, accessibility } = await loadAccessibilityFixture(` - -
Only
- `); - const event = { target: quest.root.querySelector('#ONLY'), type: 'change', preventDefault: vi.fn() }; - - accessibility.handleRadioCheckboxTableEvents(event); - await vi.advanceTimersByTimeAsync(100); - expect(quest.errors.some(([message]) => message === 'Active question not found')).toBe(true); - - quest.root.insertAdjacentHTML('afterbegin', '
'); - accessibility.handleRadioCheckboxTableEvents(event); - await vi.advanceTimersByTimeAsync(100); - expect(quest.errors.some(([message]) => message === 'Next question button not found')).toBe(true); - }); - - it('walks past non-response siblings when ArrowUp leaves a nested text field', async () => { - const { quest, accessibility } = await loadAccessibilityFixture(` -
spacing
-
- `); - const current = quest.root.querySelector('#CURRENT'); - current.focus(); - - accessibility.handleUpDownArrowKeys({ key: 'ArrowUp', target: current, preventDefault: vi.fn() }); - await vi.advanceTimersByTimeAsync(0); - expect(document.activeElement).toBe(quest.root.querySelector('#PREVIOUS')); + expect(document.activeElement).toBe(input); }); it('returns safely when announcer dependencies are absent and clears an existing region', async () => { diff --git a/tests/integration/authoredMarkupFidelity.spec.js b/tests/integration/authoredMarkupFidelity.spec.js new file mode 100644 index 0000000..7b1c921 --- /dev/null +++ b/tests/integration/authoredMarkupFidelity.spec.js @@ -0,0 +1,237 @@ +import { describe, expect, it, vi } from 'vitest'; +import { renderFreshQuest } from '../helpers/questRuntime.js'; + +const authoredMarkupSurvey = ` +{"name":"MARKUP_FIDELITY"} + +[SOURCE] Source response. +|__|id=SOURCE_VALUE| + +[RICH] Heading detail + +First production-style paragraph keeps italic detail and underlined detail. + +Second production-style paragraph contains |displayif=doesNotEqual(SOURCE,"")|{$SOURCE_VALUE}| and includes |popup|more information|Help title|Authored popup detail|. + +(1) Keep later response +(2) Clear later response + +[SUMMARY] |displayif=doesNotEqual(SOURCE,"")|Chosen source: {$SOURCE_VALUE}| +|displayif=equals(SOURCE,"")|Fallback participant| + +[END,end] Done. +`; + +async function activeQuestion(quest, questionID) { + if (questionID) { + await vi.waitFor(() => { + expect(quest.root.querySelector('form.question.active')?.id).toBe(questionID); + }); + } + return quest.root.querySelector('form.question.active'); +} + +async function next(quest) { + (await activeQuestion(quest)).querySelector('.next').click(); +} + +async function back(quest) { + (await activeQuestion(quest)).querySelector('.previous').click(); +} + +function nonWhitespaceChildOrder(element) { + return Array.from(element.childNodes) + .filter((node) => node.nodeType !== Node.TEXT_NODE || node.textContent.trim()) + .filter((node) => !node.classList?.contains('screen-reader-focus')) + .map((node) => ( + node.nodeType === Node.TEXT_NODE + ? `text:${node.textContent.replace(/\s+/g, ' ').trim()}` + : node.tagName + )); +} + +function richSignature(question) { + const fieldset = question.querySelector('fieldset'); + const legend = fieldset.querySelector(':scope > legend'); + const helper = fieldset.querySelector('.screen-reader-focus'); + const firstResponse = fieldset.querySelector('.response'); + const pipedValue = legend.querySelector('[original-forid="SOURCE_VALUE"], [forid="SOURCE_VALUE"]'); + const popover = legend.querySelector('[data-bs-toggle="popover"]'); + + return { + legendCount: fieldset.querySelectorAll(':scope > legend').length, + helperCount: fieldset.querySelectorAll('.screen-reader-focus').length, + helperTabIndex: helper.tabIndex, + helperImmediatelyBeforeResponse: helper.nextElementSibling === firstResponse, + helperParentTag: helper.parentElement.tagName, + helperPrecedesPopover: Boolean(popover && (helper.compareDocumentPosition(popover) & Node.DOCUMENT_POSITION_FOLLOWING)), + legendText: legend.textContent.replace(/\s+/g, ' ').trim(), + legendChildOrder: nonWhitespaceChildOrder(legend), + fieldsetBreakCount: fieldset.querySelectorAll(':scope > br').length, + legendBreakCount: legend.querySelectorAll('br').length, + boldTexts: Array.from(legend.querySelectorAll('b'), (element) => element.textContent), + italicTexts: Array.from(legend.querySelectorAll('i'), (element) => element.textContent), + underlinedTexts: Array.from(legend.querySelectorAll('u'), (element) => element.textContent), + pipedValue: pipedValue && { + text: pipedValue.textContent, + parentTag: pipedValue.parentElement.tagName, + boldText: pipedValue.closest('b')?.textContent, + displayIf: pipedValue.closest('[displayif]')?.getAttribute('displayif'), + visible: pipedValue.closest('[displayif]')?.style.display !== 'none', + }, + popover: popover && { + text: popover.textContent, + title: popover.getAttribute('title'), + content: popover.getAttribute('data-bs-content'), + role: popover.getAttribute('role'), + tabindex: popover.getAttribute('tabindex'), + trigger: popover.getAttribute('data-bs-trigger'), + parentTag: popover.parentElement.tagName, + }, + }; +} + +function summarySignature(question) { + const fieldset = question.querySelector('fieldset'); + const legend = fieldset.querySelector(':scope > legend'); + const displayIfs = Array.from(legend.querySelectorAll('[displayif]')); + const helper = fieldset.querySelector('.screen-reader-focus'); + const populated = displayIfs.find((element) => element.getAttribute('displayif') === "doesNotEqual(SOURCE,'')"); + const fallback = displayIfs.find((element) => element.getAttribute('displayif') === "equals(SOURCE,'')"); + + return { + legendCount: fieldset.querySelectorAll(':scope > legend').length, + helperCount: fieldset.querySelectorAll('.screen-reader-focus').length, + helperTabIndex: helper.tabIndex, + breakCount: fieldset.querySelectorAll('br').length, + populated: { text: populated.textContent, display: populated.style.display }, + fallback: { text: fallback.textContent, display: fallback.style.display }, + }; +} + +function updateSource(input, value) { + input.value = value; + input.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); +} + +describe('authored question markup fidelity', () => { + it('keeps production-style rich markup stable through navigation and source edits', async () => { + const quest = await renderFreshQuest({ markdown: authoredMarkupSurvey }); + const sourceInput = (await activeQuestion(quest, 'SOURCE')).querySelector('#SOURCE_VALUE'); + + updateSource(sourceInput, 'Original participant'); + await next(quest); + + const rich = await activeQuestion(quest, 'RICH'); + const richBeforeNavigation = richSignature(rich); + const legendText = rich.querySelector('legend').textContent; + const responseControls = Array.from(rich.querySelectorAll('.response input')); + + expect(richBeforeNavigation).toEqual({ + legendCount: 1, + helperCount: 1, + helperTabIndex: -1, + helperImmediatelyBeforeResponse: false, + helperParentTag: 'LEGEND', + helperPrecedesPopover: true, + legendText: 'Heading detail First production-style paragraph keeps italic detail and underlined detail. Second production-style paragraph contains Original participant and includes more information.', + legendChildOrder: ['B', 'BR', 'text:First production-style paragraph keeps', 'I', 'text:and', 'U', 'text:.', 'text:Second production-style paragraph contains', 'B', 'text:and includes', 'A', 'text:.'], + fieldsetBreakCount: 1, + legendBreakCount: 1, + boldTexts: ['Heading detail', 'Original participant'], + italicTexts: ['italic detail'], + underlinedTexts: ['underlined detail'], + pipedValue: { + text: 'Original participant', + parentTag: 'SPAN', + boldText: 'Original participant', + displayIf: "doesNotEqual(SOURCE,'')", + visible: true, + }, + popover: { + text: 'more information', + title: 'Help title', + content: 'Authored popup detail', + role: 'button', + tabindex: '0', + trigger: 'manual', + parentTag: 'LEGEND', + }, + }); + expect(legendText).toContain('Heading detail\n\nFirst production-style paragraph'); + expect(legendText).toContain('underlined detail.\n\nSecond production-style paragraph'); + expect(responseControls).toHaveLength(2); + for (const control of responseControls) { + expect(control.labels).toHaveLength(1); + expect(control.labels[0].htmlFor).toBe(control.id); + expect(control.labels[0].control).toBe(control); + } + + responseControls[0].click(); + await next(quest); + const summary = await activeQuestion(quest, 'SUMMARY'); + + expect(summarySignature(summary)).toEqual({ + legendCount: 1, + helperCount: 1, + helperTabIndex: -1, + breakCount: 1, + populated: { text: 'Chosen source: Original participant', display: '' }, + fallback: { text: 'Fallback participant', display: 'none' }, + }); + + await back(quest); + const reactivatedRich = await activeQuestion(quest, 'RICH'); + expect(richSignature(reactivatedRich)).toEqual(richBeforeNavigation); + expect(reactivatedRich.querySelector('#RICH_1').checked).toBe(true); + + await back(quest); + const returnedSource = await activeQuestion(quest, 'SOURCE'); + updateSource(returnedSource.querySelector('#SOURCE_VALUE'), ''); + await next(quest); + + const richWithoutSource = await activeQuestion(quest, 'RICH'); + expect(richWithoutSource.querySelectorAll(':scope > fieldset > legend')).toHaveLength(1); + expect(richWithoutSource.querySelectorAll('.screen-reader-focus')).toHaveLength(1); + expect(richWithoutSource.querySelector('#RICH_1').checked).toBe(false); + expect(richWithoutSource.querySelector('#RICH_2').checked).toBe(false); + expect(quest.state.getSurveyState().RICH).toBeUndefined(); + expect(richSignature(richWithoutSource).pipedValue).toMatchObject({ text: '', visible: false }); + + await next(quest); + const fallbackSummary = await activeQuestion(quest, 'SUMMARY'); + const fallbackSignature = summarySignature(fallbackSummary); + expect(fallbackSignature).toEqual({ + legendCount: 1, + helperCount: 1, + helperTabIndex: -1, + breakCount: 1, + populated: { text: 'Chosen source: ', display: 'none' }, + fallback: { text: 'Fallback participant', display: '' }, + }); + + await back(quest); + await back(quest); + const sourceForUpdate = await activeQuestion(quest, 'SOURCE'); + updateSource(sourceForUpdate.querySelector('#SOURCE_VALUE'), 'Updated participant'); + await next(quest); + + const richWithUpdatedSource = await activeQuestion(quest, 'RICH'); + expect(richSignature(richWithUpdatedSource)).toMatchObject({ + legendCount: 1, + helperCount: 1, + helperTabIndex: -1, + pipedValue: { text: 'Updated participant', visible: true }, + }); + expect(richWithUpdatedSource.querySelector('#RICH_1').checked).toBe(false); + + await next(quest); + const updatedSummary = await activeQuestion(quest, 'SUMMARY'); + expect(summarySignature(updatedSummary)).toEqual({ + ...fallbackSignature, + populated: { text: 'Chosen source: Updated participant', display: '' }, + fallback: { text: 'Fallback participant', display: 'none' }, + }); + expect(quest.errors).toEqual([]); + }); +}); diff --git a/tests/integration/eventHandlers.spec.js b/tests/integration/eventHandlers.spec.js index fd402e3..2dfbc75 100644 --- a/tests/integration/eventHandlers.spec.js +++ b/tests/integration/eventHandlers.spec.js @@ -8,6 +8,42 @@ const TEXT_SURVEY = ` [END,end] Done. `; +const NATIVE_ARROW_SURVEY = ` +{"name":"EVENT_NATIVE_ARROWS"} +[NOTES?] Enter two short notes. +|___|notes| +[END,end] Done. +`; + +const NATIVE_SELECT_SURVEY = ` +{"name":"EVENT_NATIVE_SELECT"} +[STATE?] In which state do you live? +|state|id=home_state| +[END,end] Done. +`; + +const CHOICE_LINKED_TEXT_SURVEY = ` +{"name":"EVENT_CHOICE_LINKED_TEXT"} +[OTHER?] Choose an option and add details if needed. +(1:OTHER_GROUP|OTHER_LABEL) Other details +(2) No additional details +[END,end] Done. +`; + +const CHECKBOX_LINKED_TEXT_SURVEY = ` +{"name":"EVENT_CHECKBOX_LINKED_TEXT"} +[OTHER?] Choose an option and add details if needed. +[1:OTHER_GROUP|OTHER_LABEL] Other details +[2] No additional details +[END,end] Done. +`; + +const POPOVER_SURVEY = ` +{"name":"EVENT_POPOVER"} +[HELP] Read |popup|more information|Help title|Synthetic help text|. +[END,end] Done. +`; + describe('delegated runtime event handling', () => { afterEach(() => { vi.clearAllTimers(); @@ -100,6 +136,207 @@ describe('delegated runtime event handling', () => { expect(phone.value).toBe('555-555-'); }); + it('does not cancel ArrowDown on a standalone native textarea (CONNECT-1587)', async () => { + const quest = await renderFreshQuest({ markdown: NATIVE_ARROW_SURVEY }); + const textarea = quest.root.querySelector('#notes'); + const arrowDown = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 'ArrowDown', + }); + + expect(textarea.dispatchEvent(arrowDown)).toBe(true); + expect(arrowDown.defaultPrevented).toBe(false); + }); + + it('records a standalone textarea response on delegated focusout', async () => { + const quest = await renderFreshQuest({ markdown: NATIVE_ARROW_SURVEY }); + const textarea = quest.root.querySelector('#notes'); + + textarea.value = 'Persist this response'; + textarea.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + + expect(quest.state.getActiveQuestionState().NOTES).toBe('Persist this response'); + }); + + it('removes a standalone textarea response when its form is reset programmatically', async () => { + const quest = await renderFreshQuest({ markdown: NATIVE_ARROW_SURVEY }); + const textarea = quest.root.querySelector('#notes'); + textarea.value = 'Remove this response'; + textarea.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + + const { resetChildren } = await import('../../eventHandlers.js'); + expect(() => resetChildren(textarea.form)).not.toThrow(); + + expect(quest.state.getActiveQuestionState().NOTES).toBeUndefined(); + }); + + it('does not cancel native select navigation, activation, or dismissal keys (CONNECT-1587)', async () => { + const quest = await renderFreshQuest({ markdown: NATIVE_SELECT_SURVEY }); + const select = quest.root.querySelector('#home_state'); + + for (const key of ['ArrowDown', 'ArrowUp', ' ', 'Enter', 'Escape']) { + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key, + }); + + expect(select.dispatchEvent(event)).toBe(true); + expect(event.defaultPrevented).toBe(false); + } + }); + + it('does not cancel arrows or move focus from a choice-linked native textarea (CONNECT-1587)', async () => { + vi.useFakeTimers(); + const quest = await renderFreshQuest({ markdown: CHOICE_LINKED_TEXT_SURVEY }); + const textarea = quest.root.querySelector('#OTHER_TEXT'); + vi.clearAllTimers(); + textarea.value = 'first\nsecond'; + textarea.focus(); + textarea.setSelectionRange(0, 0); + + for (const key of ['ArrowDown', 'ArrowUp']) { + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key, + }); + + expect(textarea.dispatchEvent(event)).toBe(true); + expect(event.defaultPrevented).toBe(false); + await vi.advanceTimersByTimeAsync(0); + expect(document.activeElement).toBe(textarea); + } + }); + + it('keeps keyboard activation on a linked checkbox while retaining pointer-only text focus', async () => { + vi.useFakeTimers(); + const quest = await renderFreshQuest({ markdown: CHECKBOX_LINKED_TEXT_SURVEY }); + const checkbox = quest.root.querySelector('#OTHER_GROUP_1'); + const label = quest.root.querySelector('#OTHER_LABEL'); + const textarea = quest.root.querySelector('#OTHER_TEXT'); + vi.clearAllTimers(); + + checkbox.focus(); + checkbox.click(); + await vi.advanceTimersByTimeAsync(0); + expect(checkbox.checked).toBe(true); + expect(document.activeElement).toBe(checkbox); + + checkbox.click(); + expect(checkbox.checked).toBe(false); + label.dispatchEvent(new Event('pointerdown', { bubbles: true })); + checkbox.click(); + await vi.advanceTimersByTimeAsync(0); + expect(checkbox.checked).toBe(true); + expect(document.activeElement).toBe(textarea); + }); + + it('gives a role-button popover explicit native-equivalent activation and dismissal', async () => { + const quest = await renderFreshQuest({ markdown: POPOVER_SURVEY }); + const trigger = quest.root.querySelector('[data-bs-toggle="popover"]'); + const instance = bootstrap.Popover.getInstance(trigger); + expect(instance).not.toBeNull(); + const hidePopover = vi.spyOn(instance, 'hide'); + expect(trigger.dataset.bsTrigger).toBe('manual'); + + trigger.focus(); + expect(trigger.hasAttribute('aria-describedby')).toBe(false); + + const closedEscape = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Escape' }); + expect(trigger.dispatchEvent(closedEscape)).toBe(true); + expect(closedEscape.defaultPrevented).toBe(false); + expect(hidePopover).not.toHaveBeenCalled(); + + const space = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: ' ' }); + expect(trigger.dispatchEvent(space)).toBe(false); + expect(space.defaultPrevented).toBe(true); + const popoverId = trigger.getAttribute('aria-describedby'); + const popoverElement = document.getElementById(popoverId); + expect(popoverElement).not.toBeNull(); + expect(popoverElement.classList.contains('show')).toBe(true); + + const escape = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Escape' }); + expect(trigger.dispatchEvent(escape)).toBe(false); + expect(escape.defaultPrevented).toBe(true); + expect(hidePopover).toHaveBeenCalledOnce(); + expect(popoverElement.classList.contains('show')).toBe(false); + expect(trigger.hasAttribute('aria-describedby')).toBe(false); + expect(document.activeElement).toBe(trigger); + + const click = new MouseEvent('click', { bubbles: true, cancelable: true }); + expect(trigger.dispatchEvent(click)).toBe(false); + expect(click.defaultPrevented).toBe(true); + const reopenedPopoverElement = document.getElementById(trigger.getAttribute('aria-describedby')); + expect(reopenedPopoverElement).not.toBeNull(); + expect(reopenedPopoverElement.classList.contains('show')).toBe(true); + + trigger.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + expect(reopenedPopoverElement.classList.contains('show')).toBe(false); + }); + + it('disposes an open popover after its hidden lifecycle event', async () => { + const quest = await renderFreshQuest({ markdown: POPOVER_SURVEY }); + const trigger = quest.root.querySelector('[data-bs-toggle="popover"]'); + const instance = bootstrap.Popover.getInstance(trigger); + const hiddenPopover = vi.fn(); + const hiddenModal = vi.fn(); + trigger.addEventListener('hidden.bs.popover', hiddenPopover); + trigger.addEventListener('hidden.bs.modal', hiddenModal); + + instance.show(); + const popoverElement = document.getElementById(trigger.getAttribute('aria-describedby')); + expect(popoverElement).not.toBeNull(); + expect(popoverElement.classList.contains('show')).toBe(true); + + const { disposePopovers } = await import('../../questionnaire.js'); + disposePopovers(quest.root); + + expect(hiddenPopover).toHaveBeenCalledOnce(); + expect(hiddenModal).not.toHaveBeenCalled(); + expect(popoverElement.classList.contains('show')).toBe(false); + expect(trigger.hasAttribute('aria-describedby')).toBe(false); + expect(bootstrap.Popover.getInstance(trigger)).toBeNull(); + }); + + it('does not emit duplicate popover lifecycle events for repeated show or hide calls', async () => { + const quest = await renderFreshQuest({ markdown: POPOVER_SURVEY }); + const trigger = quest.root.querySelector('[data-bs-toggle="popover"]'); + const instance = bootstrap.Popover.getInstance(trigger); + const shownPopover = vi.fn(); + const hiddenPopover = vi.fn(); + trigger.addEventListener('shown.bs.popover', shownPopover); + trigger.addEventListener('hidden.bs.popover', hiddenPopover); + + instance.hide(); + expect(hiddenPopover).not.toHaveBeenCalled(); + + instance.show(); + instance.show(); + expect(shownPopover).toHaveBeenCalledOnce(); + + instance.hide(); + instance.hide(); + expect(hiddenPopover).toHaveBeenCalledOnce(); + }); + + it('disposes an initialized but unopened popover without emitting a hidden event', async () => { + const quest = await renderFreshQuest({ markdown: POPOVER_SURVEY }); + const trigger = quest.root.querySelector('[data-bs-toggle="popover"]'); + const hiddenPopover = vi.fn(); + trigger.addEventListener('hidden.bs.popover', hiddenPopover); + + expect(bootstrap.Popover.getInstance(trigger)).not.toBeNull(); + expect(trigger.hasAttribute('aria-describedby')).toBe(false); + + const { disposePopovers } = await import('../../questionnaire.js'); + disposePopovers(quest.root); + + expect(hiddenPopover).not.toHaveBeenCalled(); + expect(bootstrap.Popover.getInstance(trigger)).toBeNull(); + }); + it('updates the live selection announcement without requiring listeners on individual controls', async () => { vi.useFakeTimers(); const quest = await renderFreshQuest(); @@ -116,13 +353,13 @@ describe('delegated runtime event handling', () => { expect(quest.root.querySelector('#ariaLiveSelectionAnnouncer').textContent).toContain('Second answer Unselected.'); }); - it('builds a legend, a single hidden focus stop, and accessible break semantics', async () => { + it('builds a legend, a programmatic-only question focus target, and accessible break semantics', async () => { const quest = await renderFreshQuest(); const fieldset = quest.root.querySelector('#Q1 fieldset'); expect(fieldset.querySelector('legend.question-text')?.textContent).toContain('Choose one answer'); expect(fieldset.querySelectorAll('.screen-reader-focus')).toHaveLength(1); - expect(fieldset.querySelector('.screen-reader-focus').tabIndex).toBe(0); + expect(fieldset.querySelector('.screen-reader-focus').tabIndex).toBe(-1); expect([...fieldset.querySelectorAll('br')].every((br) => br.getAttribute('aria-hidden') === 'true')).toBe(true); expect(quest.root.querySelectorAll('#srAnnouncerContainer [aria-live="polite"]')).toHaveLength(2); }); @@ -137,8 +374,11 @@ describe('delegated runtime event handling', () => { await vi.advanceTimersByTimeAsync(0); expect(quest.root.querySelector('form.active')?.id).toBe('Q2'); - await vi.advanceTimersByTimeAsync(500); - expect(document.activeElement).toBe(quest.root.querySelector('#Q2 .screen-reader-focus')); + const focusTarget = quest.root.querySelector('#Q2 .screen-reader-focus'); + await vi.advanceTimersByTimeAsync(499); + expect(document.activeElement).not.toBe(focusTarget); + await vi.advanceTimersByTimeAsync(1); + expect(document.activeElement).toBe(focusTarget); }); it('keeps host controls outside the delegated event boundary unchanged', async () => { diff --git a/tests/integration/questionProcessor.spec.js b/tests/integration/questionProcessor.spec.js index 42708d9..22ac1d4 100644 --- a/tests/integration/questionProcessor.spec.js +++ b/tests/integration/questionProcessor.spec.js @@ -159,6 +159,7 @@ describe('QuestionProcessor constructs', () => { expect(intro.querySelector('[data-encoded-expression]')).not.toBeNull(); expect(intro.querySelector('.displayif').getAttribute('displayif')).toBe('equals(SHOW,1)'); expect(intro.querySelector('[data-bs-toggle="popover"]').getAttribute('data-bs-content')).toBe('Synthetic help text'); + expect(intro.querySelector('[data-bs-toggle="popover"]').getAttribute('data-bs-trigger')).toBe('manual'); expect(intro.querySelector('img').src).toBe('https://example.org/test.png'); expect(intro.querySelector('input[type="hidden"]').getAttribute('skipto')).toBe('TARGET'); expect(grid.dataset.grid).toBe('true'); diff --git a/tests/integration/questionnaireHelpers.spec.js b/tests/integration/questionnaireHelpers.spec.js index 9a58483..71bb912 100644 --- a/tests/integration/questionnaireHelpers.spec.js +++ b/tests/integration/questionnaireHelpers.spec.js @@ -197,6 +197,6 @@ describe('questionnaire runtime helpers', () => { expect(isMobileDevice()).toBe(true); window.matchMedia.mockReturnValueOnce({ matches: false }); expect(isMobileDevice()).toBe(false); - expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 576px)'); + expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 575.98px)'); }); }); diff --git a/tests/knownDefects/registry.js b/tests/knownDefects/registry.js index eb4120a..df34162 100644 --- a/tests/knownDefects/registry.js +++ b/tests/knownDefects/registry.js @@ -24,10 +24,6 @@ export const runtimeDefects = Object.freeze({ treePrune: defect('QD-TREE-002', 'Tree.prune branch removal', 'Pruning does not return to the authored predecessor with the expected sibling structure intact.'), treeHasNext: defect('QD-TREE-003', 'Tree.hasNext non-mutating lookahead', 'Lookahead reports false for the first root child even though a next value exists.'), gridRowCondition: defect('QD-GRID-001', 'Radio-grid row displayif encoding', 'The row condition is encoded more than once and cannot be decoded to the authored expression.'), - gridBackFocusLifecycle: defect('QD-GRID-002', 'Grid Back focus-helper lifecycle', 'Returning to a previously answered grid restores its values but logs that the focus helper is missing instead of preserving a valid focus-management node.', { - sourcePaths: ['prod/module2.txt'], - questionId: 'D_981441822', - }), mathDotValue: defect('QD-MATH-001', 'MathJS dot-notation value lookup', 'A valid leaf in a one-property response object is not returned.'), mathDotExists: defect('QD-MATH-002', 'MathJS dot-notation existence lookup', 'exists() does not recognize a valid nested response leaf.'), mathMonthRange: defect('QD-MATH-003', 'dateCompare documented month range', 'dateCompare accepts month 12 even though its contract documents zero through eleven.'), @@ -49,10 +45,17 @@ export const runtimeDefects = Object.freeze({ missingConfirmationTarget: defect('QD-QP-003', 'Missing confirmation target handling', 'A confirmation input that references a missing peer removes its invalid attribute but then dereferences the missing peer.'), missingQuestionId: defect('QD-QP-004', 'Missing question-ID lookup', 'findQuestion logs a missing ID but then calls startsWith on the absent value instead of returning its documented not-found result.'), explicitCombinedChoiceName: defect('QD-QP-005', 'Explicit name metadata on legacy combined choices', 'The combined-choice parser interpolates the full regular-expression match array, producing a duplicated comma-separated name instead of the authored name.'), - submitFocusRestore: defect('QD-A11Y-001', 'Submit-dialog Escape focus restoration', 'The dialog closes on Escape but leaves focus inactive instead of returning it to the Submit Survey trigger.'), - modalQuestionFocusRace: defect('QD-A11Y-002', 'Question-to-modal focus handoff', 'A pending delayed question-focus callback can run after a response dialog opens and move focus away from the dialog title.'), staleSelectionAnnouncement: defect('QD-A11Y-003', 'Selection announcement after navigation', 'A delayed selection announcement can repopulate the live region after Next or Back explicitly clears it.'), - gridDeferredFocusAfterNavigation: defect('QD-A11Y-004', 'Windows grid focus-helper after navigation', 'A pending Windows grid-focus callback can move the shared helper back into an inactive grid after the participant advances.'), + textareaReset: defect('QD-RESET-001', 'Standalone textarea Reset behavior', 'Standalone textarea questions do not receive a Reset action, and the existing reset routine does not clear textarea values.', { + sourcePaths: [ + 'prod/module1.txt', + 'prod/module1Spanish.txt', + 'prod/module2026ROIPreferences.txt', + 'prod/module2026ROIPreferencesSpanish.txt', + ], + questionIds: ['D_868232409', 'D_233198706', 'D_395168461'], + automatedContract: 'A standalone textarea question offers Reset, and keyboard activation clears both its visible value and active response state.', + }), authoringFallbackClear: defect('QD-AUTHOR-001', 'Authoring clear-memory operation after localforage fallback', 'The fallback storage adapter has no removeItem method, so Clear Memory throws after initialization falls back.', { source: 'index.html authoring interface', }), @@ -65,14 +68,20 @@ export const axeDefects = Object.freeze({ validationContrast: defect('QD-AXE-004', 'Validation message contrast', 'The visible validation message does not meet the required color contrast.', { ruleId: 'color-contrast', impact: 'serious', targets: ['.validation-container > span'] }), validationLabel: defect('QD-AXE-005', 'Bounded numeric input label', 'The input relies on a title-only label relationship.', { ruleId: 'label-title-only', impact: 'serious', targets: ['#bounded'] }), imageAlt: defect('QD-AXE-006', 'Question image text alternative', 'QuestionProcessor emits an image without an alternative text attribute.', { ruleId: 'image-alt', impact: 'critical', targets: ['#PLAIN img'] }), + actionHoverContrast: defect('QD-AXE-007', 'Participant action hover contrast', 'The white action-button text does not retain sufficient contrast against the lighter hover background.', { ruleId: 'color-contrast', impact: 'serious', targets: ['.next'] }), }); export const accessibilityDefects = Object.freeze({ compoundQuestionContext: defect('QD-A11Y-005', 'Compound-question radio context', 'Radio choices in a multi-subgroup form are named only by their response option, not the subgroup prompt that gives the choice its meaning.', { - sourcePaths: ['prod/moduleDietScreener', 'prod/moduleDietScreenerSpanish.txt'], - questionId: 'D_916948380', + sourcePaths: [ + 'prod/moduleDietScreener', + 'prod/moduleDietScreenerSpanish.txt', + 'prod/moduleQoL.txt', + 'prod/moduleQoLSpanish.txt', + ], + questionIds: ['D_916948380', 'D_284353934'], automatedContract: 'Each radio choice exposes both its subgroup prompt and its response option in its accessible name or equivalent accessible context.', - manualContract: 'Verify that VoiceOver and JAWS announce the food or sub-question prompt together with the selected frequency when moving across a compound form.', + manualContract: 'Verify that VoiceOver and JAWS announce the food or sub-question prompt together with each response option when moving across a compound form.', }), 1079: defect('CONNECT-1079', 'Quest 2 participant choice semantics', 'Choice inputs are not consistently exposed to assistive technology with their native role, accessible name, and checked state.', { issue: 'https://github.com/episphere/connect/issues/1079', @@ -82,14 +91,6 @@ export const accessibilityDefects = Object.freeze({ manualContract: 'Recheck real VoiceOver/Safari and JAWS/Chrome or Edge announcement, focus, and activation after the PWA redesign.', limitation: 'Playwright accessibility trees do not run VoiceOver or JAWS and cannot validate their command-routing modes.', }), - 1587: defect('CONNECT-1587', 'Native participant keyboard behavior', 'Quest delegated key handling suppresses browser-native input behavior.', { - issue: 'https://github.com/episphere/connect/issues/1587', - title: 'Keyboard Navigation for Quest', - scope: 'Keyboard-only operation without requiring a screen reader.', - automatedContract: 'Focused native radios, checkboxes, and selects retain their standard Space and arrow-key behavior.', - manualContract: 'Test Tab/Shift+Tab, Enter, Space, radio arrows, checkbox Space, and native select keys separately from screen-reader commands.', - limitation: 'VoiceOver Quick Nav and the JAWS Virtual Cursor alter key routing; those are recorded in the manual matrix instead of treated as raw browser key events.', - }), }); export const allKnownDefects = Object.freeze([ diff --git a/tests/knownDefects/runtime.spec.js b/tests/knownDefects/runtime.spec.js index a48c6fa..362a44f 100644 --- a/tests/knownDefects/runtime.spec.js +++ b/tests/knownDefects/runtime.spec.js @@ -15,6 +15,13 @@ const expectedCovidGridResponseIds = [ // contain this identical, truncated display condition for D_114280729. const MALFORMED_COVID_GRID_COMPLEMENT = 'someSelected("D_488415137_0","D_488415137_1","D_167695804_0","D_167695804_1","D_730334054_0","D_730334054_1","D_215996690_0","D_215996690_1","D_462737492_0","D_462737492_1","D_469675296_0","D_469675296_1","D_962475128_0","D_962475128_1","D_989576239_0","D_989576239_1","D_338613869_0","D_338613869_1","D_126794793_0","D_126794793_1","D_218793117_0","D_218793117_1","D_524096053_0","SRVCOV_COV19C1_V1R0_1,1","D_814101706_0","D_814101706_1","D_635026188_0","D_238135048_0","D_238135048_1","D_632714520_0","D_632714520_1'; +const STANDALONE_TEXTAREA_SURVEY = ` +{"name":"TEXTAREA_RESET"} +[NOTES?] Enter two short notes. +|___|notes| +[END,end] Done. +`; + function buildBranchedTree() { const tree = new Tree(); tree.add(['Q1', 'Q2', 'Q3']); @@ -157,6 +164,22 @@ describe('characterized Quest runtime defects', () => { expect(evaluateCondition('equals(PRIOR,"yes")')).toBe(true); }); + it.fails(`${runtimeDefects.textareaReset.localDefectId}: renders Reset for a standalone textarea`, async () => { + const quest = await renderFreshQuest({ markdown: STANDALONE_TEXTAREA_SURVEY }); + expect(quest.root.querySelector('#NOTES [data-click-type="reset"]')).not.toBeNull(); + }); + + it.fails(`${runtimeDefects.textareaReset.localDefectId}: clears a standalone textarea's visible value`, async () => { + const quest = await renderFreshQuest({ markdown: STANDALONE_TEXTAREA_SURVEY }); + const textarea = quest.root.querySelector('#notes'); + textarea.value = 'Clear this response'; + + const { resetChildren } = await import('../../eventHandlers.js'); + resetChildren(textarea.form); + + expect(textarea.value).toBe(''); + }); + it.fails(`${runtimeDefects.corpusMalformedCondition.localDefectId}: rejects a truncated function expression`, async () => { const evaluateCondition = await loadEvaluator(); expect(evaluateCondition('someSelected("D_632714520_1')).toBe(false); diff --git a/tests/setup/jsdom.js b/tests/setup/jsdom.js index 4b5b2f9..ace3cbb 100644 --- a/tests/setup/jsdom.js +++ b/tests/setup/jsdom.js @@ -46,6 +46,7 @@ function installOfflineNetworkBoundary() { class BootstrapComponentStub { static instances = new WeakMap(); + static eventNamespace = 'modal'; constructor(element) { this._element = element; @@ -56,14 +57,30 @@ class BootstrapComponentStub { return this.instances.get(element) ?? null; } + static getOrCreateInstance(element, options) { + return this.getInstance(element) ?? new this(element, options); + } + show() { - this._element?.classList.add('show'); - this._element?.dispatchEvent(new Event('shown.bs.modal')); + if (!this._element || this._element.classList.contains('show')) return; + + this._element.classList.add('show'); + this._element.dispatchEvent(new Event(`shown.bs.${this.constructor.eventNamespace}`)); } hide() { - this._element?.classList.remove('show'); - this._element?.dispatchEvent(new Event('hidden.bs.modal')); + if (!this._element || !this._element.classList.contains('show')) return; + + this._element.classList.remove('show'); + this._element.dispatchEvent(new Event(`hidden.bs.${this.constructor.eventNamespace}`)); + } + + toggle() { + if (this._element?.classList.contains('show')) { + this.hide(); + } else { + this.show(); + } } dispose() { @@ -77,8 +94,68 @@ beforeEach(() => { document.body.innerHTML = ''; globalThis.bootstrap = { - Modal: class ModalStub extends BootstrapComponentStub {}, - Popover: class PopoverStub extends BootstrapComponentStub {}, + Modal: class ModalStub extends BootstrapComponentStub { + static instances = new WeakMap(); + static eventNamespace = 'modal'; + }, + Popover: class PopoverStub extends BootstrapComponentStub { + static instances = new WeakMap(); + static eventNamespace = 'popover'; + + static nextTipId = 1; + + constructor(element) { + super(element); + this._tip = null; + } + + _getTipElement() { + if (!this._tip) { + this._tip = this._element.ownerDocument.createElement('div'); + this._tip.id = `test-popover-${this.constructor.nextTipId++}`; + this._tip.classList.add('popover'); + this._tip.setAttribute('role', 'tooltip'); + } + + return this._tip; + } + + show() { + const tip = this._getTipElement(); + if (tip.classList.contains('show')) return; + + this._element.setAttribute('aria-describedby', tip.id); + this._element.ownerDocument.body.append(tip); + tip.classList.add('show'); + this._element.dispatchEvent(new Event(`shown.bs.${this.constructor.eventNamespace}`)); + } + + hide() { + const tip = this._tip; + if (!tip?.classList.contains('show')) return; + + tip.classList.remove('show'); + this._element.removeAttribute('aria-describedby'); + tip.remove(); + this._tip = null; + this._element.dispatchEvent(new Event(`hidden.bs.${this.constructor.eventNamespace}`)); + } + + toggle() { + if (this._tip?.classList.contains('show')) { + this.hide(); + } else { + this.show(); + } + } + + dispose() { + this._element.removeAttribute('aria-describedby'); + this._tip?.remove(); + this._tip = null; + super.dispose(); + } + }, }; window.matchMedia = vi.fn().mockImplementation((query) => ({ diff --git a/tests/unit/buildGrid.spec.js b/tests/unit/buildGrid.spec.js index dc6f8cd..dcd91a9 100644 --- a/tests/unit/buildGrid.spec.js +++ b/tests/unit/buildGrid.spec.js @@ -19,6 +19,7 @@ describe('parseGrid', () => { expect(decodeURIComponent(form.getAttribute('displayif'))).toBe('equals(ELIGIBLE,1)'); expect(form.querySelectorAll('tbody tr')).toHaveLength(2); expect(form.querySelectorAll('input[type="radio"]')).toHaveLength(4); + expect(form.querySelector('#srFocusHelper')).toBeNull(); expect(form.querySelector('#ROW1_0').value).toBe('1'); expect(form.querySelector('span[data-gridreplace="name"]')).not.toBeNull(); expect(form.querySelector('span[data-gridreplace="firstName"]')).not.toBeNull();