diff --git a/ActiveLogic.css b/ActiveLogic.css index 64f9e50..e83439b 100644 --- a/ActiveLogic.css +++ b/ActiveLogic.css @@ -36,6 +36,20 @@ clear: left; } +/* Native compound subgroup legends replace plain prompt blocks. Neutralize + Bootstrap's larger, floated legend styling so participant layout is stable. */ +.question .compound-radio-group > legend.compound-radio-group-legend { + float: none; + width: auto; + margin-bottom: 0; + font-size: inherit; + line-height: inherit; +} + +.question .compound-radio-group.compound-radio-group-first > legend.compound-radio-group-legend { + margin-bottom: 1.5rem; +} + /* this is an answer with a text area...*/ .freeresponse { display: flex; @@ -126,7 +140,8 @@ input[type="text"] { word-wrap: break-word; } - .quest-grid.table-layout th.nr { + .quest-grid.table-layout th.nr, + .quest-grid.table-layout td.grid-corner-spacer { padding: clamp(5px, 1vw, 10px); text-align: center; vertical-align: middle; diff --git a/Default.css b/Default.css index 7793a7d..dbf7df6 100644 --- a/Default.css +++ b/Default.css @@ -3,6 +3,10 @@ input[type="checkbox"] + label { margin: 5px; } +.validation-container > span { + color: rgb(193, 18, 31); +} + .question-text { font-size: 1rem; display: block; @@ -15,6 +19,20 @@ input[type="checkbox"] + label { clear: left; } +/* Native compound subgroup legends replace plain prompt blocks. Neutralize + Bootstrap's larger, floated legend styling so participant layout is stable. */ +.question .compound-radio-group > legend.compound-radio-group-legend { + float: none; + width: auto; + margin-bottom: 0; + font-size: inherit; + line-height: inherit; +} + +.question .compound-radio-group.compound-radio-group-first > legend.compound-radio-group-legend { + margin-bottom: 1.5rem; +} + /* CSS for grids */ .quest-grid.table-layout { width: 100%; @@ -59,7 +77,8 @@ input[type="checkbox"] + label { word-wrap: break-word; } - .quest-grid.table-layout th.nr { + .quest-grid.table-layout th.nr, + .quest-grid.table-layout td.grid-corner-spacer { padding: clamp(5px, 1vw, 10px); text-align: center; vertical-align: middle; diff --git a/Style1.css b/Style1.css index df50b08..4c4ef82 100644 --- a/Style1.css +++ b/Style1.css @@ -165,8 +165,12 @@ input[type="checkbox"]:checked + label { .next:hover, .reset:hover, .previous:hover { - background-color: rgb(55, 133, 203); - border: solid 3px rgb(55, 133, 203); + background-color: rgb(44, 109, 168); + border: solid 3px rgb(44, 109, 168); +} + +.validation-container > span { + color: rgb(193, 18, 31); } .next:focus, diff --git a/accessibleQuestionTextBuilder.js b/accessibleQuestionTextBuilder.js index 935d0fd..5a46484 100644 --- a/accessibleQuestionTextBuilder.js +++ b/accessibleQuestionTextBuilder.js @@ -1,28 +1,131 @@ 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; +const QUESTION_FOCUS_CANCEL_EVENTS = ['focusin', 'keydown', 'pointerdown', 'click']; +let pendingQuestionFocusHandoff = null; +let selectionAnnouncementTimeout = null; + +/** + * Begin the focus handoff for a newly activated question. + * Participant or host interaction before the next animation frame cancels it. + * @param {Document} ownerDocument - The document containing the active question. + * @returns {{schedule: (focusableEle: HTMLElement, options?: {onInteractionCancel?: () => void}) => void, cancel: (event?: Event) => void} | null} + */ +export function beginQuestionFocusHandoff(ownerDocument) { + clearQuestionFocusHandoff(); + + const ownerWindow = ownerDocument?.defaultView; + if (moduleParams.isRenderer || !ownerWindow?.requestAnimationFrame) return null; + + let active = true; + let animationFrameId = null; + let interactionCancelHandler = null; + let wasCancelledByInteraction = false; + let handoff; + + const clear = ({ cancelFrame = true } = {}) => { + if (!active) return; + active = false; + + if (cancelFrame && animationFrameId !== null) { + ownerWindow.cancelAnimationFrame(animationFrameId); + } + animationFrameId = null; + + QUESTION_FOCUS_CANCEL_EVENTS.forEach((eventName) => { + ownerDocument.removeEventListener(eventName, handoff.cancel, true); + }); + + if (pendingQuestionFocusHandoff === handoff) { + pendingQuestionFocusHandoff = null; + } + }; + + const notifyInteractionCancel = (handler = interactionCancelHandler) => { + interactionCancelHandler = null; + wasCancelledByInteraction = false; + handler?.(); + }; + + handoff = { + schedule(focusableEle, { onInteractionCancel } = {}) { + if (!active) { + if (wasCancelledByInteraction) notifyInteractionCancel(onInteractionCancel); + return; + } + if (pendingQuestionFocusHandoff !== handoff || animationFrameId !== null) return; + + interactionCancelHandler = onInteractionCancel; + + animationFrameId = ownerWindow.requestAnimationFrame(() => { + if (!active || pendingQuestionFocusHandoff !== handoff) return; + + // Remove the focusin listener before moving focus so the handoff + // does not interpret its own focus event as participant activity. + interactionCancelHandler = null; + clear({ cancelFrame: false }); + focusAccessibleQuestionTarget(focusableEle); + }); + }, + cancel(event) { + const cancelledByInteraction = Boolean(event?.type); + if (cancelledByInteraction) wasCancelledByInteraction = true; + const handler = cancelledByInteraction ? interactionCancelHandler : null; + clear(); + if (cancelledByInteraction && handler) notifyInteractionCancel(handler); + }, + }; + + pendingQuestionFocusHandoff = handoff; + QUESTION_FOCUS_CANCEL_EVENTS.forEach((eventName) => { + ownerDocument.addEventListener(eventName, handoff.cancel, true); + }); + + return handoff; +} + +/** + * Cancel any question-focus handoff left by the current render or transition. + */ +export function clearQuestionFocusHandoff() { + pendingQuestionFocusHandoff?.cancel(); +} /** * 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. + * Schedule focus at the next rendering opportunity after question preparation completes. * @param {HTMLElement} fieldsetEle - The fieldset element containing the question text. * @param {Boolean} questionFocusSet - The flag to manage screen reader focus. + * @param {{schedule: (focusableEle: HTMLElement, options?: {onInteractionCancel?: () => void}) => void, cancel: (event?: Event) => void} | null} [questionFocusHandoff] - The transition's cancellable focus handoff. + * @param {HTMLElement | null} [preferredFocusTarget] - Static feedback that should receive the transition focus instead of the generated question target. * @returns {Boolean} - The updated questionFocusSet flag. */ -export function manageAccessibleQuestion(fieldsetEle, questionFocusSet) { +export function manageAccessibleQuestion( + fieldsetEle, + questionFocusSet, + questionFocusHandoff, + preferredFocusTarget = null, +) { if (fieldsetEle && !questionFocusSet) { + const questionLiveRegion = moduleParams.questDiv?.querySelector('#ariaLiveQuestionAnnouncer'); + if (questionLiveRegion) questionLiveRegion.textContent = ''; + // Build the question text and get the focusable element let focusableEle = buildQuestionText(fieldsetEle); + const transitionFocusTarget = preferredFocusTarget?.isConnected + ? preferredFocusTarget + : focusableEle; - // Focus the hidden, focusable element + // Focus the hidden, programmatic target on the next animation frame. if (!moduleParams.isRenderer) { - setTimeout(() => { - focusAccessibleQuestionTarget(focusableEle); - }, QUESTION_TRANSITION_FOCUS_DELAY_MS); + const handoff = questionFocusHandoff ?? beginQuestionFocusHandoff(fieldsetEle.ownerDocument); + handoff?.schedule(transitionFocusTarget, { + onInteractionCancel: preferredFocusTarget + ? () => announcePreferredFocusTarget(preferredFocusTarget) + : undefined, + }); } questionFocusSet = true; @@ -31,6 +134,26 @@ export function manageAccessibleQuestion(fieldsetEle, questionFocusSet) { return questionFocusSet; } +function announcePreferredFocusTarget(preferredFocusTarget) { + const activeQuestion = preferredFocusTarget?.closest('form.question.active'); + const openModal = moduleParams.questDiv?.querySelector('.modal.show'); + if ( + !preferredFocusTarget?.isConnected + || !activeQuestion + || !moduleParams.questDiv?.contains(preferredFocusTarget) + || openModal + ) return; + + const announcementText = ( + preferredFocusTarget.innerText + || preferredFocusTarget.firstElementChild?.innerText + || preferredFocusTarget.textContent + || '' + ).replace(/\s+/g, ' ').trim(); + const liveRegion = moduleParams.questDiv.querySelector('#ariaLiveQuestionAnnouncer'); + if (liveRegion && announcementText) liveRegion.textContent = announcementText; +} + 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 @@ -54,12 +177,14 @@ function focusAccessibleQuestionTarget(focusableEle) { function buildQuestionText(fieldsetEle) { let focusNode = null; let multiQuestionStartIndex = null; + const staticCompoundPlan = createStaticCompoundRadioPlan(fieldsetEle); + const staticCompoundFirstPrompt = staticCompoundPlan?.firstPrompt ?? null; // The conditions for building textContent (survey questions) for the screen reader. const textNodeConditional = (node) => node.nodeType === Node.TEXT_NODE || (node.nodeType === Node.ELEMENT_NODE && - !['INPUT', 'BR', 'LABEL', 'LEGEND', 'TABLE'].includes(node.tagName) && + !['INPUT', 'TEXTAREA', 'SELECT', 'BR', 'LABEL', 'LEGEND', 'TABLE'].includes(node.tagName) && !node.classList.contains('response')); const isTerminalText = (text) => { @@ -76,6 +201,18 @@ function buildQuestionText(fieldsetEle) { for (let nodeIndex = 0; nodeIndex < childNodes.length; nodeIndex++) { const node = childNodes[nodeIndex]; + + // A static compound question has an overall instruction followed by a + // distinct prompt for each native radio subgroup. Stop before the + // first subgroup prompt so it does not become part of the outer + // fieldset's legend. The multi-question pass below will preserve it as + // the first subgroup's visible label. + if (node === staticCompoundFirstPrompt) { + focusNode = node; + multiQuestionStartIndex = nodeIndex; + break; + } + if (textNodeConditional(node)) { // Special handling to retain spacing for top headings with question text below. if (node.tagName === 'B' && nodeIndex <= 1 && (nodeIndex === 0 || (childNodes[nodeIndex - 1].nodeType === Node.TEXT_NODE && !childNodes[nodeIndex - 1].textContent.trim()))) { @@ -161,7 +298,94 @@ function buildQuestionText(fieldsetEle) { // Create the tag for screen readers and move the question text into it. const updatedFieldset = manageAccessibleFieldset(fieldsetEle, questionElements); // Create and return the hidden, focusable element for screen reader focus management. - return createFocusableElement(updatedFieldset, focusNode); + const focusableEle = createFocusableElement(updatedFieldset, focusNode); + manageCompoundRadioGroups(updatedFieldset, Boolean(staticCompoundPlan)); + return focusableEle; +} + +/** + * Validate the complete source structure for an unconditional compound-radio + * question before separating its first subgroup prompt from the outer legend. + * Each subgroup prompt must occupy its own source line immediately before the + * subgroup's first response. + * Conditional groups retain their existing non-reparenting ARIA path. + * @param {HTMLElement} fieldsetEle - The fieldset before question text is rebuilt. + * @returns {{firstPrompt: Node} | null} - The validated first subgroup boundary. + */ +function createStaticCompoundRadioPlan(fieldsetEle) { + if (fieldsetEle.querySelector('.displayif, [displayif]')) return null; + + const radioResponses = Array.from( + fieldsetEle.querySelectorAll(':scope > .response'), + ).map((response) => ({ + response, + input: response.querySelector(':scope > input[type="radio"][name]'), + })).filter(({ input }) => input); + if (new Set(radioResponses.map(({ input }) => input.name)).size <= 1) return null; + + const responseGroups = []; + radioResponses.forEach(({ response, input }) => { + const currentGroup = responseGroups.at(-1); + const previousResponse = currentGroup?.responses.at(-1); + if (currentGroup?.name === input.name && responsesSharePrompt(previousResponse, response)) { + currentGroup.responses.push(response); + } else { + responseGroups.push({ name: input.name, responses: [response] }); + } + }); + if (new Set(responseGroups.map(({ name }) => name)).size !== responseGroups.length) return null; + if (responseGroups.some(({ responses }) => responses.some(({ hidden, style }) => ( + hidden || style.display === 'none' + )))) return null; + + const promptNodeGroups = responseGroups.map(({ responses }) => ( + findStaticCompoundSourcePrompt(responses[0]) + )); + if (promptNodeGroups.some((promptNodes) => !promptNodes)) return null; + + const promptNodes = promptNodeGroups.flat(); + if (new Set(promptNodes).size !== promptNodes.length) return null; + const firstPrompt = promptNodeGroups[0][0]; + + // Do not split away the only available prompt. The outer fieldset must + // retain a non-empty legend in order to name the complete question. + for (let previous = firstPrompt.previousSibling; previous; previous = previous.previousSibling) { + if ( + previous.nodeType === Node.TEXT_NODE && previous.textContent.trim() !== '' + || previous.nodeType === Node.ELEMENT_NODE && previous.tagName !== 'BR' + ) return { firstPrompt }; + } + return null; +} + +function findStaticCompoundSourcePrompt(firstResponse) { + let node = firstResponse.previousSibling; + + // Ignore line endings and indentation immediately before the response. + while (node && ( + node.nodeType === Node.ELEMENT_NODE && node.tagName === 'BR' + || node.nodeType === Node.TEXT_NODE && node.textContent.trim() === '' + )) { + node = node.previousSibling; + } + + const promptNodes = []; + while (node) { + if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'BR') break; + if (node.classList?.contains('response')) break; + if ( + node.nodeType !== Node.TEXT_NODE + && !(node.nodeType === Node.ELEMENT_NODE && ['U', 'B', 'I'].includes(node.tagName)) + ) return null; + if (node.nodeType !== Node.TEXT_NODE || node.textContent.trim() !== '') { + promptNodes.unshift(node); + } + node = node.previousSibling; + } + + return promptNodes.length > 0 && promptNodes.some(({ textContent }) => textContent.trim() !== '') + ? promptNodes + : null; } // Find additional questions (e.g. QoL multi-question surveys). @@ -179,8 +403,8 @@ function handleMultiQuestionSurveyAccessibility(childNodes, fieldsetEle, startIn for (let i = startIndex; i < childNodes.length; i++) { const node = childNodes[i]; - // Stop at the first input/Table/Label node. Multi-question surveys don't have these nodes. - if (['INPUT', 'TABLE', 'LABEL'].includes(node.tagName)) { + // Stop at the first response control/Table/Label node. Multi-question surveys don't have these nodes. + if (['INPUT', 'TEXTAREA', 'SELECT', 'TABLE', 'LABEL'].includes(node.tagName)) { break; } @@ -241,6 +465,221 @@ function handleMultiQuestionSurveyAccessibility(childNodes, fieldsetEle, startIn }); } +/** + * Give each named radio subgroup in a compound question its own accessible group label. + * @param {HTMLElement} fieldsetEle - The fieldset containing the compound form. + * @param {boolean} useNativeStaticGroups - Whether the complete static source structure was validated. + */ +function manageCompoundRadioGroups(fieldsetEle, useNativeStaticGroups = false) { + const radioResponses = Array.from( + fieldsetEle.querySelectorAll(':scope > .response'), + ).map((response) => ({ + response, + input: response.querySelector(':scope > input[type="radio"][name]'), + })).filter(({ input }) => input); + + const radioNames = new Set(radioResponses.map(({ input }) => input.name)); + if (radioNames.size <= 1) return; + + const responseGroups = []; + radioResponses.forEach(({ response, input }) => { + const currentGroup = responseGroups.at(-1); + const previousResponse = currentGroup?.responses.at(-1); + if (currentGroup?.name === input.name && responsesSharePrompt(previousResponse, response)) { + currentGroup.responses.push(response); + } else { + responseGroups.push({ name: input.name, responses: [response] }); + } + }); + + // Resolve every prompt before changing the DOM (prevents a partially grouped fieldset). + if (new Set(responseGroups.map(({ name }) => name)).size !== responseGroups.length) return; + + const labelledGroups = responseGroups.map(({ name, responses }, groupIndex) => { + const prompt = findCompoundRadioPrompt(fieldsetEle, responses[0], groupIndex); + return { + name, + responses, + prompt, + configuration: prompt + ? getCompoundRadioGroupConfiguration(prompt, responses) + : null, + }; + }); + if (labelledGroups.some(({ prompt, configuration }) => !prompt || !configuration)) return; + if (new Set(labelledGroups.map(({ prompt }) => prompt)).size !== labelledGroups.length) return; + + const groupKinds = new Set(labelledGroups.map(({ configuration }) => configuration.kind)); + if (groupKinds.size !== 1) return; + if (groupKinds.has('static') && fieldsetEle.querySelector('.displayif, [displayif]')) return; + if (groupKinds.has('conditional')) { + const inputs = labelledGroups.flatMap(({ configuration }) => configuration.inputs); + const inputIds = inputs.map(({ id }) => id); + if (new Set(inputIds).size !== inputIds.length) return; + + // A connected question must not resolve to another host element with the same ID. + if (fieldsetEle.isConnected) { + const idCounts = new Map(); + fieldsetEle.ownerDocument.querySelectorAll('[id]').forEach(({ id }) => { + idCounts.set(id, (idCounts.get(id) ?? 0) + 1); + }); + if (inputs.some((input) => ( + idCounts.get(input.id) !== 1 + || fieldsetEle.ownerDocument.getElementById(input.id) !== input + ))) return; + } + } + + const questionId = fieldsetEle.closest('.question')?.id || 'question'; + labelledGroups.forEach(({ name, responses, prompt, configuration }, groupIndex) => { + if (configuration.kind === 'conditional') { + // Conditional response rows must remain direct fieldset children for Quest's display logic and layout. + // ARIA ownership provides the group relationship without moving those rows. + prompt.setAttribute('role', 'radiogroup'); + prompt.setAttribute('aria-label', configuration.label); + prompt.setAttribute('aria-owns', configuration.inputs.map(({ id }) => id).join(' ')); + prompt.removeAttribute('aria-labelledby'); + prompt.removeAttribute('tabindex'); + return; + } + + const labelId = ensureCompoundRadioPromptId(prompt, questionId, name); + if (!useNativeStaticGroups) { + if (prompt.getAttribute('role') === 'alert') { + prompt.removeAttribute('role'); + if (prompt.getAttribute('tabindex') === '0') { + prompt.removeAttribute('tabindex'); + } + } + + const radioGroup = document.createElement('div'); + radioGroup.classList.add('compound-radio-group'); + radioGroup.setAttribute('role', 'radiogroup'); + radioGroup.setAttribute('aria-labelledby', labelId); + fieldsetEle.insertBefore(radioGroup, responses[0]); + responses.forEach((response) => radioGroup.appendChild(response)); + return; + } + + const radioGroup = document.createElement('fieldset'); + radioGroup.classList.add('compound-radio-group'); + if (groupIndex === 0) radioGroup.classList.add('compound-radio-group-first'); + + const groupLegend = document.createElement('legend'); + groupLegend.classList.add('compound-radio-group-legend'); + groupLegend.id = labelId; + while (prompt.firstChild) { + groupLegend.appendChild(prompt.firstChild); + } + + radioGroup.appendChild(groupLegend); + fieldsetEle.insertBefore(radioGroup, prompt); + prompt.remove(); + responses.forEach((response) => radioGroup.appendChild(response)); + }); +} + +function getCompoundRadioGroupConfiguration(prompt, responses) { + const promptHasCondition = prompt.hasAttribute('displayif'); + const conditionedResponses = responses.filter((response) => response.hasAttribute('displayif')); + + if (promptHasCondition || conditionedResponses.length > 0) { + if (!promptHasCondition || conditionedResponses.length !== responses.length) return null; + + const promptCondition = normalizeCompoundRadioCondition(prompt.getAttribute('displayif')); + const responseConditions = responses.map((response) => ( + normalizeCompoundRadioCondition(response.getAttribute('displayif')) + )); + if (!promptCondition || responseConditions.some((condition) => condition !== promptCondition)) { + return null; + } + + const inputs = responses.map((response) => ( + response.querySelector(':scope > input[type="radio"][name]') + )); + const inputIds = inputs.map((input) => input?.id).filter(Boolean); + if (inputs.length < 2 || inputIds.length !== inputs.length || new Set(inputIds).size !== inputs.length) { + return null; + } + + const label = prompt.textContent.replace(/\s+/g, ' ').trim(); + if (!label) return null; + + return { kind: 'conditional', inputs, label }; + } + + if (responses.some(({ hidden, style }) => hidden || style.display === 'none')) return null; + return { kind: 'static' }; +} + +function normalizeCompoundRadioCondition(condition) { + if (!condition) return ''; + try { + return decodeURIComponent(condition).replace(/\s+/g, ' ').trim(); + } catch { + return condition.replace(/\s+/g, ' ').trim(); + } +} + +function responsesSharePrompt(previousResponse, currentResponse) { + if (!previousResponse) return false; + + for (let node = previousResponse.nextSibling; node && node !== currentResponse; node = node.nextSibling) { + if (node.nodeType === Node.TEXT_NODE && node.textContent.trim() === '') continue; + if (node.nodeType === Node.ELEMENT_NODE && ( + node.tagName === 'BR' || node.classList.contains('screen-reader-focus') + )) continue; + return false; + } + return true; +} + +function findCompoundRadioPrompt(fieldsetEle, firstResponse, groupIndex) { + let previousNode = firstResponse.previousSibling; + while (previousNode) { + if (previousNode.nodeType === Node.TEXT_NODE && previousNode.textContent.trim() === '') { + previousNode = previousNode.previousSibling; + continue; + } + + if (previousNode.nodeType === Node.ELEMENT_NODE) { + if (previousNode.tagName === 'BR' || previousNode.classList.contains('screen-reader-focus')) { + previousNode = previousNode.previousSibling; + continue; + } + if ( + previousNode.getAttribute('role') === 'alert' + || previousNode.matches('.displayif[displayif]') + ) { + return previousNode; + } + if (previousNode.matches('.response, .compound-radio-group')) { + break; + } + } + break; + } + + return groupIndex === 0 + ? fieldsetEle.querySelector(':scope > legend') + : null; +} + +function ensureCompoundRadioPromptId(prompt, questionId, radioName) { + if (prompt.id) return prompt.id; + + const safeIdPart = (value) => String(value).replace(/[^A-Za-z0-9_-]/g, '-'); + const baseId = `${safeIdPart(questionId)}-compound-radio-${safeIdPart(radioName)}-label`; + let promptId = baseId; + let suffix = 2; + while (document.getElementById(promptId) && document.getElementById(promptId) !== prompt) { + promptId = `${baseId}-${suffix}`; + suffix += 1; + } + prompt.id = promptId; + return promptId; +} + /** * Insert the tag for the question text. This is the accessible question text for screen readers. * Check for an existing tag since the user can navigate back and forth between questions. @@ -535,7 +974,7 @@ function createFocusableElement(fieldsetEle, focusNode) { border: 0; `; - if (focusNode && fieldsetEle.contains(focusNode)) { + if (focusNode && focusNode !== fieldsetEle && fieldsetEle.contains(focusNode)) { fieldsetEle.insertBefore(focusableEle, focusNode); } else { const legendEle = fieldsetEle.querySelector('legend'); @@ -568,12 +1007,19 @@ function createFocusableElement(fieldsetEle, focusNode) { * Restore question context after an unanswered-response modal closes. * Focus the question target after Bootstrap finishes hiding the modal. */ -export function closeModalAndFocusQuestion() { +export function closeModalAndFocusQuestion(event) { if (moduleParams.isRenderer) return; + if (event?.currentTarget?._questRenderDisposal) return; + + const questDiv = moduleParams.questDiv; + // An obsolete modal can finish hiding after a sequential question render. + // Never let its lifecycle move focus inside the replacement Quest instance. + if (event?.currentTarget && !questDiv?.contains(event.currentTarget)) 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'); + // For a soft-modal continuation, the newly activated question is already in + // the DOM when Bootstrap's hidden event runs. Its normal handoff is replaced + // here so the question receives focus only once. + const activeQuestion = questDiv?.querySelector('.question.active'); if (!activeQuestion) return; const accessibleQuestion = activeQuestion.querySelector('fieldset') || activeQuestion; @@ -583,14 +1029,48 @@ export function closeModalAndFocusQuestion() { // final markup is available. if (!focusableEle) return; - setTimeout(() => { - focusAccessibleQuestionTarget(focusableEle); - }, MODAL_RETURN_FOCUS_DELAY_MS); + // Bootstrap has finished hiding the dialog before this event fires, and + // the question markup is already prepared. Cancel any transition handoff + // so no later task can pull focus away from the participant's next action. + clearQuestionFocusHandoff(); + + const ownerDocument = accessibleQuestion.ownerDocument; + const dismissedModal = event?.currentTarget; + const activeElement = ownerDocument.activeElement; + const focusStillBelongsToDismissal = !activeElement + || activeElement === ownerDocument.body + || activeElement === ownerDocument.documentElement + || dismissedModal?.contains(activeElement); + + // A participant, host, or future Bootstrap trigger may have already moved + // focus while the dialog was closing. Respect that newer focus decision. + if (!focusStillBelongsToDismissal) return; + + focusAccessibleQuestionTarget(focusableEle); +} + +function scheduleSelectionAnnouncement(liveRegion, announcementText, delay) { + // The selection announcer is shared by every question & control. Only + // the latest request can remain valid. Navigation & sequential renders + // use the same clear operation to cancel current announcement work. + clearSelectionAnnouncement(); + + const timeoutId = setTimeout(() => { + if (selectionAnnouncementTimeout !== timeoutId) return; + selectionAnnouncementTimeout = null; + + const currentLiveRegion = moduleParams.questDiv?.querySelector('#ariaLiveSelectionAnnouncer'); + if (liveRegion.isConnected && liveRegion === currentLiveRegion) { + liveRegion.textContent = announcementText; + } + }, delay); + + selectionAnnouncementTimeout = timeoutId; } // Update the aria-live region with the current selection announcement in a list (for screen readers). export function updateAriaLiveSelectionAnnouncer(responseDiv) { - const liveRegion = moduleParams.questDiv.querySelector('#ariaLiveSelectionAnnouncer'); + const liveRegion = moduleParams.questDiv?.querySelector('#ariaLiveSelectionAnnouncer'); const label = responseDiv.querySelector('label'); const input = responseDiv.querySelector('input[type="checkbox"], input[type="radio"]'); @@ -604,19 +1084,16 @@ export function updateAriaLiveSelectionAnnouncer(responseDiv) { ? `${actionText}` : `${label.textContent} ${actionText}`; - liveRegion.textContent = ''; - - setTimeout(() => { - liveRegion.textContent = announcementText; - }, 100); + scheduleSelectionAnnouncement(liveRegion, announcementText, 100); } // Update the aria-live region with the current selection announcement in a table (for screen readers). // Note: cell-specific targeting is required for dependable selection announcements. export function updateAriaLiveSelectionAnnouncerTable(responseDiv) { - const liveRegion = moduleParams.questDiv.querySelector('#ariaLiveSelectionAnnouncer'); + const liveRegion = moduleParams.questDiv?.querySelector('#ariaLiveSelectionAnnouncer'); const cell = responseDiv.closest('td'); // Get the closest table cell (td) const label = cell?.querySelector('label'); // Find the label within the cell + const responseText = label?.querySelector('.grid-label-response-text'); const input = cell?.querySelector('input[type="checkbox"], input[type="radio"]'); if (!liveRegion || !cell || !label || !input) { @@ -624,17 +1101,19 @@ export function updateAriaLiveSelectionAnnouncerTable(responseDiv) { } const actionText = input.checked ? 'Selected.' : 'Unselected.'; - const announcementText = `${label.textContent} ${actionText}`; + const announcementText = `${responseText?.textContent ?? label.textContent} ${actionText}`; - liveRegion.textContent = ''; - setTimeout(() => { - liveRegion.textContent = announcementText; - }, 250); + scheduleSelectionAnnouncement(liveRegion, announcementText, 250); } -// Clear the selection accnouncer when a user is navigating between questions (next/back buttons) +// Clear the selection announcer and cancel current announcement work. export function clearSelectionAnnouncement() { - const liveRegion = moduleParams.questDiv.querySelector('#ariaLiveSelectionAnnouncer'); + if (selectionAnnouncementTimeout !== null) { + clearTimeout(selectionAnnouncementTimeout); + selectionAnnouncementTimeout = null; + } + + const liveRegion = moduleParams.questDiv?.querySelector('#ariaLiveSelectionAnnouncer'); if (liveRegion) { liveRegion.textContent = ''; } diff --git a/buildGrid.js b/buildGrid.js index cc0ac44..add7547 100644 --- a/buildGrid.js +++ b/buildGrid.js @@ -42,13 +42,14 @@ function buildHtmlTable(grid_obj, gridButtonDiv) { ${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. - grid_html += ''; + // Build the table header row with the response headers. The first cell is a + // visual spacer above the row-header column, not a header of its own. + grid_html += ''; grid_obj.responses.forEach((resp) => { const header_text = resp.text; grid_html += `${header_text}`; }); - grid_html += ''; + grid_html += ''; // now lets handle each question... grid_obj.questions.forEach((question) => { @@ -59,17 +60,18 @@ function buildHtmlTable(grid_obj, gridButtonDiv) { // Start the row for the question, then add the row header (question text) grid_html += - ` + ` ${question_text}`; // All selectable responses for a given question share the same 'name' attribute to link them as a group - // The label is used as a click target for the radio/checkbox input + // The label is used as a click target for the radio/checkbox input. Its + // hidden row context is populated after piped and conditional text resolves. grid_obj.responses.forEach((resp, resp_index) => { grid_html += ` - + - ${resp.text} + ${resp.text} `; }); @@ -127,7 +129,9 @@ export function parseGrid(text, ...args) { // the value, then evaluate the markdown. question_text = grid_replace_piped_variables(question_text) - let question_obj = { id: match[1], question_text: question_text, displayif: encodeURIComponent(displayIf) }; + // Keep the expression raw in the parsed model. The HTML + // boundary in buildHtmlTable encodes it once for the data attribute. + let question_obj = { id: match[1], question_text: question_text, displayif: displayIf }; grid_obj.questions.push(question_obj); } diff --git a/common.js b/common.js index 346570b..56afecc 100644 --- a/common.js +++ b/common.js @@ -23,7 +23,7 @@ export const ariaLiveAnnouncementRegions = () => { export const progressBar = () => { return moduleParams.showProgressBarInQuest ? ` - + 0% Complete @@ -33,16 +33,16 @@ export const progressBar = () => { export const responseRequestedModal = () => { return ` - + ${translate('responseRequestedLabel')} - + - - + + ${translate('continueWithoutAnsweringButton')} @@ -62,11 +62,11 @@ export const responseRequiredModal = () => { ${translate('responseRequiredLabel')} - + - +