Skip to content

Commit eeae2fc

Browse files
authored
handle updated mod 4 markdown (#19)
* handle updated mod 4 markdown * cleanup * revisions - Mod4, Mod3, and backtesting previously released modules.
1 parent f2231a0 commit eeae2fc

4 files changed

Lines changed: 336 additions & 150 deletions

File tree

accessibleQuestionTextBuilder.js

Lines changed: 175 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { moduleParams } from './questionnaire.js';
2-
import { getStateManager } from './stateManager.js';
1+
import { evaluateCondition } from './evaluateConditions.js';
2+
import { handleForIDAttributes, moduleParams } from './questionnaire.js';
33

44
/**
55
* Initialize the question text and focus management for screen readers.
@@ -46,6 +46,13 @@ function buildQuestionText(fieldsetEle) {
4646
(node.nodeType === Node.ELEMENT_NODE &&
4747
!['INPUT', 'BR', 'LABEL', 'LEGEND', 'TABLE'].includes(node.tagName) &&
4848
!node.classList.contains('response'));
49+
50+
const isTerminalText = (text) => {
51+
const trimmed = text.trim();
52+
return trimmed.endsWith('?') ||
53+
trimmed.endsWith('...') ||
54+
trimmed.endsWith('tion:');
55+
};
4956

5057
const childNodes = Array.from(fieldsetEle.childNodes);
5158

@@ -87,19 +94,30 @@ function buildQuestionText(fieldsetEle) {
8794
}
8895

8996
// Stop collecting for legend if we hit text + number input node.
90-
if (node.nodeType === Node.TEXT_NODE && nodeIndex + 1 < childNodes.length && childNodes[nodeIndex + 1] && childNodes[nodeIndex + 1].tagName === 'INPUT' && childNodes[nodeIndex + 1].type === 'number') {
97+
if (node.nodeType === Node.TEXT_NODE && nodeIndex + 1 < childNodes.length && childNodes[nodeIndex + 1] && childNodes[nodeIndex + 1].tagName === 'INPUT' && (childNodes[nodeIndex + 1].type === 'number')) {
9198
focusNode = node;
9299
break;
93100
}
94101

95102
questionElements.push(node.cloneNode(true));
96103

104+
// Stop collecting for legend if we hit the text node with a question-terminating condition.
105+
// Let the handleMultiQuestionSurveyAccessibility() handle the focus node.
106+
if (node.nodeType === Node.TEXT_NODE && isTerminalText(node.textContent)) {
107+
break;
108+
}
109+
97110
// Stop looping if the text contains the 'a summary' text (otherwise the summary prompts get compressed).
98111
if (node.textContent && node.textContent.includes('a summary')) {
99112
focusNode = node.nextSibling;
100113
break;
101114
}
115+
102116
} else if (node.tagName === 'BR') {
117+
if (nodeIndex + 2 < childNodes.length && childNodes[nodeIndex + 1] && childNodes[nodeIndex + 1].tagName === 'BR' && childNodes[nodeIndex + 2] && childNodes[nodeIndex + 2].tagName === 'BR') {
118+
//remove one <br> tag to ensure only two <br> tags are present after the <b> tag.
119+
fieldsetEle.removeChild(node); // Remove the first <br> tag.
120+
}
103121
continue; // Skip <br> tags.
104122
} else {
105123
focusNode = node; // The focus node splits questions and responses. The invisible focusable element is placed here.
@@ -115,8 +133,7 @@ function buildQuestionText(fieldsetEle) {
115133
}
116134

117135
// Create the <legend> tag for screen readers and move the question text into it.
118-
const updatedFieldset = manageLegendTag(fieldsetEle, questionElements);
119-
136+
const updatedFieldset = manageAccessibleFieldset(fieldsetEle, questionElements);
120137
// Create and return the hidden, focusable element for screen reader focus management.
121138
return createFocusableElement(updatedFieldset, focusNode);
122139
}
@@ -207,14 +224,17 @@ function handleMultiQuestionSurveyAccessibility(childNodes, fieldsetEle, focusNo
207224
* @param {Array<Node>} questionElements - array of nodes to be added to the legend.
208225
*/
209226

210-
function manageLegendTag(fieldsetEle, questionElements) {
227+
function manageAccessibleFieldset(fieldsetEle, questionElements) {
228+
// On return to question, the legend will already be built.
211229
const existingLegend = fieldsetEle.querySelector('legend');
212230
if (existingLegend) {
213-
// Update the existing displayifs in case the user changed a response.
214-
manageLegendDisplayIfs(existingLegend);
231+
// Update the existing displayifs and forids in case the user changed a response.
232+
const returnToQuestion = true;
233+
manageFieldsetConditionals(fieldsetEle, returnToQuestion);
215234
return fieldsetEle;
216235
}
217236

237+
// Typical path: new question is loaded. Build the legend.
218238
let legendEle = document.createElement('legend');
219239
legendEle.classList.add('question-text');
220240

@@ -229,31 +249,86 @@ function manageLegendTag(fieldsetEle, questionElements) {
229249
}
230250
});
231251

232-
// Manage displayifs in the legend (question text) for summary pages and dynamic questions.
233-
manageLegendDisplayIfs(legendEle);
234-
235252
// The table case: no fieldset exists, create it.
236253
const table = fieldsetEle.querySelector('table');
237254
if (table) {
238255
// Create a new <fieldset> element, then add the <legend> to it.
239256
const newFieldset = document.createElement('fieldset');
240257
newFieldset.appendChild(legendEle);
258+
manageFieldsetConditionals(newFieldset);
241259

242260
// Move the table inside the new <fieldset>
243261
table.parentNode.insertBefore(newFieldset, table);
244262
newFieldset.appendChild(table);
245-
removeBRAfterLegend(newFieldset);
246-
263+
handleQuestionBRElements(newFieldset);
247264
return newFieldset;
248265

249266
} else {
250267
// Insert the <legend> as the first child of the existing <fieldset> for non-table questions.
251268
fieldsetEle.insertBefore(legendEle, fieldsetEle.firstChild);
252-
removeBRAfterLegend(fieldsetEle);
269+
manageFieldsetConditionals(fieldsetEle);
270+
handleQuestionBRElements(fieldsetEle);
253271
return fieldsetEle;
254272
}
255273
}
256274

