1import { FranklinBlock, blockColorThemeStructure, platformFetchPage, decorateSections, decorateBlocks, loadSections } from '../../scripts/lib-franklin.js'; 2 3const ANIMATION_DELAY_SHORT = 10; 4const ANIMATION_DELAY_LONG = 100; 5 6/** 7 * Creates a promise that resolves after the specified timeout 8 * @param {number} timeout - Time in milliseconds to wait 9 * @returns {Promise} Promise that resolves after timeout 10 */ 11const sleep = (timeout) => 12 new Promise((resolve) => { 13 setTimeout(resolve, timeout); 14 }); 15 16/** 17 * Checks if accordion item is expanded 18 * @param {HTMLElement} summary - The summary element to check 19 * @returns {boolean} True if the item is expanded 20 */ 21const isExpanded = (summary) => summary.classList.contains('is-open'); 22 23/** 24 * Expands an accordion item 25 * @param {HTMLElement} summary - The summary element to expand 26 * @param {boolean} singleOpen - Whether to close other open accordions 27 */ 28async function expand(summary, singleOpen) { 29 const openSummary = summary.closest('.core-accordion').querySelector('summary.is-open'); 30 const details = summary.closest('details'); 31 details.setAttribute('open', ''); 32 // waiting for the animation 33 await sleep(ANIMATION_DELAY_SHORT); 34 summary.classList.add('is-open'); 35 summary.setAttribute('aria-expanded', 'true'); 36 37 // Dispatch custom event with details element 38 const event = new CustomEvent('accordion:expand', { 39 detail: details, 40 bubbles: true, 41 composed: true, 42 }); 43 44 if (singleOpen && openSummary && openSummary !== summary) { 45 // eslint-disable-next-line no-use-before-define 46 collapse(openSummary); 47 } 48 49 details.dispatchEvent(event); 50} 51 52/** 53 * Collapses an accordion item 54 * @param {HTMLElement} summary - The summary element to collapse 55 */ 56async function collapse(summary) { 57 summary.classList.remove('is-open'); 58 summary.setAttribute('aria-expanded', 'false'); 59 // waiting for the animation 60 await sleep(ANIMATION_DELAY_LONG); 61 const details = summary.closest('details'); 62 details.removeAttribute('open'); 63} 64 65/** 66 * Adds click event listeners to all accordion summary elements 67 * @param {HTMLElement} block - The accordion block element 68 * @param {boolean} singleOpen - Whether only one accordion can be open at a time 69 * @param {boolean} autoOpenNext - Whether to auto-open next accordion on close 70 */ 71function addListeners(block, singleOpen = false, autoOpenNext = false) { 72 const summaries = block.querySelectorAll('summary'); 73 74 summaries.forEach((summary) => 75 summary.addEventListener('click', async (event) => { 76 event.preventDefault(); 77 78 if (isExpanded(summary)) { 79 // Closing current accordion 80 await collapse(summary); 81 82 // If autoOpenNext is enabled, open the next accordion 83 if (autoOpenNext) { 84 const currentIndex = Array.from(summaries).indexOf(summary); 85 const nextIndex = currentIndex + 1 < summaries.length ? currentIndex + 1 : 0; 86 const nextSummary = summaries[nextIndex]; 87 88 if (nextSummary && !isExpanded(nextSummary)) { 89 await expand(nextSummary, singleOpen); 90 } 91 } 92 } else { 93 // Opening current accordion 94 await expand(summary, singleOpen); 95 } 96 97 summary.blur(); 98 }) 99 ); 100 101 // handles the case when the browser toggles the details element on hash navigation 102 block.querySelectorAll('details').forEach((details) => { 103 details.addEventListener('toggle', () => { 104 if (details.open) { 105 details.querySelector('summary').classList.add('is-open'); 106 } else { 107 details.querySelector('summary').classList.remove('is-open'); 108 } 109 }); 110 }); 111} 112 113export default class Accordion extends FranklinBlock { 114 beforeBlockRender() { 115 this.inputData.accordionCollection = this.inputData.accordionCollection.map((item, index) => ({ 116 index: index + 1, 117 ...item, 118 })); 119 } 120 121 /** 122 * Prepares block structure before reading data 123 */ 124 async beforeBlockDataRead() { 125 Array.from(this.block.children).forEach((div) => { 126 const firstChild = div.firstElementChild; 127 if (firstChild?.textContent.trim().toLowerCase() !== 'subheader') { 128 div.classList.add('accordion-div-target'); 129 } 130 }); 131 } 132 133 /** 134 * Adds an image subcaption to accordion item if one exists 135 * @param {HTMLElement} accordionItem - The accordion item element 136 */ 137 static addImageSubCaption(accordionItem) { 138 const rawCaptionEl = accordionItem.querySelector('picture + em, picture + p:has(em), p:has(picture) + em, p:has(picture) + p:has(em)'); 139 if (rawCaptionEl) { 140 const captionText = rawCaptionEl.textContent.trim(); 141 if (captionText) { 142 const caption = document.createElement('div'); 143 caption.classList.add('accordion-image-sub-caption'); 144 caption.innerHTML = captionText; 145 rawCaptionEl.parentElement.replaceChild(caption, rawCaptionEl); 146 } 147 } 148 } 149 150 /** 151 * Adds a disclaimer to accordion item if one exists 152 * Processes trailing paragraphs that are ENTIRELY wrapped in em (disclaimer pattern) 153 * and merges them into a single disclaimer container. 154 * @param {HTMLElement} accordionItem - The accordion item element 155 */ 156 static addDisclaimer(accordionItem) { 157 // Only creates disclaimers for trailing paragraphs that are fully wrapped in em. 158 const paragraphs = Array.from(accordionItem.querySelectorAll(':scope > div:not(.core-accordion-media) > p, :scope > p')); 159 if (paragraphs.length === 0) return; 160 161 const trailingDisclaimerParagraphs = []; 162 163 for (let index = paragraphs.length - 1; index >= 0; index -= 1) { 164 const paragraph = paragraphs[index]; 165 const emTag = paragraph.querySelector(':scope > em'); 166 if (!emTag) break; 167 168 // Double-check that the em tag contains ALL the text content of the paragraph 169 const paragraphText = paragraph.textContent.trim(); 170 const emText = emTag.textContent.trim(); 171 172 // Stop once a non-disclaimer paragraph is found so only trailing disclaimers are converted 173 if (paragraphText !== emText) break; 174 175 trailingDisclaimerParagraphs.unshift(paragraph); 176 } 177 178 if (trailingDisclaimerParagraphs.length === 0) return; 179 180 const disclaimer = document.createElement('div'); 181 disclaimer.classList.add('atoms-disclaimer'); 182 disclaimer.innerHTML = trailingDisclaimerParagraphs 183 .map((paragraph) => paragraph.querySelector(':scope > em').innerHTML) 184 .join('<br><br>'); 185 186 const firstDisclaimerParagraph = trailingDisclaimerParagraphs[0]; 187 firstDisclaimerParagraph.textContent = ''; 188 firstDisclaimerParagraph.appendChild(disclaimer); 189
190 trailingDisclaimerParagraphs.slice(1).forEach((paragraph) => paragraph.remove()); 191 } 192 193 static onHashChange() { 194 if (!window.location.hash) return; 195 const hash = window.location.hash.substring(1); 196 const bookmarkedElement = document.getElementById(hash); 197 if (bookmarkedElement) { 198 const details = bookmarkedElement.closest('details'); 199 if (!details) return; 200 details.setAttribute('open', ''); 201 const summary = details.querySelector('summary'); 202 if (summary) { 203 summary.classList.add('is-open'); 204 setTimeout(() => { 205 bookmarkedElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); 206 }, 100); 207 } 208 } 209 } 210 211 static listenHashChange() { 212 window.addEventListener('hashchange', Accordion.onHashChange); 213 Accordion.onHashChange(); 214 } 215 216 afterBlockRender() { 217 // Check if accordion is inside accordion-2, accordion-3, or accordion-4 container 218 const parentContainer = this.block.closest('.core-accordion-container'); 219 const isInSpecialContainer = 220 parentContainer && 221 (parentContainer.classList.contains('accordion-2') || parentContainer.classList.contains('accordion-3') || parentContainer.classList.contains('accordion-4')); 222 223 // Enable single-open mode and auto-open-next only for accordion-2, accordion-3, accordion-4 224 const singleOpen = isInSpecialContainer; 225 const autoOpenNext = isInSpecialContainer; 226 addListeners(this.block, singleOpen, autoOpenNext); 227 228 this.block.querySelectorAll('.core-accordion-item-body').forEach((accordionItem) => { 229 Accordion.addImageSubCaption(accordionItem); 230 Accordion.addDisclaimer(accordionItem); 231 }); 232 233 // Listen for accordion expand events 234 this.block.addEventListener('accordion:expand', (event) => { 235 Accordion.handleAccordionExpand(event); 236 }); 237 238 // Open the first accordion by default only for accordion-2, accordion-3, accordion-4 239 if (isInSpecialContainer) { 240 const firstSummary = this.block.querySelector('summary'); 241 if (firstSummary && !isExpanded(firstSummary)) { 242 expand(firstSummary, singleOpen); 243 } 244 } 245 246 this.block.classList.add('block-focus-visible'); 247 248 // global standard rules for block padding /////////////////////// 249 this.block.classList.add('block-padding-standard'); 250 251 const isEditorialWidth = this.block.classList.contains('editorial-width') || this.block.classList.contains('inline'); 252 253 if (isEditorialWidth) { 254 this.block.classList.add('block-padding-desktop-x-190'); 255 } 256 257 // Respect the heading level (h2/h3/h4...) the author selected for the subheader. 258 // The template always wraps the subheader in an <h2>. When an author sets the 259 // text to a heading in the docx, the browser cannot nest headings, so it hoists 260 // that heading out, leaving an empty <h2 class="core-accordion-subheader"></h2> 261 // followed by a sibling heading. Normal text instead stays wrapped in a <p> 262 // inside the <h2>. Normalise both cases into a single subheader element that 263 // keeps the authored heading level and any inline formatting. 264 const subHeading = this.block.querySelector('.core-accordion-subheader'); 265 if (subHeading) { 266 const sibling = subHeading.nextElementSibling; 267 if (subHeading.textContent.trim() === '' && sibling && /^H[1-6]$/.test(sibling.tagName)) { 268 sibling.classList.add('core-accordion-subheader'); 269 subHeading.remove(); 270 } else if (subHeading.children.length === 1 && subHeading.firstElementChild.tagName === 'P') { 271 subHeading.innerHTML = subHeading.firstElementChild.innerHTML; 272 } 273 } 274 this.afterBlockRenderColorTheming(); 275 } 276 277 afterBlockRenderColorTheming() { 278 // Skip theming if inside accordion-4 container 279 const parentContainer = this.block.closest('.core-accordion-container'); 280 if (parentContainer?.classList.contains('accordion-4')) { 281 return; 282 } 283 284 const themeTargets = [ 285 { target: '.block', classToAdd: 'surface' }, 286 { target: '.block .core-accordion-subheader', classToAdd: 'body-copy' }, 287 { target: '.block .core-accordion-item-label h3, .block .core-accordion-item-label h4', classToAdd: 'body-copy' }, 288 { target: '.block .core-accordion-item-body.atoms-content p', classToAdd: 'body-copy' }, 289 { target: '.block .core-accordion-item-body.atoms-content p a', classToAdd: 'text-link', multiple: true }, 290 { target: '.block .core-accordion details', classToAdd: 'border-neutral' }, 291 { target: '.block summary span.icon', classToAdd: 'themed-icon' }, 292 { target: '.block .accordion-image-sub-caption', classToAdd: 'disclaimer' }, 293 ]; 294
295 const themeConfiguration = { 296 'color-theme-1-white': { 297 isDefaultVariant: true, 298 colorThemeCollection: 'data-color-theme-one', 299 colorModeValue: 'white', 300 domTargets: [...themeTargets], 301 isMatch: this.block.classList.contains('color-theme-1-white'), 302 }, 303 'color-theme-1-core-tinted': { 304 colorThemeCollection: 'data-color-theme-one', 305 colorModeValue: 'core-tinted', 306 domTargets: [...themeTargets], 307 isMatch: this.block.classList.contains('color-theme-1-core-tinted'), 308 }, 309 }; 310 311 blockColorThemeStructure(this.block, themeConfiguration); 312 313 const icons = this.block.querySelectorAll('.block summary span.icon'); 314 icons.forEach((icon) => { 315 icon.classList.add('themed-icon'); 316 }); 317 318 const detailsElements = this.block.querySelectorAll('.block .core-accordion details'); 319 detailsElements.forEach((details) => { 320 details.classList.add('border-neutral'); 321 }); 322 } 323 324 /** 325 * Handles accordion expand event (for accordion-4 mobile fragment loading) 326 * @param {CustomEvent} event - The accordion expand event 327 */ 328 static async handleAccordionExpand(event) { 329 if (!Accordion.isMobile()) return; 330 const accordionItem = event.detail; 331 const container = accordionItem.querySelector('.accordion-item-fragment'); 332 if (container) return; 333 const fragmentContainer = Accordion.createFragmentContainer(accordionItem); 334 await Accordion.loadFragment(accordionItem, fragmentContainer); 335 accordionItem.querySelector('.core-accordion-media').appendChild(fragmentContainer); 336 } 337 338 static isMobile = () => !!window.matchMedia('(max-width: 1024px)').matches; 339 340 static createFragmentContainer() { 341 const container = document.createElement('div'); 342 container.classList.add('accordion-item-fragment'); 343 return container; 344 } 345 346 static async loadFragment(accordionItem, fragmentContainer) { 347 const fragmentLink = accordionItem.querySelector('a[href*="loadcontent=true"]'); 348 if (fragmentLink) { 349 const fragmentUrl = new URL(fragmentLink.href); 350 351 if (fragmentUrl.origin !== window.location.origin) { 352 console.error('Cannot load overlay content from different origin:', fragmentUrl.origin); 353 return; 354 } 355 const contentHTML = await platformFetchPage('accordion-fragment', fragmentUrl.pathname); 356 357 if (!contentHTML) { 358 console.error('No content found in accordion'); 359 return; 360 } 361 362 fragmentContainer.innerHTML = contentHTML; // sanitize? 363 decorateSections(fragmentContainer); 364 decorateBlocks(fragmentContainer); 365 loadSections(fragmentContainer); 366 } 367 } 368} 369 370setTimeout(Accordion.listenHashChange, 1500);
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.