From 4ad7682dc335cb2aced0876640eb4fc5d1e2c8dd Mon Sep 17 00:00:00 2001 From: Joe Armani <93854858+JoeArmani@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:34:14 -0400 Subject: [PATCH 1/7] Improve Quest keyboard navigation and accessibility --- ActiveLogic.css | 67 +- Default.css | 61 +- Quest.css | 11 + Style1.css | 39 +- accessibleQuestionTextBuilder.js | 238 +---- buildGrid.js | 1 - docs/accessibilityManualTest.md | 10 +- eventHandlers.js | 78 +- main.js | 26 +- playwright.config.js | 20 +- questionProcessor.js | 2 +- questionnaire.js | 77 +- tests/README.md | 2 +- .../corpus/productionMarkupEdgeCases.spec.js | 157 ++++ tests/e2e/accessibility.spec.js | 118 ++- ...accessibilityLifecycleKnownDefects.spec.js | 106 +-- tests/e2e/corpusFullRender.spec.js | 17 +- tests/e2e/gridDeepCoverage.spec.js | 13 +- .../e2e/gridDeepCoverageKnownDefects.spec.js | 43 - tests/e2e/keyboardNavigation.spec.js | 842 ++++++++++++++++++ tests/e2e/knownDefects.spec.js | 172 +--- tests/e2e/participantControls.spec.js | 10 +- tests/e2e/popoverAccessibility.spec.js | 167 +++- tests/e2e/productionCompoundForms.spec.js | 111 ++- tests/e2e/productionMarkupFidelity.spec.js | 289 ++++++ tests/e2e/responsive.spec.js | 126 ++- ...ant-grid-chromium-phone-chromium-phone.png | Bin 8156 -> 22500 bytes tests/e2e/support/test.js | 27 +- tests/e2e/visual.spec.js | 31 + ...choice-keyboard-focus-chromium-desktop.png | Bin 0 -> 2254 bytes ...t-grid-keyboard-focus-chromium-desktop.png | Bin 0 -> 5632 bytes tests/e2e/windowsEnvironment.spec.js | 88 +- tests/fixtures/canonical/unansweredModals.txt | 10 + tests/harness/participant.js | 15 + .../integration/accessibilityBehavior.spec.js | 58 +- .../integration/accessibilityBuilder.spec.js | 106 +-- .../authoredMarkupFidelity.spec.js | 237 +++++ tests/integration/eventHandlers.spec.js | 145 ++- tests/integration/questionProcessor.spec.js | 1 + .../integration/questionnaireHelpers.spec.js | 2 +- tests/knownDefects/registry.js | 35 +- tests/knownDefects/runtime.spec.js | 26 + tests/setup/jsdom.js | 12 + tests/unit/buildGrid.spec.js | 1 + 44 files changed, 2790 insertions(+), 807 deletions(-) create mode 100644 tests/corpus/productionMarkupEdgeCases.spec.js delete mode 100644 tests/e2e/gridDeepCoverageKnownDefects.spec.js create mode 100644 tests/e2e/keyboardNavigation.spec.js create mode 100644 tests/e2e/productionMarkupFidelity.spec.js create mode 100644 tests/e2e/visual.spec.js-snapshots/participant-choice-keyboard-focus-chromium-desktop.png create mode 100644 tests/e2e/visual.spec.js-snapshots/participant-grid-keyboard-focus-chromium-desktop.png create mode 100644 tests/fixtures/canonical/unansweredModals.txt create mode 100644 tests/integration/authoredMarkupFidelity.spec.js diff --git a/ActiveLogic.css b/ActiveLogic.css index 1fc6885..a0cafce 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; @@ -83,7 +89,8 @@ input[type="text"] { table-layout: fixed; font-family: Arial, Helvetica, sans-serif; font-size: medium; - margin-top: 10px; + /* Preserve the established gap above grids without a focusable spacer node. */ + margin-top: calc(10px + 1.5rem); overflow-x: auto; gap: 1px; overflow-y: visible; @@ -163,7 +170,7 @@ input[type="text"] { cursor: pointer; border: none; outline: none; - overflow: hidden; + overflow: visible; padding: 1em; } @@ -206,6 +213,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,10 +237,12 @@ 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; + /* Block table margins collapse on mobile; retain the former spacer height. */ + padding-top: 1.5rem; } .quest-grid.table-layout tbody, @@ -279,5 +295,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..a37045d 100644 --- a/Default.css +++ b/Default.css @@ -16,7 +16,8 @@ input[type="checkbox"] + label { table-layout: fixed; font-family: Arial, Helvetica, sans-serif; font-size: medium; - margin-top: 10px; + /* Preserve the established gap above grids without a focusable spacer node. */ + margin-top: calc(10px + 1.5rem); overflow-x: auto; gap: 1px; overflow-y: visible; @@ -96,7 +97,7 @@ input[type="checkbox"] + label { cursor: pointer; border: none; outline: none; - overflow: hidden; + overflow: visible; padding: 1em; } @@ -139,6 +140,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,10 +164,12 @@ 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; + /* Block table margins collapse on mobile; retain the former spacer height. */ + padding-top: 1.5rem; } .quest-grid.table-layout tbody, @@ -212,5 +222,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; } -} \ No newline at end of file +} + +@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/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..e01a596 100644 --- a/accessibleQuestionTextBuilder.js +++ b/accessibleQuestionTextBuilder.js @@ -103,9 +103,11 @@ 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; break; } @@ -500,7 +502,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,155 +526,37 @@ 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. + * Restore question context after an unanswered-response modal closes. * Re-build the question text and focus management for screen readers. - * @param {Event} event - The event object. */ -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); - } - } -} - -// 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)); - - const currentIndex = allFocusable.indexOf(currentElement); - if (currentIndex !== -1) { - let newIndex = currentIndex; - let nextElement; - - 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); - } +export function closeModalAndFocusQuestion() { + // Find the active question after Bootstrap has finished hiding the modal. + // For a soft-modal continuation this may be the newly activated question; + // for every dismissal it remains the question that requested a response. + const activeQuestion = moduleParams.questDiv.querySelector('.question.active'); + if (activeQuestion) { + const questionFocusSet = false; + setTimeout(() => { + manageAccessibleQuestion(activeQuestion.querySelector('fieldset') || activeQuestion, questionFocusSet); + }, 100); } } @@ -721,68 +604,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 +634,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..a486b5f 100644 --- a/docs/accessibilityManualTest.md +++ b/docs/accessibilityManualTest.md @@ -55,6 +55,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 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), + ); + + return { + focusPaintHeight: markerHeight + (2 * focusExtent), + labelHeight: element.getBoundingClientRect().height, + overflowY: labelStyle.overflowY, + }; + }); + + expect( + geometry.overflowY === 'visible' + || geometry.labelHeight + 0.5 >= geometry.focusPaintHeight, + `Grid focus indicator is clipped: ${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..bee9ee4 100644 --- a/tests/e2e/knownDefects.spec.js +++ b/tests/e2e/knownDefects.spec.js @@ -1,11 +1,11 @@ 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'; -const ASSISTIVE_TECH_ENGINES = new Set(['chromium-desktop', 'webkit-desktop']); const CHOICE_SEMANTIC_PROJECTS = new Set([ - ...ASSISTIVE_TECH_ENGINES, + 'chromium-desktop', + 'webkit-desktop', 'chromium-windows-ua', ]); const NATIVE_KEYBOARD_ENGINES = new Set([ @@ -64,6 +64,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 +85,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,92 +102,15 @@ 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 waitInHarness(page, 550); await activeQuestion(page, 'BOUNDED').locator('#bounded').fill('2'); await goNext(page); + await expect(activeQuestion(page, 'END')).toBeVisible(); + await waitInHarness(page, 550); const trigger = activeQuestion(page, 'END').getByRole('button', { name: 'Submit your survey' }); const modal = page.locator('#submitModal'); await trigger.click(); @@ -238,11 +126,37 @@ test.describe('open accessibility regressions @known-defect', () => { 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 page.addInitScript(() => { + const nativeSetTimeout = window.setTimeout.bind(window); + const nativeClearTimeout = window.clearTimeout.bind(window); + const pendingFocusTimers = new Map(); + let nextTimerId = 1_000_000; + + window.setTimeout = (callback, delay, ...args) => { + if (delay !== 500) return nativeSetTimeout(callback, delay, ...args); + const timerId = nextTimerId; + nextTimerId += 1; + pendingFocusTimers.set(timerId, { callback, args }); + return timerId; + }; + window.clearTimeout = (timerId) => { + if (!pendingFocusTimers.delete(timerId)) nativeClearTimeout(timerId); + }; + window.releasePendingQuestionFocusTimers = () => { + window.setTimeout = nativeSetTimeout; + window.clearTimeout = nativeClearTimeout; + const pending = [...pendingFocusTimers.values()]; + pendingFocusTimers.clear(); + pending.forEach(({ callback, args }) => callback(...args)); + return pending.length; + }; + }); await openParticipant(page); await goNext(page); const title = page.locator('#softModalTitle'); await expect(title).toBeFocused(); - await waitInHarness(page, 550); + expect(await page.evaluate(() => window.releasePendingQuestionFocusTimers())).toBe(1); + await waitInHarness(page, 25); test.fail(true, `${runtimeDefects.modalQuestionFocusRace.localDefectId}: ${runtimeDefects.modalQuestionFocusRace.reason}`); await expect(title).toBeFocused(); }); 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..0088349 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,120 @@ 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 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..268f1aa 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([ @@ -96,6 +99,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 +123,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 +150,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,18 +249,6 @@ 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; - } - `, - }); await expect(question).toHaveScreenshot( `participant-grid-${testInfo.project.name}.png`, 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 c6cd130966e03412c4d6d6b3e82ee6ec3a416279..4fbb71a6b3b33c2b37dd377657655e310565e892 100644 GIT binary patch literal 22500 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 V#imJlfiq$@C(oV0e*Mj@KLJJ`_No8? 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 0000000000000000000000000000000000000000..2fc0834c8bc0ba81c82b3f4901d8a344fd181872 GIT binary patch literal 2254 zcmeHJX;2eJ6kY*AX+Z`o)?$E}C^|@qIC2Sw(8}q^QH02iqK0yX;U);-5N{BTAw>>B z1cWH}6#@k1NCg8}j)0tk5ikS@% zN*(~9U}JsqI{>8nU_8D<3a(>~3L*e@B-@-cKNFq3Fy4*#z#>|g!X&NQIS$#&% z?$?g#UB_4?y|Fm7{RXz9NIT&1?#IEd;VJDJecP>{`Z&pi+YAO{ZeGFU&p#rU8U!V@ zr+1o{AXY+@_c4y{@(y0O%Xvl;3?c=qug@Om6Jo=E9;D$G-&r{B2SC&Ez%~HR$ru74 zqdW$Hl68nQz-T^K03h-Ih);4_l1R{>Zhz( zyk{ei1KrcNSEa4Fmlz+N=SJhJFS5lkPC?KhGNXNv$-J5g!?Vx! zA(p%uMk4F2kGbkXXu6O|!fN1TZtlxXUMboDovxwIi@n?sIUm_v{vd9?GPIul0D3?! z(ywM|%6h3;daT!eG4snZiL&2nU5#ZB=ki0-<3i58f7zQ#DX3;=^m=+{(ww}XlxqRt z!@cTH-uzwHNyZTp!f3gx8)drsQ<%MTm5#)06>hs38#5-1=+*Gv*1P!JEoNc;7J59e zHe<;^R_Gt6&xCYN#lK>q;*P~}$~EFe)$A?N#wgC6Y9Zv3I9^c#8vrm?&UTl%eh|Nu zXx+q{b@R~=nOSCni2=G31@)?jHIeP@QF2gHlkA5743fSp?GE~cV2+=Lm@qsVj%t85 zR4TX!u^##JItn_UR2Gc{Zt&p!4Q8$xoER_9{uZaFu5NDHP9_p;4ws^cMB>OKKkdGb zu5M`%v`^mAitf|A&~!F;&4o&a7p7@v(QosbUi(RgBxdg@tqlfBj|crZP`T@lyHoi8 zszF(L!8JoN7A^*22?PRc2&KH+JX<^-FQNW^rRPgFEI+Sc=#+4Rq9qgx_2Iw|ci208 z@e(EZ>7koz?y35$OJOsLus095W{^ufA!W#N*hOi75oez1bGy(FV{4Pm5%bf79Vv8{ zOlxsKg`wHUk;FX@bJpLytsF2OifrtAKeAXnK4Nb7kg-f+_v{CCqbTC~yJbcF`q~^_ zY)_<>?XY?LYW=(nW>56m8K&xm029x$Y+h0p;@xy@$66xuI2b)YL)##hp$Zi%>-uvH zdLwZSBj&0uZ*amYnO1`zt3FAI7#zzFN&zP0->(MNxBUkmwd!Qk8fqi@kTMwc@f9Ez z%Zq-)_MD}-vX0we%ehMVs0HdU?xe02|6wn5nya!iLHA<;exEhsd{A*Wx%$Lmd3&GX zUm5;TGJlJ$s=8FC3?7!{w}@INBw`rFN9#nSLAq;V>ckoVh{bwc4$1IWA}jfhNCW=o VFT&-6nl@nu*jU(|Ec?bM=@-kuN9+Iq literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..454e8f497ecad8f48b60712e60b877f2769c2d24 GIT binary patch literal 5632 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 { +test.describe('Windows user-agent native parity @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.', + 'These checks guard against user-agent-specific divergence; they do not simulate JAWS.', ); }); - test('adds response-container tab stops and persists pointer selection', async ({ page }) => { + test('keeps only native list controls in the sequential focus order', 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 waitInHarness(page, 550); + const question = activeQuestion(page, 'CHOICE'); + await expect(question.locator('.response')).toHaveCount(2); + await expect(question.locator('.response[tabindex]')).toHaveCount(0); + await expect(question.locator('#srFocusHelper')).toHaveCount(0); + + await page.locator('#host-before').focus(); + await page.keyboard.press('Tab'); + const first = question.locator('#CHOICE_1'); + const second = question.locator('#CHOICE_2'); + await expect(first).toBeFocused(); + await page.keyboard.press('ArrowRight'); + await expect(second).toBeChecked(); + await expect(second).toBeFocused(); + await page.keyboard.press('Tab'); + const next = question.getByRole('button', { name: 'Next question' }); + await expect(next).toBeFocused(); + await waitInHarness(page, 150); + await expect(next).toBeFocused(); - 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 }) => { + test('keeps radio-grid arrows and row-to-row Tab traversal native', async ({ page }) => { await openParticipant(page, { fixture: 'gridResponsive.txt' }); + await waitInHarness(page, 550); 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 expect(question.locator('#srFocusHelper')).toHaveCount(0); + await expect(question.locator('.screen-reader-focus')).toHaveAttribute('tabindex', '-1'); + await page.keyboard.press('Tab'); + await expect(question.locator('#GRID_WALK_0')).toBeFocused(); + await page.keyboard.press('ArrowRight'); + await waitInHarness(page, 150); + await expect(question.locator('#GRID_WALK_1')).toBeChecked(); + await expect(question.locator('#GRID_WALK_1')).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(question.locator('#GRID_CYCLE_0')).toBeFocused(); await expectHealthyHarness(page); }); - test('keeps checkbox-grid focus in an intermediate cell and moves it to Next at the terminal cell', async ({ page }) => { + test('keeps checkbox-grid selection on each native checkbox', async ({ page }) => { await openParticipant(page, { fixture: 'gridCheckboxFocus.txt' }); + await waitInHarness(page, 550); await goNext(page); await waitInHarness(page, 550); const question = activeQuestion(page, 'GRID_CHECK'); + const first = question.locator('#GRID_CHECK_ROW_A_0'); + const second = question.locator('#GRID_CHECK_ROW_A_1'); + const third = question.locator('#GRID_CHECK_ROW_B_0'); + const terminal = question.locator('#GRID_CHECK_ROW_B_1'); - 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 expect(question.locator('#srFocusHelper')).toHaveCount(0); + await page.keyboard.press('Tab'); + await expect(first).toBeFocused(); + await page.keyboard.press('Space'); + await waitInHarness(page, 150); + await expect(first).toBeChecked(); + await expect(first).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(second).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(third).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(terminal).toBeFocused(); + await page.keyboard.press('Space'); + await waitInHarness(page, 150); + await expect(terminal).toBeChecked(); + await expect(terminal).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(question.getByRole('button', { name: 'Next question' })).toBeFocused(); 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..ea4bb4f 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,20 +117,18 @@ 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'])('reconstructs and 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's 100 ms + 500 ms chain. 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); expect(document.activeElement).not.toBe(focusTarget); await vi.advanceTimersByTimeAsync(500); diff --git a/tests/integration/accessibilityBuilder.spec.js b/tests/integration/accessibilityBuilder.spec.js index 43f8803..275ec91 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); }); @@ -90,7 +91,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 +120,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() }; + input.focus(); + accessibility.updateAriaLiveSelectionAnnouncerTable(input.closest('.response')); - accessibility.handleRadioCheckboxTableEvents(event); - - 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..1cd8e49 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,111 @@ 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('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(); + expect(trigger.dataset.bsTrigger).toBe('manual'); + + trigger.focus(); + expect(trigger.classList.contains('show')).toBe(false); + + const space = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: ' ' }); + expect(trigger.dispatchEvent(space)).toBe(false); + expect(space.defaultPrevented).toBe(true); + expect(trigger.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(trigger.classList.contains('show')).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); + expect(trigger.classList.contains('show')).toBe(true); + + trigger.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + expect(trigger.classList.contains('show')).toBe(false); + }); + it('updates the live selection announcement without requiring listeners on individual controls', async () => { vi.useFakeTimers(); const quest = await renderFreshQuest(); @@ -116,13 +257,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); }); 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..13f5573 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.'), @@ -52,7 +48,16 @@ export const runtimeDefects = Object.freeze({ 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 +70,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 +93,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..132c51b 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,25 @@ 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 and its active response`, async () => { + const quest = await renderFreshQuest({ markdown: STANDALONE_TEXTAREA_SURVEY }); + const textarea = quest.root.querySelector('#notes'); + textarea.value = 'Clear this response'; + textarea.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + expect(quest.state.getActiveQuestionState().NOTES).toBe('Clear this response'); + + const { resetChildren } = await import('../../eventHandlers.js'); + resetChildren(textarea.form); + + expect(textarea.value).toBe(''); + expect(quest.state.getActiveQuestionState().NOTES).toBeUndefined(); + }); + 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..7825ec3 100644 --- a/tests/setup/jsdom.js +++ b/tests/setup/jsdom.js @@ -56,6 +56,10 @@ 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')); @@ -66,6 +70,14 @@ class BootstrapComponentStub { this._element?.dispatchEvent(new Event('hidden.bs.modal')); } + toggle() { + if (this._element?.classList.contains('show')) { + this.hide(); + } else { + this.show(); + } + } + dispose() { this.constructor.instances.delete(this._element); } 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(); From d07a201c6219c88ed5970ab93d3d4fe3188bd3a3 Mon Sep 17 00:00:00 2001 From: Joe Armani <93854858+JoeArmani@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:06:27 -0400 Subject: [PATCH 2/7] move updated functionality to testing suite from knownDefects + refinements --- accessibleQuestionTextBuilder.js | 8 +++- docs/accessibilityManualTest.md | 45 +++---------------- docs/keyboardNavigation.md | 74 ++++++++++++++++++++++++++++++++ eventHandlers.js | 18 +++++--- tests/README.md | 7 +-- tests/e2e/accessibility.spec.js | 40 +++++++++++++++++ tests/e2e/knownDefects.spec.js | 67 +---------------------------- tests/knownDefects/registry.js | 2 - 8 files changed, 144 insertions(+), 117 deletions(-) create mode 100644 docs/keyboardNavigation.md diff --git a/accessibleQuestionTextBuilder.js b/accessibleQuestionTextBuilder.js index e01a596..64da025 100644 --- a/accessibleQuestionTextBuilder.js +++ b/accessibleQuestionTextBuilder.js @@ -19,7 +19,13 @@ export function manageAccessibleQuestion(fieldsetEle, questionFocusSet) { // Focus the hidden, focusable element if (!moduleParams.isRenderer) { setTimeout(() => { - focusableEle.focus({ preventScroll: true }); + // A response or submit dialog may open before this delayed + // 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 }); + } }, 500); } diff --git a/docs/accessibilityManualTest.md b/docs/accessibilityManualTest.md index a486b5f..e80e90c 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/`. @@ -55,7 +58,7 @@ 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 `` interaction is implemented by the browser and operating system. The portable Quest contract is that the control remains native, its keys are not canceled, and committed state persists—not that Space, Enter, or Escape has one identical expand/select/collapse sequence in every environment. See the [HTML Standard's select element](https://html.spec.whatwg.org/multipage/form-elements.html#the-select-element) and [WCAG 2.1.1 keyboard guidance](https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html). ## VoiceOver with Safari — issue #1079 @@ -80,7 +83,7 @@ Run each browser once. State explicitly whether each observation occurred in Vir 2. In Virtual Cursor, navigate to the survey and confirm the first question is announced once with its group name. 3. Enumerate form controls. Every choice must expose radio/checkbox role, name, and checked state; a focusable generic response container is not an adequate replacement. 4. Enter Forms Mode and activate a choice with the JAWS default action. Confirm visual state, DOM state, and live announcement agree. -5. Tab through the response list. Focus must land on native inputs and actions only; generic response containers and empty helper elements must not become stops. +5. Tab through the response list. Focus must land on native inputs and actions only. Generic response containers and empty helper elements must not become stops. 6. For radio groups, verify native group exclusivity and arrow behavior while in the appropriate mode. 7. For “Other” text responses, confirm Up/Down remains native text-editing navigation and Tab/Shift+Tab leaves the field in document order. Quest must not turn editing arrows into response-navigation commands. 8. Exercise both grid fixtures. In Forms Mode, radio arrows must move only within the current row's native group, and Tab must reach the next row. Selecting a radio or checkbox must not automatically move focus to another row or to Next. Confirm JAWS announces the row prompt, column option, and checked state together. @@ -101,41 +104,3 @@ For every failure, record: - the related issue: [#1079](https://github.com/episphere/connect/issues/1079) for JAWS/VoiceOver behavior or [#1587](https://github.com/episphere/connect/issues/1587) for keyboard-only operation. Pass only when the keyboard-only matrix succeeds independently and both screen readers expose correct role/name/state and predictable focus. A Playwright pass, a user-agent simulation, or success in only one screen-reader mode is not sufficient. - -## Test record - -Copy this table into the issue or test report for every execution. Do not replace exact versions with “latest” or “current.” - -| Field | Recorded value | -| --- | --- | -| Protocol version | 1.1.0 | -| Quest commit and Quest version | | -| Questionnaire fixture/version | | -| Environment ID | | -| OS name, edition, and exact version | | -| Browser name and exact version | | -| Assistive technology and exact version | | -| AT mode / Quick Nav / Full Keyboard Access | | -| Tester and date | | -| Automated prerequisite run URL/result | | -| Steps passed | | -| Steps failed | | -| Issue/evidence links | | -| Overall result | PASS / FAIL / BLOCKED | - -Record each step separately; an aggregate PASS must never hide a skipped browser, -assistive-technology mode, or interaction. - -| Environment ID | Procedure / step | AT mode | Result | Actual focus, role/name/state, and spoken output | Evidence / defect | -| --- | --- | --- | --- | --- | --- | -| KBD-MAC | Keyboard 1 | Full Keyboard Access | PASS / FAIL / BLOCKED / NOT RUN | | | -| VO-SAF | VoiceOver 1 | Quick Nav off | PASS / FAIL / BLOCKED / NOT RUN | | | -| VO-SAF | VoiceOver 1 | Quick Nav on | PASS / FAIL / BLOCKED / NOT RUN | | | -| KBD-WIN | Keyboard 1 | Browser default | PASS / FAIL / BLOCKED / NOT RUN | | | -| JAWS-CHR | JAWS 1 | Virtual Cursor | PASS / FAIL / BLOCKED / NOT RUN | | | -| JAWS-CHR | JAWS 1 | Forms Mode | PASS / FAIL / BLOCKED / NOT RUN | | | -| JAWS-EDG | JAWS 1 | Virtual Cursor | PASS / FAIL / BLOCKED / NOT RUN | | | -| JAWS-EDG | JAWS 1 | Forms Mode | PASS / FAIL / BLOCKED / NOT RUN | | | - -Duplicate rows for every numbered step in the applicable procedure. `NOT RUN` -is not a pass and requires a reason in the final column. diff --git a/docs/keyboardNavigation.md b/docs/keyboardNavigation.md new file mode 100644 index 0000000..679f83a --- /dev/null +++ b/docs/keyboardNavigation.md @@ -0,0 +1,74 @@ +# Quest 2 keyboard controls + +Use this guide for keyboard-only operation without VoiceOver or JAWS. +Screen readers may reserve or reroute these keys. Use the screen reader's normal +navigation and activation commands when assistive technology is running. + +## Commands + +| Control | Command | Expected behavior | +| --- | --- | --- | +| Move between controls | **Tab** / **Shift+Tab** | Move forward / backward in a logical order. Focus is always visible. | +| Link | **Enter** | Open or follow the link once. | +| Button, including Next, Back, Reset, and dialog buttons | **Enter** or **Space** | Run the button's action once. | +| Survey actions | **Tab** | Move through **Next**, **Reset**, then **Back**. This logical keyboard order is intentional even though desktop layout displays Back, Reset, Next. | +| Checkbox, including checkbox-grid choices | **Space** | Check or uncheck the focused choice. Tab moves to each checkbox. | +| Radio-button question | **Tab** into the group, **Arrow keys** within it, **Space** to select | Tab treats the group as one stop. Arrows move and select one choice; Tab then leaves the group. Enter is not required to select a radio button. | +| “Other” choice with a text field | **Space**, then **Tab** | Select the choice, then enter its text field. Arrow keys stay in the text field; they do not navigate responses. | +| Grid, one answer per row | **Tab** between rows, **Arrow keys** within a row | Move to and select one choice in the current row without moving focus to another row or to Next. | +| Grid, one or more answers per row | **Tab** between choices, **Space** to toggle | Visit each choice and check or uncheck any that apply. Selection does not move focus automatically. | +| Native dropdown (`select`) | **Tab** to focus it; then use the browser/OS commands | Arrow keys navigate. Space or Alt+Down may open it, Enter may commit, and Escape may dismiss it. The exact open/close sequence varies by browser and operating system. | +| Text, number, date, month, time, and textarea fields | Use the control's native editing keys | Arrow keys move the caret or operate the native control. Enter makes a new line in a textarea; it does not advance from a single-line field. Tab leaves the field. | +| Question help popup | **Enter** or **Space** to toggle; **Escape** to close | Tab closes the popup and continues to the next control. | +| Unanswered-question dialog | **Tab** / **Shift+Tab**, then **Enter** or **Space**; **Escape** to close | Focus stays inside the dialog. An optional question offers **Continue Without Answering**; a required question does not. Returning places focus back at the question. | + +For grids, follow the question's selection instruction, not the appearance of +the circles or boxes. **Choose one answer per row** uses the first grid command. +**Select all that apply** uses the second. A screen reader should also announce +each choice as a radio button or checkbox. If the visible question does not say +whether one or multiple answers are allowed, report it; users should not have +to guess. + +## Tester expectations + +- Test without a screen reader first. Repeat assistive-technology testing using + `accessibilityManualTest.md`. +- For Safari, enable **Press Tab to highlight each item on a web page** and/or + macOS Keyboard Navigation before testing. +- Confirm every focused control has a visible indicator. +- Confirm each command performs one action, updates the visible state, and is + stored. Use Back to confirm the stored selection is restored. +- Confirm arrow keys remain native inside text fields and that Quest does not + impose a custom key sequence on native dropdowns. +- After Next, Back, or dialog dismissal, confirm focus moves to the current + question prompt. The prompt itself must not become an extra Tab stop. +- Test radio and checkbox grids at desktop and phone widths. Focus, selection, + and stored data must agree in both layouts. +- Apply this guide only inside Quest. Focus behavior on the surrounding + ConnectApp page is a separate integration responsibility. + +## Why these commands + +Quest uses native HTML links, buttons, inputs, and dropdowns wherever possible. +Native controls provide established keyboard behavior and allow the browser, +operating system, and assistive technology to work together. + +Issue [#1587](https://github.com/episphere/connect/issues/1587) describes the +right overall goal, with two portability clarifications: + +- A native radio group is one Tab stop. Arrow keys move and select within it. +- A native dropdown must keep the platform's behavior. Quest must not force one + Space/Enter/Escape sequence on every browser. In particular, Escape is not a + portable command for committing a new selection. + +References: + +- [WCAG 2.1.1: Keyboard](https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html) +- [WCAG 2.4.3: Focus Order](https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html) +- [WAI-ARIA button keyboard pattern](https://www.w3.org/WAI/ARIA/apg/patterns/button/) +- [WAI-ARIA link keyboard pattern](https://www.w3.org/WAI/ARIA/apg/patterns/link/) +- [WAI-ARIA checkbox keyboard pattern](https://www.w3.org/WAI/ARIA/apg/patterns/checkbox/) +- [WAI-ARIA radio-group keyboard pattern](https://www.w3.org/WAI/ARIA/apg/patterns/radio/) +- [WAI-ARIA modal-dialog keyboard pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) +- [HTML `select` element](https://html.spec.whatwg.org/multipage/form-elements.html#the-select-element) +- [Safari keyboard shortcuts](https://support.apple.com/guide/safari/cpsh003/mac) diff --git a/eventHandlers.js b/eventHandlers.js index 845dfdd..1f8b5fb 100644 --- a/eventHandlers.js +++ b/eventHandlers.js @@ -225,7 +225,7 @@ async function handleQuestButtons(event) { break; case 'submitSurvey': - handleSubmitSurveyClick(); + handleSubmitSurveyClick(event.submitter); break; case 'next': @@ -275,18 +275,26 @@ function debounce(func, wait) { }; } -function handleSubmitSurveyClick() { - const submitModal = new bootstrap.Modal(moduleParams.questDiv.querySelector('#submitModal')); +function handleSubmitSurveyClick(submitTrigger) { + const submitModalElement = moduleParams.questDiv.querySelector('#submitModal'); + const submitModal = new bootstrap.Modal(submitModalElement); const submitModalBodyTextEle = moduleParams.questDiv.querySelector('#submitModalBodyText'); submitModalBodyTextEle.setAttribute('tabindex', '0'); - submitModalBodyTextEle.setAttribute('role', 'alert'); + submitModalBodyTextEle.setAttribute('role', 'alert'); + + // Bootstrap cannot infer the control because Quest opens this + // modal programmatically. Restore focus after Escape, Cancel, or Close. + submitModalElement.addEventListener('hidden.bs.modal', () => { + if (submitTrigger?.isConnected) { + submitTrigger.focus({ preventScroll: true }); + } + }, { once: true }); submitModal.show(); //Force focus to the modal title moduleParams.questDiv.querySelector('#submitModalTitle').focus(); - let submitModalElement = submitModal._element; submitModalElement.querySelector('.btn-close').addEventListener('keydown', function (event) { if (event.key === 'Escape') { submitModal.hide(); diff --git a/tests/README.md b/tests/README.md index 78d4abd..e114547 100644 --- a/tests/README.md +++ b/tests/README.md @@ -57,6 +57,7 @@ prove that the current Connect PWA never calls `preventDefault()` or `stopImmediatePropagation()` first; that requires one focused integration test in the PWA when the accessibility investigation resumes. -See `docs/accessibilityManualTest.md` for the VoiceOver and JAWS -acceptance protocol. Automated browser tests characterize intended native -keyboard behavior but do not claim to run either screen reader. +See `docs/keyboardNavigation.md` for the keyboard command guide and +`docs/accessibilityManualTest.md` for the VoiceOver and JAWS acceptance +protocol. Automated browser tests characterize intended native keyboard +behavior but not either screen reader. diff --git a/tests/e2e/accessibility.spec.js b/tests/e2e/accessibility.spec.js index 9a7782d..04ff4d5 100644 --- a/tests/e2e/accessibility.spec.js +++ b/tests/e2e/accessibility.spec.js @@ -383,6 +383,46 @@ test.describe('participant accessibility contract @canonical @windows-a11y', () await waitInHarness(page, 700); await expect(page.locator('#submitModal')).not.toHaveClass(/show/); + await expect(trigger).toBeFocused(); + await expectHealthyHarness(page); + }); + + test('keeps focus on a response dialog when delayed question focus runs', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium-desktop', 'The deterministic focus-timer race runs once in Chromium.'); + + await page.addInitScript(() => { + const nativeSetTimeout = window.setTimeout.bind(window); + const nativeClearTimeout = window.clearTimeout.bind(window); + const pendingFocusTimers = new Map(); + let nextTimerId = 1_000_000; + + window.setTimeout = (callback, delay, ...args) => { + if (delay !== 500) return nativeSetTimeout(callback, delay, ...args); + const timerId = nextTimerId; + nextTimerId += 1; + pendingFocusTimers.set(timerId, { callback, args }); + return timerId; + }; + window.clearTimeout = (timerId) => { + if (!pendingFocusTimers.delete(timerId)) nativeClearTimeout(timerId); + }; + window.releasePendingQuestionFocusTimers = () => { + window.setTimeout = nativeSetTimeout; + window.clearTimeout = nativeClearTimeout; + const pending = [...pendingFocusTimers.values()]; + pendingFocusTimers.clear(); + pending.forEach(({ callback, args }) => callback(...args)); + return pending.length; + }; + }); + + await openParticipant(page); + await goNext(page); + const title = page.locator('#softModalTitle'); + await expect(title).toBeFocused(); + expect(await page.evaluate(() => window.releasePendingQuestionFocusTimers())).toBe(1); + await waitInHarness(page, 25); + await expect(title).toBeFocused(); await expectHealthyHarness(page); }); }); diff --git a/tests/e2e/knownDefects.spec.js b/tests/e2e/knownDefects.spec.js index bee9ee4..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, 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 CHOICE_SEMANTIC_PROJECTS = new Set([ 'chromium-desktop', 'webkit-desktop', '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)); @@ -102,62 +95,4 @@ test.describe('open accessibility regressions @known-defect', () => { await expect(question.getByRole('radio', { name: /Cycling.*Often|Often.*Cycling/ })).not.toBeChecked(); }); - 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 waitInHarness(page, 550); - await activeQuestion(page, 'BOUNDED').locator('#bounded').fill('2'); - await goNext(page); - await expect(activeQuestion(page, 'END')).toBeVisible(); - await waitInHarness(page, 550); - 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 page.addInitScript(() => { - const nativeSetTimeout = window.setTimeout.bind(window); - const nativeClearTimeout = window.clearTimeout.bind(window); - const pendingFocusTimers = new Map(); - let nextTimerId = 1_000_000; - - window.setTimeout = (callback, delay, ...args) => { - if (delay !== 500) return nativeSetTimeout(callback, delay, ...args); - const timerId = nextTimerId; - nextTimerId += 1; - pendingFocusTimers.set(timerId, { callback, args }); - return timerId; - }; - window.clearTimeout = (timerId) => { - if (!pendingFocusTimers.delete(timerId)) nativeClearTimeout(timerId); - }; - window.releasePendingQuestionFocusTimers = () => { - window.setTimeout = nativeSetTimeout; - window.clearTimeout = nativeClearTimeout; - const pending = [...pendingFocusTimers.values()]; - pendingFocusTimers.clear(); - pending.forEach(({ callback, args }) => callback(...args)); - return pending.length; - }; - }); - await openParticipant(page); - await goNext(page); - const title = page.locator('#softModalTitle'); - await expect(title).toBeFocused(); - expect(await page.evaluate(() => window.releasePendingQuestionFocusTimers())).toBe(1); - await waitInHarness(page, 25); - test.fail(true, `${runtimeDefects.modalQuestionFocusRace.localDefectId}: ${runtimeDefects.modalQuestionFocusRace.reason}`); - await expect(title).toBeFocused(); - }); }); diff --git a/tests/knownDefects/registry.js b/tests/knownDefects/registry.js index 13f5573..df34162 100644 --- a/tests/knownDefects/registry.js +++ b/tests/knownDefects/registry.js @@ -45,8 +45,6 @@ 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.'), 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: [ From 4db7885558c67c633a75e14dbbf5970c7e51d228 Mon Sep 17 00:00:00 2001 From: Joe Armani <93854858+JoeArmani@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:48:16 -0400 Subject: [PATCH 3/7] copilot review updates --- tests/integration/eventHandlers.spec.js | 22 ++++++++++++++++++++++ tests/setup/jsdom.js | 15 +++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/tests/integration/eventHandlers.spec.js b/tests/integration/eventHandlers.spec.js index 1cd8e49..ad534db 100644 --- a/tests/integration/eventHandlers.spec.js +++ b/tests/integration/eventHandlers.spec.js @@ -241,6 +241,28 @@ describe('delegated runtime event handling', () => { expect(trigger.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(); + trigger.setAttribute('aria-describedby', 'synthetic-popover'); + expect(trigger.classList.contains('show')).toBe(true); + + const { disposePopovers } = await import('../../questionnaire.js'); + disposePopovers(quest.root); + + expect(hiddenPopover).toHaveBeenCalledOnce(); + expect(hiddenModal).not.toHaveBeenCalled(); + expect(trigger.classList.contains('show')).toBe(false); + 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(); diff --git a/tests/setup/jsdom.js b/tests/setup/jsdom.js index 7825ec3..aff11fa 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; @@ -62,12 +63,12 @@ class BootstrapComponentStub { show() { this._element?.classList.add('show'); - this._element?.dispatchEvent(new Event('shown.bs.modal')); + this._element?.dispatchEvent(new Event(`shown.bs.${this.constructor.eventNamespace}`)); } hide() { this._element?.classList.remove('show'); - this._element?.dispatchEvent(new Event('hidden.bs.modal')); + this._element?.dispatchEvent(new Event(`hidden.bs.${this.constructor.eventNamespace}`)); } toggle() { @@ -89,8 +90,14 @@ 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'; + }, }; window.matchMedia = vi.fn().mockImplementation((query) => ({ From 07e87bf34ab6cc6a5de5fce8be88ff6c81cfa130 Mon Sep 17 00:00:00 2001 From: Joe Armani <93854858+JoeArmani@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:10:20 -0400 Subject: [PATCH 4/7] cleanups & refinements --- ActiveLogic.css | 5 +- Default.css | 11 +- accessibleQuestionTextBuilder.js | 19 +++- docs/accessibilityManualTest.md | 25 +++++ .../corpus/productionMarkupEdgeCases.spec.js | 7 +- tests/e2e/authoring.spec.js | 19 ++++ tests/e2e/keyboardNavigation.spec.js | 53 +++++++++- tests/e2e/responsive.spec.js | 4 + ...grid-chromium-desktop-chromium-desktop.png | Bin 9373 -> 16873 bytes ...ant-grid-chromium-phone-chromium-phone.png | Bin 22500 -> 22402 bytes ...t-grid-chromium-tablet-chromium-tablet.png | Bin 7044 -> 14251 bytes ...t-grid-keyboard-focus-chromium-desktop.png | Bin 5632 -> 5885 bytes tests/e2e/windowsEnvironment.spec.js | 95 ------------------ .../integration/accessibilityBuilder.spec.js | 18 ++++ tests/integration/eventHandlers.spec.js | 59 +++++++++++ tests/knownDefects/runtime.spec.js | 5 +- tests/setup/jsdom.js | 12 ++- 17 files changed, 211 insertions(+), 121 deletions(-) delete mode 100644 tests/e2e/windowsEnvironment.spec.js diff --git a/ActiveLogic.css b/ActiveLogic.css index a0cafce..64f9e50 100644 --- a/ActiveLogic.css +++ b/ActiveLogic.css @@ -89,8 +89,7 @@ input[type="text"] { table-layout: fixed; font-family: Arial, Helvetica, sans-serif; font-size: medium; - /* Preserve the established gap above grids without a focusable spacer node. */ - margin-top: calc(10px + 1.5rem); + margin-top: 10px; overflow-x: auto; gap: 1px; overflow-y: visible; @@ -241,8 +240,6 @@ input[type="text"] { @media screen and (max-width: 575.98px) { .quest-grid.table-layout { display: block; - /* Block table margins collapse on mobile; retain the former spacer height. */ - padding-top: 1.5rem; } .quest-grid.table-layout tbody, diff --git a/Default.css b/Default.css index a37045d..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%; @@ -16,8 +22,7 @@ input[type="checkbox"] + label { table-layout: fixed; font-family: Arial, Helvetica, sans-serif; font-size: medium; - /* Preserve the established gap above grids without a focusable spacer node. */ - margin-top: calc(10px + 1.5rem); + margin-top: 10px; overflow-x: auto; gap: 1px; overflow-y: visible; @@ -168,8 +173,6 @@ input[type="checkbox"] + label { @media screen and (max-width: 575.98px) { .quest-grid.table-layout { display: block; - /* Block table margins collapse on mobile; retain the former spacer height. */ - padding-top: 1.5rem; } .quest-grid.table-layout tbody, diff --git a/accessibleQuestionTextBuilder.js b/accessibleQuestionTextBuilder.js index 64da025..e9463ba 100644 --- a/accessibleQuestionTextBuilder.js +++ b/accessibleQuestionTextBuilder.js @@ -47,6 +47,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) => @@ -114,6 +115,11 @@ function buildQuestionText(fieldsetEle) { // 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; } @@ -139,7 +145,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. @@ -149,11 +159,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 = []; diff --git a/docs/accessibilityManualTest.md b/docs/accessibilityManualTest.md index e80e90c..bc98786 100644 --- a/docs/accessibilityManualTest.md +++ b/docs/accessibilityManualTest.md @@ -40,6 +40,31 @@ For the keyboard-only command guide, see “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. diff --git a/tests/corpus/productionMarkupEdgeCases.spec.js b/tests/corpus/productionMarkupEdgeCases.spec.js index 2c0cb58..f98ee99 100644 --- a/tests/corpus/productionMarkupEdgeCases.spec.js +++ b/tests/corpus/productionMarkupEdgeCases.spec.js @@ -111,9 +111,10 @@ describe('locked production markup edge cases', () => { expect(unmatchedParagraph).toContain('today?

