1import { 2 getCellText, 3 getCellHTML, 4 getCellUrl, 5 getFirstCell, 6 isUniversalEditorMode, 7 togglePanel, 8} from '../../scripts/utility/shared.js'; 9import { DEFAULT_SITE_CONTENT_ROOT } from '../../scripts/disclaimer-config.js'; 10 11const blockDataByElement = new WeakMap(); 12const blockClickHandlers = new WeakMap(); 13const triggerHandlers = new WeakMap(); 14const activeSourceLinksByBlock = new WeakMap(); 15const pendingHighlightWorkByBlock = new WeakMap(); 16const STABLE_CONTENT_SECTION_CLASSES = [ 17 'disclaimer-top-cf', 18 'disclaimer-top', 19 'disclaimer-auto', 20 'disclaimer-bottom-cf', 21 'disclaimer-bottom', 22]; 23 24/** 25 * Extract disclaimer block data from authored block DOM. 26 * 27 * Block-level model: each field = one row (not cells within a row). 28 * Row 0: heading | Row 1: topCFPath | Row 2: text-topContent 29 * Row 3: bottomCFPath | Row 4: text-bottomContent 30 */ 31function extractData(block) { 32 const retainedData = blockDataByElement.get(block); 33 if (retainedData) return retainedData; 34 35 const rows = [...block.children]; 36 37 if (!rows.length) { 38 const data = { 39 heading: 'Things you should know', 40 topCFPath: '', 41 topContent: '', 42 bottomCFPath: '', 43 bottomContent: '', 44 }; 45 blockDataByElement.set(block, data); 46 return data; 47 } 48 49 const heading = getCellText(getFirstCell(rows[0])) || 'Things you should know'; 50 const topCFPath = getCellUrl(getFirstCell(rows[1])); 51 const topContent = getCellHTML(getFirstCell(rows[2])) || ''; 52 const bottomCFPath = getCellUrl(getFirstCell(rows[3])); 53 const bottomContent = getCellHTML(getFirstCell(rows[4])) || ''; 54 const data = { 55 heading, 56 topCFPath, 57 topContent, 58 bottomCFPath, 59 bottomContent, 60 }; 61 blockDataByElement.set(block, data); 62 63 return data; 64} 65 66/** 67 * Render the disclaimer block HTML string. 68 * Returns the inner HTML for the disclaimer card. 69 */ 70function renderHTML(data) { 71 const { heading, topContent, bottomContent } = data; 72 const { response } = data; 73 const resolvedTopCF = response.top.map(({ html }) => html || '').join(''); 74 const resolvedBottomCF = response.bottom.map(({ html }) => html || '').join(''); 75 const disclaimers = response.disclaimers.map(({ number, html }) => ({ 76 index: number, 77 html, 78 })); 79 80 let topCFSection = ''; 81 if (resolvedTopCF) { 82 topCFSection = `<div class="disclaimer-top-cf">${resolvedTopCF}</div>`; 83 } 84 85 let topRTESection = ''; 86 if (topContent) { 87 topRTESection = `<div class="disclaimer-top">${topContent}</div>`; 88 } 89 90 let autoSection = ''; 91 if (disclaimers && disclaimers.length > 0) { 92 const items = disclaimers 93 .map( 94 ({ index, html }) => ` 95 <div id="disclaimer-${index}" class="disclaimer-item" tabindex="-1"> 96 <span class="disclaimer-index">[${index}]</span> 97 <div class="disclaimer-item-content"> 98 ${html} 99 <a href="#" class="disclaimer-return" aria-label="Return to footnote ${index}" data-index="${index}">Return<span class="sr-only"> to footnote ${index}</span><svg class="disclaimer-return-icon" width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M6 3.8683L10.9409 8.8091L12 7.75L6 1.75L0 7.75L1.0591 8.8092L6 3.8683Z" fill="currentColor"/></svg></a> 100 </div> 101 </div>`, 102 ) 103 .join(''); 104 105 autoSection = `<div class="disclaimer-auto" role="region" aria-label="Disclaimer section">${items}</div>`; 106 } 107 108 let bottomCFSection = ''; 109 if (resolvedBottomCF) { 110 bottomCFSection = `<div class="disclaimer-bottom-cf">${resolvedBottomCF}</div>`; 111 } 112 113 let bottomRTESection = ''; 114 if (bottomContent) { 115 bottomRTESection = `<div class="disclaimer-bottom">${bottomContent}</div>`; 116 } 117 118 return ` 119 <div> 120 <div class="disclaimer-card"> 121 <button class="disclaimer-header" aria-expanded="false" data-analytics-action="action card: ${heading.replace(/"/g, '"').toLowerCase()}"> 122 <h2 class="disclaimer-heading">${heading}</h2> 123 <span class="disclaimer-icon" aria-hidden="true"></span> 124 </button>
125 <div id="disclaimer-content" class="disclaimer-content" hidden> 126 ${topCFSection} 127 ${topRTESection} 128 ${autoSection} 129 ${bottomCFSection} 130 ${bottomRTESection} 131 </div> 132 </div> 133 </div> 134 `; 135} 136 137/** 138 * Calculate the total fixed offset at the top of the viewport 139 * (sticky header + site alert banner if present). 140 * @returns {number} Pixel offset to subtract from scroll target 141 */ 142function getFixedHeaderOffset() { 143 const header = document.querySelector('header'); 144 const alert = document.querySelector('.site-alert'); 145 let offset = 0; 146 if (header) offset += header.getBoundingClientRect().height; 147 if (alert) offset += alert.getBoundingClientRect().height; 148 return offset + 16; // 16px breathing room 149} 150 151/** 152 * Smooth-scroll an element into view, accounting for sticky header + alert banner. 153 * @param {HTMLElement} el - The element to scroll to 154 */ 155function scrollToElement(el) { 156 const offset = getFixedHeaderOffset(); 157 const top = el.getBoundingClientRect().top + window.scrollY - offset; 158 window.scrollTo({ top, behavior: 'smooth' }); 159} 160 161function getChildIndexPath(section, target) { 162 const childIndexPath = []; 163 let currentElement = target; 164 165 while (currentElement && currentElement !== section) { 166 const { parentElement } = currentElement; 167 if (!parentElement) return null; 168 169 const childIndex = [...parentElement.children].indexOf(currentElement); 170 if (childIndex < 0) return null; 171 172 childIndexPath.unshift(childIndex); 173 currentElement = parentElement; 174 } 175 176 return currentElement === section ? childIndexPath : null; 177} 178 179function resolveChildIndexPath(section, childIndexPath) { 180 return childIndexPath.reduce( 181 (currentElement, childIndex) => currentElement?.children[childIndex] || null, 182 section, 183 ); 184} 185 186function getStableDescendantFocusTarget(block, activeElement) { 187 const section = activeElement.closest( 188 STABLE_CONTENT_SECTION_CLASSES.map((className) => `.${className}`).join(', '), 189 ); 190 if (!section || !block.contains(section)) return null; 191 192 const matchesSectionClass = (className) => section.classList.contains(className); 193 const sectionClass = STABLE_CONTENT_SECTION_CLASSES.find(matchesSectionClass); 194 const childIndexPath = getChildIndexPath(section, activeElement); 195 if (!sectionClass || !childIndexPath) return null; 196 197 return { type: 'descendant', sectionClass, childIndexPath }; 198} 199 200function getInteractionState(block) { 201 const header = block.querySelector('.disclaimer-header'); 202 const { activeElement } = document; 203 let focusTarget = null; 204 205 if (activeElement && block.contains(activeElement)) { 206 const returnLink = activeElement.closest('.disclaimer-return'); 207 208 if (returnLink?.dataset.index) { 209 focusTarget = { type: 'return', value: returnLink.dataset.index }; 210 } else if (activeElement.classList.contains('disclaimer-item') && activeElement.id) { 211 focusTarget = { type: 'item', value: activeElement.id }; 212 } else if (activeElement.closest('.disclaimer-header')) { 213 focusTarget = { type: 'header' }; 214 } else { 215 focusTarget = getStableDescendantFocusTarget(block, activeElement); 216 } 217 } 218 219 return { 220 expanded: header?.getAttribute('aria-expanded') === 'true', 221 focusTarget, 222 visibleReturnIndices: [...block.querySelectorAll('.disclaimer-return.is-visible')] 223 .map((returnLink) => returnLink.dataset.index) 224 .filter(Boolean), 225 }; 226} 227 228function restoreInteractionState(block, interactionState) { 229 const header = block.querySelector('.disclaimer-header'); 230 const content = block.querySelector('.disclaimer-content'); 231 232 if (interactionState.expanded && content) { 233 header.setAttribute('aria-expanded', 'true'); 234 content.removeAttribute('hidden'); 235 content.classList.add('open'); 236 content.style.maxHeight = 'none'; 237 } 238 239 const visibleReturnIndices = new Set(interactionState.visibleReturnIndices); 240 block.querySelectorAll('.disclaimer-return').forEach((returnLink) => { 241 if (visibleReturnIndices.has(returnLink.dataset.index)) { 242 returnLink.classList.add('is-visible'); 243 } 244 }); 245 246 const { focusTarget } = interactionState; 247 if (!focusTarget) return; 248 249 let target = null; 250 if (focusTarget.type === 'header') { 251 target = header; 252 } else if (focusTarget.type === 'item') { 253 const disclaimerItem = document.getElementById(focusTarget.value); 254 if (disclaimerItem && block.contains(disclaimerItem)) target = disclaimerItem; 255 } else if (focusTarget.type === 'return') { 256 target = [...block.querySelectorAll('.disclaimer-return')].find( 257 (returnLink) => returnLink.dataset.index === focusTarget.value, 258 ); 259 } else if (focusTarget.type === 'descendant') { 260 const { sectionClass } = focusTarget; 261 const matchesSectionClass = (element) => element.classList.contains(sectionClass); 262 const section = [...block.querySelectorAll('.disclaimer-content > .disclaimer-top-cf, .disclaimer-content > .disclaimer-top, .disclaimer-content > .disclaimer-auto, .disclaimer-content > .disclaimer-bottom-cf, .disclaimer-content > .disclaimer-bottom')].find( 263 matchesSectionClass, 264 );
265 if (section) target = resolveChildIndexPath(section, focusTarget.childIndexPath); 266 } 267 268 target?.focus({ preventScroll: true }); 269} 270 271function removePendingHighlightWork(block, highlightWork) { 272 const pendingHighlightWork = pendingHighlightWorkByBlock.get(block); 273 if (!pendingHighlightWork) return; 274 275 pendingHighlightWork.delete(highlightWork); 276 if (pendingHighlightWork.size === 0) pendingHighlightWorkByBlock.delete(block); 277} 278 279function cancelPendingHighlightWork(block) { 280 const pendingHighlightWork = pendingHighlightWorkByBlock.get(block); 281 if (!pendingHighlightWork) return; 282 283 pendingHighlightWork.forEach((highlightWork) => { 284 highlightWork.cancelled = true; 285 highlightWork.observer.disconnect(); 286 if (highlightWork.fallbackTimeoutId !== null) { 287 window.clearTimeout(highlightWork.fallbackTimeoutId); 288 } 289 if (highlightWork.highlightTimeoutId !== null) { 290 window.clearTimeout(highlightWork.highlightTimeoutId); 291 } 292 highlightWork.target.classList.remove('disclaimer-highlight'); 293 }); 294 pendingHighlightWorkByBlock.delete(block); 295} 296 297function scheduleHighlight(block, target) { 298 let pendingHighlightWork = pendingHighlightWorkByBlock.get(block); 299 if (!pendingHighlightWork) { 300 pendingHighlightWork = new Set(); 301 pendingHighlightWorkByBlock.set(block, pendingHighlightWork); 302 } 303 304 const highlightWork = { 305 target, 306 observer: null, 307 fallbackTimeoutId: null, 308 highlightTimeoutId: null, 309 highlighted: false, 310 cancelled: false, 311 }; 312 pendingHighlightWork.add(highlightWork); 313 314 const applyHighlight = () => { 315 if (highlightWork.cancelled || highlightWork.highlighted) return; 316 if (!block.contains(target)) { 317 removePendingHighlightWork(block, highlightWork); 318 return; 319 } 320 321 highlightWork.highlighted = true; 322 if (highlightWork.fallbackTimeoutId !== null) { 323 window.clearTimeout(highlightWork.fallbackTimeoutId); 324 highlightWork.fallbackTimeoutId = null; 325 } 326 target.classList.add('disclaimer-highlight'); 327 highlightWork.highlightTimeoutId = window.setTimeout(() => { 328 target.classList.remove('disclaimer-highlight'); 329 highlightWork.highlightTimeoutId = null; 330 removePendingHighlightWork(block, highlightWork); 331 }, 1500); 332 }; 333 334 const observer = new IntersectionObserver( 335 (entries) => { 336 if (entries[0].isIntersecting) { 337 observer.disconnect(); 338 applyHighlight(); 339 } 340 }, 341 { threshold: 0.5 }, 342 ); 343 highlightWork.observer = observer; 344 highlightWork.fallbackTimeoutId = window.setTimeout(() => { 345 highlightWork.fallbackTimeoutId = null; 346 observer.disconnect(); 347 applyHighlight(); 348 }, 2000); 349 observer.observe(target); 350} 351 352function teardownDisclaimer(block) { 353 cancelPendingHighlightWork(block); 354 355 const blockClickHandler = blockClickHandlers.get(block); 356 if (blockClickHandler) { 357 block.removeEventListener('click', blockClickHandler); 358 blockClickHandlers.delete(block); 359 } 360 361 const eventRoot = block.closest('main') || block.parentElement; 362 const triggerHandler = triggerHandlers.get(eventRoot); 363 if (triggerHandler) { 364 eventRoot.removeEventListener('click', triggerHandler); 365 triggerHandlers.delete(eventRoot); 366 } 367 368 const activeSourceLinks = activeSourceLinksByBlock.get(block); 369 activeSourceLinks?.clear(); 370 activeSourceLinksByBlock.delete(block); 371 372 block.replaceChildren(); 373 block.style.display = 'none'; 374} 375 376/** 377 * Bind all disclaimer interaction event handlers. 378 * Uses event delegation on the block root where possible. 379 */ 380function bindDisclaimerEventHandlers(block) { 381 const header = block.querySelector('.disclaimer-header'); 382 const content = block.querySelector('.disclaimer-content'); 383 const eventRoot = block.closest('main') || block.parentElement; 384 let activeSourceLinks = activeSourceLinksByBlock.get(block); 385 if (!activeSourceLinks) { 386 activeSourceLinks = new Map(); 387 activeSourceLinksByBlock.set(block, activeSourceLinks); 388 } 389 390 const existingBlockClickHandler = blockClickHandlers.get(block); 391 if (existingBlockClickHandler) { 392 block.removeEventListener('click', existingBlockClickHandler); 393 } 394 395 const blockClickHandler = (event) => { 396 const returnLink = event.target.closest('.disclaimer-return'); 397 if (returnLink) { 398 event.preventDefault(); 399 const idx = returnLink.dataset.index; 400 returnLink.classList.remove('is-visible'); 401 const sourceLink = activeSourceLinks.get(idx); 402 if (sourceLink) { 403 scrollToElement(sourceLink); 404 sourceLink.focus({ preventScroll: true }); 405 activeSourceLinks.delete(idx); 406 } 407 return; 408 } 409 410 // Only toggle when clicking the header button itself 411 if (event.target.closest('.disclaimer-header')) { 412 togglePanel(header, content); 413 } 414 }; 415 block.addEventListener('click', blockClickHandler); 416 blockClickHandlers.set(block, blockClickHandler); 417 418 const existingTriggerHandler = triggerHandlers.get(eventRoot); 419 if (existingTriggerHandler) { 420 eventRoot.removeEventListener('click', existingTriggerHandler); 421 } 422 423 // Superscript triggers live outside the block, so delegate from the nearest page-content root. 424 const triggerHandler = (event) => { 425 const link = event.target.closest('.disclaimer-link'); 426 if (!link || !eventRoot.contains(link)) return; 427 428 event.preventDefault(); 429 const targetId = link.dataset.target || link.getAttribute('href')?.replace('#', ''); 430 if (!targetId) return; 431 const targetEl = document.getElementById(targetId); 432 433 // Store which specific link was clicked so Return goes back to it 434 const idx = targetId.replace('disclaimer-', ''); 435 activeSourceLinks.set(idx, link); 436 437 // Open accordion if closed 438 if (header.getAttribute('aria-expanded') === 'false') { 439 togglePanel(header, content); 440 } 441 442 // Scroll, focus, and highlight â delay until element is visible 443 if (targetEl) { 444 // Show the return link for this disclaimer item 445 const returnLink = targetEl.querySelector('.disclaimer-return'); 446 if (returnLink) returnLink.classList.add('is-visible'); 447 448 scrollToElement(targetEl); 449 targetEl.focus({ preventScroll: true }); 450 scheduleHighlight(block, targetEl); 451 } 452 }; 453 454 eventRoot.addEventListener('click', triggerHandler); 455 triggerHandlers.set(eventRoot, triggerHandler); 456} 457 458export function getDisclaimerBlockData(block) { 459 return blockDataByElement.get(block) || null; 460} 461 462export default async function decorate(block, response = null, options = {}) { 463 const { siteContentRoot = DEFAULT_SITE_CONTENT_ROOT } = options; 464 465 const data = extractData(block); 466 467 const universalEditorMode = isUniversalEditorMode(); 468 if (universalEditorMode && !response) return; 469 const resolvedResponse = response || { top: [], disclaimers: [], bottom: [] }; 470 471 const hasContent = [ 472 resolvedResponse.top.some(({ html }) => html), 473 resolvedResponse.disclaimers.length > 0, 474 resolvedResponse.bottom.some(({ html }) => html), 475 Boolean(data.topContent), 476 Boolean(data.bottomContent), 477 ].some(Boolean); 478 if (!hasContent) { 479 teardownDisclaimer(block); 480 return; 481 } 482 483 const interactionState = getInteractionState(block); 484 cancelPendingHighlightWork(block); 485 block.style.display = ''; 486 block.innerHTML = renderHTML({ ...data, response: resolvedResponse }); 487 488 const servletSections = block.querySelectorAll( 489 '.disclaimer-top-cf, .disclaimer-auto, .disclaimer-bottom-cf', 490 ); 491 servletSections.forEach((section) => { 492 const links = section.querySelectorAll( 493 `a[href="${siteContentRoot}"], a[href^="${siteContentRoot}/"]`, 494 ); 495 links.forEach((link) => { 496 const href = link.getAttribute('href'); 497 link.setAttribute('href', href.slice(siteContentRoot.length) || '/'); 498 }); 499 }); 500 501 // Bind interactions 502 bindDisclaimerEventHandlers(block); 503 restoreInteractionState(block, interactionState); 504}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.