PageSourceSearch

https://pewneauto.pl/assets/common/js/helpers-RCbCIFu.js

js pewneauto.pl collected 2026-10-08 04:44:28 UTC 54,381 bytes, 1,500 lines download raw bytes

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.