1/** 2 * Provides utility functions for DOM selection, ARIA state management, 3 * debouncing, class toggling, and smooth scrolling. 4 */ 5 6import {gaEvents} from './gaEvents.js'; 7import {settings} from './settings.js'; 8import {formHelpers} from './forms.js'; 9 10// Fallback ids for validation messages on fields the server rendered without one, so 11// aria-describedby always has something to point at. 12let errorIdSeq = 0; 13 14export const helpers = { 15 16 /** 17 * Query a single element within an optional parent. 18 * @param {Element|Document} [parent=document] - Container to query within 19 * @param {string} selector - CSS selector string 20 * @returns {Element|null} 21 */ 22 qs(parent, sel) { 23 return (parent || document).querySelector(sel); 24 }, 25 26 27 /** 28 * Query multiple elements within an optional parent. 29 * @param {Element|Document} [parent=document] - Container to query within 30 * @param {string} selector - CSS selector string 31 * @returns {Element[]} 32 */ 33 qsa(parent, sel) { 34 return Array.from((parent || document).querySelectorAll(sel)); 35 }, 36 37 38 /** 39 * Retrieve all focusable elements inside a given container. 40 * 41 * Invisible candidates are dropped: a `display: none` field cannot take focus, so leaving it 42 * in makes a focus trap jump to nothing (the honeypot `.hnpt` input is exactly that case). 43 * getClientRects() is empty for anything not rendered, and [hidden] ancestors are excluded 44 * because a child of a hidden subtree is unreachable too. 45 * 46 * @param {Element} container - Element within which to find focusable items 47 * @returns {Array<Element>} 48 */ 49 getFocusable(container) { 50 const candidates = container.querySelectorAll('a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled])'); 51 52 return Array.from(candidates).filter(el => 53 el.getClientRects().length > 0 && !el.closest('[hidden]') 54 ); 55 }, 56 57 58 /** 59 * Set multiple ARIA attributes on an element. 60 * @param {Element} el - Target element 61 * @param {Object<string, string>} states - Key/value pairs of ARIA states 62 */ 63 setAriaStates(el, obj = {}) { 64 if (!el) return; 65 Object.entries(obj).forEach(([k, v]) => el.setAttribute(`aria-${k}`, String(v))); 66 }, 67 68 69 /** 70 * Debounce a function, ensuring it only executes after a pause. 71 * @param {Function} fn - Function to debounce 72 * @param {number} [delay=200] - Milliseconds to wait after last call 73 * @returns {Function} 74 */ 75 debounce(fn, delay = 200) { 76 let t; 77 return (...args) => { 78 clearTimeout(t); 79 t = setTimeout(() => fn.apply(this, args), delay); 80 }; 81 }, 82 83 84 /** 85 * Attaches an âoutsideâclickâ listener to a <dialog> that closes it 86 * when clicking outside its bounds, and automatically removes itself 87 * once the dialog is closed. 88 * 89 * @param {HTMLDialogElement} dialogEl 90 */ 91 bindBackdropClickClose(dialogEl) { 92 if (dialogEl._outsideClickHandler) { 93 dialogEl.removeEventListener('click', dialogEl._outsideClickHandler); 94 } 95 96 dialogEl._outsideClickHandler = function(e) { 97 if (!e.pointerType) return; 98 99 const { left, right, top, bottom } = dialogEl.getBoundingClientRect(); 100 const outside = 101 e.clientX < left || 102 e.clientX > right || 103 e.clientY < top || 104 e.clientY > bottom; 105 106 if (outside) { 107 dialogEl.close(); 108 helpers.clearDialogTemplate(dialogEl); 109 } 110 }; 111 112 dialogEl.addEventListener('click', dialogEl._outsideClickHandler); 113 }, 114 115 116 /** 117 * Set a browser cookie with a given name, value and expiration. 118 * @param {string} name - Cookie name 119 * @param {string} value - Cookie value 120 * @param {number} days - Expiration time in days 121 */ 122 setCookie(name, value, days) { 123 const date = new Date(); 124 date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000)); 125 document.cookie = `${name}=${value}; expires=${date.toUTCString()}; path=/; SameSite=Lax`; 126 }, 127 128 129 /** 130 * Retrieve a cookie value by its name. 131 * @param {string} name - Cookie name 132 * @returns {string|undefined} - Cookie value if found, otherwise undefined 133 */ 134 getCookie(name) { 135 return document.cookie.split('; ') 136 .find(row => row.startsWith(name + '=')) 137 ?.split('=')[1]; 138 }, 139 140 141 /** 142 * attachSwipe(el, options) 143 * Emits CustomEvents: 'swipeleft' and 'swiperight' on `el`. 144 * Works with Pointer Events only (mouse/touch/pen). 145 * 146 * Usage: 147 * const cleanup = attachSwipe(figureEl); 148 * figureEl.addEventListener('swipeleft', () => next()); 149 * figureEl.addEventListener('swiperight', () => prev()); 150 */ 151 attachSwipe(el, options = {}){ 152 if (!el || !('PointerEvent' in window)) return () => {}; 153 154 const cfg = { 155 thresholdPx: 60, 156 offAxisLimitPx: 90, 157 dragStartPx: 8, 158 suppressClickAfterDrag: true, 159 ...options 160 }; 161 162 let startX = 0, startY = 0; 163 let tracking = false;
164 let dragging = false; 165 let activePointerId = null; 166 167 function suppressNextClickOnce() { 168 const stop = (e) => { 169 e.preventDefault(); 170 e.stopPropagation(); 171 window.removeEventListener('click', stop, true); 172 }; 173 window.addEventListener('click', stop, true); 174 } 175 176 function emit(type, detail) { 177 el.dispatchEvent(new CustomEvent(type, { bubbles: true, detail })); 178 } 179 180 function commit(dx, dy) { 181 // too vertical => ignore 182 if (Math.abs(dy) > cfg.offAxisLimitPx) return; 183 184 if (dx <= -cfg.thresholdPx) { 185 emit('swipeleft', { dx, dy }); 186 } else if (dx >= cfg.thresholdPx) { 187 emit('swiperight', { dx, dy }); 188 } 189 } 190 191 function onPointerDown(e) { 192 if (!e.isPrimary) return; 193 if (e.pointerType === 'mouse' && e.button !== 0) return; 194 195 activePointerId = e.pointerId; 196 startX = e.clientX; 197 startY = e.clientY; 198 tracking = true; 199 dragging = false; 200 201 try { el.setPointerCapture(activePointerId); } catch (_) {} 202 } 203 204 function onPointerMove(e) { 205 if (!tracking || activePointerId !== e.pointerId) return; 206 207 const dx = e.clientX - startX; 208 const dy = e.clientY - startY; 209 210 if (!dragging && (Math.abs(dx) > cfg.dragStartPx || Math.abs(dy) > cfg.dragStartPx)) { 211 dragging = true; 212 } 213 214 // if user drags mostly horizontally, prevent default browser gestures 215 if (dragging && Math.abs(dx) > Math.abs(dy)) { 216 e.preventDefault(); 217 } 218 } 219 220 function onPointerUp(e) { 221 if (!tracking || activePointerId !== e.pointerId) return; 222 223 const dx = e.clientX - startX; 224 const dy = e.clientY - startY; 225 226 tracking = false; 227 activePointerId = null; 228 229 if (dragging && cfg.suppressClickAfterDrag) suppressNextClickOnce(); 230 commit(dx, dy); 231 } 232 233 function onPointerCancel(e) { 234 if (!tracking || activePointerId !== e.pointerId) return; 235 tracking = false; 236 dragging = false; 237 activePointerId = null; 238 } 239 240 el.addEventListener('pointerdown', onPointerDown); 241 el.addEventListener('pointermove', onPointerMove); 242 el.addEventListener('pointerup', onPointerUp); 243 el.addEventListener('pointercancel', onPointerCancel); 244 245 // cleanup 246 return () => { 247 el.removeEventListener('pointerdown', onPointerDown); 248 el.removeEventListener('pointermove', onPointerMove); 249 el.removeEventListener('pointerup', onPointerUp); 250 el.removeEventListener('pointercancel', onPointerCancel); 251 }; 252 253 }, 254 255 256 257 /** 258 * Initialize and display the cookie consent modal if no consent is stored. 259 */ 260 initCookieModal() { 261 const modal = document.getElementById('cookieModal'); 262 const cookiesSubpage = document.querySelector('.cookies-content'); 263 if (!modal || cookiesSubpage) return; 264 265 const consent = helpers.getCookie('agree') || localStorage.getItem('agree'); 266 if (!consent) { 267 modal.showModal(); 268 } 269 }, 270 271 272 /** 273 * Parse JSON data from a trigger element for dialog content. 274 * Expects a valid JSON string in `target-dialog-data` attribute. 275 * 276 * @param {Element} el - Element containing dialog trigger 277 * @returns {Object|false} Parsed data object or false on failure 278 */ 279 getDialogData(el) { 280 if (!el) return false; 281 const raw = el.getAttribute('target-dialog-data'); 282 if (!raw) return false; 283 try { 284 return JSON.parse(raw); 285 } catch { 286 return false; 287 } 288 }, 289 290 291 /** 292 * Clear all elements inside a dialog that use the [template] attribute. 293 * - For <img> elements â removes `src` and `alt`. 294 * - For other elements â clears innerHTML. 295 * 296 * @param {HTMLElement} dialogEl - Dialog container to clear 297 */ 298 clearDialogTemplate(dialogEl) { 299 if (!(dialogEl instanceof HTMLElement)) return; 300 301 dialogEl.querySelectorAll('[template]').forEach(el => { 302 if (el.tagName.toLowerCase() === 'img') { 303 el.removeAttribute('src'); 304 el.removeAttribute('alt'); 305 } else { 306 el.innerHTML = ''; 307 } 308 }); 309 }, 310 311 312 /** 313 * Open or close a <dialog> element and fill it with passed data. 314 * 315 * - Looks up the dialog by id, class, or selector string. 316 * - If the dialog is already open â clears template content and closes it. 317 * - If closed â injects data into elements with a [template] attribute, 318 * replacing <img> src/alt or innerHTML for other elements. 319 * - Binds backdrop click to close the dialog automatically. 320 * 321 * @param {string} dialog - Selector, id, or class to identify the dialog element 322 * @param {Object<string, string|number|boolean>} [dialogData=false] - Optional key/value data to fill [template] elements 323 */ 324 openDialogTemplate(dialog, dialogData = false) { 325 let targetEl = document.getElementById(dialog) 326 || document.querySelector(`.${dialog}`) 327 || document.querySelector(dialog); 328 329 if (targetEl instanceof HTMLDialogElement && typeof targetEl.showModal === 'function') { 330 331 if (targetEl.open) { 332 helpers.clearDialogTemplate(targetEl); 333 targetEl.close(); 334 return; 335 } 336 337 if (dialogData) { 338 targetEl.querySelectorAll('[template]').forEach(el => {
339 const key = el.getAttribute('template'); 340 if (!key || !(key in dialogData)) return; 341 342 const value = dialogData[key]; 343 344 if (el.tagName.toLowerCase() === 'img') { 345 el.setAttribute('src', value); 346 el.setAttribute('alt', dialogData.name || ''); 347 } else { 348 el.innerHTML = value; 349 } 350 }); 351 } 352 353 targetEl.showModal(); 354 355 setTimeout(() => { 356 // focus() on a <dialog> without tabindex is a no-op, which left the close button 357 // as the first thing announced. Focusing the heading the dialog is named by makes 358 // its purpose the first thing read instead. 359 const labelEl = targetEl.getAttribute('aria-labelledby') 360 ? document.getElementById(targetEl.getAttribute('aria-labelledby')) 361 : null; 362 363 if (labelEl) { 364 if (!labelEl.hasAttribute('tabindex')) labelEl.setAttribute('tabindex', '-1'); 365 labelEl.focus(); 366 } else { 367 targetEl.focus(); 368 } 369 370 // Focus trap. The list is recomputed on every Tab rather than captured here: 371 // content can arrive after the dialog opens (the dealer map builds itself 372 // asynchronously), and a list taken at open time would trap focus on whatever 373 // existed in that first frame - on the map dialog, the close button alone. 374 const focusables = () => { 375 const closeBtn = targetEl.querySelector("button[close-dialog]"); 376 const els = helpers.getFocusable(targetEl); 377 378 if (closeBtn && !els.includes(closeBtn)) els.unshift(closeBtn); 379 380 return els; 381 }; 382 383 if (targetEl._focusTrapHandler) { 384 targetEl.removeEventListener('keydown', targetEl._focusTrapHandler); 385 } 386 387 // create and store new trap 388 targetEl._focusTrapHandler = function(e) { 389 if (e.key !== "Tab") return; 390
391 const els = focusables(); 392 if (!els.length) return; 393 394 const first = els[0]; 395 const last = els[els.length - 1]; 396 397 if (e.shiftKey && document.activeElement === first) { 398 e.preventDefault(); 399 last.focus(); 400 } else if (!e.shiftKey && document.activeElement === last) { 401 e.preventDefault(); 402 first.focus(); 403 } 404 }; 405 406 targetEl.addEventListener('keydown', targetEl._focusTrapHandler); 407 408 }); 409 } 410 }, 411 412 413 /** 414 * Set validation state for a form field and optionally display an error message. 415 * - Removes old validation classes and feedback. 416 * - Adds `field-valid` or `field-invalid` class. 417 * - Appends `.invalid-feedback` span if message is provided. 418 * - Wires the message to the field with `aria-invalid` + `aria-describedby`, so a screen 419 * reader reads what is wrong instead of only the colour and icon saying that something is. 420 * 421 * @param {HTMLElement} field - Target input/select/textarea element 422 * @param {'valid'|'invalid'} state - Validation state to apply 423 * @param {string} [message] - Optional error message for invalid state 424 */ 425 setFieldState(field, state, message) { 426 if (!field) return; 427 428 field.classList.remove('field-valid', 'field-invalid'); 429 const oldFeedback = field.parentElement.querySelector('.invalid-feedback'); 430 if (oldFeedback) oldFeedback.remove(); 431 432 // Only ever our own id is added or removed - Symfony puts its help text id in the same 433 // attribute, and overwriting it would silence the field's own hint. 434 const errorId = field.dataset.errorId 435 || (field.id ? `${field.id}-error` : `field-error-${++errorIdSeq}`); 436 field.dataset.errorId = errorId; 437 438 const described = (field.getAttribute('aria-describedby') || '') 439 .split(/\s+/) 440 .filter(id => '' !== id && id !== errorId); 441 442 field.removeAttribute('aria-invalid'); 443 444 if (state === 'valid') { 445 field.classList.add('field-valid'); 446 } 447 448 if (state === 'invalid') { 449 field.classList.add('field-invalid'); 450 451 // The field is invalid whether or not there is a message to show; only the pointer to 452 // the message depends on the span existing. 453 field.setAttribute('aria-invalid', 'true'); 454 455 if (message) { 456 const span = document.createElement('span'); 457 span.className = 'invalid-feedback'; 458 span.id = errorId; 459 span.textContent = message; 460 field.insertAdjacentElement('afterend', span); 461 462 described.push(errorId); 463 } 464 } 465 466 if (described.length) { 467 field.setAttribute('aria-describedby', described.join(' ')); 468 } else { 469 field.removeAttribute('aria-describedby'); 470 } 471 }, 472 473 474 /** 475 * Validate a single form field by sending its name/value to the API. 476 * - Endpoint is resolved from settings.endpoints[validateAttr]. 477 * - On success (200) â field marked as valid. 478 * - On validation error (422) â checks if error exists for this field name, 479 * marks as invalid and appends feedback, otherwise marks as valid. 480 * 481 * @param {HTMLFormElement} form - Parent form element 482 * @param {string} name - Field name attribute 483 * @param {string} value - Current field value 484 * @param {Object} headers - Headers for request 485 */ 486 validateField(form, name, value, headers = {}) { 487 const formValidateAttr = form.getAttribute('validate'); 488 if (!formValidateAttr) return; 489 490 const endpoint = location.origin + '/' + settings.endpoints[formValidateAttr]; 491 492 fetch(endpoint, { 493 method: 'POST', 494 headers: headers, 495 body: JSON.stringify({[name]: value}) 496 }) 497 .then(response => response.json()) 498 .then(data => { 499 const field = form.querySelector(`[name="${name}"]`); 500 if (!field) return; 501 502 if (data.code === 200) { 503 helpers.setFieldState(field, 'valid'); 504 } else if (data.code === 422 && data.data.validationErrors) { 505 if (data.data.validationErrors[name]) { 506 helpers.setFieldState(field, 'invalid', data.data.validationErrors[name]); 507 } else { 508 helpers.setFieldState(field, 'valid'); 509 } 510 } 511 }) 512 .catch(err => console.error('Validation request failed:', err)); 513 }, 514 515 516 /** 517 * Submit a form via AJAX for valuation. 518 * - Endpoint is resolved from settings.endpoints[valuateAttr]. 519 * - On success (200) â alerts that form was sent. 520 * - On validation error (422) â iterates payload keys,
521 * sets field state to valid/invalid depending on errors in response. 522 * 523 * @param {HTMLFormElement} form - Form element with [valuate] attribute 524 * @param {Object} payload - Key/value pairs of form data 525 * @param valuateType 526 * @param {Object} headers - Headers for request 527 */ 528 valuateForm(form, payload, valuateType, headers = {}) { 529 const formValuateAttr = form.getAttribute('valuate'); 530 if (!formValuateAttr) return; 531 532 // Guard against double-submit: a second submit while the first request 533 // is still in flight would fire the GA conversion event twice (PA-7663). 534 if (form.getAttribute('state') === 'loading') return; 535 536 const submitBtn = this.qs(form, 'button[type="submit"]'); 537 const endpoint = location.origin + '/' + settings.endpoints[formValuateAttr]; 538 539 // read before the request - a successful submit clears the dialog template 540 const email = gaEvents.readFormEmail(form); 541 542 submitBtn.classList.add('loading'); 543 submitBtn.disabled = true; 544 form.setAttribute('state', 'loading'); 545 546 fetch(endpoint, { 547 method: 'POST', 548 headers: headers, 549 body: JSON.stringify(payload) 550 }) 551 .then(response => response.json()) 552 .then(data => { 553 form.removeAttribute('state'); 554 submitBtn.classList.remove('loading'); 555 submitBtn.disabled = false; 556 557 if (data.code === 200) { 558 // fire first - a throw in the success-screen handling below must not drop the conversion 559 gaEvents.valuateEvent(valuateType, email); 560 561 this.qsa(document, 'dialog[open]').forEach(dialog => { 562 helpers.clearDialogTemplate(dialog); 563 dialog.close(); 564 }); 565 566 if (valuateType == 'valuateCarValuate') { 567 formHelpers.prepareValuateCarUploadStep(data.data); 568 } else { 569 form.setAttribute('state', 'sent'); 570 helpers.openDialogTemplate('confirmation', data.data); 571 } 572 573 helpers.applyAdditionalJS(data.data); 574 575 } else if (data.code === 422 && data.data.validationErrors) { 576 577 // set invalid by api response 578 Object.entries(data.data.validationErrors).forEach(([name, value]) => { 579 const field = form.querySelector(`[name="${name}"]`); 580 if (!field) return; 581 582 helpers.setFieldState(field, 'invalid', value); 583 }); 584 585 // set valid if not exist in response 586 Object.entries(payload).forEach(([name, value]) => { 587 const field = form.querySelector(`[name="${name}"]`); 588 if (!field) return; 589 590 if (!data.data.validationErrors[name]) { 591 helpers.setFieldState(field, 'valid'); 592 } 593 }); 594 595 const firstInvalid = form.querySelector('.field-invalid'); 596 if (firstInvalid) { 597 firstInvalid.scrollIntoView({ 598 behavior: 'smooth', 599 block: 'center' 600 }); 601 firstInvalid.focus({preventScroll: true}); 602 } 603 } 604 }) 605 .catch(err => { 606 form.removeAttribute('state'); 607 submitBtn.classList.remove('loading'); 608 submitBtn.disabled = false; 609 console.error('Valuation request failed:', err); 610 }); 611 }, 612 613 614 /** 615 * Reads dropzone texts from the element data attributes so they follow the page 616 * language. Falls back to the settings when an attribute is missing. 617 * 618 * @param {HTMLElement|null} drop - Element with [component="dropzone"] 619 */ 620 dropzoneTexts(drop) { 621 const data = drop?.dataset ?? {}; 622 623 return { 624 removeFile: data.textRemoveFile || settings.dropzone.removeFile, 625 maxFileText: data.textMaxFiles || settings.dropzone.maxFileText, 626 notAllowedText: data.textNotAllowed || settings.dropzone.notAllowedText, 627 successText: data.textSuccess || settings.dropzone.successText, 628 errorText: data.textError || settings.dropzone.errorText, 629 }; 630 }, 631 632 /** 633 * @param {HTMLFormElement} form - Form element with [upload] attribute 634 * @param {FormData} formData - A FormData instance containing uploaded files and fields. 635 * @param {String} uploadType - Name of form 636 * @param {String} path - Extra part for endpoint path 637 */ 638 uploadForm(form, formData, uploadType, path = '') { 639 const formUploadAttr = form.getAttribute('upload'); 640 if (!formUploadAttr) return; 641 642 const submitBtn = this.qs(form, 'button[type="submit"]'); 643 const endpoint = location.origin + '/' + settings.endpoints[formUploadAttr] + path; 644 const texts = this.dropzoneTexts(form.querySelector('[component="dropzone"]')); 645 646 let progressBar = form.querySelector('progress'); 647 648 // prepare XHR 649 const xhr = new XMLHttpRequest(); 650 xhr.open('POST', endpoint, true); 651 652 xhr.upload.onprogress = function (e) { 653 if (e.lengthComputable) { 654 const percent = Math.round((e.loaded / e.total) * 100); 655 progressBar.value = percent; 656 } 657 }; 658 659 xhr.onloadstart = () => { 660 submitBtn?.classList.add('loading'); 661 form.classList.add('uploading'); 662 progressBar.classList.remove('error', 'success'); 663 }; 664 665 xhr.onload = () => { 666 submitBtn?.classList.remove('loading'); 667 form.classList.remove('uploading'); 668 form.classList.add('uploaded'); 669 670 if (xhr.status >= 200 && xhr.status < 300) { 671 let data; 672 try { 673 data = JSON.parse(xhr.responseText); 674 } catch { 675 data = {}; 676 } 677 678 progressBar.classList.add('success'); 679 680 if (data.code === 200) { 681 682 if (uploadType === 'valuateCarPhotosUpload') { 683 const formDecision = document.querySelector('.car-valuation__decision'); 684 formDecision.classList.remove('show-upload'); 685 formDecision.classList.add('show-confirmation'); 686 } 687 } 688 689 // reset dropzone 690 const drop = form.querySelector('[component="dropzone"]'); 691 if (drop) { 692 drop.dropzoneFiles = []; 693 const list = drop.querySelector('.drop-zone__list'); 694 if (list) list.innerHTML = ''; 695 } 696 697 helpers.showToaster(texts.successText, 'success'); 698 } else { 699 progressBar.classList.add('error'); 700 helpers.showToaster(texts.errorText, 'error'); 701 } 702 }; 703 704 xhr.onerror = () => { 705 form.removeAttribute('state'); 706 submitBtn?.classList.remove('loading'); 707 form.classList.remove('uploading'); 708 form.classList.remove('uploaded'); 709 progressBar.classList.add('error'); 710 helpers.showToaster(texts.errorText, 'error'); 711 }; 712 713 xhr.send(formData); 714 715 }, 716 717 718 /**
719 * Renders an HTML template from a <script type="text/template"> element, 720 * replacing [[placeholders]] with values from the provided data object. 721 * 722 * Supports: 723 * - [[key]] and [[nested.key]] 724 * - [[#key]]...[[/key]] (conditional or loop sections) 725 * - [[#array]]...[[/array]] (loops) 726 * - [[.]] or [[value]] inside loops 727 * - [[key]] inside object-like loops (associative) 728 * 729 * @param {string} templateId - ID of the <script> template element 730 * @param {object} data - Object containing data for replacement 731 * @param {object} [context={}] - Optional context that can override `data` values 732 * @returns {string} - Rendered HTML string 733 */ 734 renderTemplate(templateId, data = {}, context = {}) { 735 const tpl = document.getElementById(templateId); 736 if (!tpl) return ''; 737 let html = tpl.innerHTML; 738 739 // === Merge data and context (context takes priority) === 740 const mergedData = {...data, ...context}; 741 742 html = html.replace(/\[\[#([\w.-]+)\]\]([\s\S]*?)\[\[\/\1\]\]/g, (_, keyPath, content) => { 743 const value = this.resolvePath(mergedData, keyPath); 744 745 // === CASE 1: Array === 746 if (Array.isArray(value)) { 747 return value 748 .map((val, i) => { 749 if (typeof val === 'object' && val !== null) { 750 return this.renderString(content, {...val, index: i}, mergedData); 751 } 752 return this.renderString(content, {index: i, value: val, '.': val}, mergedData); 753 }) 754 .join(''); 755 } 756 757 // === CASE 2: Associative Object === 758 if (typeof value === 'object' && value !== null) { 759 const allPrimitive = Object.values(value).every( 760 (v) => typeof v !== 'object' || v === null 761 ); 762 763 if (allPrimitive) { 764 return Object.entries(value) 765 .map(([key, val]) => 766 this.renderString(content, {key, value: val, '.': val}, mergedData) 767 ) 768 .join(''); 769 } 770 771 // === CASE 3: Regular object (conditional) === 772 return this.renderString(content, value, mergedData); 773 } 774 775 // === CASE 4: Primitive truthy/falsy === 776 return value ? this.renderString(content, mergedData, mergedData) : ''; 777 }); 778 779 html = this.renderString(html, mergedData); 780 return html; 781 }, 782 783 renderString(str, data, parentData = {}) { 784 return str.replace(/\[\[([\w.-]+)\]\]/g, (_, keyPath) => { 785 let val = this.resolvePath(data, keyPath); 786 if (val === undefined) val = this.resolvePath(parentData, keyPath); 787 return val != null ? val : ''; 788 }); 789 }, 790 791 resolvePath(obj, path) { 792 return path 793 .split('.') 794 .reduce((acc, key) => (acc && acc[key] !== undefined ? acc[key] : undefined), obj); 795 }, 796 797 798 /** 799 * copy text to clipboard 800 * @param {string} text - text to copy 801 */ 802 copyToClipboard(text) { 803 if (navigator.clipboard) { 804 try { 805 navigator.clipboard.writeText(text); 806 helpers.showToaster(settings.clipboard.copySuccess, 'success'); 807 } catch (err) { 808 helpers.showToaster(settings.clipboard.copyFaild, 'error'); 809 } 810 } else { 811 helpers.showToaster(settings.clipboard.copyFaild, 'error'); 812 } 813 }, 814 815 816 /** 817 * Show simple toastr on screen. 818 * @param {string} message - message content 819 * @param {'success' | 'error' | 'info'} [type='info'] - msg type 820 */ 821 showToaster(message, type = 'info') { 822 const toaster = document.createElement('div'); 823 toaster.className = `toaster toaster--${type}`; 824 toaster.textContent = message; 825 826 // Into a live region that already exists, not straight into <body>: a region created in 827 // the same tick as its content is not announced by screen readers. 828 helpers.getToasterRegion(type).appendChild(toaster); 829 830 // animation trigger (reflow) 831 setTimeout(() => toaster.classList.add('toaster--visible'), 10); 832 833 // auto remove 834 setTimeout(() => { 835 toaster.classList.remove('toaster--visible'); 836 setTimeout(() => toaster.remove(), 300); // after reflow animation 837 }, 5000); 838 }, 839 840 841 /** 842 * The live region a toaster of this type belongs in, created on first use. 843 * 844 * Errors go to role="alert" (assertive - it interrupts), everything else to 845 * role="status"/aria-live="polite". The wrapper carries no styles: `.toaster` is 846 * position: fixed and a plain div creates no containing block, so the placement is unchanged. 847 * 848 * @param {'success'|'error'|'info'} type 849 * @returns {HTMLElement} 850 */ 851 getToasterRegion(type) { 852 const isAlert = 'error' === type; 853 const id = isAlert ? 'toaster-alert' : 'toaster-status'; 854 855 let region = document.getElementById(id); 856 if (region) return region; 857 858 region = document.createElement('div'); 859 region.id = id; 860 861 if (isAlert) { 862 region.setAttribute('role', 'alert'); 863 } else { 864 region.setAttribute('role', 'status'); 865 region.setAttribute('aria-live', 'polite'); 866 region.setAttribute('aria-atomic', 'true'); 867 } 868 869 document.body.appendChild(region); 870 871 return region; 872 }, 873 874 875 /** 876 * Create both toaster live regions at page load, so the first message is announced too. 877 */ 878 initToasterRegions() { 879 helpers.getToasterRegion('info'); 880 helpers.getToasterRegion('error'); 881 }, 882 883 884 /** 885 * Enhances a native <select> with a custom dropdown UI + supports searching & keyboard navigation 886 * @param {HTMLElement} selectContainer 887 */ 888 enhanceSelect(selectContainer) { 889 const select = selectContainer.querySelector('select'); 890 if (!(select instanceof HTMLSelectElement)) return; 891 if (select.multiple) return; 892 893 // prevent double init
894 if (selectContainer.dataset.selectEnhanced === 'true') return; 895 selectContainer.dataset.selectEnhanced = 'true'; 896 897 // Tapping a <label for="â¦"> synthetically clicks the linked control, 898 // which bypasses pointer-events:none on the native <select> and pops 899 // iOS Safari's native picker on top of our custom dropdown. Detach 900 // the label's for-attribute and re-associate it via aria-labelledby 901 // so screen readers still pair them. 902 // select.labels = every <label> the browser already pairs with this control, wherever it 903 // sits in the DOM - the dealer map picker wraps the select alone and keeps the row's label 904 // outside the container (see templates/tce/form/dealer-map-picker.html.twig). Only labels 905 // linked via for="â¦" are touched; a wrapping <label> has no for-attribute to detach. 906 if (select.id) { 907 Array.from(select.labels ?? []).filter(label => label.htmlFor === select.id).forEach(label => { 908 if (!label.id) { 909 label.id = `${select.id}-label`; 910 } 911 const labelledBy = (select.getAttribute('aria-labelledby') || '').split(/\s+/).filter(Boolean); 912 if (!labelledBy.includes(label.id)) { 913 labelledBy.push(label.id); 914 select.setAttribute('aria-labelledby', labelledBy.join(' ')); 915 } 916 label.removeAttribute('for'); 917 }); 918 } 919 select.setAttribute('tabindex', '-1'); 920 921 // Calling select.focus() programmatically on iOS Safari re-pops the 922 // native picker on top of the closed custom dropdown â see PA-7459 v3 923 // QA push-back. Focus the container instead; mark it programmatically 924 // focusable (skipped by Tab) so .focus() works without joining the 925 // tab order. 926 if (selectContainer.tabIndex < 0) { 927 selectContainer.tabIndex = -1; 928 } 929 930 // --- build dropdown UI inside container --- 931 const dropdown = document.createElement('div'); 932 dropdown.className = 'component-select'; 933 dropdown.hidden = true; 934 dropdown.setAttribute('aria-hidden', 'true'); 935 dropdown.inert = true; 936 937 const uid = `select-${Math.random().toString(6).slice(2)}`; 938 const finderId = `${uid}-finder`; 939 const listId = `${uid}-list`; 940 941 dropdown.innerHTML = ` 942 <div class="form-finder"> 943 <input 944 id="${finderId}" 945 type="text" 946 class="form-control" 947 role="combobox" 948 aria-autocomplete="list" 949 aria-controls="${listId}" 950 aria-expanded="true" 951 aria-activedescendant="" 952 autocomplete="off" 953 spellcheck="false" 954 placeholder="${settings.select.search}" 955 /> 956 <button 957 type="button" 958 class="component-select__clear" 959 aria-label="${settings.select.clear}" 960 title="${settings.select.clear}" 961 ><i class="w-icon-cross" aria-hidden="true"></i></button> 962 </div> 963 964 <ul id="${listId}" role="listbox" class="component-select__list"></ul> 965 966 <p class="no-results">${settings.select.noResults}</p> 967 968 <div class="dropdown-footer"> 969 <button type="button" class="component-select__close"> 970 <i class="w-icon-cross2" aria-hidden="true"></i><span>${settings.select.close}</span> 971 </button> 972 </div> 973 `; 974 975 selectContainer.appendChild(dropdown); 976 977 const finder = dropdown.querySelector(`#${CSS.escape(finderId)}`); 978 const clearBtn = dropdown.querySelector('.component-select__clear'); 979 const list = dropdown.querySelector(`#${CSS.escape(listId)}`); 980 const closeBtn = dropdown.querySelector('.component-select__close'); 981 982 if (!(finder instanceof HTMLInputElement)) return; 983 if (!(list instanceof HTMLUListElement)) return; 984 985 select.setAttribute('aria-haspopup', 'listbox'); 986 select.setAttribute('aria-expanded', 'false'); 987 988 // --- build list from select 989 const optionItems = []; 990 list.innerHTML = ''; 991
992 Array.from(select.options).forEach((opt, i) => { 993 if (opt.value === '') return; // skip empty 994 if (opt.disabled) return; // skip disabled 995 996 const li = document.createElement('li'); 997 li.setAttribute('aria-hidden', 'false'); 998 999 const btn = document.createElement('button'); 1000 btn.type = 'button'; 1001 btn.setAttribute('role', 'option'); 1002 btn.setAttribute('aria-selected', 'false'); 1003 btn.dataset.value = opt.value; 1004 btn.textContent = (opt.textContent || '').trim(); 1005 1006 btn.id = `${uid}-opt-${i}`; 1007 1008 li.appendChild(btn); 1009 list.appendChild(li); 1010 optionItems.push(li); 1011 }); 1012 1013 const syncSelected = (value) => { 1014 Array.from(list.querySelectorAll('button[role="option"]')).forEach(btn => { 1015 const isSel = (btn.dataset.value ?? '') === (value ?? ''); 1016 btn.setAttribute('aria-selected', isSel ? 'true' : 'false'); 1017 btn.classList.toggle('is-selected', isSel); 1018 }); 1019 }; 1020 1021 const setRovingActive = (visibleBtns, idx) => { 1022 const btn = visibleBtns[idx]; 1023 if (!btn) return; 1024 1025 visibleBtns.forEach(b => b.classList.remove('is-active')); 1026 btn.classList.add('is-active'); 1027 1028 finder.setAttribute('aria-activedescendant', btn.id); 1029 btn.scrollIntoView({ block: 'nearest' }); 1030 }; 1031 1032 const closeAll = (exceptContainer) => { 1033 document.querySelectorAll('[component="select"].input-open').forEach(el => { 1034 if (el === exceptContainer) return; 1035 1036 el.classList.remove('input-open'); 1037 1038 const dd = el.querySelector('.component-select'); 1039 const sel = el.querySelector('select'); 1040 1041 if (dd) { 1042 dd.hidden = true; 1043 dd.setAttribute('aria-hidden', 'true'); 1044 dd.inert = true; 1045 } 1046 if (sel) sel.setAttribute('aria-expanded', 'false'); 1047 }); 1048 }; 1049 1050 const filter = () => { 1051 const query = finder.value.trim().toLowerCase(); 1052 1053 optionItems.forEach(li => { 1054 const btn = li.querySelector('button[role="option"]'); 1055 if (!btn) return; 1056 1057 const text = btn.textContent.trim().toLowerCase(); 1058 const isMatch = query === '' ? true : text.includes(query); 1059 1060 li.setAttribute('aria-hidden', isMatch ? 'false' : 'true'); 1061 }); 1062 1063 // set active descendant to first visible 1064 const first = optionItems.find(li => li.getAttribute('aria-hidden') === 'false'); 1065 if (!first) { 1066 finder.setAttribute('aria-activedescendant', ''); 1067 return; 1068 } 1069 const firstBtn = first.querySelector('button[role="option"]'); 1070 finder.setAttribute('aria-activedescendant', firstBtn?.id || ''); 1071 }; 1072 1073 const close = () => { 1074 dropdown.hidden = true; 1075 dropdown.setAttribute('aria-hidden', 'true'); 1076 dropdown.inert = true; 1077 1078 selectContainer.classList.remove('input-open'); 1079 select.setAttribute('aria-expanded', 'false'); 1080 select.dispatchEvent(new Event('blur', { bubbles: true })); 1081 }; 1082 1083 const open = () => { 1084 if (select.disabled) return; 1085 1086 closeAll(selectContainer); 1087 1088 dropdown.hidden = false; 1089 dropdown.setAttribute('aria-hidden', 'false'); 1090 dropdown.inert = false; 1091 1092 select.setAttribute('aria-expanded', 'true'); 1093 selectContainer.classList.add('input-open'); 1094 1095 // the value can have been set from outside since the dropdown was last open - the 1096 // dealer map picker writes to the select directly 1097 syncSelected(select.value); 1098 1099 // reset filter 1100 finder.value = ''; 1101 optionItems.forEach(li => li.setAttribute('aria-hidden', 'false')); 1102 filter(); 1103 1104 let preventReleaseClick = true; 1105 const removeFirstClick = (ev) => { 1106 if (!preventReleaseClick) return; 1107 preventReleaseClick = false;
1108 ev.preventDefault(); 1109 ev.stopPropagation(); 1110 document.removeEventListener('click', removeFirstClick, true); 1111 }; 1112 document.addEventListener('click', removeFirstClick, true); 1113 1114 // focus finder 1115 finder.focus({ preventScroll: true }); 1116 setTimeout(() => finder.focus({ preventScroll: true }), 0); 1117 }; 1118 1119 // --- events --- 1120 // Bind on the wrapper so taps work even when the native <select> 1121 // has pointer-events:none (set via CSS once enhanced â keeps iOS 1122 // Safari from popping its own picker over the styled dropdown). 1123 selectContainer.addEventListener('pointerdown', (e) => { 1124 if (e.pointerType && !['mouse', 'touch', 'pen'].includes(e.pointerType)) return; 1125 if (e.target instanceof Element && e.target.closest('.component-select')) return; 1126 e.preventDefault(); 1127 open(); 1128 }, { passive: false }); 1129 1130 select.addEventListener('keydown', (e) => { 1131 if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') { 1132 e.preventDefault(); 1133 open(); 1134 } 1135 }); 1136 1137 closeBtn?.addEventListener('click', (e) => { 1138 e.preventDefault(); 1139 close(); 1140 selectContainer.focus(); 1141 }); 1142 1143 document.addEventListener('pointerdown', (e) => { 1144 if (!selectContainer.classList.contains('input-open')) return; 1145 if (selectContainer.contains(e.target)) return; 1146 close(); 1147 }); 1148 1149 document.addEventListener('keydown', (e) => { 1150 if (!selectContainer.classList.contains('input-open')) return; 1151 if (e.key === 'Escape') { 1152 e.preventDefault(); 1153 close(); 1154 selectContainer.focus(); 1155 } 1156 }); 1157 1158 finder.addEventListener('input', filter); 1159 1160 clearBtn?.addEventListener('click', (e) => { 1161 e.preventDefault(); 1162 finder.value = ''; 1163 filter(); 1164 finder.focus(); 1165 }); 1166 1167 // choose option 1168 optionItems.forEach(li => { 1169 const btn = li.querySelector('button[role="option"]'); 1170 if (!btn) return; 1171 1172 btn.addEventListener('click', () => { 1173 const value = btn.dataset.value ?? ''; 1174 select.value = value; 1175 select.dispatchEvent(new Event('change', { bubbles: true })); 1176 1177 syncSelected(value); 1178 close(); 1179 selectContainer.focus(); 1180 }); 1181 }); 1182 1183 // keyboard navigation in finder 1184 finder.addEventListener('keydown', (e) => { 1185 const visibleBtns = Array.from(list.querySelectorAll('li[aria-hidden="false"] button[role="option"]')).filter(btn => !btn.disabled); 1186 1187 if (!visibleBtns.length) return; 1188 1189 const currentIdx = visibleBtns.findIndex(b => b.classList.contains('is-active')); 1190 let idx = currentIdx >= 0 ? currentIdx : 0; 1191 1192 switch (e.key) { 1193 case 'ArrowDown': 1194 e.preventDefault(); 1195 idx = (idx + 1) % visibleBtns.length; 1196 setRovingActive(visibleBtns, idx); 1197 break; 1198 case 'ArrowUp': 1199 e.preventDefault(); 1200 idx = (idx - 1 + visibleBtns.length) % visibleBtns.length; 1201 setRovingActive(visibleBtns, idx); 1202 break; 1203 case 'Home': 1204 e.preventDefault(); 1205 setRovingActive(visibleBtns, 0); 1206 break; 1207 case 'End': 1208 e.preventDefault(); 1209 setRovingActive(visibleBtns, visibleBtns.length - 1); 1210 break; 1211 case 'Enter': 1212 e.preventDefault(); 1213 (visibleBtns[idx] || visibleBtns[0]).click(); 1214 break; 1215 } 1216 }); 1217 1218 // Anything that sets the value from outside - the dealer map picker, a reset, another 1219 // script - announces it with a change event; the highlighted option follows it. 1220 select.addEventListener('change', () => syncSelected(select.value)); 1221 1222 // initial sync 1223 syncSelected(select.value); 1224 }, 1225 1226 1227 applyAdditionalJS(data) { 1228 if (data.hasOwnProperty('additionalJS') && typeof data['additionalJS'] === 'string') { 1229 document.body.insertAdjacentHTML('beforeend', data['additionalJS']); 1230 } 1231 1232 if (data.hasOwnProperty('conversionPixels') && Array.isArray(data['conversionPixels'])) { 1233 document.body.insertAdjacentHTML('beforeend', data['conversionPixels'].join('')); 1234 } 1235 } 1236}; 1237 1238 1239/** 1240 * Delegates click events for elements with custom attributes: 1241 * 1242 * 1. [target-toggle]: toggles a CSS class on the specified target. 1243 * - The attribute value is used to find the target by ID, class, or selector. 1244 * - Optional [target-toggle-class] specifies which class to toggle (defaults to "active"). 1245 * 1246 * 2. [target-scroll]: smoothly scrolls the page to the specified target. 1247 * - The attribute value is used to find the target by ID, class, or selector. 1248 * 1249 * 3. [target-dialog]: open specific dialog/modal. 1250 * - The attribute value is used to find the target by ID, class, or selector. 1251 * 1252 * 4. [close-dialog]: close closest dialog/modal. 1253 * 1254 * 5. [data-href*='/api/get-phone-number?']: show real phone number 1255 * 1256 * 6. [save-cookie]: set cookie value by specific params 1257 * 1258 * 7. [share-link]: use navigator.share() or copy to clipboard 1259 * 1260 * Any elements added to the DOM later (e.g. via AJAX) will also be handled. 1261 */ 1262 1263document.addEventListener('click', (e) => { 1264 1265 // target-toggle 1266 const toggleTrigger = e.target instanceof Element 1267 ? e.target.closest('[target-toggle]') 1268 : null; 1269 if (toggleTrigger) { 1270 e.preventDefault(); 1271 const sel = (toggleTrigger.getAttribute('target-toggle') || '').trim(); 1272 const cls = (toggleTrigger.getAttribute('target-toggle-class') || 'active').trim(); 1273 1274 // lookup by id, then class, then any selector 1275 let target = document.getElementById(sel) 1276 || document.querySelector(`.${sel}`) 1277 || document.querySelector(sel); 1278 1279 if (target) { 1280 target.classList.toggle(cls) 1281 1282 if (target.classList.contains('rules-info')) { 1283 target.scrollIntoView({ 1284 behavior: 'smooth', 1285 block: 'start' 1286 }); 1287 } 1288 } 1289 return; 1290 } 1291 1292 1293 // target-scroll 1294 const scrollTrigger = e.target instanceof Element 1295 ? e.target.closest('[target-scroll]') 1296 : null; 1297 if (scrollTrigger) { 1298 e.preventDefault(); 1299 const sel = (scrollTrigger.getAttribute('target-scroll') || '').trim(); 1300 1301 if (sel === 'top') { 1302 window.scrollTo({ top: 0, behavior: 'smooth' }); 1303 return; 1304 } 1305 1306 let targetEl = document.getElementById(sel) 1307 || document.querySelector(`.${sel}`) 1308 || document.querySelector(sel); 1309 1310 if (targetEl) { 1311 targetEl.scrollIntoView({behavior: 'smooth'}); 1312 } 1313 } 1314 1315 1316 // target-dialog 1317 const dialogTrigger = e.target instanceof Element 1318 ? e.target.closest('[target-dialog]') 1319 : null; 1320 if (dialogTrigger) { 1321 e.preventDefault(); 1322 1323 const dialog = (dialogTrigger.getAttribute('target-dialog') || '').trim(); 1324 const dialogData = helpers.getDialogData(dialogTrigger); 1325 1326 helpers.openDialogTemplate(dialog, dialogData); 1327 } 1328 1329 1330 // close-dialog 1331 const btnClose = e.target instanceof Element 1332 ? e.target.closest('button[close-dialog]') 1333 : null; 1334 if (btnClose) { 1335 e.preventDefault(); 1336 const dialog = btnClose.closest('dialog'); 1337 if (dialog instanceof HTMLDialogElement) { 1338 helpers.clearDialogTemplate(dialog); 1339 dialog.close(); 1340 } 1341 } 1342 1343 1344 // get-phone-number 1345 const link = e.target instanceof Element 1346 ? e.target.closest('button[data-href*="/api/phone-number?"]') 1347 : null; 1348 if (link) { 1349 e.preventDefault(); 1350 const href = link.getAttribute('data-href'); 1351 link.classList.add('loading'); 1352 1353 fetch(href, {headers: {'X-Requested-With': 'XMLHttpRequest'}}) 1354 .then(response => { 1355 if (!response.ok) { 1356 if (response.status === 404) { 1357 link.outerHTML = '---'; 1358 } 1359 throw new Error(response.status); 1360 } 1361 return response.json(); 1362 }) 1363 .then(html => { 1364 if (html.data.phone.data.phoneNumbersAsHtml.length) { 1365 link.outerHTML = html.data.phone.data.phoneNumbersAsHtml.trim(); 1366 } else { 1367 link.outerHTML = '---'; 1368 } 1369 }) 1370 .catch(err => { 1371 if (err.message !== '404') { 1372 console.error(err); 1373 } 1374 }); 1375 } 1376 1377 1378 // [save-cookie] 1379 const btnCookie = e.target instanceof Element 1380 ? e.target.closest('button[save-cookie]') 1381 : null; 1382 if (btnCookie) { 1383 e.preventDefault(); 1384 const cookieType = btnCookie.getAttribute('save-cookie').trim(); 1385 const cookieSettings = { 1386 all: [ 1387 {name: 'agree', value: true, days: 365}, 1388 { 1389 name: 'settings', value: [ 1390 {name: 'analysis', value: true},
1391 {name: 'efficiencyCookie', value: true}, 1392 {name: 'targetCookie', value: true}, 1393 ], days: 365 1394 }, 1395 ], 1396 form: [ 1397 {name: 'agree', value: true, days: 365}, 1398 { 1399 name: 'settings', value: [ 1400 {name: 'analysis', value: undefined}, 1401 {name: 'efficiencyCookie', value: undefined}, 1402 {name: 'targetCookie', value: undefined}, 1403 ], days: 365 1404 }, 1405 ] 1406 }; 1407 1408 if (cookieType == 'form') { 1409 const form = btnCookie.closest('form'); 1410 if (!form) return; 1411 1412 const checkboxes = Array.from(form.querySelectorAll('input[type="checkbox"][name]')); 1413 const setting = JSON.parse(JSON.stringify(cookieSettings.form[1])); // settings deep clone 1414 1415 setting.value.forEach(entry => { 1416 const checkbox = checkboxes.find(cb => cb.name === entry.name); 1417 if (checkbox) { 1418 entry.value = checkbox.checked; 1419 } 1420 }); 1421 helpers.setCookie(cookieSettings.form[0].name, JSON.stringify(cookieSettings.form[0].value), cookieSettings.form[0].days); 1422 helpers.setCookie(setting.name, JSON.stringify(setting.value), setting.days); 1423 } else { 1424 const setting = cookieSettings[cookieType]; 1425 if (setting) { 1426 setting.forEach(cookie => { 1427 helpers.setCookie(cookie.name, JSON.stringify(cookie.value), cookie.days); 1428 }) 1429 } 1430 } 1431 try { 1432 localStorage.setItem('agree', 'true'); 1433 } catch (e) {} 1434 1435 if (typeof gtag === 'function') { 1436 const consentUpdate = { 1437 analytics_storage: 'granted', 1438 ad_storage: 'granted', 1439 ad_user_data: 'granted', 1440 ad_personalization: 'granted', 1441 }; 1442 1443 if (cookieType === 'form') { 1444 const form = btnCookie.closest('form'); 1445 if (form) { 1446 const cb = (name) => form.querySelector(`input[type="checkbox"][name="${name}"]`); 1447 if (!cb('efficiencyCookie')?.checked) consentUpdate.analytics_storage = 'denied'; 1448 if (!cb('targetCookie')?.checked) { 1449 consentUpdate.ad_storage = 'denied'; 1450 consentUpdate.ad_user_data = 'denied'; 1451 consentUpdate.ad_personalization = 'denied'; 1452 } 1453 } 1454 } 1455 1456 gtag('consent', 'update', consentUpdate); 1457 } 1458 1459 location.reload(); 1460 } 1461 1462 1463 // share-link 1464 const shareBtn = e.target instanceof Element 1465 ? e.target.closest('button[share-link]') 1466 : null; 1467 if (shareBtn) { 1468 e.preventDefault(); 1469 1470 if (navigator.share) { 1471 try { 1472 navigator.share({ 1473 url: window.location.href, 1474 }); 1475 } catch (e) { 1476 helpers.copyToClipboard(window.location.href); 1477 } 1478 } else { 1479 helpers.copyToClipboard(window.location.href); 1480 } 1481 } 1482 1483 1484 // disabled-input & label click delegation 1485 const disabledInputLabel = e.target instanceof Element 1486 ? e.target.closest('label') 1487 : null; 1488 if (disabledInputLabel) { 1489 const disabledInput = disabledInputLabel.querySelector('input[aria-disabled="true"]'); 1490 if (disabledInput) { 1491 e.preventDefault(); 1492 e.stopImmediatePropagation(); 1493 } 1494 } 1495 1496}); 1497 1498 1499window.SP = window.SP || {}; 1500window.SP.helpers = helpers;
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.