275+
// If we're reuturning to a question, we need to re-check all conditionals for changes.
276+
function manageFieldsetConditionals(fieldset, returnToQuestion = false) {
277+
const legend = fieldset.querySelector('legend');
278+
if (!legend) return;
279+
280+
const questionElement = fieldset.closest('.question');
281+
const isSummaryPage = questionElement?.id?.includes('SUM');
282+
283+
// Insert a <br> tag between the legend and the first displayif element. User hasupdated attribute to prevent multiple updates.
284+
const nextLegendSibling = legend.nextElementSibling;
285+
if (nextLegendSibling && nextLegendSibling.classList.contains('displayif') && nextLegendSibling.style.display !== 'none' && nextLegendSibling.getAttribute('hasupdate') !== 'true') {
286+
const brEle1 = document.createElement('br');
287+
const brEle2 = document.createElement('br');
288+
brEle1.setAttribute('hasupdate', 'true');
289+
brEle2.setAttribute('hasupdate', 'true');
290+
const afterElement = nextLegendSibling.nextElementSibling;
291+
fieldset.insertBefore(brEle1, afterElement);
292+
fieldset.insertBefore(brEle2, afterElement);
293+
nextLegendSibling.setAttribute('hasupdate', 'true');
294+
}
295+
296+
// Collect text nodes that are adjacent to a hidden element with the displayif attribute.
297+
if (!isSummaryPage) {
298+
for (let i = 0; i < fieldset.childNodes.length; i++) {
299+
const node = fieldset.childNodes[i];
300+
const prevElem = node.previousElementSibling;
301+
const nextElem = node.nextElementSibling;
302+
303+
// use hasupdate attribute to prevent multiple updates
304+
if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'BR') {
305+
if ((node.getAttribute('hasupdate') !== 'true' &&
306+
prevElem && prevElem.classList.contains('displayif') && prevElem.style.display === 'none' &&
307+
nextElem && !nextElem.classList.contains('response'))) {
308+
node.style.display = 'none';
309+
node.setAttribute('hasupdate', 'true');
310+
}
311+
}
312+
313+
if (node.nodeType === Node.TEXT_NODE) {
314+
if ((prevElem && prevElem.classList.contains('response') && prevElem.hasAttribute('displayif') && prevElem.style.display === 'none') ||
315+
(nextElem && nextElem.classList.contains('response') && nextElem.hasAttribute('displayif') && nextElem.style.display === 'none')) {
316+
const cleaned = node.textContent.replace(/\s+/g, ' ');
317+
// Set its content to the cleaned version
318+
node.textContent = cleaned.trim() ? cleaned : '';
319+
}
320+
}
321+
}
322+
}
323+
324+
// Handle displayifs and forids in the fieldset and it's child (legend). Separate for speficity.
325+
const fieldsetForIdSpans = Array.from(fieldset.querySelectorAll('[forid]'))
326+
.filter(el => !legend.contains(el));
327+
328+
handleForIDAttributes(fieldsetForIdSpans, returnToQuestion);
329+
manageLegendDisplayIfs(legend, returnToQuestion);
330+
}
331+
257332
/**
258333
* Displayifs are in the legend (question text) for summary pages and dynamic questions.
259334
* E.g. "Are you still experiencing ______ ?" or "Here's the information you gave us:"
@@ -269,46 +344,45 @@ function manageLegendTag(fieldsetEle, questionElements) {
269344
* @returns {void} - The forid values are updated directly in the legend
270345
*/
271346

