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).
343423function 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.
0 commit comments