1(function(){ 2 // Accordion: any button[aria-expanded] toggles its controlled panel. Exclude 3 // the header dropdown toggles (owned by the header block below). 4 document.querySelectorAll('[aria-controls][aria-expanded]:not(.nav-menu-toggle)').forEach(function(btn){ 5 btn.addEventListener('click', function(){ 6 var open = btn.getAttribute('aria-expanded') === 'true'; 7 btn.setAttribute('aria-expanded', String(!open)); 8 var panel = document.getElementById(btn.getAttribute('aria-controls')); 9 if (panel) panel.hidden = open; 10 }); 11 }); 12 13 // ââ Header states âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 14 // The mirrored CSS styles three header states the stripped theme JS used to 15 // drive, so without this block none of them could ever be reached: .fixed 16 // (the white sticky bar), ul.is-active (the desktop dropdowns) and 17 // header.is-active (the mobile overlay). Ported from the theme's own 18 // components -- header.js, ps-header-nav.js and ps-toggle.js on staging -- so 19 // the behaviour matches the source of truth. Structure, labels and link 20 // targets are untouched; this only toggles classes and ARIA state. 21 var header = document.getElementById('header'); 22 23 // .fixed as soon as the page has scrolled clear of the header's own band. 24 // 25 // The theme keyed this off the hero instead -- .fixed for as long as the hero
26 // was out of view -- which made the bar arrive at wildly different points 27 // from page to page, because it is really measuring hero height: 200px into 28 // /legal/how-to-invoice-plugsurfing, 900 on the front page, 1300 on /contact. 29 // On the taller heroes that is most of a screen of scrolling with the bar 30 // still transparent over content it no longer sits on. 31 // 32 // The trigger is the header's own height instead, so the bar turns into the 33 // scrolled pill the moment the page moves out from under it, on every page. 34 // A hysteresis band keeps it from flickering when a scroll lands exactly on 35 // the threshold, which is easy to hit with momentum scrolling. 36 if (header) { 37 var ON = 64, OFF = 48, ticking = false; 38 var syncFixed = function(){ 39 ticking = false; 40 var y = window.pageYOffset || document.documentElement.scrollTop || 0; 41 var on = header.classList.contains('fixed'); 42 if (!on && y > ON) header.classList.add('fixed'); 43 else if (on && y < OFF) header.classList.remove('fixed'); 44 }; 45 var onScroll = function(){ 46 if (ticking) return; 47 ticking = true; 48 requestAnimationFrame(syncFixed); 49 }; 50 addEventListener('scroll', onScroll, { passive: true }); 51 // A reload can restore a scroll offset before this runs. 52 syncFixed(); 53 } 54 55 // ps-toggle.js: the burger controls #header, and the CSS keys the overlay off 56 // is-active. Roles the custom element used to supply are set here too. 57 var DESKTOP = '(min-width: 1025px)'; 58 var burger = document.querySelector('.header-menu-toggler'); 59 var focusWasOnBurger = false; 60 var closeOverlay = function(){}; 61 if (burger && header) { 62 if (!burger.hasAttribute('role')) burger.setAttribute('role', 'button'); 63 if (!burger.hasAttribute('tabindex')) burger.setAttribute('tabindex', '0'); 64 burger.setAttribute('aria-expanded', 'false'); 65 66 // The overlay covers the page opaquely, so the rest of the document is made 67 // inert while it is open. Without that, tabbing past the last nav link 68 // walks into content nobody can see and the focus ring vanishes. 69 // Everything outside the header, including the skip link that sits before 70 // it â Shift+Tab off the first nav item landed on that link behind the 71 // overlay, so containment was never actually closed. 72 var behind = [ 73 document.getElementById('main'), 74 document.querySelector('body>div>footer'), 75 document.querySelector('a.visually-hidden-focusable'), 76 ].filter(Boolean); 77 var returnTo = null; 78 // The burger is a sibling of ps-header-nav, so the nav's own focus tracking 79 // never sees it. Crossing up to desktop hides it, and hiding an element 80 // blurs it, so this is the only record that the keyboard position was there. 81 burger.addEventListener('focusin', function(){ focusWasOnBurger = true; }); 82 burger.addEventListener('focusout', function(e){ 83 if (e.relatedTarget) focusWasOnBurger = false; 84 }); 85 var setOverlay = function(open){ 86 if (open) returnTo = document.activeElement; 87 header.classList.toggle('is-active', open); 88 burger.setAttribute('aria-expanded', String(open));
89 behind.forEach(function(el){ 90 if (!el) return; 91 if (open) { el.setAttribute('inert', ''); el.setAttribute('aria-hidden', 'true'); } 92 else { el.removeAttribute('inert'); el.removeAttribute('aria-hidden'); } 93 }); 94 if (!open && returnTo && returnTo.focus) { returnTo.focus(); returnTo = null; } 95 // returnTo is whatever was focused when the overlay opened, and a click 96 // never focuses a button on macOS â so it is usually the body, and 97 // body.focus() does nothing. Escape from a link inside the overlay then 98 // left focus on an element the mobile rules had just hidden. The burger 99 // is the only way back in, so it takes focus when nothing visible has it. 100 // On the desktop crossing the burger is display:none and this is a no-op, 101 // which is what that path wants. 102 if ( 103 !open && burger.focus && 104 (document.activeElement === document.body || 105 document.activeElement.closest('ps-header-nav')) 106 ) { 107 burger.focus(); 108 } 109 }; 110 closeOverlay = function(){ 111 if (header.classList.contains('is-active')) setOverlay(false); 112 }; 113 burger.addEventListener('click', function(){ 114 setOverlay(!header.classList.contains('is-active')); 115 }); 116 burger.addEventListener('keydown', function(e){ 117 if (e.key === 'Enter' || e.key === ' ') { 118 e.preventDefault(); 119 setOverlay(!header.classList.contains('is-active')); 120 } else if (e.key === 'Escape') { 121 closeOverlay(); 122 } 123 }); 124 // Crossing to the desktop layout hides the burger. The rules that give the 125 // header its full height and lock body scroll are unmediated, so an overlay 126 // left open there survives the breakpoint and traps the page with nothing 127 // visible to dismiss it. 128 var wide = window.matchMedia(DESKTOP); 129 var onWide = function(){ if (wide.matches) closeOverlay(); }; 130 // Both, deliberately: matchMedia change is the precise signal, but resize 131 // is the one that fires everywhere. Belt and braces on a state that traps 132 // the page when it is missed. 133 addEventListener('resize', onWide); 134 if (wide.addEventListener) wide.addEventListener('change', onWide); 135 else wide.addListener(onWide); 136 } 137 138 // ps-header-nav.js: one dropdown open at a time, announced honestly, and 139 // scoped to the layout that actually has dropdowns. 140 // 141 // Two separate ideas, which is what makes it behave. is-active is the pinned 142 // state a click sets; aria-expanded is only what the button announces. CSS 143 // (nav.css) also opens a submenu on hover and for as long as .nav-item 144 // matches :focus-within, so the announcement has to follow that too â but if 145 // focus pinned the menu, the click that follows it would read the menu as 146 // already open and close it on the way in. 147 var headerNav = document.querySelector('ps-header-nav'); 148 if (headerNav) { 149 var toggles = [].slice.call(headerNav.querySelectorAll('button.nav-menu-toggle[aria-controls]')); 150 var menuOf = function(btn){ 151 return document.getElementById(btn.getAttribute('aria-controls')) || btn.parentNode.querySelector('ul'); 152 }; 153 var announce = function(btn, open){ btn.setAttribute('aria-expanded', String(open)); }; 154 var pin = function(btn, open){ 155 var menu = menuOf(btn); 156 if (menu) { 157 menu.classList.toggle('is-active', open); 158 // A previous dismissal is inline; opening has to lift it or the class 159 // would be set on a menu still told to stay hidden. 160 if (open) { menu.style.visibility = ''; menu.style.opacity = ''; } 161 } 162 announce(btn, open); 163 }; 164 var closeAll = function(){ toggles.forEach(function(btn){ pin(btn, false); }); }; 165 /** 166 * Close a menu the pointer or focus is still inside. 167 * 168 * nav.css opens a submenu on :hover and on :focus-within, and it does so 169 * from an unlayered stylesheet â which outranks these overrides whatever 170 * their specificity. So clearing the class is not enough: after a closing 171 * click the pointer is necessarily still over the item and the menu stays 172 * on screen while the button announces it shut. Dropping focus deals with 173 * :focus-within; hover needs the element itself to say hidden, which is why 174 * this reaches for inline styles. Both are cleared the moment the pointer 175 * leaves, so hovering back in behaves normally. 176 */
177 var itemOf = function(btn){ 178 return (btn.closest ? btn.closest('.nav-item') : null) || btn.parentNode; 179 }; 180 /** 181 * Drop focus from inside the nav, without that reading as the visitor 182 * leaving it. 183 * 184 * blur() dispatches focusout synchronously with a null relatedTarget, which 185 * is indistinguishable from focus going somewhere outside â so the 186 * nav-level handler ran closeShown(), found the menu the pointer had just 187 * entered visible through nav.css, and suppressed it inline. The pointer 188 * was then sitting on a dropdown that had been announced open and hidden in 189 * the same gesture, and it stayed hidden until the pointer left and came 190 * back. The flag is only ever true for the duration of the blur itself. 191 */ 192 var ownBlur = false; 193 var dropFocus = function(el){ 194 var a = el || document.activeElement; 195 if (!a || !a.blur || !headerNav.contains(a)) return; 196 ownBlur = true; 197 a.blur(); 198 ownBlur = false; 199 }; 200 var release = function(menu){ 201 if (!menu) return; 202 menu.style.visibility = ''; 203 menu.style.opacity = ''; 204 }; 205 /** 206 * Close a menu, suppressing hover only when hover is what is holding it 207 * open. 208 * 209 * The inline hide is lifted on mouseleave, so applying it when the pointer 210 * is elsewhere strands the menu: that mouseleave never comes, and nav.css
211 * cannot override an inline style when focus returns. A keyboard close 212 * therefore just clears state and drops focus, which is enough â nothing is 213 * holding the menu open once :focus-within stops matching. 214 */ 215 var dismiss = function(btn){ 216 var menu = menuOf(btn); 217 var item = itemOf(btn); 218 pin(btn, false); 219 if (!menu || !item.matches(':hover')) return; 220 menu.style.visibility = 'hidden'; 221 menu.style.opacity = '0'; 222 // once: true so repeated dismissals cannot pile listeners onto the item. 223 item.addEventListener('mouseleave', function(){ release(menu); }, { once: true }); 224 }; 225 /** 226 * Close everything, suppressing hover only where it is actually holding a 227 * menu open. 228 * 229 * dismiss() works by pinning visibility:hidden inline and lifting it on the 230 * next mouseleave. Applied to a menu the pointer never entered, that 231 * mouseleave may never come â so the menu stays hidden until someone 232 * hovers and leaves it, unreachable by hover or keyboard in the meantime. 233 * Below the breakpoint it is worse: the overlay shows its submenus inline, 234 * so hiding them all would empty the navigation. 235 */ 236 var closeShown = function(){ 237 if (!desktop.matches) return; 238 toggles.forEach(function(btn){ 239 var menu = menuOf(btn); 240 var shown = !!menu && ( 241 menu.classList.contains('is-active') || 242 getComputedStyle(menu).visibility === 'visible' 243 ); 244 if (shown) dismiss(btn); else pin(btn, false); 245 }); 246 dropFocus(); 247 }; 248 // Was focus inside the nav, and did it go away on its own rather than being 249 // moved somewhere else? A focusout carrying a relatedTarget is the visitor 250 // tabbing on; a null one is the element being hidden or otherwise losing 251 // focus, which is the case worth rescuing at a breakpoint crossing. 252 var focusWasInNav = false;
253 headerNav.addEventListener('focusin', function(){ focusWasInNav = true; }); 254 headerNav.addEventListener('focusout', function(e){ 255 if (e.relatedTarget && !headerNav.contains(e.relatedTarget)) focusWasInNav = false; 256 }); 257 var navFocusLost = function(){ 258 var a = document.activeElement; 259 return focusWasInNav && (!a || a === document.body); 260 }; 261 262 // Below the breakpoint the theme leaves every submenu on screen inside the 263 // overlay, so there is nothing to expand or collapse: toggling there only 264 // told assistive technology the links were hidden while they were plainly 265 // listed. The buttons are left alone until the dropdown layout applies. 266 var desktop = window.matchMedia(DESKTOP); 267 // Only at a real crossing: wired to resize as well as to the media query â 268 // the same belt and braces the overlay uses, since a missed crossing here 269 // leaves the announcements describing the other layout â and resize fires 270 // continuously during a window drag, where re-running this would shut a 271 // dropdown the visitor had just opened. 272 var lastScope = null; 273 var applyScope = function(){ 274 if (desktop.matches === lastScope) return; 275 lastScope = desktop.matches; 276 if (desktop.matches) { 277 toggles.forEach(function(btn){ pin(btn, false); }); 278 // Crossing up from the overlay can leave focus on a nested link that 279 // only the mobile layout listed. The burger is display:none by now, so 280 // the overlay's own focus return has nowhere to go, and nav.css holds 281 // that group open through :focus-within while the loop above has just 282 // announced every button collapsed. Hand focus to the group's toggle â 283 // visible at this width â and let it own the menu it is already 284 // showing. 285 var stranded = document.activeElement; 286 var owner = null; 287 toggles.forEach(function(btn){ 288 if (stranded && itemOf(btn).contains(stranded)) owner = btn; 289 }); 290 if (owner) { 291 owner.focus(); 292 pin(owner, true); 293 } else if ( 294 toggles.length && 295 focusWasOnBurger && 296 (!stranded || stranded === document.body || stranded === burger) 297 ) { 298 // The burger itself was holding focus and has just been hidden. There 299 // is no group to hand back to, so the first dropdown toggle takes it â 300 // the nearest visible equivalent at this width. Announced state is 301 // left alone: focusing it opens nothing that was not already open. 302 toggles[0].focus(); 303 } 304 focusWasOnBurger = false; 305 } else { 306 closeAll(); 307 toggles.forEach(function(btn){ btn.removeAttribute('aria-expanded'); }); 308 // The mobile rules hide ps-header-nav outright, so a keyboard position 309 // inside it does not survive the crossing. Testing activeElement is not 310 // enough: hiding an element blurs it, so by the time this runs focus 311 // has usually been dropped on the body rather than left on something 312 // invisible. navFocusLost is what distinguishes that from focus the 313 // visitor moved out of the nav themselves â only the first should be 314 // rescued, and the burger is what replaces the nav at this width. 315 if ( 316 burger && burger.focus && 317 (headerNav.contains(document.activeElement) || navFocusLost()) 318 ) { 319 burger.focus(); 320 } 321 focusWasInNav = false;
322 } 323 }; 324 applyScope(); 325 (desktop.addEventListener ? desktop.addEventListener.bind(desktop, 'change') : desktop.addListener.bind(desktop))(applyScope); 326 addEventListener('resize', applyScope); 327 328 toggles.forEach(function(btn){ 329 var item = btn.closest ? btn.closest('.nav-item') : btn.parentNode; 330 if (!item) item = btn.parentNode; 331 btn.addEventListener('click', function(){ 332 if (!desktop.matches) return; 333 var menu = menuOf(btn); 334 var open = !!menu && menu.classList.contains('is-active'); 335 closeAll(); 336 if (open) { dismiss(btn); dropFocus(btn); } else pin(btn, true); 337 }); 338 // The announcement follows the group, not the button: focus moving from 339 // the button into a link inside its own menu keeps :focus-within 340 // matching, so the menu is still open. 341 // nav.css opens the menu on :hover with no script involved, so the 342 // announcement has to follow the pointer as well as focus â otherwise a 343 // hovered menu sits open while its button reports itself collapsed. 344 item.addEventListener('mouseenter', function(){ 345 if (!desktop.matches) return; 346 // One at a time applies to the pointer as well: a menu pinned by an 347 // earlier click keeps its class while nav.css opens this one on hover, 348 // which is two menus open at once. Focus has to go with it â clearing 349 // the class alone leaves :focus-within showing a menu whose button now 350 // says collapsed, which is the same contradiction one step over. 351 toggles.forEach(function(other){ 352 if (other === btn) return; 353 var otherItem = itemOf(other); 354 if (otherItem.contains(document.activeElement)) dropFocus(); 355 pin(other, false); 356 }); 357 announce(btn, true); 358 }); 359 item.addEventListener('mouseleave', function(){ 360 if (!desktop.matches) return; 361 var menu = menuOf(btn); 362 announce( 363 btn, 364 (!!menu && menu.classList.contains('is-active')) || 365 item.contains(document.activeElement), 366 ); 367 }); 368 item.addEventListener('focusin', function(){ 369 if (!desktop.matches) return; 370 // Focus is a legitimate way to open this menu, so any suppression left 371 // from an earlier close has to go or nav.css cannot reveal it. 372 release(menuOf(btn)); 373 // Focus arriving here means CSS has opened this group. Any other group 374 // still pinned from a click would stay open beside it â nav.css shows a 375 // pinned submenu regardless of focus â so one dropdown at a time has to 376 // be enforced on focus as well as on click. dismiss() rather than pin(), 377 // because the pointer may still be resting on the group that was 378 // clicked: unpinning it leaves :hover showing a menu whose button has 379 // just been told to announce itself shut. 380 toggles.forEach(function(other){ if (other !== btn) dismiss(other); }); 381 announce(btn, true); 382 }); 383 item.addEventListener('focusout', function(e){ 384 if (!desktop.matches || item.contains(e.relatedTarget)) return; 385 var menu = menuOf(btn); 386 // Hover counts here for the same reason focus counts in mouseleave: 387 // tabbing from the last link of a hovered menu to a plain nav link 388 // leaves the pointer holding that menu open, and the nav-level handler 389 // stays quiet because focus never left the nav. Announcing on the class 390 // alone called a visible menu collapsed until the pointer moved. 391 // Hover only counts while nothing is suppressing the menu. Escape 392 // dismisses a hovered dropdown by pinning visibility:hidden inline and 393 // then drops focus, and that blur arrives here â announcing on hover 394 // alone called the menu open again a moment after it was hidden. 395 announce( 396 btn, 397 (!!menu && menu.classList.contains('is-active')) || 398 (item.matches(':hover') && !!menu && menu.style.visibility !== 'hidden'), 399 ); 400 }); 401 }); 402 403 // On the header, not the nav: the overlay also contains the logo, which is 404 // focusable and sits outside ps-header-nav. Shift+Tab from the burger lands 405 // there, where Escape reached neither this listener nor the burger's own â 406 // leaving a full-screen overlay open with the body still scroll-locked. 407 (header || headerNav).addEventListener('keydown', function(e){ 408 if (e.key !== 'Escape') return; 409 // Suppress hover only for menus that are actually on screen: clearing 410 // the class alone leaves nav.css's :hover rule showing a menu the button 411 // now calls shut, but suppressing every menu strands the ones nobody 412 // touched. 413 closeShown(); 414 // Below the breakpoint this is the handler Escape reaches once focus is 415 // in the menu â the toggler never sees it â so the overlay has to be 416 // dismissed from here or it stays open with the body scroll-locked. 417 if (header && header.classList.contains('is-active')) closeOverlay(); 418 }); 419 headerNav.addEventListener('focusout', function(e){ 420 // closeShown(), not closeAll(): focus can leave the nav while the pointer 421 // is still resting on a dropdown, and clearing the class alone leaves 422 // nav.css showing a menu the button has just called collapsed. No 423 // mouseenter follows to correct it, so it stays wrong until the pointer 424 // happens to leave. 425 if (ownBlur) return; 426 if (desktop.matches && !headerNav.contains(e.relatedTarget)) closeShown(); 427 }); 428 } 429 430 // White-label-app "Try how it works": switch brand colours + visible mockup.
431 var wlContainer = document.querySelector('.hero-white-label-app-container'); 432 var wlTabs = document.querySelectorAll('.hero-white-label-app-tabs [role="tab"]'); 433 if (wlContainer && wlTabs.length) { 434 var wlPill = document.querySelector('.hero-white-label-app-tabs .selected-button-bg'); 435 wlTabs.forEach(function(tab){ 436 tab.addEventListener('click', function(){ 437 wlContainer.setAttribute('data-company', tab.getAttribute('data-company')); 438 wlTabs.forEach(function(x){ 439 var sel = x === tab; 440 x.setAttribute('aria-selected', String(sel)); 441 x.setAttribute('tabindex', sel ? '0' : '-1'); 442 var panel = document.getElementById(x.getAttribute('aria-controls')); 443 if (panel) panel.classList.toggle('hidden', !sel); 444 }); 445 if (wlPill) tab.appendChild(wlPill); 446 }); 447 }); 448 } 449 450 // ââ Carousels (ps-carousel: home "Instant access", driver/business 451 // testimonials, partner logos wherever this component is reused) âââââââ 452 // The theme's carousel JS is part of the stripped bundle, so without this 453 // every carousel ships its prev/next/dot buttons permanently disabled and 454 // sits on its first slide with no way to scroll it. CSS already declares 455 // scroll-snap-type/scroll-padding for native snapping; overflow-x is left 456 // off .carousel-slides in the mirrored CSS (the stripped JS presumably set 457 // it), so it's added here too, which also means a no-JS visitor sees a 458 // static first slide rather than a half-working scroller. Dot/arrow clicks 459 // scroll to the target slide; an IntersectionObserver keeps the dots, 460 // arrows and per-slide aria-hidden (used by the dimmed-neighbour styling) 461 // in sync with manual swipes too. 462 document.querySelectorAll('ps-carousel').forEach(function(root){ 463 var viewport = root.querySelector('[data-carousel-viewport]'); 464 var slidesEl = root.querySelector('[data-carousel-slides]'); 465 if (!viewport || !slidesEl) return; 466 var slides = Array.prototype.filter.call(slidesEl.children, function(el){ 467 return el.nodeType === 1; 468 }); 469 if (slides.length < 2) return; 470 slidesEl.style.overflowX = 'auto'; 471 var prevBtn = root.querySelector('[data-carousel-prev]'); 472 var nextBtn = root.querySelector('[data-carousel-next]'); 473 var dots = Array.prototype.slice.call(root.querySelectorAll('[data-carousel-pagination-item]')); 474 var current = 0; 475 476 function render(){ 477 slides.forEach(function(slide, i){ 478 if (i === current) slide.removeAttribute('aria-hidden'); 479 else slide.setAttribute('aria-hidden', 'true'); 480 }); 481 dots.forEach(function(dot, i){ 482 dot.disabled = false; 483 dot.classList.toggle('active', i === current); 484 }); 485 if (prevBtn) prevBtn.disabled = current === 0; 486 if (nextBtn) nextBtn.disabled = current === slides.length - 1; 487 } 488 489 function goTo(index){ 490 index = Math.max(0, Math.min(slides.length - 1, index)); 491 slides[index].scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); 492 } 493 494 if (prevBtn) prevBtn.addEventListener('click', function(){ goTo(current - 1); }); 495 if (nextBtn) nextBtn.addEventListener('click', function(){ goTo(current + 1); }); 496 dots.forEach(function(dot, i){ dot.addEventListener('click', function(){ goTo(i); }); }); 497 498 if ('IntersectionObserver' in window) { 499 // Two+ slides can be above any single ratio threshold at once (desktop 500 // shows a 50%-width peek of the next card), so picking "current" off 501 // whichever entry's callback merely crosses a threshold is unreliable 502 // -- it settles on whichever fired last, not the most-visible one. 503 // Track every slide's latest known ratio and always take the max. 504 var ratios = slides.map(function(){ return 0; }); 505 var observer = new IntersectionObserver(function(entries){ 506 entries.forEach(function(entry){ 507 var i = slides.indexOf(entry.target); 508 if (i !== -1) ratios[i] = entry.intersectionRatio; 509 }); 510 var best = 0; 511 ratios.forEach(function(r, i){ if (r > ratios[best]) best = i; }); 512 if (best !== current) { current = best; render(); } 513 }, { root: viewport, threshold: [0, 0.25, 0.5, 0.6, 0.75, 1] });
514 slides.forEach(function(slide){ observer.observe(slide); }); 515 } 516 517 render(); 518 }); 519})();(function(){ 520// ââ Driver testimonials carousel ââââââââââââââââââââââââââââââââââââââââââ 521 // The mirrored <ps-carousel> element was never defined (its script is not 522 // part of the page), so the arrows shipped disabled and nothing moved. The 523 // markup is a scroll-snap track, which already swipes on its own; this adds 524 // what scrolling cannot express by itself -- which card is centred, the 525 // arrow buttons, and the wrap-around. 526 // 527 // Looping is done by cloning the whole set either side of the real one and 528 // silently jumping back by one set width whenever the scroll settles inside 529 // a clone. The clones carry aria-hidden so the quotes are announced once, 530 // and they are added here rather than in the markup so that without this 531 // script the track is still a plain, working snap carousel of four cards. 532 var testi = document.querySelector('.ps-testi'); 533 var track = testi && testi.querySelector('[data-testi-track]'); 534 if (testi && track) { 535 var real = [].slice.call(track.querySelectorAll('.ps-testi-card')); 536 var n = real.length; 537 var prev = testi.querySelector('[data-testi-prev]'); 538 var next = testi.querySelector('[data-testi-next]'); 539 var loop = n > 1; 540 var cards = real; 541 542 if (loop) { 543 var head = document.createDocumentFragment(); 544 var tail = document.createDocumentFragment(); 545 real.forEach(function (card) { 546 var a = card.cloneNode(true); 547 var b = card.cloneNode(true); 548 a.setAttribute('aria-hidden', 'true'); 549 b.setAttribute('aria-hidden', 'true'); 550 a.setAttribute('data-testi-clone', ''); 551 b.setAttribute('data-testi-clone', ''); 552 head.appendChild(a); 553 tail.appendChild(b); 554 }); 555 track.insertBefore(head, real[0]); 556 track.appendChild(tail); 557 cards = [].slice.call(track.querySelectorAll('.ps-testi-card')); 558 } 559 560 // Off-centre cards only dim once we can tell which one is centred. 561 testi.setAttribute('data-testi-ready', ''); 562 563 // offsetLeft is measured from the offsetParent, which here is the body -- 564 // not the track. Mixing that with track.scrollLeft is wrong by however far 565 // the section sits from the page's left edge, which is 24px at 1440 but 566 // 520px once the layout hits its max-width and starts centring. Snap hides 567 // the error while it is under half a card; past that it picks the wrong 568 // card and the active one lands well left of centre. So measure every 569 // position against the track itself. 570 var posIn = function (card) { 571 return card.getBoundingClientRect().left - track.getBoundingClientRect().left + track.scrollLeft; 572 }; 573 var offsetFor = function (card) { 574 return posIn(card) - (track.clientWidth - card.offsetWidth) / 2; 575 }; 576 var jumpTo = function (left) { 577 var was = track.style.scrollBehavior; 578 track.style.scrollBehavior = 'auto'; 579 track.scrollLeft = left; 580 track.style.scrollBehavior = was; 581 }; 582 583 var current = loop ? n : 0; 584 var setWidth = 0; 585 586 var nearest = function () { 587 var mid = track.scrollLeft + track.clientWidth / 2; 588 var best = 0; 589 var bestDist = Infinity; 590 cards.forEach(function (card, i) { 591 var dist = Math.abs(posIn(card) + card.offsetWidth / 2 - mid); 592 if (dist < bestDist) { bestDist = dist; best = i; } 593 }); 594 return best; 595 }; 596 597 var mark = function () { 598 cards.forEach(function (card, i) { card.classList.toggle('is-active', i === current); }); 599 if (!loop) { 600 if (prev) prev.disabled = current === 0; 601 if (next) next.disabled = current === cards.length - 1; 602 } 603 }; 604 var sync = function () { 605 current = nearest(); 606 mark(); 607 }; 608 609 // Highlight the target as the click lands rather than waiting for the 610 // scroll handler, so the dimming leads the animation instead of trailing it. 611 // Recentre onto the identical real card whenever we are sitting in a clone 612 // set. Same pixels on screen, so the correction is invisible; it just puts 613 // the runway back on both sides. 614 var normalise = function () { 615 if (!loop || !setWidth) return; 616 if (current < n) { jumpTo(track.scrollLeft + setWidth); current += n; } 617 else if (current >= n * 2) { jumpTo(track.scrollLeft - setWidth); current -= n; } 618 else return; 619 mark(); 620 }; 621 622 var step = function (delta) { 623 // Normalise first: from anywhere in the real set there is always a card
624 // to step onto, so the arrows never run out of track no matter how fast 625 // they are clicked -- they do not have to wait for a scroll event. 626 normalise(); 627 var card = cards[current + delta]; 628 if (!card) return; 629 current += delta; 630 mark(); 631 track.scrollTo({ left: offsetFor(card), behavior: 'smooth' }); 632 }; 633 if (prev) prev.addEventListener('click', function () { step(-1); }); 634 if (next) next.addEventListener('click', function () { step(1); }); 635 636 var raf = null; 637 var settle = null; 638 track.addEventListener('scroll', function () { 639 if (!raf) raf = requestAnimationFrame(function () { raf = null; sync(); }); 640 // scrollend is not everywhere yet, so fall back to a quiet period. 641 clearTimeout(settle); 642 settle = setTimeout(normalise, 140); 643 }, { passive: true }); 644 if ('onscrollend' in window) { 645 track.addEventListener('scrollend', function () { clearTimeout(settle); normalise(); }); 646 } 647 648 var measure = function () { 649 if (loop) setWidth = posIn(cards[n]) - posIn(cards[0]); 650 jumpTo(offsetFor(cards[loop ? n : 0])); 651 sync(); 652 }; 653 window.addEventListener('resize', measure); 654 measure(); 655 // Fonts landing late change how the quotes wrap, which moves every card. 656 if (document.fonts && document.fonts.ready) document.fonts.ready.then(measure); 657 } 658 659 660 // ââ Charging-card glow ââââââââââââââââââââââââââââââââââââââââââââââââââââ 661 // The glow animates on the compositor, but an infinite animation still keeps 662 // its layer alive for the whole visit, and this section sits a long way down 663 // the page. Run it only while the section is actually on screen. 664 var glowSection = document.querySelector('section.charging-card'); 665 if (glowSection && glowSection.querySelector('.charging-card-art')) { 666 if ('IntersectionObserver' in window) { 667 new IntersectionObserver(function (entries) { 668 entries.forEach(function (e) { glowSection.classList.toggle('is-glowing', e.isIntersecting); }); 669 }, { rootMargin: '120px' }).observe(glowSection); 670 } else { 671 glowSection.classList.add('is-glowing'); 672 } 673 } 674})();
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.