272-
function manageLegendDisplayIfs(legend) {
273-
const manageForIdSpans = (forIdSpanArray) => {
274-
const appState = getStateManager();
275-
forIdSpanArray.forEach(forIdSpan => {
276-
const forID = forIdSpan.getAttribute('forid');
277-
const foundValue = appState.findResponseValue(forID) ?? '';
278-
279-
foundValue === ''
280-
? forIdSpan.style.display = 'none'
281-
: forIdSpan.style.display = null;
282-
forIdSpan.textContent = foundValue;
283-
});
284-
}
285-
347+
function manageLegendDisplayIfs(legend, returnToQuestion) {
286348
// If the legend contains displayifs, manage those first.
287-
const displayIfSpans = Array.from(legend.querySelectorAll('span.displayif'));
349+
// Toggle visibility and update values based on the user's previous responses.
350+
const displayIfSpans = Array.from(legend.querySelectorAll("span.displayif"));
288351
if (displayIfSpans.length > 0) {
289352
displayIfSpans.forEach(span => {
290-
const forIdSpans = span.querySelectorAll('[forid]');
291-
manageForIdSpans(forIdSpans);
353+
354+
const displayIfAttribute = span.getAttribute('displayif');
355+
let conditionBool = evaluateCondition(displayIfAttribute);
356+
if (conditionBool) {
357+
span.style.display = '';
358+
} else {
359+
span.style.display = 'none';
360+
}
361+
362+
if (/^\d{9}$/.test(span?.textContent?.trim())) {
363+
span.textContent = '';
364+
}
292365
});
293366

294-
resolveDisplayIfLegendWhitespace(legend);
367+
const forIdSpans = legend.querySelectorAll('[forid]');
368+
if (forIdSpans.length > 0) {
369+
handleForIDAttributes(forIdSpans, returnToQuestion);
370+
}
371+
372+
resolveLegendDisplayIfWhitespace(legend);
295373
return;
296374
}
297375

298376
// If no displayifs are found, check for raw forIds.
299377
const forIdSpans = Array.from(legend.querySelectorAll('[forid]'));
300378
if (forIdSpans.length > 0) {
301-
manageForIdSpans(forIdSpans);
379+
handleForIDAttributes(forIdSpans, returnToQuestion);
302380
}
303381
}
304382

305-
/**
306-
* Resolve extra whitespace in the legend text when many falsey displayifs are present.
307-
* Targeted: only normalizes text nodes between displayif spans.
308-
* E.G. Module4: [HOMEADD4_1] -> "Here is the information you gave us for this location"
309-
* @param {HTMLElement} legend - The legend element containing the question text (and dynamic responses)
310-
*/
311-
function resolveDisplayIfLegendWhitespace(legend) {
383+
function resolveLegendDisplayIfWhitespace(legend) {
384+
if (!legend) return;
385+
312386
const textNodes = [];
313387

314388
// Collect text nodes. Only process text nodes between displayif spans.
@@ -325,8 +399,13 @@ function resolveDisplayIfLegendWhitespace(legend) {
325399
}
326400
}
327401

328-
// Normalize collected nodes: replace sequences of whitespace with a single space
329-
textNodes.forEach(textNode => {
402+
if (textNodes.length === 0) return;
403+
normalizeCollectedTextNodes(textNodes);
404+
}
405+
406+
// Normalize collected nodes: replace sequences of whitespace with a single space
407+
function normalizeCollectedTextNodes(textNodeArray) {
408+
textNodeArray.forEach(textNode => {
330409
textNode.textContent = textNode.textContent.replace(/\s+/g, ' ');
331410

332411
if (textNode.textContent.trim() === '') {
@@ -340,16 +419,69 @@ function resolveDisplayIfLegendWhitespace(legend) {
340419
});
341420
}
342421

422+
// Remove <br> tags after the legend tag when multiple exist (too much whitespace between questions and responses).
343423
function removeBRAfterLegend(fieldsetEle) {
344424
const legendEle = fieldsetEle.querySelector('legend');
345425
if (!legendEle) return;
346426
let nextSibling = legendEle.nextSibling;
347-
while (nextSibling?.tagName === 'BR' && nextSibling.nextSibling?.tagName === 'BR') {
427+
while (nextSibling?.tagName === 'BR' && nextSibling.nextSibling?.tagName === 'BR' && nextSibling.style.display !== 'none' && nextSibling.getAttribute('hasupdate') !== 'true') {
348428
fieldsetEle.removeChild(nextSibling);
349429
nextSibling = legendEle.nextSibling;
350430
}
351431
}
352432

433+
/**
434+
* Traverse the question DOM and handle <br> elements.
435+
* @param {HTMLElement} fieldset - The question's fieldset element.
436+
* @param {number} maxBrs - The maximum number of <br> elements between HTMLElements.
437+
*/
438+
439+
function handleQuestionBRElements(fieldset, maxBrs = 3) {
440+
const questionElement = fieldset.closest('.question');
441+
442+
// Remove any <br> elements after the legend tag.
443+
removeBRAfterLegend(fieldset);
444+
445+
//special handling for summary pages
446+
const isSummaryPage = questionElement.id.includes('SUM');
447+
if (isSummaryPage) {
448+
maxBrs = 1;
449+
}
450+
451+
let consecutiveBrs = [];
452+
453+
// Traverse the DOM tree to find all <br> elements
454+
questionElement.querySelectorAll('br').forEach((br) => {
455+
if (consecutiveBrs.length > 0 && consecutiveBrs[consecutiveBrs.length - 1].nextElementSibling === br) {
456+
consecutiveBrs.push(br);
457+
} else {
458+
if (consecutiveBrs.length > maxBrs) {
459+
// Remove all but the first two <br> elements
460+
consecutiveBrs.slice(maxBrs).forEach((extraBr) => extraBr.remove());
461+
}
462+
// Reset the array to start tracking a new sequence
463+
consecutiveBrs = [br];
464+
}
465+
});
466+
467+
// Final check in case the last sequence of <br>s is at the end of the document
468+
if (consecutiveBrs.length > maxBrs) {
469+
consecutiveBrs.slice(maxBrs).forEach((extraBr) => extraBr.remove());
470+
}
471+
472+
//Set the brs after non-displays to not show as well
473+
if (!isSummaryPage) {
474+
[...questionElement.querySelectorAll(`[style*="display: none"]+br`)].forEach((e) => {
475+
e.style = "display: none"
476+
});
477+
}
478+
479+
// Add aria-hidden to all remaining br elements. This keeps the screen reader from reading them as 'Empty Group'.
480+
[...questionElement.querySelectorAll("br")].forEach((br) => {
481+
br.setAttribute("aria-hidden", "true");
482+
});
483+
}
484+
353485
/**
354486
* Create a hidden, focusable element for screen reader focus management in each question.
355487
* @param {HTMLElement} fieldsetEle - The fieldset element containing the question text.

eventHandlers.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,6 @@ function addSubmitSurveyListener() {
276276
try {
277277
const appState = getStateManager();
278278
const submitSurveyResponse = await appState.submitSurvey();
279-
console.log('submitSurveyResponse (in Quest)', submitSurveyResponse);
280279
if (submitSurveyResponse?.code !== 200) {
281280
throw new Error('Submit survey failed');
282281
}

questionProcessor.js

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -619,7 +619,7 @@ export class QuestionProcessor {
619619
/**
620620
* For some input elements, the input ID and the form ID are different.
621621
* This is a legacy case, where we need to continue supporting existing surveys.
622-
* Process: Search questions for the elementID. If found, return the parent formID.
622+
* Process: Search questions for the elementID. If found, evaluate the found element.
623623
* This supports 'forid' replacement and displayif conditionals.
624624
* @param {string} elementID - The ID of the input element to find.
625625
* @returns {string} - The ID of the input element's form, required for evaluating some conditionals, or null if not found.
@@ -632,13 +632,43 @@ export class QuestionProcessor {
632632
if (question) {
633633
const foundElement = question.querySelector(`#${elementID}`);
634634
if (foundElement) {
635+
const displayIfAttribute = foundElement.getAttribute('displayif');
636+
if (displayIfAttribute) {
637+
if (!this.evaluateConditionInFormSearch(displayIfAttribute)) {
638+
return '';
639+
}
640+
}
641+
// If the found element is hidden, it is not a valid result.
642+
if (foundElement.style.display === 'none') {
643+
return '';
644+
}
635645
return question.id;
646+
// If the elementID matches the questionID, there's no new property to replace/return.
647+
} else if (question.id === elementID) {
648+
return '';
636649
}
637650
}
638651
}
639652

640653
moduleParams.errorLogger(`Error, findRelatedFormID (formID not found): ${moduleParams.questName}, elementID: ${elementID}`);
641-
return null;
654+
return '';
655+
}
656+
657+
// This can be extended to handle other speficic conditionals in the form search for complex cases.
658+
// The doesNotExist case handles multi-page dependencies (e.g. address entry where the user is asked to enter missing values actoss multiple questions in Module 3).
659+
evaluateConditionInFormSearch(displayIfAttribute) {
660+
if (displayIfAttribute.includes('doesNotExist')) {
661+
const idRegex = /"(D_\d+(?:_\d+)*)"/;
662+
const match = displayIfAttribute.match(idRegex);
663+
if (match && match[1]) {
664+
const appState = getStateManager();
665+
const foundValue = appState.findResponseValue(match[1]);
666+
if (foundValue == null || foundValue === '') {
667+
return false;
668+
}
669+
}
670+
}
671+
return true;
642672
}
643673

644674
replaceDateTags(content) {

0 commit comments

Comments
 (0)