1// requires tb-base.js to be loaded first 2 3// True in the admin document and in the iframed editor canvas (whose body has no 4// `wp-admin` class). Lazy: in the canvas this script runs before <body> exists. 5function tbIsAdmin() { 6 return !!document.body?.matches('.wp-admin, .editor-styles-wrapper'); 7} 8// The element that carries the --tb-* variables: .tb-root (the body, or the 9// connector's own wrapper when our markup is embedded elsewhere). css/style.scss 10// declares its static fallbacks there too, and a declaration on that element 11// shadows anything set on html â so publishing a measurement on :root would never 12// reach the page. Lazy for the same reason as tbIsAdmin(): in the iframed editor 13// canvas this script runs before <body> exists. 14function tbCssRoot() { 15 return document.querySelector('.tb-root') || document.documentElement; 16} 17 18tbPopup = { 19 bodyElement: null, 20 popupElement: null, 21 popupContentElement: null, 22 popupTriggers: null, 23 initialized: false, 24 startingIndex: 0, 25 clickedImageInGallery: 0, 26 27 init() { 28 if (!this.initialized) { 29 this.bodyElement = document.querySelector(tb.BODY_SELECTOR); 30 this.popupElement = document.querySelector('#tb-popup'); 31 this.popupTriggers = document.querySelectorAll('.tb-popup-trigger'); 32 // The editor canvas iframe has no #tb-popup â that dialog lives in the admin document. 33 this.popupContentElement = this.popupElement?.querySelector('.tb-popup'); 34 this.initialized = true; 35 } 36 }, 37 38 showPopup(parent) { 39 this.popupContentElement.innerHTML = ''; 40 this.popupContentElement.append(...parent.childNodes); 41 if (!this.popupElement.open) { 42 this.popupElement.showModal(); 43 } 44 this.popupElement.originalParent = parent; 45 46 // copy classes from parent to popup, except for tb-popup 47 this.popupElement.classList.add(...parent.classList); 48 this.popupElement.classList.remove('tb-popup'); 49 50 // add class to body to prevent scrolling 51 this.bodyElement.classList.add('popup-is-open'); 52 53 // register click events for previous and next buttons 54 const prevButton = this.popupElement.querySelector('.tb-popup-previous'); 55 const nextButton = this.popupElement.querySelector('.tb-popup-next'); 56 57 // check if there is a slider in the popup. If so, make sure the scroll nav is up to date 58 const sliderEntries = this.popupElement.querySelector('.tb-carousel-slider .entries'); 59 if (sliderEntries) { 60 if (this.popupElement.classList.contains('tb-popup--open-clicked-image')) { 61 sliderEntries.scrollTo({ 62 left: 63 (sliderEntries.children[0].offsetWidth + sliderEntries.gap) * 64 (clickedImageInGallery - startingIndex) * 65 sliderEntries.numEntriesPerView, 66 }); 67 setTimeout(() => { 68 tbCarousel.updateScrollNav(sliderEntries); 69 }, '100'); 70 } 71 } 72 73 if (nextButton) { 74 nextButton.addEventListener('click', tbPopup.handleNextPopupClick); 75 // if it's the last element, hide the next button 76 if (!parent.closest('.tb-popup-trigger').nextElementSibling) { 77 nextButton.style.visibility = 'hidden'; 78 } 79 } 80 81 if (prevButton) { 82 prevButton.addEventListener('click', tbPopup.handlePreviousPopupCl
82ick); 83 // if it's the first element, hide the previous button 84 if (!parent.closest('.tb-popup-trigger').previousElementSibling) { 85 prevButton.style.visibility = 'hidden'; 86 } 87 } 88 89 // Emit event to allow other scripts to hook into the popup opening 90 const event = new CustomEvent('tbPopupOpened', { bubbles: true }); 91 parent.dispatchEvent(event); 92 }, 93 94 closePopup(e = null) { 95 // remove click events for previous and next buttons 96 const prevButton = this.popupElement.querySelector('.tb-popup-previous'); 97 const nextButton = this.popupElement.querySelector('.tb-popup-next'); 98 if (prevButton) { 99 prevButton.removeEventListener('click', this.handlePreviousPopupClick); 100 } 101 if (nextButton) { 102 nextButton.removeEventListener('click', this.handleNextPopupClick); 103 } 104 105 // restore content to original parent 106 if (this.popupElement.originalParent) { 107 this.popupElement.originalParent.append(...this.popupContentElement.childNodes); 108 } 109 110 if ( 111 e && 112 e.target && 113 (e.target.classList.contains('tb-popup-next') || e.target.classList.contains('tb-popup-previous')) 114 ) { 115 return; 116 } 117 118 // If not next or previous button. tb-root has to survive: it is what scopes the 119 // --tb-* variables onto this dialog, which lives outside the .tb-root wrapper 120 // whenever our markup is embedded through the Connector. 121 this.popupElement.className = 'tb-root'; 122 // Remove class from body to allow scrolling 123 this.bodyElement.classList.remove('popup-is-open'); 124 125 this.popupElement.classList.add('hide'); 126 127 let closed = false; 128 const closeDialog = () => { 129 if (closed) return; 130 closed = true; 131 this.popupElement.classList.remove('hide'); 132 this.popupElement.close(); 133 }; 134 135 this.popupElement.addEventListener('animationend', closeDialog, { once: true }); 136 setTimeout(closeDialog, 300); 137 138 // Emit event to allow other scripts to hook into the popup closing 139 const event = new CustomEvent('tbPopupClosed', { bubbles: true }); 140 this.popupElement.originalParent.dispatchEvent(event); 141 }, 142 143 // Swap the open dialog's content for another block's, keeping it open. 144 // The tb-popup-previous/next nav only works for triggers that are siblings 145 // and wrap their own dialog; blocks laid out otherwise step through here. 146 swapPopup(parent) { 147 if (!this.popupElement || !this.popupElement.open || !this.popupElement.originalParent) { 148 return; 149 } 150 this.popupElement.originalParent.append(...this.popupContentElement.childNodes); 151 this.showPopup(parent); 152 this.popupContentElement.scrollTop = 0; 153 }, 154
155 handlePreviousPopupClick(e) { 156 tbPopup.handlePrevNextClick(e, 'previous'); 157 }, 158 159 handleNextPopupClick(e) { 160 tbPopup.handlePrevNextClick(e, 'next'); 161 }, 162 163 handlePrevNextClick(e, direction) { 164 e.preventDefault(); 165 const sibling = this.popupElement.originalParent.closest('.tb-popup-trigger')[direction + 'ElementSibling']; 166 if (sibling) { 167 this.closePopup(e); 168 this.showPopup(sibling.querySelector('.tb-popup')); 169 } 170 }, 171 172 enableTriggers(element) { 173 this.init(); 174 175 if (!this.popupElement) { 176 return; 177 } 178 179 const popupTriggers = element.querySelectorAll('.tb-popup-trigger'); 180 181 const triggerEventHandler = (e) => { 182 this.popupTriggerEventHandler(element, e.currentTarget); 183 }; 184 185 for (const trigger of popupTriggers) { 186 // Remove any existing event listeners to prevent duplicates 187 trigger.removeEventListener('click', trigger._triggerEventHandler); 188 trigger._triggerEventHandler = triggerEventHandler; 189 trigger.addEventListener('click', triggerEventHandler); 190 } 191 192 const popupClickHandler = (e) => { 193 if (e.target.matches('.tb-popup-close,.tb-popup-close *,#tb-popup')) { 194 tbPopup.closePopup(); 195 } 196 }; 197 198 const popupCancelHandler = (e) => { 199 e.preventDefault(); 200 tbPopup.closePopup(); 201 }; 202 203 // Remove any existing event listeners to prevent duplicates 204 this.popupElement.removeEventListener('click', this.popupElement._popupClickHandler); 205 this.popupElement._popupClickHandler = popupClickHandler; 206 this.popupElement.addEventListener('click', popupClickHandler); 207 208 this.popupElement.removeEventListener('cancel', this.popupElement._popupCancelHandler); 209 this.popupElement._popupCancelHandler = popupCancelHandler; 210 this.popupElement.addEventListener('cancel', popupCancelHandler); 211 }, 212 213 popupTriggerEventHandler(element, trigger) { 214 const popupTriggers = element.querySelectorAll('.tb-popup-trigger'); 215 216 const thesePopupTriggers = trigger.parentElement.parentElement.querySelectorAll('.tb-popup-trigger'); 217 startingIndex = Array.from(popupTriggers).indexOf(thesePopupTriggers[0]); 218 clickedImageInGallery = Array.from(popupTriggers).indexOf(trigger); 219 if (trigger.closest('.popup-on-readmore.is-text-complete, .popup-on-readmore.is-readmore-inline')) { 220 // the card shows the whole text already, or unfolds it in place 221 return; 222 } 223 if (trigger.closest('.popup-on-readmore') && trigger.closest('.is-mobile-width')) { 224 // At mobile width the card holds its own gallery slider, so a tap on the card 225 // must not open the popup â the read more link does that (see below). 226 return; 227 } 228 229 // find popup content element 230 let popupContentElement = trigger.querySelector('.tb-popup'); 231 232 if (!popupContentElement) { 233 // if not found within the triggering element, look for closest parent with class tb-has-popup-content 234 if ((popupContentElement = trigger.closest('.tb-has-popup-content'))) { 235 popupContentElement = trigger.closest('.tb-has-popup-content').querySelector('.tb-popup'); 236 } 237 } 238 239 tbPopup.showPopup(popupContentElement); 240 }, 241 242 initLangSwitchSuggestion(element) { 243 // only proceed if TRP is activated 244 if (!tb.TRP_IS_ACTIVATED) { 245 return; 246 } 247 248 // only proceed if is frontend 249 if (tbIsAdmin()) { 250 return; 251 } 252 253 // only proceed if it's not a TB Connector request 254 if (tb.IS_TB_CONNECTOR_REQUEST) { 255 return; 256 } 257 258 // if the cookie doesn't exist yet, create it (so CS scanner picks it up) 259 if (!tb.cookieExists('tb_language_switcher_dismissed')) { 260 tb.setCookie( 261 'tb_language_switcher_dismissed', 262 '0', 263 '/', 264 7, // expire in days 265 ); 266 } 267 268 // only proceed if cookie actually exists (CookieScript can block the creation of it) or if it's not "1" 269 if ( 270 !tb.cookieExists('tb_language_switcher_dismissed') || 271 tb.getCookie('tb_language_switcher_dismissed') === '1' 272 ) { 273 return; 274 } 275 276 const bodyElement = element.querySelector(tb.BODY_SELECTOR); 277 const urlData = { 278 request_uri: window.location.origin + window.location.pathname, 279 querystring: window.location.search, 280 }; 281 282 // cookie set and not "1" so get the dialog 283 fetch(tb.AJAX_URL, { 284 method: 'POST', 285 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 286 body: new URLSearchParams({ 287 action: 'tb_load_lang_switcher_dialog', 288 url: JSON.stringify(urlData), 289 tb_current_language: tb.CURRENT_LANGUAGE, 290 }), 291 }) 292 .then((response) => response.json()) 293 .then((data) => { 294 if (data.success && data.data.html) { 295 // Insert the template HTML directly into the page 296 let lang_switch_container = element.createElement('div'); 297 lang_switch_container.innerHTML = data.data.html; // Convert HTML string to DOM nodes 298 document.body.appendChild(lang_switch_container.firstElementChild); 299 const langSwitchModal = element.querySelector('.tb-popup-lang-switcher'); 300 301 if (langSwitchModal) { 302 bodyElement.classList.add('popup-is-open'); 303 langSwitchModal.showModal(); 304 305 const cookieName = langSwitchModal.getAttribute('data-dialog-dismiss-cookie'); 306 const popupClickHandler = (e) => { 307 if ( 308 e.target.matches( 309 '.tb-popup-close,.tb-popup-close *,.tb-popup-lang-switcher,#dialog-dismiss', 310 ) 311 ) { 312 bodyElement.classList.remove('popup-is-open'); 313 langSwitchModal.close(); 314 tb.setCookie( 315 'tb_language_switcher_dismissed', 316 1, 317 '/', 318 7, // expire in days 319 ); 320 } 321 322 if (e.target.matches('#dialog-switch-language-button')) { 323 e.preventDefault(); 324 const selectedRadio = langSwitchModal.querySelector( 325 '.dialog-switch-language-options .radio-option input:checked', 326 ); 327 if (selectedRadio) { 328 const url = selectedRadio.getAttribute('data-url'); 329 if (url) { 330 const isCurrentLang = selectedRadio.getAttribute('data-current-lang') !== null; 331 //if (cookieName && isCurrentLang) { 332 // only save cookie if selection is also current lang 333 tb.setCookie( 334 'tb_language_switcher_dismissed', 335 1, 336 '/', 337 7, // expire in days 338 ); 339 //} 340 window.location.href = url; // Redirect to the URL 341 } 342 } 343 } 344 }; 345 346 const popupCancelHandler = (e) => { 347 e.preventDefault(); 348 bodyElement.classList.remove('popup-is-open'); 349 element.close(); 350 }; 351 352 // Remove any existing event listeners to prevent duplicates 353 langSwitchModal.removeEventListener('click', langSwitchModal._popupClickHandler); 354 langSwitchModal._popupClickHandler = popupClickHandler; 355 langSwitchModal.addEventListener('click', popupClickHandler); 356 357 langSwitchModal.removeEventListener('cancel', langSwitchModal._popupCancelHandler); 358 langSwitchModal._popupCancelHandler = popupCancelHandler; 359 langSwitchModal.addEventListener('cancel', popupCancelHandler); 360 } 361 } 362 }) 363 .catch(() => { 364 // Fetch can fail when the user navigates away mid-request (iOS "Load failed"). 365 // Non-critical: the language suggestion simply won't show. 366 }); 367 }, 368}; 369 370tbCarousel = { 371 init(carouselContainer) { 372 // Guard against double init: observers/handlers would stack on the same element. 373 if (carouselContainer.dataset.tbCarouselInitialized) { 374 return; 375 } 376 carouselContainer.dataset.tbCarouselInitialized = '1'; 377
378 // make navigation buttons visible when content is wider than container 379 const navElement = carouselContainer.querySelector(':scope > .tb-carousel-nav'); 380 if (!navElement) { 381 return; 382 } 383 384 //slide to the right image 385 const firstImageInSlider = carouselContainer.querySelector('.entries').dataset.sliderFirst; 386 const entriesElement = carouselContainer.querySelector(':scope > .entries'); 387 388 entriesElement.navElement = navElement; 389 entriesElement.carouselContainer = carouselContainer; 390 391 entriesElement.prevButton = navElement.querySelector('.prev'); 392 entriesElement.nextButton = navElement.querySelector('.next'); 393 394 this.updateChildList(entriesElement); 395 396 tb.onElementResized(entriesElement, (entry) => { 397 this.updateSize(entriesElement); 398 }); 399 400 // recalculate when child elements get removed or added 401 const mo = new MutationObserver((mutationList, observer) => { 402 const mutation = mutationList[0]; 403 const entriesElement = mutation.target.closest('.entries'); 404 this.updateChildList(entriesElement); 405 }); 406 mo.observe(entriesElement, { 407 childList: true, 408 attributes: false, 409 subtree: true, 410 }); 411 412 entriesElement.addEventListener('scroll', (e) => this.handleScroll(e)); 413 414 navElement.addEventListener('click', (e) => { 415 if (e.target.closest('div').matches('.prev')) { 416 entriesElement.scrollTo({ 417 left: 418 entriesElement.scrollLeft - 419 (entriesElement.entryWidth - entriesElement.gap) * entriesElement.numEntriesPerView, 420 behavior: 'smooth', 421 }); 422 } 423 if (e.target.closest('div').matches('.next')) { 424 entriesElement.scrollTo({ 425 left: 426 entriesElement.scrollLeft + 427 (entriesElement.entryWidth + entriesElement.gap) * entriesElement.numEntriesPerView, 428 behavior: 'smooth', 429 }); 430 } 431 if (e.target.matches('.dot')) { 432 const index = parseInt(e.target.dataset.index); 433 entriesElement.scrollTo({ 434 left: (entriesElement.entryWidth + entriesElement.gap) * index * entriesElement.numEntriesPerView, 435 behavior: 'smooth', 436 }); 437 } 438 }); 439 }, 440 441 handleScroll(e) { 442 this.updateScrollNav(e.currentTarget); 443 }, 444 445 updateScrollNav(entriesElement) { 446 // get scroll x position 447 const scrollX = entriesElement.scrollLeft; 448 const activeIndex = Math.ceil( 449 scrollX / 450 ((entriesElement.entryWidth + entriesElement.gap) * entriesElement.numEntriesPerView + 451 entriesElement.gap), 452 ); 453 // set active dot 454 if (entriesElement.dotsElement) { 455 const activeDot = entriesElement.dotsElement.querySelector('.dot.active'); 456 if (activeDot) { 457 activeDot.classList.remove('active'); 458 } 459 const newActiveDot = entriesElement.dotsElement.querySelector(`.dot[data-index="${activeIndex}"]`); 460 if (newActiveDot) { 461 newActiveDot.classList.add('active'); 462 } 463 464 // disable prev button if first dot is active. 465 const firstDot = entriesElement.dotsElement.querySelector('.dot'); 466 if (firstDot) { 467 if (entriesElement.prevButton) { 468 if (firstDot.classList.contains('active')) { 469 entriesElement.prevButton.classList.add('disabled'); 470 } else { 471 entriesElement.prevButton.classList.remove('disabled'); 472 } 473 } 474 } 475 476 // disable next button if last dot is active. 477 const lastDot = entriesElement.dotsElement.querySelector('.dot:last-child'); 478 if (lastDot) { 479 if (entriesElement.nextButton) { 480 if (lastDot.classList.contains('active')) {
481 entriesElement.nextButton.classList.add('disabled'); 482 } else { 483 entriesElement.nextButton.classList.remove('disabled'); 484 } 485 } 486 } 487 } 488 }, 489 490 updateSize(entriesElement) { 491 const entriesWidth = entriesElement.scrollWidth; 492 if (entriesElement.firstEntryElement) { 493 entriesElement.entryWidth = entriesElement.firstEntryElement.offsetWidth; 494 495 const carouselContainerWidth = entriesElement.carouselContainer.clientWidth; 496 if (entriesWidth > carouselContainerWidth + 40) { 497 498 entriesElement.style.paddingBottom = ''; 499 entriesElement.navElement.style.opacity = 1; 500 entriesElement.navElement.style.display = 'flex'; 501 502 // calculate how many entries fit in the container 503 entriesElement.numEntriesPerView = this.calculateNumEntriesPerView(entriesElement); 504 505 //remove snap-here class from all entries 506 entriesElement.querySelectorAll(':scope > .entry').forEach((el) => { 507 el.classList.remove('snap-here'); 508 }); 509 510 entriesElement.entryElements.forEach((el, index) => { 511 // give every first element in a "view" the class snap-here 512 if ((index + entriesElement.numEntriesPerView) % entriesElement.numEntriesPerView === 0) { 513 el.classList.add('snap-here'); 514 } 515 }); 516 517 const numViews = Math.ceil(entriesElement.numEntries / entriesElement.numEntriesPerView); 518 const dotsHtml = [...Array(numViews).keys()] 519 .map((i) => `<div class="dot ${i == 0 ? ' active' : ''}" data-index="${i}"></div>`) 520 .join(''); 521 if (entriesElement.dotsElement) { 522 entriesElement.dotsElement.innerHTML = dotsHtml; 523 } 524 entriesElement.classList.remove('no-overflow'); 525 } else { 526 entriesElement.style.paddingBottom = '20px'; 527 entriesElement.navElement.style.opacity = 0; 528 entriesElement.navElement.style.display = 'none'; 529 entriesElement.classList.add('no-overflow'); 530 } 531 } 532 }, 533 534 calculateContentWidth(entriesElement) { 535 const navEl = entriesElement.navElement; 536 if (!(navEl instanceof Element)) { 537 return 0; 538 } 539 const computedStyle = getComputedStyle(navEl); 540 let elementWidth = navEl.clientWidth; // width with padding 541 elementWidth -= parseFloat(computedStyle.paddingLeft) + parseFloat(computedStyle.paddingRight); 542 return elementWidth; 543 }, 544 545 calculateNumEntriesPerView(entriesElement) { 546 const contentWidth = this.calculateContentWidth(entriesElement); 547 return Math.max(1, Math.floor(contentWidth / (entriesElement.entryWidth + entriesElement.gap))); 548 }, 549 550 updateChildList(entriesElement) { 551 if (!(entriesElement instanceof Element)) { 552 return; 553 } 554 entriesElement.entryElements = entriesElement.querySelectorAll(':scope > .entry:not(.hidden)'); 555 entriesElement.firstEntryElement = entriesElement.entryElements[0]; 556 entriesElement.numEntries = entriesElement.entryElements.length; 557 entriesElement.entryWidth = entriesElement.entryWidth || 100; 558 entriesElement.dotsElement = entriesElement.navElement.querySelector('.dots'); 559 entriesElement.gap = parseInt(window.getComputedStyle(entriesElement).gap) || 0; 560 entriesElement.numEntriesPerView = this.calculateNumEntriesPerView(entriesElement); 561 562 this.updateSize(entriesElement); 563 }, 564}; 565 566tb.onRootElementReady((tbRootElement) => { 567 // ----------------------------------- 568 // Add mobile / desktop classes to body and set current body width as CSS variable 569 // ----------------------------------- 570 571 // The observer also fires when only the height changes â an accordion o
571pening, 572 // a mobile browser hiding its address bar. Listeners relayout on this event, so 573 // it goes out on a real width change only. 574 let lastMeasuredWidth = null; 575 576 tb.onElementResized(tbRootElement, (entry) => { 577 // this is a bit funky, but necessary to keep it in sync with media queries while also keeping support for admin editor 578 const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth; 579 const width = entry.contentRect.width + scrollBarWidth; 580 const widthChanged = width !== lastMeasuredWidth; 581 lastMeasuredWidth = width; 582 583 if (width <= tb.MOBILE_BREAKPOINT) { 584 tbRootElement.classList.add('is-mobile-width'); 585 tbRootElement.classList.remove('is-desktop-width'); 586 if (widthChanged) { 587 tbRootElement.dispatchEvent(new CustomEvent('tb-width-changed', { detail: { width: 'mobile' } })); 588 } 589 } else { 590 tbRootElement.classList.add('is-desktop-width'); 591 tbRootElement.classList.remove('is-mobile-width'); 592 if (widthChanged) { 593 tbRootElement.dispatchEvent(new CustomEvent('tb-width-changed', { detail: { width: 'desktop' } })); 594 } 595 } 596 tbCssRoot().style.setProperty('--tb-dynamic-body-width', width + 'px'); 597 }); 598 599 // ----------------------------------- 600 // Add platform classes to body 601 // ----------------------------------- 602 var iOS = 603 (/iPad|iPhone|iPod/.test(navigator.platform) || 604 (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) && 605 !window.MSStream; 606 if (iOS) { 607 tbRootElement.classList.add('platform-ios'); 608 } 609 610 // ----------------------------------- 611 // Add classes to body with current scroll state (for sticky header) 612 // ----------------------------------- 613 const bodyElement = document.querySelector(tb.BODY_SELECTOR); 614 615 let scrollPosition = 0; 616 let needsScrollUpdate = true; 617 let scrollStarted = false; 618 let lastScrollDirection = ''; 619 620 // Observe the elements 621 // The hero the scroll threshold is measured against: a tb/header block, or 622 // the theme-rendered header on single posts. Templates like the RTA guide 623 // article are `single` but render a real tb/header â falling back keeps the 624 // nav transparent there instead of flagging the page as heroless. 625 // `.tb-nav-hero` is the opt-in hook for theme templates that paint their own 626 // hero band (404) and want the transparent nav on top of it. 627 let headerEl = null; 628 if (document.querySelector('body').classList.contains('single')) { 629 headerEl = document.querySelector('.tb-page-blog-post__header'); 630 } 631 if (!headerEl) { 632 headerEl = document.querySelector('.tb-header') || document.querySelector('.tb-nav-hero'); 633 } 634 635 let subNavEl = document.querySelector('.tb-header__subnav'); 636 // The fixed top nav. Its full (unscrolled) height feeds --tb-header-height-full; 637 // it shrinks to --tb-header-height once the user scrolls (scrolled-1-3-header). 638 let siteHeaderEl = document.querySelector('#header'); 639 let headerHeight, 640 subNavHeight = 0; 641 642 // Measure the fixed nav in its full state and publish it as a CSS variable so 643 // nothing has to hardcode the height. Only refresh while at the top of the page, 644 // because once scrolled the header is collapsed and would report the small height. 645 // When a re-measure is requested while scrolled (header collapsed), we can't 646 // measure the full height yet. Remember it and do it once we're back at top. 647 let pendingFullHeaderMeasure = false; 648 let fullHeaderMeasureTimeout; 649 function updateFullHeaderHeight() { 650 if (!siteHeaderEl) return; 651 if (window.scrollY !== 0) { 652 pendingFullHeaderMeasure = true; 653 return; 654 } 655 pendingFullHeaderMeasure = false; 656 // .site-header transitions for 0.2s (its padding changes across the desktop 657 // breakpoint); measuring mid-transition would publish a stale height. 658 clearTimeout(fullHeaderMeasureTimeout); 659 fullHeaderMeasureTimeout = setTimeout(() => { 660 tbCssRoot().style.setProperty('--tb-header-height-full', siteHeaderEl.offsetHeight + 'px'); 661 }, 250); 662 } 663 664 // Function to update heights 665 function updateHeightsForNav() { 666 headerHeight = (headerEl && headerEl.offsetHeight) || 0; 667 subNavHeight = (subNavEl && subNavEl.offsetHeight) || 0; 668 updateFullHeaderHeight(); 669 } 670 671 let lastObservedWidth = document.documentElement.clientWidth; 672 const resizeObserver = new ResizeObserver(() => { 673 // ResizeObserver also fires on height changes (scroll collapse, subnav, 674 // late media). We only react to WIDTH changes; bail otherwise. 675 const width = document.documentElement.clientWidth; 676 if (width === lastObservedWidth) return; 677 lastObservedWidth = width; 678 updateHeightsForNav(); 679 }); 680 681 if (headerEl) { 682 resizeObserver.observe(headerEl); 683 684 // Initial call to set the heights on page load. 685 updateHeightsForNav(); 686 687 if (subNavEl) { 688 resizeObserver.observe(subNavEl); 689 } 690 } else { 691 // No hero/header element to measure against, so the scroll threshold 692 // (headerHeight) never becomes defined and the nav would otherwise stay 693 // transparent forever. Give it the solid background from the start. 694 tbRootElement.classList.add('nav-no-header'); 695 } 696 697 // Keep --tb-header-height-full correct even on pages without a hero header, and 698 // when the viewport changes while scrolled (re-measured next time we hit the top). 699 if (siteHeaderEl) { 700 resizeObserver.observe(siteHeaderEl); 701 updateFullHeaderHeight(); 702 } 703 704 const scrollClasses = ['scrolled-to-top', 'scrolled-down', 'scrolled-up']; 705 tbRootElement.classList.add('scrolled-to-top'); 706 document.addEventListener( 707 'scroll', 708 function () { 709 // call handleScroll function only once every 100ms 710 if (needsScrollUpdate) { 711 needsScrollUpdate = false;
712 window.handleScrollTimeout = setTimeout(function () { 713 handleScroll(scrollStarted ? 'scrolling' : 'scrollStart'); 714 needsScrollUpdate = true; 715 scrollStarted = true; 716 }, 100); 717 } 718 719 // call handleScroll function a final time when scrolling ends 720 clearTimeout(window.handleScrollEndTimeout); 721 window.handleScrollEndTimeout = setTimeout(function () { 722 handleScroll('scrollEnd'); 723 scrollStarted = false; 724 }, 100); 725 }, 726 { passive: true, capture: false }, 727 ); 728 729 function handleScroll(type = 'scrolling') { 730 let scrollDirection = lastScrollDirection; 731 if (window.scrollY > scrollPosition) { 732 scrollDirection = 'down'; 733 } else if (window.scrollY < scrollPosition) { 734 scrollDirection = 'up'; 735 } 736 737 if (window.scrollY == 0 && type == 'scrollEnd') { 738 tbRootElement.classList.remove(...scrollClasses); 739 tbRootElement.classList.add('scrolled-to-top'); 740 // Back at the top: only re-measure if a width change was requested 741 // while we were scrolled (header collapsed). Otherwise nothing changed 742 // and the value is still correct â don't update on every scroll-to-top. 743 if (pendingFullHeaderMeasure) { 744 updateFullHeaderHeight(); 745 } 746 } else if (scrollDirection !== lastScrollDirection) { 747 tbRootElement.classList.remove(...scrollClasses); 748 tbRootElement.classList.add('scrolled-' + scrollDirection); 749 } 750 751 lastScrollDirection = scrollDirection; 752 scrollPosition = window.scrollY; 753 754 //topnav should disappear when 1/3 the header 755 if (window.scrollY > headerHeight / 3) { 756 tbRootElement.classList.add('scrolled-1-3-header'); 757 } else { 758 tbRootElement.classList.remove('scrolled-1-3-header'); 759 } 760 761 //topnav should disappear when halfway the header 762 if (window.scrollY > headerHeight / 2) { 763 tbRootElement.classList.add('scrolled-halfway-header'); 764 } else { 765 tbRootElement.classList.remove('scrolled-halfway-header'); 766 } 767 768 //subnav 769 if (window.scrollY > headerHeight) { 770 tbRootElement.classList.add('scrolled-past-header'); 771 } else { 772 tbRootElement.classList.remove('scrolled-past-header'); 773 } 774 775 if (window.scrollY > headerHeight + subNavHeight) { 776 tbRootElement.classList.add('scrolled-past-subnav'); 777 } else { 778 tbRootElement.classList.remove('scrolled-past-subnav'); 779 } 780 } 781 782 // ----------------------------------- 783 // Popups with triggers 784 // ----------------------------------- 785 tbPopup.enableTriggers(document); 786 787 // ----------------------------------- 788 // Language Switcher Popup 789 // ----------------------------------- 790 tbPopup.initLangSwitchSuggestion(document); 791 792 // ----------------------------------- 793 // Section toggler 794 // ----------------------------------- 795 796 initSectionTogglers(); 797 798 // ----------------------------------- 799 // Scroll down button 800 // ----------------------------------- 801 802 const scrollDownButton = document.querySelector('.scroll-down-button'); 803 if (scrollDownButton) { 804 scrollDownButton.addEventListener('click', (e) => { 805 // get second block ID 806 const secondBlockId = document.querySelector('.tb-block:nth-child(2)').id; 807 // add block to URL as hash and scroll to it 808 window.location.hash = ''; 809 window.location.hash = secondBlockId; 810 e.preventDefault(); 811 }); 812 } 813 814 // ----------------------------------- 815 // Read more 816 // ----------------------------------- 817 818 const readmoreGroups = collectReadmoreGroups(document); 819 const refreshReadmoreLinks = () => refreshReadmoreIn(document); 820 821 tbRootElement.addEventListener('tb-width-changed', refreshReadmoreLinks); 822 // a webfont swapping in changes how many lines the text takes 823 document.fonts?.ready.then(refreshReadmoreLinks); 824 // so does an image in the text arriving late 825 window.addEventListener('load', refreshReadmoreLinks); 826 827 let readmoreResizeTimer; 828 window.addEventListener('resize', () => { 829 clearTimeout(readmoreResizeTimer); 830 readmoreResizeTimer = setTimeout(refreshReadmoreLinks, 200); 831 }); 832 833 setTimeout(() => { 834 refreshReadmoreLinks(); 835 836 for (const entry of [...readmoreGroups.values()].flat()) { 837 const { link, content, card } = entry; 838 839 // The whole text part reacts, the read more link included. The image 840 // part stays out of it: it holds the card's own gallery slider. 841 const textPart = link.closest('.tb-card-content') || link; 842 843 textPart.addEventListener('click', (e) => { 844 // At desktop width the card itself is the popup trigger and handles the click. 845 if (!textPart.closest('.is-mobile-width')) { 846 return; 847 } 848 // let real links and buttons in the card do their own thing 849 if (e.target.closest('a, button')) { 850 return; 851 } 852 if (card.classList.contains('is-readmore-inline')) { 853 e.stopPropagation();
854 toggleReadmoreExpanded(entry); 855 return; 856 } 857 if (!card.classList.contains('is-readmore-popup')) { 858 // nothing to unfold: the card already shows the whole text 859 return; 860 } 861 tbPopup.init(); 862 if (!tbPopup.popupContentElement) { 863 return; 864 } 865 e.stopPropagation(); 866 tbPopup.showPopup(card.querySelector('.tb-popup')); 867 }); 868 } 869 }, 500); 870 871 // ----------------------------------- 872 // Readmore accordion 873 // ----------------------------------- 874 875 const readmoreAccordions = document.querySelectorAll('.readmore-accordion'); 876 877 for (const readmoreAccordion of readmoreAccordions) { 878 const readmoreLink = readmoreAccordion.querySelector('.readmore-accordion-link'); 879 const readmoreContent = readmoreAccordion.querySelector('.readmore-accordion-content'); 880 const readmoreContentItems = readmoreAccordion.querySelectorAll('.readmore-accordion-content-item'); 881 882 readmoreLink.addEventListener('click', () => { 883 if (readmoreContent) { 884 var originalHeight = readmoreContent.scrollHeight; 885 readmoreContent.style.maxHeight = `${Math.ceil(originalHeight) + 10}px`; 886 } 887 if (readmoreContentItems) { 888 readmoreContentItems.forEach((item) => { 889 item.style.maxHeight = `${Math.ceil(item.scrollHeight) + 10}px`; 890 }); 891 } 892 893 if (readmoreAccordion.classList.contains('readmore-accordion--expanded')) { 894 if (readmoreContent) { 895 readmoreContent.style.maxHeight = '0'; 896 } 897 if (readmoreContentItems) { 898 readmoreContentItems.forEach((item) => { 899 item.style.maxHeight = '0'; 900 }); 901 } 902 903 readmoreAccordion.classList.remove('readmore-accordion--expanded'); 904 } else { 905 readmoreAccordion.classList.add('readmore-accordion--expanded'); 906 } 907 }); 908 } 909 910 // if screensize changes, reset the readmore accordion 911 tbRootElement.addEventListener('tb-width-changed', (e) => { 912 if (e.detail.width === 'desktop') { 913 for (const readmoreAccordion of readmoreAccordions) { 914 const readmoreContent = readmoreAccordion.querySelector('.readmore-accordion-content'); 915 if (readmoreContent) { 916 readmoreContent.style.maxHeight = ''; 917 } 918 const readmoreContentItems = readmoreAccordion.querySelectorAll('.readmore-accordion-content-item'); 919 if (readmoreContentItems) { 920 readmoreContentItems.forEach((item) => { 921 item.style.maxHeight = ''; 922 }); 923 } 924 } 925 } 926 }); 927 928 // ----------------------------------- 929 // Carousel 930 // ----------------------------------- 931 const carouselContainers = document.querySelectorAll('.tb-carousel-container'); 932 for (const carouselContainer of carouselContainers) { 933 tbCarousel.init(carouselContainer); 934 } 935 936 // ----------------------------------- 937 // Accordion 938 // ----------------------------------- 939 940 class TB_AccordionItem { 941 constructor(el, parent) { 942 this.parent = parent; 943 944 // Store the <details> element 945 this.el = el; 946 // Store the <summary> element 947 this.summary = el.querySelector('.summary'); 948 // Store the <div class="content"> element 949 this.content = el.querySelector('.content'); 950 951 this.borderWidth = 1; 952 953 // Store the animation object (so we can cancel it if needed) 954 this.animation = null; 955 // Store if the element is closing 956 this.isClosing = false; 957 // Store if the element is expanding 958 this.isExpanding = false;
959 // Detect user clicks on the summary element 960 this.summary.addEventListener('click', (e) => this.onClick(e)); 961 } 962 963 onClick(e) { 964 // Stop default behaviour from the browser 965 e.preventDefault(); 966 967 // Add an overflow on the <details> to avoid content overflowing 968 this.el.style.overflow = 'hidden'; 969 970 if (this.el.tagName !== 'DETAILS') { 971 // Check if the element is being closed or is already closed 972 if (this.isClosing || !this.el.classList.contains('open')) { 973 this.open(); 974 // Check if the element is being opened or is already open 975 } else if (this.isExpanding || this.el.classList.contains('open')) { 976 this.close(); 977 } 978 } else { 979 if (this.isClosing || !this.el.open) { 980 this.open(); 981 // Check if the element is being openned or is already open 982 } else if (this.isExpanding || this.el.open) { 983 this.close(); 984 } 985 } 986 987 // close other accordions 988 this.parent.closeOthers(this); 989 } 990 991 close() { 992 // Set the element as "being closed" 993 this.isClosing = true; 994 995 // set overflow to hidden 996 this.el.style.overflow = 'hidden'; 997 998 // Store the current height of the element 999 const startHeight = `${this.el.offsetHeight}px`; 1000 // Calculate the height of the summary 1001 const endHeight = `${this.summary.offsetHeight + this.borderWidth * 2}px`; 1002 1003 // If there is already an animation running 1004 if (this.animation) { 1005 // Cancel the current animation 1006 this.animation.cancel(); 1007 } 1008 1009 // Start a WAAPI animation 1010 this.animation = this.el.animate( 1011 { 1012 // Set the keyframes from the startHeight to endHeight 1013 height: [startHeight, endHeight], 1014 }, 1015 { 1016 duration: 200, 1017 easing: 'ease-out', 1018 }, 1019 ); 1020 1021 // When the animation is complete, call onAnimationFinish() 1022 this.animation.onfinish = () => this.onAnimationFinish(false); 1023 // If the animation is cancelled, isClosing variable is set to false 1024 this.animation.oncancel = () => (this.isClosing = false); 1025 } 1026 1027 open() { 1028 // Apply a fixed height on the element 1029 this.el.style.height = `${this.el.offsetHeight}px`; 1030 1031 if (this.el.tagName == 'DETAILS') { 1032 // Force the [open] attribute on the details element 1033 this.el.open = true; 1034 } else { 1035 //set overflow to hidden 1036 this.el.style.overflow = 'hidden'; 1037 //add class open to the element 1038 this.el.classList.add('open'); 1039 } 1040 1041 // Wait for the next frame to call the expand function 1042 window.requestAnimationFrame(() => this.expand()); 1043 } 1044 1045 expand() { 1046 // Set the element as "being expanding" 1047 this.isExpanding = true; 1048 // Get the current fixed height of the element 1049 const startHeight = `${this.el.offsetHeight}px`; 1050 // Calculate the open height of the element (summary height + content height) 1051 1052 const endHeight = `${this.summary.offsetHeight + this.content.offsetHeight + this.borderWidth * 2}px`; 1053 1054 // If there is already an animation running 1055 if (this.animation) { 1056 // Cancel the current animation 1057 this.animation.cancel(); 1058 } 1059 1060 // Start a WAAPI animation 1061 this.animation = this.el.animate( 1062 { 1063 // Set the keyframes from the startHeight to endHeight 1064 height: [startHeight, endHeight], 1065 }, 1066 { 1067 duration: 200, 1068 easing: 'ease-out', 1069 }, 1070 ); 1071 // When the animation is complete, call onAnimationFinish() 1072 this.animation.onfinish = () => this.onAnimationFinish(true); 1073 // If the animation is cancelled, isExpanding variable is set to false 1074 this.animation.oncancel = () => (this.isExpanding = false); 1075 } 1076 1077 onAnimationFinish(open) { 1078 if (this.el.tagName == 'DETAILS') { 1079 this.el.open = open; 1080 } else { 1081 // Set the open class based on the parameter 1082 if (!open) { 1083 this.el.classList.remove('open'); 1084 } 1085 } 1086 1087 // Clear the stored animation 1088 this.animation = null; 1089 // Reset isClosing & isExpanding 1090 this.isClosing = false;
1091 this.isExpanding = false; 1092 // Remove the overflow hidden and the fixed height 1093 // this.el.style.height = ""; 1094 // this.el.style.overflow = ""; 1095 this.el.style.height = this.el.style.overflow = ''; 1096 } 1097 } 1098 1099 class TB_Accordion { 1100 constructor(el) { 1101 this.el = el; 1102 this.items = []; 1103 this.el.querySelectorAll('.details').forEach((el) => { 1104 // Skip items broken by bad editor HTML (missing .summary/.content) 1105 if (!el.querySelector('.summary') || !el.querySelector('.content')) { 1106 return; 1107 } 1108 this.items.push(new TB_AccordionItem(el, this)); 1109 }); 1110 } 1111 1112 closeOthers(item) { 1113 this.items.forEach((otherItem) => { 1114 if (otherItem.el.tagName == 'DETAILS') { 1115 if (otherItem !== item && otherItem.el.open == true) { 1116 otherItem.close(); 1117 } 1118 } else { 1119 if (otherItem !== item && otherItem.el.classList.contains('open')) { 1120 otherItem.close(); 1121 } 1122 } 1123 }); 1124 } 1125 } 1126 1127 document.querySelectorAll('.tb-accordion--animation').forEach((el) => { 1128 new TB_Accordion(el); 1129 }); 1130 1131 // ----------------------------------- 1132 // highlight navigation items depending on scroll position 1133 // ----------------------------------- 1134 1135 initIntersectionNavigation(document.querySelectorAll('.tb-block'), document.querySelector('.main-nav'), '200px'); 1136 initIntersectionNavigation( 1137 document.querySelectorAll('.tb-block'), 1138 document.querySelector('.tb-header__subnav'), 1139 '50px', 1140 ); 1141 1142 // ----------------------------------- 1143 // Custom selects 1144 // ----------------------------------- 1145 1146 initCustomSelects(); 1147 1148 // ----------------------------------- 1149 // Show label tooltip on hover 1150 // ----------------------------------- 1151 1152 const showToolTipOnHoverItems = document.querySelectorAll('.tb-show-label-tooltip-on-hover'); 1153 if (showToolTipOnHoverItems.length > 0) { 1154 showToolTipOnHoverItems.forEach((item) => { 1155 if (item.querySelector('.tb-tooltip-label')) { 1156 const itemLabel = item.querySelector('.tb-tooltip-label').innerText; 1157 1158 let tooltipElement = null; 1159 1160 const showTooltip = () => { 1161 tooltipElement = document.createElement('div'); 1162 tooltipElement.classList.add('tb-tooltip'); 1163 tooltipElement.innerHTML = itemLabel; 1164 item.appendChild(tooltipElement); 1165 1166 const tooltipRect = tooltipElement.getBoundingClientRect(); 1167 const screenWidth = window.innerWidth; 1168 if (tooltipRect.right > screenWidth) { 1169 tooltipElement.style.right = 0; 1170 tooltipElement.style.left = 'auto'; 1171 tooltipElement.style.transform = 'none'; 1172 } 1173 1174 setTimeout(() => { 1175 tooltipElement.classList.add('tb-tooltip--active'); 1176 }, 0); 1177 }; 1178 1179 const hideTooltip = () => { 1180 if (tooltipElement) { 1181 tooltipElement.classList.remove('tb-tooltip--active'); 1182 setTimeout(() => { 1183 tooltipElement?.remove(); 1184 tooltipElement = null; 1185 }, 200); 1186 } 1187 }; 1188 1189 item.addEventListener('mouseenter', showTooltip); 1190 1191 item.addEventListener('mouseleave', (e) => { 1192 // Check if the mouse is leaving the trigger or the tooltip itself 1193 if (!item.contains(e.relatedTarget)) { 1194 hideTooltip(); 1195 } 1196 }); 1197 1198 // Ensure tooltip is removed if the mouse leaves the document or parent container 1199 document.addEventListener('mousemove', (e) => { 1200 if (!item.contains(e.target)) { 1201 hideTooltip(); 1202 } 1203 }); 1204 } 1205 }); 1206 } 1207 1208 // ----------------------------------- 1209 // Phone tooltip functionality 1210 // ----------------------------------- 1211 1212 const phoneTooltipTriggers = document.querySelectorAll('.tb-phone-tooltip-trigger');
1213 phoneTooltipTriggers.forEach((trigger) => { 1214 const tooltipElement = trigger.querySelector('.tb-phone-tooltip'); 1215 let isTooltipVisible = false; 1216 1217 const showPhoneTooltip = () => { 1218 if (isTooltipVisible || !tooltipElement) return; 1219 1220 // Position the tooltip 1221 const tooltipRect = tooltipElement.getBoundingClientRect(); 1222 const screenWidth = window.innerWidth; 1223 if (tooltipRect.right > screenWidth) { 1224 tooltipElement.style.right = '0'; 1225 tooltipElement.style.left = 'auto'; 1226 tooltipElement.style.transform = 'none'; 1227 } 1228 1229 setTimeout(() => { 1230 tooltipElement.classList.add('tb-phone-tooltip--active'); 1231 isTooltipVisible = true; 1232 }, 0); 1233 }; 1234 1235 const hidePhoneTooltip = () => { 1236 if (tooltipElement && isTooltipVisible) { 1237 tooltipElement.classList.remove('tb-phone-tooltip--active'); 1238 isTooltipVisible = false; 1239 } 1240 }; 1241 1242 trigger.addEventListener('click', (e) => { 1243 // Don't toggle if clicking on the tooltip itself 1244 if (e.target.closest('.tb-tooltip')) { 1245 return; 1246 } 1247 e.preventDefault(); 1248 if (isTooltipVisible) { 1249 hidePhoneTooltip(); 1250 } else { 1251 showPhoneTooltip(); 1252 } 1253 }); 1254 1255 // Close tooltip when clicking outside 1256 document.addEventListener('click', (e) => { 1257 if (!trigger.contains(e.target) && isTooltipVisible) { 1258 hidePhoneTooltip(); 1259 } 1260 }); 1261 }); 1262 1263 initActionInputs(); 1264}); 1265function initCustomSelects() { 1266 const customSelects = document.querySelectorAll('.tb-custom-select'); 1267 1268 if (customSelects.length > 0) { 1269 customSelects.forEach((customSelect) => { 1270 if (!customSelect.classList.contains('tb-custom-select--initialized')) { 1271 initCustomSelect(customSelect); 1272 } 1273 }); 1274 } 1275} 1276 1277function initCustomSelect(customSelect) { 1278 const options = customSelect.querySelectorAll('option'); 1279 const select = customSelect.querySelector('select'); 1280 const optionsContainer = document.createElement('div'); 1281 const selectedOptionElement = document.createElement('div'); 1282 optionsContainer.classList.add('tb-select'); 1283 const optionsInnerContainer = document.createElement('div'); 1284 optionsInnerContainer.classList.add('tb-select-inner'); 1285 // optionsContainer.classList.add('tb-input'); 1286 selectedOptionElement.classList.add('tb-input'); 1287 optionsContainer.value = select.value; 1288 1289 // Create and style the selected option element 1290 selectedOptionElement.classList.add('selected-option'); 1291 selectedOptionElement.textContent = options[select.selectedIndex].textContent; 1292 // customSelect.insertBefore(selectedOptionElement, select); 1293 customSelect.appendChild(selectedOptionElement); 1294 1295 options.forEach((option) => { 1296 const optionElement = document.createElement('div'); 1297 optionElement.classList.add('tb-option'); 1298 if (option.selected) { 1299 optionElement.classList.add('selected'); 1300 } 1301 if (option.disabled) { 1302 optionElement.classList.add('tb-option--disabled'); 1303 optionElement.setAttribute('aria-disabled', 'true'); 1304 } 1305 if (select.multiple) { 1306 optionElement.innerHTML = "<div class='tb-select-box'></div><span>" + option.innerHTML + '</span>'; 1307 } else { 1308 optionElement.innerHTML = "<div class='tb-radio-box'></div><span>" + option.innerHTML + '</span>'; 1309 } 1310 optionElement.addEventListener('click', (e) => { 1311 e.stopPropagation(); // Prevent the dropdown from closing 1312 if (option.disabled) { 1313 return; 1314 } 1315 // Remove selected class from all options 1316 optionsContainer.querySelectorAll('.tb-option').forEach((el) => el.classList.remove('selected')); 1317 // Add selected class to the clicked option 1318 optionElement.classList.add('selected'); 1319 select.value = option.value; 1320 select.dispatchEvent(new Event('change')); 1321 // Update the selected option element text 1322 selectedOptionElement.textContent = option.textContent; 1323 }); 1324 optionsInnerContainer.appendChild(optionElement); 1325 }); 1326 1327 optionsContainer.appendChild(optionsInnerContainer); 1328 customSelect.appendChild(optionsContainer); 1329 customSelect.addEventListener('click', (e) => { 1330 e.stopPropagation(); // Prevent the dropdown from closing 1331 1332 // Close all other open custom selects before toggling this one 1333 document.querySelectorAll('.tb-custom-select.tb-open').forEach((openSelect) => { 1334 if (openSelect !== customSelect) { 1335 openSelect.classList.remove('tb-open'); 1336 const openOptionsContainer = openSelect.querySelector('div.tb-select'); 1337 if (openOptionsContainer) { 1338 openOptionsContainer.style.height = '0px'; 1339 } 1340 openSelect.classList.remove('tb-custom-select--has-overflow'); 1341 } 1342 }); 1343 1344 customSelect.classList.toggle('tb-open'); 1345 if (customSelect.classList.contains('tb-open')) { 1346 optionsContainer.style.height = 0; 1347 requestAnimationFrame(() => { 1348 optionsContainer.style.height = optionsContainer.scrollHeight + 'px'; 1349 1350 // Listen for the transition end event 1351 const onTransitionEnd = () => { 1352 checkSelectOverflow(optionsInnerContainer); 1353 optionsContainer.removeEventListener('transitionend', onTransitionEnd); 1354 }; 1355 optionsContainer.addEventListener('transitionend', onTransitionEnd); 1356 }); 1357 } else { 1358 optionsContainer.style.height = 0; 1359 customSelect.classList.remove('tb-custom-select--has-overflow'); 1360 } 1361 }); 1362 1363 // Close the dropdown when clicking outside of it 1364 document.addEventListener('click', (e) => { 1365 if (!customSelect.contains(e.target)) { 1366 customSelect.classList.remove('tb-open'); 1367 optionsContainer.style.height = 0; 1368 } 1369 }); 1370 1371 // Listen for changes on the original select element 1372 select.addEventListener('change', () => { 1373 // Remove selected class from all options 1374 optionsContainer.querySelectorAll('.tb-option').forEach((el) => el.classList.remove('selected')); 1375 // Add selected class to the newly selected option 1376 const selectedOption = optionsContainer.querySelector(`.tb-option:nth-child(${select.selectedIndex + 1})`); 1377 if (selectedOption) { 1378 selectedOption.classList.add('selected'); 1379 } 1380 // Update the selected option element text 1381 selectedOptionElement.textContent = options[select.selectedIndex].textContent; 1382 }); 1383 customSelect.classList.add('tb-custom-select--initialized'); 1384 1385 function checkSelectOverflow(optionsInnerContainer) { 1386 if (optionsInnerContainer.parentElement.clientHeight > 0) { 1387 if (checkIfElementHasOverflow(optionsInnerContainer, optionsInnerContainer.parentElement)) { 1388 customSelect.classList.add('tb-custom-select--has-overflow'); 1389 } else { 1390 customSelect.classList.remove('tb-custom-select--has-overflow'); 1391 } 1392 } 1393 } 1394 1395 checkSelectOverflow(optionsInnerContainer); 1396 window.removeEventListener('resize', checkSelectOverflow(optionsInnerContainer)); 1397 window.addEventListener('resize', checkSelectOverflow(optionsInnerContainer)); 1398} 1399 1400// ----------------------------------- 1401// highlight navigation items depending on scroll position functions 1402// ----------------------------------- 1403function initIntersectionNavigation(sections, navElement, intersectionRootMargin = '50px') { 1404 if (!navElement) { 1405 return; 1406 } 1407 1408 const changeNav = (entries, observer) => { 1409 let updated = false;
1410 1411 entries.forEach((entry) => { 1412 if (updated) { 1413 return; 1414 } 1415 1416 let activeBlock; 1417 1418 const nextSibling = entry.target.nextElementSibling; 1419 const previousSibling = entry.target.previousElementSibling; 1420 1421 if (entry.isIntersecting) { 1422 activeBlock = entry.target; 1423 } else { 1424 if (isElementInViewport(nextSibling)) { 1425 activeBlock = nextSibling; 1426 } else if (isElementInViewport(previousSibling)) { 1427 activeBlock = previousSibling; 1428 while (isElementInViewport(activeBlock.previousElementSibling)) { 1429 activeBlock = activeBlock.previousElementSibling; 1430 } 1431 } 1432 } 1433 1434 if (activeBlock) { 1435 const id = activeBlock.getAttribute('id'); 1436 const a = navElement.querySelector(`[href="#${id}"]`); 1437 if (a) { 1438 const navItemElement = a.closest('li'); 1439 if (navItemElement && !navItemElement.classList.contains('active')) { 1440 navElement.querySelectorAll('li.active').forEach((el) => { 1441 el.classList.remove('active'); 1442 }); 1443 navItemElement.classList.add('active'); 1444 1445 if ( 1446 navElement.querySelector('ul') && 1447 navElement.querySelector('ul').scrollWidth > navElement.querySelector('ul').clientWidth 1448 ) { 1449 // Only scroll on click, not on intersection change 1450 // scrollHorizontalMenu(navItemElement); 1451 } 1452 updated = true; 1453 } 1454 } 1455 } 1456 }); 1457 }; 1458 1459 function isElementInViewport(el) { 1460 if (!el) { 1461 return false; 1462 } 1463 const rect = el.getBoundingClientRect(); 1464 return rect.top >= 0 && rect.bottom <= window.innerHeight; 1465 } 1466 1467 function scrollHorizontalMenu(activeLink) { 1468 activeLink.scrollIntoView({ behavior: 'smooth', inline: 'center' }); 1469 } 1470 1471 const changeNavObserver = new IntersectionObserver(changeNav, { 1472 rootMargin: `-${intersectionRootMargin} 0px 0px 0px`, // Apply margin only to the top 1473 root: null, // Use null for the root; document is not a valid root 1474 threshold: [], 1475 }); 1476 1477 sections.forEach((sectionNavTrigger) => { 1478 changeNavObserver.observe(sectionNavTrigger); 1479 }); 1480 1481 // Add click event listeners to navigation items 1482 const navItems = navElement.querySelectorAll('li'); 1483 navItems.forEach((navItem) => { 1484 navItem.removeEventListener('click', () => { 1485 scrollHorizontalMenu(navItem); 1486 }); 1487 navItem.addEventListener('click', () => { 1488 scrollHorizontalMenu(navItem); 1489 }); 1490 }); 1491 1492 let intersectingEntries = []; 1493 1494 // const observer = new IntersectionObserver((entries, observer) => { 1495 // if (navElement) { 1496 // entries.forEach((entry) => { 1497 // const id = entry.target.getAttribute('id'); 1498 // const navLink = navElement.querySelector(`[href="#${id}"]`); 1499 // if (navLink) { 1500 // const navItemElement = navLink.closest('li'); 1501 1502 // if (entry.isIntersecting) { 1503 // if (!intersectingEntries.includes(navLink)) { 1504 // intersectingEntries.push({ link: navLink, entry: entry }); 1505 // } 1506 // } else { 1507 // intersectingEntries = intersectingEntries.filter((item) => item.link !== navLink); 1508 // } 1509 // } 1510 // }); 1511 1512 // intersectingEntries.sort((a, b) => { 1513 // return a.entry.boundingClientRect.top - b.entry.boundingClientRect.top; 1514 // }); 1515 1516 // navElement.querySelectorAll('li.active').forEach((el) => { 1517 // el.classList.remove('active'); 1518 // }); 1519 1520 // if (intersectingEntries.length > 0) { 1521 // intersectingEntries[0].link.closest('li').classList.add('active'); 1522 // } 1523 // } 1524 // }, options); 1525 1526 // sections.forEach((section) => { 1527 // observer.observe(section); 1528 // }); 1529 1530 // ----------------------------------- 1531 // Autoplay video when in viewport 1532 // ----------------------------------- 1533 1534 const html5Videos = document.querySelectorAll('.tb-video-part video'); 1535 1536 const loadAndPlayVideo = (video) => { 1537 video.setAttribute('preload', 'auto'); 1538 //video.setAttribute('autoplay', true); 1539 }; 1540 1541 const handleIntersection = (entries, observer) => {
1542 entries.forEach((entry) => { 1543 if (entry.isIntersecting) { 1544 loadAndPlayVideo(entry.target); 1545 observer.unobserve(entry.target); // Stop observing the video after loading and playing 1546 } 1547 }); 1548 }; 1549 1550 const optionsHtml5Videos = { 1551 root: null, // Use the viewport as the root 1552 rootMargin: '100px', // Trigger 100px before the video comes into the viewport 1553 threshold: 0, // Trigger as soon as any part of the video is visible 1554 }; 1555 1556 const observerHtml5Videos = new IntersectionObserver(handleIntersection, optionsHtml5Videos); 1557 1558 // html5Videos.forEach((video) => { 1559 // observerHtml5Videos.observe(video); 1560 // }); 1561} 1562 1563function initSectionTogglers() { 1564 const sectionTogglers = document.querySelectorAll('.section-toggler'); 1565 for (const toggler of sectionTogglers) { 1566 if (toggler) { 1567 const innerToggler = toggler.querySelector('.section-toggler__toggles'); 1568 const togglerWrapper = toggler.querySelector('.section-toggler__toggles-wrapper'); 1569 1570 const handleClick = (e) => { 1571 if (e.target.tagName !== 'LABEL' && e.detail.type !== 'toggleClick') { 1572 return; 1573 } 1574 const targetId = e.detail.type === 'toggleClick' ? e.detail.targetId : e.target.dataset.targetId; 1575 1576 const tbBlock = e.currentTarget.closest('.tb-block'); 1577 const allSections = tbBlock.querySelectorAll('.tb-section'); 1578 const firstLayerSections = Array.from(allSections).filter( 1579 (section) => section.closest('.tb-block') === tbBlock, 1580 ); 1581 const sections = firstLayerSections; 1582 1583 const targetElement = toggler.closest('.tb-block'); 1584 const offset = -100; 1585 const bodyRect = document.body.getBoundingClientRect().top; 1586 const elementRect = targetElement.getBoundingClientRect().top; 1587 const elementPosition = elementRect - bodyRect; 1588 const offsetPosition = elementPosition + offset; 1589 1590 // window.scrollTo({ 1591 // top: offsetPosition, 1592 // behavior: 'smooth', 1593 // }); 1594 1595 for (const section of sections) { 1596 if (section.id === targetId) { 1597 section.style.display = 'flex'; 1598 // Only now that it is on screen: a hidden section measures as nothing. 1599 refreshReadmoreIn(section); 1600 } else { 1601 section.style.display = 'none'; 1602 } 1603 } 1604 }; 1605 1606 toggler.removeEventListener('click', handleClick); 1607 toggler.addEventListener('click', handleClick); 1608 1609 document.addEventListener('DOMContentLoaded', function () { 1610 if (togglerWrapper) { 1611 setTogglerOverflowClasses(togglerWrapper, toggler); 1612 togglerWrapper.addEventListener('scroll', () => { 1613 setTogglerOverflowClasses(togglerWrapper, toggler); 1614 }); 1615 } 1616 }); 1617 } 1618 } 1619 1620 const handleResize = () => { 1621 for (const toggler of sectionTogglers) { 1622 if (toggler) { 1623 const togglerWrapper = toggler.querySelector('.section-toggler__toggles-wrapper'); 1624 if (togglerWrapper) { 1625 setTogglerOverflowClasses(togglerWrapper, toggler); 1626 } 1627 } 1628 } 1629 }; 1630 1631 window.removeEventListener('resize', handleResize); 1632 window.addEventListener('resize', handleResize); 1633 1634 // Handle hash navigation for section toggles 1635 // (eg: using #options-2 in the url will scroll to the #options block and open the second section) 1636 const handleHashNavigation = () => { 1637 const hash = window.location.hash.substring(1); // Remove the # 1638 1639 if (hash) { 1640 // Convert hash format from "example-1" to "example-section-0" 1641 // Match pattern: any text followed by dash and number(s) 1642 const match = hash.match(/^(.+)-(\d+)$/); 1643 1644 if (match) { 1645 const blockId = match[1]; 1646 const sectionNumber = parseInt(match[2]) - 1; // Convert to 0-based index 1647 const fullSectionId = `${blockId}-section-${sectionNumber}`; 1648 1649 // Find the label element 1650 const radioId = fullSectionId + '-label'; 1651 const radioInput = document.getElementById(radioId); 1652 const label = radioInput ? document.querySelector(`label[for="${radioId}"]`) : null; 1653 1654 if (label) { 1655 // Trigger a click on the label to activate the toggle logic 1656 label.click(); 1657 1658 // Scroll to the main container 1659 const mainContainer = document.getElementById(blockId); 1660 if (mainContainer) { 1661 setTimeout(() => { 1662 mainContainer.scrollIntoView({ behavior: 'smooth' }); 1663 }, 100); 1664 } 1665 } 1666 } 1667 } 1668 }; 1669 1670 // Run on page load 1671 handleHashNavigation(); 1672 1673 // Also handle hash changes (when clicking links on the same page) 1674 window.removeEventListener('hashchange', handleHashNavigation); 1675 window.addEventListener('hashchange', handleHashNavigation); 1676} 1677
1678function setTogglerOverflowClasses(innerToggler, toggler) { 1679 if (innerToggler.scrollWidth > 0) { 1680 if (checkIfElementHasOverflow(innerToggler, toggler) && innerToggler.scrollLeft < toggler.scrollWidth - 100) { 1681 toggler.classList.add('section-toggler--has-overflow'); 1682 } else { 1683 toggler.classList.remove('section-toggler--has-overflow'); 1684 } 1685 } 1686} 1687 1688// Cards in one slider behave the same way, so a visitor swiping through them meets 1689// one pattern: the group is the carousel track, or the block when a card stands on 1690// its own. 1691function collectReadmoreGroups(root) { 1692 const groups = new Map(); 1693 1694 for (const link of root.querySelectorAll('.js-readmore')) { 1695 const content = link.previousElementSibling; 1696 const card = link.closest('.popup-on-readmore'); 1697 const group = link.closest('.entries') || link.closest('.tb-block'); 1698 1699 if (!card || !group || !content || !content.classList.contains('readmore-content')) { 1700 continue; 1701 } 1702 1703 if (!groups.has(group)) { 1704 groups.set(group, []); 1705 } 1706 1707 groups.get(group).push({ link, content, card }); 1708 } 1709 1710 for (const entries of groups.values()) { 1711 for (const entry of entries) { 1712 entry.group = entries; 1713 } 1714 } 1715 1716 return groups; 1717} 1718 1719function refreshReadmoreIn(root) { 1720 for (const entries of collectReadmoreGroups(root).values()) { 1721 applyReadmoreMode(entries); 1722 } 1723} 1724 1725// Up to this many lines a card simply shows its text: no link, no fade. 1726const TB_READMORE_FIT_LINES = 7; 1727// One text this long in a slider sends every card in it to the popup â unfolding 1728// that much inside a card would push the ones next to it far off screen. 1729const TB_READMORE_POPUP_LINES = 12; 1730 1731// Decides, for one slider, whether its cards unfold in place or open a popup, and 1732// hides the link on the cards that show their whole text already. 1733function applyReadmoreMode(entries) { 1734 // The block's design setting switched the read more off at this width: every 1735 // card shows its whole text, and a tap on it does nothing. 1736 if (!!entries[0] && readmoreIsOff(entries[0].card)) { 1737 for (const entry of entries) { 1738 if (entry.card.classList.contains('is-expanded')) { 1739 collapseReadmore(entry, false); 1740 } 1741 1742 entry.link.hidden = true; 1743 entry.card.classList.add('is-text-complete'); 1744 entry.card.classList.remove('is-readmore-inline', 'is-readmore-popup', 'is-text-clamped'); 1745 } 1746 1747 return; 1748 } 1749 1750 // A card only unfolds in place where its own stylesheet says it can grow â 1751 // the fixed-height desktop card cannot. The flag comes from CSS so it follows 1752 // the block's container query, which the root's width class does not. 1753 const inlinePossible = !!entries[0] && readmoreAllowsInline(entries[0].card); 1754 const lineCounts = entries.map((entry) => readmoreLineCount(entry.content)); 1755 const longest = Math.max(0, ...lineCounts); 1756 1757 // Nothing rendered â a section the toggler keeps hidden. Leave the cards as 1758 // they are; they are measured again when the section is shown.
1759 if (longest === 0) { 1760 return; 1761 } 1762 1763 const mode = inlinePossible && longest < TB_READMORE_POPUP_LINES ? 'inline' : 'popup'; 1764 1765 entries.forEach((entry, i) => { 1766 const { link, card } = entry; 1767 const expanded = card.classList.contains('is-expanded'); 1768 const fits = !expanded && lineCounts[i] <= TB_READMORE_FIT_LINES && !readmoreContentIsCut(entry.content); 1769 1770 if (expanded && mode !== 'inline') { 1771 collapseReadmore(entry, false); 1772 } 1773 1774 link.toggleAttribute('hidden', fits); 1775 card.classList.toggle('is-text-complete', fits); 1776 card.classList.toggle('is-readmore-inline', !fits && mode === 'inline'); 1777 card.classList.toggle('is-readmore-popup', !fits && mode === 'popup'); 1778 // drives the fade over the last line: only the script knows the text was cut 1779 card.classList.toggle('is-text-clamped', !fits && !card.classList.contains('is-expanded')); 1780 }); 1781} 1782 1783const TB_READMORE_SLIDE_MS = 300; 1784 1785function readmoreAllowsInline(card) { 1786 return getComputedStyle(card).getPropertyValue('--tb-readmore-inline').trim() === '1'; 1787} 1788 1789// Set by the block's stylesheet under its `readmore-mobile-off` class, so it 1790// follows the same width as the clamp it lifts. 1791function readmoreIsOff(card) { 1792 return getComputedStyle(card).getPropertyValue('--tb-readmore-off').trim() === '1'; 1793} 1794 1795// One link speaks for the whole slider: unfolding a card unfolds the ones next to 1796// it, so the row is read in one go instead of card by card. 1797function toggleReadmoreExpanded(entry) { 1798 const expand = !entry.card.classList.contains('is-expanded'); 1799 const targets = (entry.group || [entry]).filter((other) => { 1800 if (other === entry) { 1801 return true; 1802 } 1803 // a card showing its whole text has nothing to unfold 1804 if (!other.card.classList.contains('is-readmore-inline')) { 1805 return false; 1806 } 1807 1808 return other.card.classList.contains('is-expanded') !== expand; 1809 }); 1810 1811 // measured before any card moves: cards in a row share their height 1812 const heights = targets.map((other) => other.content.getBoundingClientRect().height); 1813 1814 targets.forEach((other, i) => { 1815 if (expand) { 1816 expandReadmore(other, true, heights[i]); 1817 } else { 1818 collapseReadmore(other, true, heights[i]); 1819 } 1820 }); 1821} 1822 1823function expandReadmore(entry, animate, from = entry.content.getBoundingClientRect().height) { 1824 entry.card.classList.add('is-expanded'); 1825 entry.card.classList.remove('is-text-clamped'); 1826 // Lifts the clamp without fighting the block's own max-height rule. 1827 entry.content.style.maxHeight = 'none'; 1828 1829 slideReadmore(entry.content, from, animate, () => { 1830 entry.content.style.maxHeight = 'none'; 1831 }); 1832} 1833 1834function collapseReadmore(entry, animate, from = entry.content.getBoundingClientRect().height) { 1835 entry.card.classList.remove('is-expanded'); 1836 entry.card.classList.add('is-text-clamped'); 1837 entry.content.style.maxHeight = ''; 1838 1839 slideReadmore(entry.content, from, animate, () => { 1840 entry.content.style.maxHeight = ''; 1841 }); 1842} 1843 1844// Slides between the height the text had and the one it has now. The end state 1845// is already applied when this runs, so the animation is a replay of the jump 1846// that just happened â and skipping it leaves the card correct either way. 1847function slideReadmore(content, from, animate, settle) { 1848 if (!animate || window.matchMedia('(prefers-reduced-motion: reduce)').matches) { 1849 return; 1850 } 1851 1852 const to = content.getBoundingClientRect().height; 1853 1854 content.style.transition = 'none'; 1855 content.style.maxHeight = `${from}px`; 1856 content.style.overflow = 'hidden'; 1857 1858 requestAnimationFrame(() => { 1859 content.style.transition = `max-height ${TB_READMORE_SLIDE_MS}ms ease-out`; 1860 content.style.maxHeight = `${to}px`; 1861 }); 1862 1863 window.setTimeout(() => { 1864 content.style.transition = ''; 1865 content.style.overflow = ''; 1866 settle(); 1867 }, TB_READMORE_SLIDE_MS); 1868} 1869 1870// How many lines the text takes when nothing clips it. Counted from the tops of 1871// the text's own boxes, so paragraph margins do not pass for lines and a bold run 1872// in the middle of a line does not count twice. 1873function readmoreLineCount(content) { 1874 const range = document.createRange(); 1875 range.selectNodeContents(content); 1876 1877 const tops = new Set(); 1878 1879 for (const rect of range.getClientRects()) { 1880 if (rect.width > 0 && rect.height > 0) { 1881 tops.add(Math.round(rect.top)); 1882 } 1883 } 1884 1885 return tops.size; 1886} 1887 1888function readmoreContentIsCut(content) { 1889 const textBottom = readmoreTextBottom(content); 1890 1891 if (textBottom === null) { 1892 return false;
1893 } 1894 1895 // Where the text stops being visible: the clamp, or the card around it when 1896 // that is the shorter of the two. The card content box keeps its height 1897 // while it slides on hover, so both rects move together. 1898 let clipBottom = content.getBoundingClientRect().bottom; 1899 const box = content.closest('.tb-card-content'); 1900 1901 if (box) { 1902 const paddingBottom = parseFloat(getComputedStyle(box).paddingBottom) || 0; 1903 clipBottom = Math.min(clipBottom, box.getBoundingClientRect().bottom - paddingBottom); 1904 } 1905 1906 // The slack absorbs sub-pixel line heights: a rounded-up bottom is not a cut line. 1907 return textBottom > clipBottom + 2; 1908} 1909 1910// The bottom of the last line a visitor can actually read. Measured from the 1911// text's own boxes, which sit where they would without the clamp â scrollHeight 1912// would also count a trailing empty paragraph and report a cut that isn't one. 1913function readmoreTextBottom(content) { 1914 const range = document.createRange(); 1915 range.selectNodeContents(content); 1916 1917 let bottom = null; 1918 1919 for (const rect of range.getClientRects()) { 1920 if (rect.width > 0 && rect.height > 0) { 1921 bottom = bottom === null ? rect.bottom : Math.max(bottom, rect.bottom); 1922 } 1923 } 1924 1925 return bottom; 1926} 1927 1928function checkIfElementHasOverflow(el, parentel) { 1929 if (el) { 1930 return el.scrollHeight > parentel.clientHeight || el.scrollWidth > parentel.clientWidth; 1931 } 1932} 1933 1934function initActionInputs() { 1935 const chatFormControls = document.querySelectorAll('.input-wrapper--chat'); 1936 const mayaChatForms = document.querySelectorAll('.maya-chat-form'); 1937 1938 chatFormControls.forEach((formControl) => { 1939 const chatInput = formControl.querySelector('input'); 1940 const customPlaceholder = formControl.querySelector('.custom-placeholder'); 1941 1942 if (!chatInput || !customPlaceholder) return; 1943 1944 const togglePlaceholder = () => { 1945 const hasValue = chatInput.value.trim() !== ''; 1946 1947 if (hasValue) { 1948 chatInput.classList.add('has-value'); 1949 customPlaceholder.style.display = 'none'; 1950 } else { 1951 chatInput.classList.remove('has-value'); 1952 customPlaceholder.style.display = ''; 1953 } 1954 }; 1955 1956 chatInput.addEventListener('focus', function () { 1957 customPlaceholder.style.display = 'none'; 1958 }); 1959 1960 chatInput.addEventListener('blur', function () { 1961 togglePlaceholder(); 1962 }); 1963 1964 chatInput.addEventListener('input', function () { 1965 togglePlaceholder(); 1966 }); 1967 1968 chatInput.addEventListener('keydown', function (e) { 1969 if (e.key === 'Enter') { 1970 setTimeout(() => { 1971 chatInput.value = ''; 1972 togglePlaceholder(); 1973 }, 100); 1974 } 1975 }); 1976 1977 togglePlaceholder(); 1978 }); 1979 1980 mayaChatForms.forEach((form) => { 1981 initMayaChatForm(form); 1982 }); 1983} 1984 1985function initMayaChatForm(form) { 1986 if (!form) return; 1987 form.addEventListener('submit', function (e) { 1988 e.preventDefault(); 1989 1990 const mayaWidget = document.querySelector('#maya-b2b-full-page'); 1991 if (!mayaWidget || mayaWidget.getAttribute('data-widget-mode') !== 'popover') return; 1992 1993 const input = form.elements.namedItem('chat')?.value; 1994 if (input && window.sendMayaUserMessage) { 1995 window.sendMayaUserMessage(input); 1996 } 1997 }); 1998 1999}
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.