'); const breakQuest = await renderFreshQuest({ - // Keep both authored production blocks intact; placing the dependent - // source after the active target lets Quest resolve the pipe without - // changing the malformed target markup under characterization. + // Participant mode mounts the first question block, so keep the malformed + // dependent target first. The later production source remains searchable + // by QuestionProcessor, which resolves the pipe without rewriting either + // authored block under characterization. markdown: survey('PRODUCTION_NONSTANDARD_BREAK', nonstandardBreak, medicationSource), }); const fertilityQuestion = breakQuest.root.querySelector('#D_466346054'); diff --git a/tests/e2e/authoring.spec.js b/tests/e2e/authoring.spec.js index 819df0e..5ab5aa3 100644 --- a/tests/e2e/authoring.spec.js +++ b/tests/e2e/authoring.spec.js @@ -91,6 +91,25 @@ test.describe('checked-in authoring application @authoring', () => { await expect(page.locator('#pagestyle')).toHaveAttribute('href', 'Default.css'); await expect(page.locator('#pagelogic')).toHaveAttribute('href', 'Default.css'); + await page.locator('#rendering').evaluate((rendering) => { + const probe = document.createElement('form'); + probe.id = 'default-layout-probe'; + probe.className = 'question'; + probe.innerHTML = ` +
+ Question prompt + +
Response
+
+ `; + rendering.append(probe); + }); + const defaultLayoutProbe = page.locator( + '#default-layout-probe fieldset > legend + .screen-reader-focus + *', + ); + await expect(defaultLayoutProbe).toHaveCSS('clear', 'left'); + await page.locator('#default-layout-probe').evaluate((probe) => probe.remove()); + await openAuthoringSettings(page); await page.getByRole('switch', { name: 'Activate Logic' }).check(); await expect(page.locator('#pagelogic')).toHaveAttribute('href', 'ActiveLogic.css'); diff --git a/tests/e2e/keyboardNavigation.spec.js b/tests/e2e/keyboardNavigation.spec.js index 855a565..a61cd15 100644 --- a/tests/e2e/keyboardNavigation.spec.js +++ b/tests/e2e/keyboardNavigation.spec.js @@ -87,6 +87,10 @@ async function expectGridFocusIndicatorUnclipped(label) { 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 @@ -95,18 +99,63 @@ async function expectGridFocusIndicatorUnclipped(label) { 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), - labelHeight: element.getBoundingClientRect().height, + 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 clipped: ${JSON.stringify(geometry)}`, + `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); } diff --git a/tests/e2e/responsive.spec.js b/tests/e2e/responsive.spec.js index 268f1aa..e305380 100644 --- a/tests/e2e/responsive.spec.js +++ b/tests/e2e/responsive.spec.js @@ -84,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'); @@ -250,6 +252,8 @@ test.describe('participant responsive layout @responsive @canonical', () => { await question.locator('tr[data-question-id="GRID_WALK"] label', { hasText: 'Sometimes' }).click(); await page.mouse.move(0, 0); + // 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 a2a6da1f188d6a0267dbb5379a9aa26ff064f68f..b49d6938c8e3cd97f0b041c1b2b171684a1369e2 100644 GIT binary patch literal 16873 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 J@chmD{{bxiBklkI literal 9373 zcmeHtXH=8iMq&Mjur1wOm*9b^2 zQF;%dhlJ$pjPt+iuKWG0b?#c{TM{7euk8J_jsJ5c8Tu1fPSDWM(96j_eMv)ez--qJ{(L#K z?e5Itf~Me6^#~k>xQQx^Pa5i+KJco?;*=BVnS_mLXdYfE7o?%N%J`OvhUVP4szWqa zul?`K|2>EQ(M9lP$)G))({ZkAvOT1v}ckv;<6?P8G#bzyJU)zNYsinun_94M4~ z?k&^arOml+K3?8YJM@7hVJ4z)tf+RaW*BAOn@U$vBs;u2>1mgzUT{Ukmbvqfa?3=I zrJR!&Z?;tgYUq1Aw|%%q*cWJJcHH~vXZBpqv@6Db%Ab3%P}eird&ic`c zF59}XfJ0JXxCMg#7vq47(}pMn;nsSLb){5RS_Lw&Z=_=@UEjFYX3>MI`k(o%?MiYIZk66-Q% zqoM0OP~*HTnEU2F&-$eZw2OXv)D% zgfe-52+g{zEU~RB9huTdZ`~4Wmc-cU*dxY$-z$FNn#Zk)8fUnTfE2`j2*Gwf(REUe zJ5jafBx{(≻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 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 4fbb71a6b3b33c2b37dd377657655e310565e892..ae17d41db02dd797bca3d558b3ea4622758f07b1 100644 GIT binary patch literal 22402 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 Niphb>zkK)mKLF?SK#~9e literal 7044 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%0AmwIBRQ-3Z;k~KTc~P{Lv5rH~W+5Kq>%- z<3nh*dmF%6jL-N%w^g7Zzzm%$XU8R>`K*do%b?-GtlQew*7FhzoEb2YV{Bw%lwst; zkk#dhX8OV~9k90OJ_m3bKz;pYR|NdBQM)H!qvnb~xx!Hu2n0`WZ|}G>LPq=k{XA>* z#!60F)^1|A@(ZiWsm&1ix(}@l$-Ty4jSd|z_EdpnJz?_7M6_1^p_;5E3Bih(M+&dc;We z&uHWv=|DN?~ND_zEUdbP$Ej|w%$^2(9V{(ONr~ zm6Zodr*{3Usd)R;=jnC8iXemF+}h$vhylkFCr%hAE1F*f@_(S#uJL!S|xpPgate@**It4DIpE3YWNgmk-!S#I8FgY zU3#yh%x!7(b8-Y!#Gcz5=LEH0=+Np0{0^kieHo1tCtS9E&+?c_a(^R{qfHf23c3S4 zgj0Wty%-d`i;Ih0RA*FP-I6yZG+8=t4kP#Qp6er0VmZ8SL$nQY#c6!=2rLu z>x9KZoBiM&z!yr*eD4>Z(SY!n28KJ3Ox z5rJ?6S`mhD7T*9P+1BrOLk-Y_Q2A3S?gyi>a z(Dnda9?k#yq#JHt`Oq;fr}ORGx5*{Yt(_(TQ_5@ARi=x`yK}O#o`6=!iv$GKx~?f` zCS9Cb?Y%b-Ga^?qA#|VGzl0b?~OS{!L@nTlvGO-1jY-i2k6FJS&_*Y^YKwA|euB zQ*VJfqr6lwF<)f%Os=vLOvw;t6Kf9y!eZ@&J`e$)vG(r@XEq4y1cz%Y|BM${f!xqF LxmI-b_VfP$mo}UP 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 index 454e8f497ecad8f48b60712e60b877f2769c2d24..abb6f2b047dbe4e3485d4432cd26d2269f2cb21a 100644 GIT binary patch literal 5885 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 { - test.beforeEach(async ({}, testInfo) => { - test.skip( - testInfo.project.name !== 'chromium-windows-ua', - 'These checks guard against user-agent-specific divergence; they do not simulate JAWS.', - ); - }); - - test('keeps only native list controls in the sequential focus order', async ({ page }) => { - await openParticipant(page); - await waitInHarness(page, 550); - const question = activeQuestion(page, 'CHOICE'); - await expect(question.locator('.response')).toHaveCount(2); - await expect(question.locator('.response[tabindex]')).toHaveCount(0); - await expect(question.locator('#srFocusHelper')).toHaveCount(0); - - await page.locator('#host-before').focus(); - await page.keyboard.press('Tab'); - const first = question.locator('#CHOICE_1'); - const second = question.locator('#CHOICE_2'); - await expect(first).toBeFocused(); - await page.keyboard.press('ArrowRight'); - await expect(second).toBeChecked(); - await expect(second).toBeFocused(); - await page.keyboard.press('Tab'); - const next = question.getByRole('button', { name: 'Next question' }); - await expect(next).toBeFocused(); - await waitInHarness(page, 150); - await expect(next).toBeFocused(); - - await expectHealthyHarness(page); - }); - - test('keeps radio-grid arrows and row-to-row Tab traversal native', async ({ page }) => { - await openParticipant(page, { fixture: 'gridResponsive.txt' }); - await waitInHarness(page, 550); - await goNext(page); - await waitInHarness(page, 550); - - const question = activeQuestion(page, 'GRID_RATE'); - await expect(question.locator('#srFocusHelper')).toHaveCount(0); - await expect(question.locator('.screen-reader-focus')).toHaveAttribute('tabindex', '-1'); - await page.keyboard.press('Tab'); - await expect(question.locator('#GRID_WALK_0')).toBeFocused(); - await page.keyboard.press('ArrowRight'); - await waitInHarness(page, 150); - await expect(question.locator('#GRID_WALK_1')).toBeChecked(); - await expect(question.locator('#GRID_WALK_1')).toBeFocused(); - await page.keyboard.press('Tab'); - await expect(question.locator('#GRID_CYCLE_0')).toBeFocused(); - await expectHealthyHarness(page); - }); - - test('keeps checkbox-grid selection on each native checkbox', async ({ page }) => { - await openParticipant(page, { fixture: 'gridCheckboxFocus.txt' }); - await waitInHarness(page, 550); - await goNext(page); - await waitInHarness(page, 550); - const question = activeQuestion(page, 'GRID_CHECK'); - const first = question.locator('#GRID_CHECK_ROW_A_0'); - const second = question.locator('#GRID_CHECK_ROW_A_1'); - const third = question.locator('#GRID_CHECK_ROW_B_0'); - const terminal = question.locator('#GRID_CHECK_ROW_B_1'); - - await expect(question.locator('#srFocusHelper')).toHaveCount(0); - await page.keyboard.press('Tab'); - await expect(first).toBeFocused(); - await page.keyboard.press('Space'); - await waitInHarness(page, 150); - await expect(first).toBeChecked(); - await expect(first).toBeFocused(); - await page.keyboard.press('Tab'); - await expect(second).toBeFocused(); - await page.keyboard.press('Tab'); - await expect(third).toBeFocused(); - await page.keyboard.press('Tab'); - await expect(terminal).toBeFocused(); - await page.keyboard.press('Space'); - await waitInHarness(page, 150); - await expect(terminal).toBeChecked(); - await expect(terminal).toBeFocused(); - await page.keyboard.press('Tab'); - await expect(question.getByRole('button', { name: 'Next question' })).toBeFocused(); - await expectHealthyHarness(page); - }); -}); diff --git a/tests/integration/accessibilityBuilder.spec.js b/tests/integration/accessibilityBuilder.spec.js index 275ec91..9193523 100644 --- a/tests/integration/accessibilityBuilder.spec.js +++ b/tests/integration/accessibilityBuilder.spec.js @@ -47,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(`
diff --git a/tests/integration/eventHandlers.spec.js b/tests/integration/eventHandlers.spec.js index ad534db..2855342 100644 --- a/tests/integration/eventHandlers.spec.js +++ b/tests/integration/eventHandlers.spec.js @@ -149,6 +149,28 @@ describe('delegated runtime event handling', () => { 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'); @@ -263,6 +285,43 @@ describe('delegated runtime event handling', () => { 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(); diff --git a/tests/knownDefects/runtime.spec.js b/tests/knownDefects/runtime.spec.js index 132c51b..362a44f 100644 --- a/tests/knownDefects/runtime.spec.js +++ b/tests/knownDefects/runtime.spec.js @@ -169,18 +169,15 @@ describe('characterized Quest runtime defects', () => { expect(quest.root.querySelector('#NOTES [data-click-type="reset"]')).not.toBeNull(); }); - it.fails(`${runtimeDefects.textareaReset.localDefectId}: clears a standalone textarea and its active response`, async () => { + 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'; - textarea.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); - expect(quest.state.getActiveQuestionState().NOTES).toBe('Clear this response'); const { resetChildren } = await import('../../eventHandlers.js'); resetChildren(textarea.form); expect(textarea.value).toBe(''); - expect(quest.state.getActiveQuestionState().NOTES).toBeUndefined(); }); it.fails(`${runtimeDefects.corpusMalformedCondition.localDefectId}: rejects a truncated function expression`, async () => { diff --git a/tests/setup/jsdom.js b/tests/setup/jsdom.js index aff11fa..2ce7b3f 100644 --- a/tests/setup/jsdom.js +++ b/tests/setup/jsdom.js @@ -62,13 +62,17 @@ class BootstrapComponentStub { } show() { - this._element?.classList.add('show'); - this._element?.dispatchEvent(new Event(`shown.bs.${this.constructor.eventNamespace}`)); + 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.${this.constructor.eventNamespace}`)); + 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() { From 69b6bef1ba5708febd679bfde42b17dda511280f Mon Sep 17 00:00:00 2001 From: Joe Armani <93854858+JoeArmani@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:17:33 -0400 Subject: [PATCH 5/7] unanswered question modal handling refinements --- accessibleQuestionTextBuilder.js | 51 ++++++++++++------- tests/e2e/accessibility.spec.js | 49 +++++++++++++++++- tests/e2e/participantAsync.spec.js | 45 ++++++++++++++++ .../integration/accessibilityBehavior.spec.js | 22 ++++++-- tests/integration/eventHandlers.spec.js | 7 ++- 5 files changed, 148 insertions(+), 26 deletions(-) diff --git a/accessibleQuestionTextBuilder.js b/accessibleQuestionTextBuilder.js index e9463ba..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,14 +21,8 @@ export function manageAccessibleQuestion(fieldsetEle, questionFocusSet) { // Focus the hidden, focusable element if (!moduleParams.isRenderer) { setTimeout(() => { - // A response or submit dialog may open before this delayed - // 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 }); - } - }, 500); + focusAccessibleQuestionTarget(focusableEle); + }, QUESTION_TRANSITION_FOCUS_DELAY_MS); } questionFocusSet = true; @@ -35,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. @@ -560,19 +566,26 @@ function createFocusableElement(fieldsetEle, focusNode) { /** * Restore question context after an unanswered-response modal closes. - * Re-build the question text and focus management for screen readers. + * Focus the question target after Bootstrap finishes hiding the modal. */ export function closeModalAndFocusQuestion() { - // Find the active question after Bootstrap has finished hiding the modal. - // For a soft-modal continuation this may be the newly activated question; - // for every dismissal it remains the question that requested a response. + if (moduleParams.isRenderer) return; + + // 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) { - const questionFocusSet = false; - setTimeout(() => { - manageAccessibleQuestion(activeQuestion.querySelector('fieldset') || activeQuestion, questionFocusSet); - }, 100); - } + if (!activeQuestion) return; + + 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; + + setTimeout(() => { + focusAccessibleQuestionTarget(focusableEle); + }, MODAL_RETURN_FOCUS_DELAY_MS); } // Update the aria-live region with the current selection announcement in a list (for screen readers). diff --git a/tests/e2e/accessibility.spec.js b/tests/e2e/accessibility.spec.js index 04ff4d5..2a67e36 100644 --- a/tests/e2e/accessibility.spec.js +++ b/tests/e2e/accessibility.spec.js @@ -58,6 +58,19 @@ async function expectModalFocusCycle(page, modal, firstFocusable, lastFocusable) await expect(lastFocusable).toBeFocused(); } +async function closeUnansweredModalWithKeyboard(page, { + modal, + dialog, + questionId, + key, +}) { + const closeButton = dialog.getByRole('button', { name: 'Close' }); + await closeButton.focus(); + await page.keyboard.press(key); + await expect(modal).not.toHaveClass(/show/); + await expect(activeQuestion(page, questionId).locator('.screen-reader-focus')).toBeFocused(); +} + async function traverseHostBoundary(page, key, terminalId, maximumPresses = 20) { const path = []; for (let press = 0; press < maximumPresses; press += 1) { @@ -278,13 +291,47 @@ test.describe('participant accessibility contract @canonical @windows-a11y', () ); } await dialog.getByRole('button', { name: 'Close' }).click(); - await waitInHarness(page, 700); await expect(modal).not.toHaveClass(/show/); await expect(activeQuestion(page, 'CHOICE').locator('.screen-reader-focus')).toBeFocused(); await expectHealthyHarness(page); }); + for (const key of ['Enter', 'Space']) { + test(`promptly restores requested and required question context when Close is activated with ${key}`, async ({ page }) => { + await openParticipant(page, { fixture: 'unansweredModals.txt' }); + await expect(activeQuestion(page, 'SOFT').locator('.screen-reader-focus')).toBeFocused(); + await goNext(page); + + const softModal = page.locator('#softModal'); + const softDialog = page.getByRole('dialog', { name: 'Response Requested' }); + await expect(softModal).toHaveClass(/show/); + await closeUnansweredModalWithKeyboard(page, { + modal: softModal, + dialog: softDialog, + questionId: 'SOFT', + key, + }); + + await goNext(page); + await softDialog.getByRole('button', { name: 'Continue Without Answering' }).click(); + await expect(activeQuestion(page, 'HARD')).toBeVisible(); + await expect(activeQuestion(page, 'HARD').locator('.screen-reader-focus')).toBeFocused(); + await goNext(page); + + const hardModal = page.locator('#hardModal'); + const hardDialog = page.getByRole('dialog', { name: 'Response Required' }); + await expect(hardModal).toHaveClass(/show/); + await closeUnansweredModalWithKeyboard(page, { + modal: hardModal, + dialog: hardDialog, + questionId: 'HARD', + key, + }); + await expectHealthyHarness(page); + }); + } + test('distinguishes requested and required unanswered-response dialogs', async ({ page }, testInfo) => { await openParticipant(page, { fixture: 'unansweredModals.txt' }); await expect(activeQuestion(page, 'SOFT').locator('.screen-reader-focus')).toBeFocused(); 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/integration/accessibilityBehavior.spec.js b/tests/integration/accessibilityBehavior.spec.js index ea4bb4f..27a8d61 100644 --- a/tests/integration/accessibilityBehavior.spec.js +++ b/tests/integration/accessibilityBehavior.spec.js @@ -117,22 +117,36 @@ describe('screen-reader and keyboard behavior', () => { expect(nativeArrow.defaultPrevented).toBe(false); }); - it.each(['softModal', 'hardModal'])('reconstructs and restores question focus after closing %s', async (modalId) => { + it.each(['softModal', 'hardModal'])('restores question focus after closing %s', async (modalId) => { const quest = await renderFreshQuest(); 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 hidden-modal 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.dispatchEvent(new Event('hidden.bs.modal')); - 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/eventHandlers.spec.js b/tests/integration/eventHandlers.spec.js index 2855342..89d3ddb 100644 --- a/tests/integration/eventHandlers.spec.js +++ b/tests/integration/eventHandlers.spec.js @@ -359,8 +359,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 () => { From 0ff4efe462cc88a2e5df12ff5e7f761fa10021ab Mon Sep 17 00:00:00 2001 From: Joe Armani <93854858+JoeArmani@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:07:02 -0400 Subject: [PATCH 6/7] CI updates --- tests/e2e/module4AddressPaths.spec.js | 11 +++++++++++ 1 file changed, 11 insertions(+) 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); From be6e6ef57a77d095bdb55e266e641f4af74bc314 Mon Sep 17 00:00:00 2001 From: Joe Armani <93854858+JoeArmani@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:18:43 -0400 Subject: [PATCH 7/7] update popover handling and related tests --- questionnaire.js | 11 +++-- tests/e2e/popoverAccessibility.spec.js | 11 +++++ tests/integration/eventHandlers.spec.js | 31 ++++++++++---- tests/setup/jsdom.js | 54 +++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 11 deletions(-) diff --git a/questionnaire.js b/questionnaire.js index 6a3a5b6..8243a50 100644 --- a/questionnaire.js +++ b/questionnaire.js @@ -1224,11 +1224,16 @@ function handlePopoverKeydown(event) { event.preventDefault(); getOrCreatePopover(event.currentTarget).toggle(); } else if (event.key === 'Escape') { - const popover = bootstrap.Popover.getInstance(event.currentTarget); - if (popover) { + const trigger = event.currentTarget; + const popover = bootstrap.Popover.getInstance(trigger); + // Popover instances are created eagerly, so require this trigger's rendered tip to be open. + const popoverId = trigger.getAttribute('aria-describedby'); + const popoverElement = popoverId && trigger.ownerDocument.getElementById(popoverId); + + if (popover && popoverElement?.classList.contains('show')) { event.preventDefault(); popover.hide(); - event.currentTarget.focus({ preventScroll: true }); + trigger.focus({ preventScroll: true }); } } } diff --git a/tests/e2e/popoverAccessibility.spec.js b/tests/e2e/popoverAccessibility.spec.js index 0088349..42b6bb1 100644 --- a/tests/e2e/popoverAccessibility.spec.js +++ b/tests/e2e/popoverAccessibility.spec.js @@ -86,6 +86,17 @@ test.describe('production-shaped popover accessibility @canonical', () => { 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); + await page.keyboard.press('Enter'); const popover = helpPopover(page); await expect(popover).toBeVisible(); diff --git a/tests/integration/eventHandlers.spec.js b/tests/integration/eventHandlers.spec.js index 89d3ddb..2dfbc75 100644 --- a/tests/integration/eventHandlers.spec.js +++ b/tests/integration/eventHandlers.spec.js @@ -238,29 +238,42 @@ describe('delegated runtime event handling', () => { 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.classList.contains('show')).toBe(false); + 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); - expect(trigger.classList.contains('show')).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(trigger.classList.contains('show')).toBe(false); + 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); - expect(trigger.classList.contains('show')).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(trigger.classList.contains('show')).toBe(false); + expect(reopenedPopoverElement.classList.contains('show')).toBe(false); }); it('disposes an open popover after its hidden lifecycle event', async () => { @@ -273,15 +286,17 @@ describe('delegated runtime event handling', () => { trigger.addEventListener('hidden.bs.modal', hiddenModal); instance.show(); - trigger.setAttribute('aria-describedby', 'synthetic-popover'); - expect(trigger.classList.contains('show')).toBe(true); + 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(trigger.classList.contains('show')).toBe(false); + expect(popoverElement.classList.contains('show')).toBe(false); + expect(trigger.hasAttribute('aria-describedby')).toBe(false); expect(bootstrap.Popover.getInstance(trigger)).toBeNull(); }); diff --git a/tests/setup/jsdom.js b/tests/setup/jsdom.js index 2ce7b3f..ace3cbb 100644 --- a/tests/setup/jsdom.js +++ b/tests/setup/jsdom.js @@ -101,6 +101,60 @@ beforeEach(() => { 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(); + } }, };