PageSourceSearch

https://pewneauto.pl/assets/common/js/components-3kHyZEE.js

js pewneauto.pl collected 2026-10-08 04:44:29 UTC 26,835 bytes, 829 lines download raw bytes

1/**
2 * Provides accessible UI components: tabs, accordions, content trimming, and dropdowns.
3 */
4
5import { helpers } from './helpers.js';
6import { settings } from './settings.js';
7import { translations } from './translations.js';
8
9
10export const components = (() => {
11  const { qs, qsa, setAriaStates } = helpers;
12
13
14  /**
15   * Initialize all tab components.
16   * Finds each [component="tabs"] once.
17   */
18  function initTabs() {
19    qsa(document, '[component="tabs"]').forEach(tabList => {
20      if (tabList.dataset.tabsInitialized) return;
21      tabList.dataset.tabsInitialized = 'true';
22
23      const tabs = Array.from(qsa(tabList, '[role="tab"]'));
24      const panelsContainer = qs(tabList.parentElement, '[component="tabs-content"]');
25      if (!panelsContainer) return;
26      const panels = Array.from(qsa(panelsContainer, '[role="tabpanel"]'));
27
28      function activateTab(tab) {
29        tabs.forEach(t => {
30          setAriaStates(t, { selected: false });
31          t.tabIndex = -1;
32        });
33        panels.forEach(p => setAriaStates(p, { hidden: true }));
34
35        setAriaStates(tab, { selected: true });
36        tab.tabIndex = 0;
37        const panel = qs(panelsContainer, `#${tab.getAttribute('aria-controls')}`);
38        if (panel) setAriaStates(panel, { hidden: false });
39        tab.focus();
40      }
41
42      tabs.forEach((tab, idx) => {
43        // ensure each tab has an ID
44        if (!tab.id) tab.id = `tab-${idx}-${Date.now()}`;
45        // ensure its panel has that ID
46        const panelId = tab.getAttribute('aria-controls');
47        const panelEl = qs(panelsContainer, `#${panelId}`);
48        if (panelEl && !panelEl.id) panelEl.id = panelId;
49
50        tab.addEventListener('click', () => activateTab(tab));
51        tab.addEventListener('keydown', e => {
52          let newIdx = null;
53          switch (e.key) {
54            case 'ArrowLeft':  newIdx = idx - 1; break;
55            case 'ArrowRight': newIdx = idx + 1; break;
56            case 'Home':       newIdx = 0;       break;
57            case 'End':        newIdx = tabs.length - 1; break;
58            default:           return;
59          }
60          e.preventDefault();
61          if (newIdx < 0) newIdx = tabs.length - 1;
62          if (newIdx >= tabs.length) newIdx = 0;
63          activateTab(tabs[newIdx]);
64        });
65      });
66    });
67  }
68
69  /**
70   * Initialize all accordion components.
71   * Finds each [component="accordion"] once.
72   */
73  function initAccordion() {
74    qsa(document, '[component="accordion"]').forEach(acc => {
75      if (acc.dataset.accordionInitialized) return;
76      acc.dataset.accordionInitialized = 'true';
77
78      const isSwitch = acc.getAttribute('accordion') === 'switch';
79      const groups = Array.from(qsa(acc, 'section'));
80
81      groups.forEach((group, idx) => {
82        const header = qs(group, 'h3 button');
83        const panel  = qs(group, '[role="region"]');
84        if (!header || !panel) return;
85
86        // ensure IDs for aria-controls / aria-labelledby
87        if (!header.id) header.id = `accordion-header-${idx}-${Date.now()}`;
88        if (!panel.id)  panel.id  = `accordion-panel-${idx}-${Date.now()}`;
89        setAriaStates(header, { controls: panel.id });
90
91        header.addEventListener('click', () => {
92          if (isSwitch) {
93            // close all, open this
94            groups.forEach(g2 => {
95              const h2 = qs(g2, 'h3 button');
96              const p2 = qs(g2, '[role="region"]');
97              setAriaStates(h2, { expanded: false });
98              h2.tabIndex = -1;
99              setAriaStates(p2, { hidden: true });
100            });
101            setAriaStates(header, { expanded: true });
102            header.tabIndex = 0;
103            setAriaStates(panel, { hidden: false });
104          } else {
105            // toggle just this
106            const expanded = header.getAttribute('aria-expanded') === 'true';
107            setAriaStates(header, { expanded: !expanded });
108            header.tabIndex = expanded ? -1 : 0;
109            setAriaStates(panel, { hidden: expanded });
110          }
111          header.focus();
112        });
113
114        header.addEventListener('keydown', e => {
115          let newIdx = null;
116          switch (e.key) {
117            case 'ArrowUp':    newIdx = idx - 1;        break;
118            case 'ArrowDown':  newIdx = idx + 1;        break;
119            case 'Home':       newIdx = 0;              break;
120            case 'End':        newIdx = groups.length-1;break;
121            case 'Enter':
122            case ' ':          e.preventDefault(); header.click(); return;
123            default:           return;
124          }
125          e.preventDefault();
126          if (newIdx < 0) newIdx = groups.length - 1;
127          if (newIdx >= groups.length) newIdx = 0;
128          qs(groups[newIdx], 'h3 button').focus();
129        });
130      });
131    });
132  }
133
134  /**
135   * Initialize all trimming components.
136   * Finds each [component="trim"] once.
137   */
138  function initTrims() {
139    qsa(document, '[component="trim"]').forEach((wrapper, idx) => {
140      if (wrapper.dataset.trimInitialized) return;
141      wrapper.dataset.trimInitialized = 'true';
142
143      const content = qs(wrapper, '[trim]');
144      if (!content) return;
145
146      const limitAttr = content.getAttribute('trim').trim();
147      const limit = parseFloat(limitAttr);
148      const fullHeight = content.scrollHeight;
149      if (fullHeight <= limit) return;
150
151      wrapper.classList.add('content-trimmed');
152      content.style.maxHeight = limitAttr;
153
154      if (!content.id) content.id = `trim-content-${idx}-${Date.now()}`;
155
156      const btn = document.createElement('button');
157      btn.type = 'button';
158      btn.className = 'btn btn--sm btn--wide';
159      setAriaStates(btn, { expanded: false, controls: content.id });
160      const labelExpand = wrapper.dataset.labelExpand || translations.component.seeFullDescription;
161      const labelCollapse = wrapper.dataset.labelCollapse || translations.component.collapse;
162
163      const span = document.createElement('span');
164      span.textContent = labelExpand;
165      btn.appendChild(span);
166
167      btn.addEventListener('click', () => {
168        const expanded = btn.getAttribute('aria-expanded') === 'true';
169        if (expanded) {
170          // collapse
171          wrapper.classList.add('content-trimmed');
172          content.style.maxHeight = limitAttr;
173          span.textContent = labelExpand;
174          setAriaStates(btn, { expanded: false });
175        } else {
176          // expand
177          wrapper.classList.remove('content-trimmed');
178          content.style.maxHeight = '';
179          span.textContent = labelCollapse;
180          setAriaStates(btn, { expanded: true });
181        }
182      });
183
184      content.insertAdjacentElement('afterend', btn);
185    });
186  }
187
188
189  /**
190   * Initialize all galleries components.
191   * Finds each [component="gallery-*"] once.
192   */
193  function initVdpGallery() {
194    const galleryItems = qs(document, '[component="gallery-items"]');
195    const galleryWindow = qs(document, '[component="gallery-window"]');
196
197    if( !galleryWindow ) return;
198
199    const preview = qs(galleryWindow, '[active-element]');
200    if (!preview) return;
201
202    const thumbs = galleryItems ? qsa(galleryItems, '[gallery]') : [];
203
204    function isVc360Thumb(thumbEl) {
205      const img = thumbEl.querySelector('img');
206      return !!img?.getAttribute('data-iframe-src');
207    }
208
209    // --- FULL GALLERY (MODAL) SETUP ------------------------------------------
210
211    function buildFullItems() {
212      // 1) normal images from thumbs
213      const fromThumbs = thumbs
214        .filter(t => !isVc360Thumb(t))
215        .map(t => {
216          const img = t.querySelector('img');
217          return {
218            galleryIndex: parseInt(t.getAttribute('gallery'), 10),
219            src: img?.getAttribute('data-img-src') || img?.getAttribute('src'),
220            alt: img?.getAttribute('alt') || ''
221          };
222        })
223        .filter(x => !!x.src);
224
225      if (fromThumbs.length) return fromThumbs;
226
227      // 2) single image in preview
228      const previewImg = preview.querySelector('img');
229      if (previewImg) {
230        const src = previewImg.getAttribute('data-img-src') || previewImg.currentSrc || previewImg.getAttribute('src');
231        if (src) {
232          return [{
233            galleryIndex: parseInt(preview.getAttribute('active-element'), 10) || 1,
234            src,
235            alt: previewImg.getAttribute('alt') || ''
236          }];
237        }
238      }
239
240      // 3) nothing to show
241      return [];
242    }
243
244    let fullItems = buildFullItems();
245
246    function rebuildMappings() {
247      fullItems = buildFullItems();
248
249      return new Map(fullItems.map((it, idx) => [it.galleryIndex, idx]));
250    }
251
252    let fullIndexByGalleryIndex = rebuildMappings();
253
254    // helper: actual index to start from in full gallery modal
255    function resolveFullStartIndex() {
256      const activeGalleryIndex = parseInt(preview.getAttribute('active-element'), 10) || 1;
257      const mapped = fullIndexByGalleryIndex.get(activeGalleryIndex);
258
259      // If the active element is a vc360 (no mapping) -> start from the first photo
260      return typeof mapped === 'number' ? mapped : 0;
261    }
262
263    // create <dialog> (only once)
264    let dlg = document.querySelector('.vdp-gallery-modal');
265    if (!dlg) {
266      dlg = document.createElement('dialog');
267      dlg.className = 'vdp-gallery-modal';
268      dlg.setAttribute('aria-label', translations.galleryModal.ariaLabel);
269
270      dlg.innerHTML = `
271        <div class="vdp-gallery-modal__panel" role="document">
272          <button type="button" class="vdp-gallery-modal__close btn btn--link" aria-label="${translations.galleryModal.close}"><i class="w-icon-cross2" aria-hidden="true"></i></button>
273
274          <button type="button" class="vdp-gallery-modal__nav vdp-gallery-modal__nav--prev btn btn--circle btn--white btn--shadow btn--sm" aria-label="${translations.galleryModal.prevSlide}"><i class="w-icon-chevron-left" aria-hidden="true"></i></button>
275          <figure class="vdp-gallery-modal__figure">
276            <img draggable="false" class="vdp-gallery-modal__img" alt="">
277            <figcaption class="vdp-gallery-modal__counter" aria-live="polite"></figcaption>
278          </figure>
279          <button type="button" class="vdp-gallery-modal__nav vdp-gallery-modal__nav--next btn btn--circle btn--white btn--shadow btn--sm" aria-label="${translations.galleryModal.nextSlide}"><i class="w-icon-chevron-right" aria-hidden="true"></i></button>
280
281          <ul class="vdp-gallery-modal__thumbs" role="list"></ul>
282        </div>
283      `;
284
285      document.body.appendChild(dlg);
286    }
287
288    const modalImg = dlg.querySelector('.vdp-gallery-modal__img');
289    const modalCounter = dlg.querySelector('.vdp-gallery-modal__counter');
290    const modalThumbs = dlg.querySelector('.vdp-gallery-modal__thumbs');
291    const modalFigure = dlg.querySelector('.vdp-gallery-modal__figure');
292
293    let modalIndex = 0;
294
295    function renderModalThumbs() {
296      if (!modalThumbs) return;
297
298      modalThumbs.innerHTML = fullItems
299        .map((it, idx) => `
300          <li>
301            <button type="button"
302              class="vdp-gallery-modal__thumb ${idx === modalIndex ? 'is-active' : ''}"
303              data-full-index="${idx}"
304              aria-label="${translations.galleryModal.goToSlide}: ${idx + 1}">
305              <img draggable="false" src="${it.src}" alt="">
306            </button>
307          </li>
308        `)
309        .join('');
310
311      modalThumbs.parentElement?.classList.toggle('has-no-thumbs', fullItems.length <= 1);
312    }
313
314    function setModalActive(idx) {
315      if (!fullItems.length) return;
316
317      modalIndex = (idx + fullItems.length) % fullItems.length;
318      const item = fullItems[modalIndex];
319
320      if (modalImg) {
321        modalImg.src = item.src;
322        modalImg.alt = item.alt || '';
323      }
324
325      if (modalCounter) {
326        modalCounter.textContent = `${modalIndex + 1} / ${fullItems.length}`;
327      }
328
329      // update active thumb classes
330      if (modalThumbs) {
331        let activeThumb = null;
332
333        for (const btn of modalThumbs.querySelectorAll('.vdp-gallery-modal__thumb')) {
334          const isActive = parseInt(btn.dataset.fullIndex, 10) === modalIndex;
335          btn.classList.toggle('is-active', isActive);
336          if (isActive) activeThumb = btn;
337        }
338
339        activeThumb?.scrollIntoView({
340          behavior: 'smooth',
341          block: 'nearest',
342          inline: 'start'
343        });
344      }
345    }
346
347    function openModalAt(idx) {
348      if (!fullItems.length) return;
349
350      // set start
351      modalIndex = Math.max(0, Math.min(idx, fullItems.length - 1));
352
353      renderModalThumbs();
354      setModalActive(modalIndex);
355      dlg.showModal();
356    }
357
358    function closeModal() {
359      if (dlg.open) dlg.close();
360    }
361
362    // close button
363    dlg.querySelector('.vdp-gallery-modal__close')?.addEventListener('click', closeModal);
364
365    // bind backdrop click to close
366    helpers.bindBackdropClickClose(dlg);
367
368    // nav buttons
369    dlg.querySelector('.vdp-gallery-modal__nav--prev')?.addEventListener('click', () => setModalActive(modalIndex - 1));
370    dlg.querySelector('.vdp-gallery-modal__nav--next')?.addEventListener('click', () => setModalActive(modalIndex + 1));
371
372    // swipe support
373    helpers.attachSwipe(modalFigure);
374    modalFigure.addEventListener('swipeleft', () => setModalActive(modalIndex + 1));
375    modalFigure.addEventListener('swiperight', () => setModalActive(modalIndex - 1));
376    dlg.addEventListener('close', () => detachSwipe());
377
378
379    // thumb click
380    modalThumbs?.addEventListener('click', (e) => {
381      const btn = e.target.closest('.vdp-gallery-modal__thumb');
382      if (!btn) return;
383      const idx = parseInt(btn.dataset.fullIndex, 10);
384      if (!isNaN(idx)) setModalActive(idx);
385    });
386
387    // keyboard arrows inside modal
388    dlg.addEventListener('keydown', (e) => {
389      if (e.key === 'ArrowLeft') { e.preventDefault(); setModalActive(modalIndex - 1); }
390      if (e.key === 'ArrowRight') { e.preventDefault(); setModalActive(modalIndex + 1); }
391    });
392
393    function handlePreviewActivate(e) {
394      if (e.type === 'keydown') {
395        if (e.key !== 'Enter' && e.key !== ' ') return;
396        e.preventDefault(); // prevent space scrolling
397      }
398
399      // if currently showing vc360 → do not open modal
400      if (preview.querySelector('iframe')) return;
401
402      openModalAt(resolveFullStartIndex());
403    }
404
405    preview.addEventListener('click', handlePreviewActivate);
406    preview.addEventListener('keydown', handlePreviewActivate);
407
408    // --- END FULL GALLERY (MODAL) SETUP -------------------------------------
409
410    // click handler
411    function setGalleryImg(el) {
412      const value = el.getAttribute('gallery');
413
414      if (!value) return;
415
416      if (!isNaN(value)) {
417        // === number ===
418        const img = el.querySelector('img');
419        if (!img) return;
420
421        // set img/iframe in 'preview'
422        preview.innerHTML = '';
423
424        const imgSrc = img.getAttribute('data-img-src');
425        const iframeSrc = img.getAttribute('data-iframe-src');
426
427        let newEl;
428
429        if (imgSrc) {
430          newEl = document.createElement('img');
431          newEl.src = imgSrc;
432          newEl.alt = img.getAttribute('alt')
433        } else if (iframeSrc) {
434          newEl = document.createElement('iframe');
435          newEl.src = iframeSrc;
436          newEl.setAttribute('allowfullscreen', 'true');
437        }
438
439        if (newEl) {
440          preview.appendChild(newEl);
441        }
442
443        // set active-element
444        preview.setAttribute('active-element', value);
445
446        // aria-selected update
447        thumbs.forEach(t => t.setAttribute('aria-selected', 'false'));
448        el.setAttribute('aria-selected', 'true');
449
450        // scroll to active element in thumbs
451        el.scrollIntoView({
452          behavior: 'smooth',
453          block: 'nearest',
454          inline: 'start'
455        });
456      } else if (value === 'prev' || value === 'next') {
457        // === prev/next ===
458        const currentIndex = parseInt(preview.getAttribute('active-element'), 10) || 1;
459        let newIndex;
460
461        if (value === 'prev') {
462          newIndex = currentIndex > 1 ? currentIndex - 1 : thumbs.length;
463        } else {
464          newIndex = currentIndex < thumbs.length ? currentIndex + 1 : 1;
465        }
466
467        const newTarget = thumbs.find(t => t.getAttribute('gallery') == newIndex);
468        if (newTarget) newTarget.click(); // click on thumb
469      }
470    }
471    if (galleryItems) {
472      galleryItems.addEventListener('keydown', (e) => {
473        const items = Array.from(galleryItems.querySelectorAll('li'));
474        const current = document.activeElement;
475        const index = items.indexOf(current);
476
477        let newIndex = index;
478
479        switch (e.key) {
480          case 'ArrowRight':
481            newIndex = (index + 1) % items.length;
482            break;
483
484          case 'ArrowLeft':
485            newIndex = (index - 1 + items.length) % items.length;
486            break;
487
488          case 'Home':
489            newIndex = 0;
490            break;
491
492          case 'End':
493            newIndex = items.length - 1;
494            break;
495          
496          default:
497            return;
498        }
499
500        e.preventDefault();
501
502        items.forEach((el, i) => el.tabIndex = i === newIndex ? 0 : -1);
503        items[newIndex].focus();
504        items[newIndex].click();
505
506      });
507    };
508
509    qsa(galleryItems, '[gallery]').forEach(btn => {
510      btn.addEventListener('click', () => setGalleryImg(btn));
511    });
512    qsa(galleryWindow, '[gallery]').forEach(btn => {
513      btn.addEventListener('click', () => setGalleryImg(btn));
514    });
515  }
516
517  /**
518   * Initialize all dropdown components.
519   * Finds each [component="dropdown"] once.
520   */
521  function initDropdown() {
522    qsa(document, '[component="dropdown"]').forEach(dropdown => {
523      if (dropdown.dataset.dropdownInitialized) return;
524      dropdown.dataset.dropdownInitialized = 'true';
525
526      const btn = qs(dropdown, 'button');
527      const panel = qs(dropdown, 'ul[role="listbox"]');
528      if (!btn || !panel) return;
529
530      let outsideClickListener = null;
531      btn.addEventListener('click', e => {
532        e.preventDefault();
533        const isOpen = btn.classList.toggle('open');
534        setAriaStates(btn, { expanded: isOpen });
535
536        if (isOpen) {
537          // close on outside click
538          outsideClickListener = evt => {
539            if (!dropdown.contains(evt.target)) {
540              btn.classList.remove('open');
541              setAriaStates(btn, { expanded: false });
542              document.removeEventListener('click', outsideClickListener);
543            }
544          };
545          document.addEventListener('click', outsideClickListener);
546        } else if (outsideClickListener) {
547          document.removeEventListener('click', outsideClickListener);
548        }
549      });
550    });
551  }
552
553
554  /**
555   * Initialize all custom select (with finder).
556   * Finds each [component="select"] once.
557   */
558  function initSelect() {
559    qsa(document, '[component="select"]').forEach(selectContainer => {
560      if (selectContainer.dataset.selectInitialized) return;
561      selectContainer.dataset.selectInitialized = 'true';
562
563      helpers.enhanceSelect(selectContainer);
564
565    });
566  }
567
568
569  /**
570   * Initialize all dropzone components.
571   * Finds each [component="dropzone"] once.
572   */
573  function initDropZone() {
574    qsa(document, '[component="dropzone"]').forEach(drop => {
575      if (drop.dataset.dropzoneInitialized) return;
576      drop.dataset.dropzoneInitialized = 'true';
577
578      const input = qs(drop, '.drop-zone__field__input');
579      const list = qs(drop, '.drop-zone__list');
580      const externalBtn = qs(drop, '.drop-zone__actions [type="button"]');
581      const submitBtn = qs(drop, '.drop-zone__actions [type="submit"]');
582      if (!input || !list) return;
583
584      const MAX_FILES = Number(drop.dataset.maxFiles) || settings.dropzone.maxFile || 5;
585      const ALLOWED_TYPES = settings.dropzone.allowedFile || ['image/jpeg'];
586      // Texts come from the template so they follow the page language.
587      const TEXTS = helpers.dropzoneTexts(drop);
588      let files = [];
589
590      drop.dropzoneFiles = files;
591
592      /** image compression */
593      async function compressImage(file, quality = 0.8, maxWidth = 1920) {
594        return new Promise((resolve) => {
595          const img = new Image();
596          img.onload = () => {
597            const shouldResize = img.width > 2500 || file.size > 3 * 1024 * 1024;
598            if (!shouldResize || settings.dropzone.compress === false) return resolve(file); // nothing to do
599
600            const scale = Math.min(1, maxWidth / img.width);
601            const canvas = document.createElement('canvas');
602            canvas.width = img.width * scale;
603            canvas.height = img.height * scale;
604            const ctx = canvas.getContext('2d');
605            ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
606
607            canvas.toBlob(
608              (blob) => {
609                if (!blob) return resolve(file);
610                const compressed = new File([blob], file.name.replace(/\.\w+$/, '.jpg'), {
611                  type: 'image/jpeg',
612                  // lastModified: Date.now()
613                });
614                resolve(compressed);
615              },
616              'image/jpeg',
617              quality
618            );
619          };
620          img.onerror = () => resolve(file);
621          img.src = URL.createObjectURL(file);
622        });
623      }
624
625      /** Renders thumbnails list */
626      function renderList() {
627
628        list.innerHTML = '';
629        files.forEach((file, idx) => {
630          const li = document.createElement('div');
631          li.className = 'drop-zone__list__item';
632
633          const img = document.createElement('img');
634          img.src = URL.createObjectURL(file);
635          img.alt = file.name;
636
637          const info = document.createElement('span');
638          info.textContent = file.name;
639
640          const removeBtn = document.createElement('button');
641          removeBtn.type = 'button';
642          removeBtn.ariaLabel = TEXTS.removeFile;
643          removeBtn.className = 'drop-zone__remove';
644          removeBtn.innerHTML = '<i class="w-icon-cross" aria-hidden="true"></i>';
645          removeBtn.addEventListener('click', () => {
646            files.splice(idx, 1);
647            renderList();
648            updateState();
649          });
650
651          li.appendChild(img);
652          li.appendChild(info);
653          li.appendChild(removeBtn);
654          list.appendChild(li);
655        });
656        drop.dropzoneFiles = files;
657      }
658
659      /** Updates UI state (limit / button) */
660      function updateState() {
661        const full = files.length >= MAX_FILES;
662        input.disabled = full;
663        if (submitBtn) submitBtn.disabled = files.length === 0;
664        if (externalBtn) externalBtn.disabled = files.length === MAX_FILES;
665      }
666
667      /** Handles adding files */
668      async function addFiles(newFiles) {
669        const all = Array.from(newFiles);
670
671        // invalid type
672        const invalidType = all.filter(f => !ALLOWED_TYPES.includes(f.type));
673        if (invalidType.length > 0) {
674          helpers.showToaster(TEXTS.notAllowedText, 'error');
675        }
676
677        // valid files
678        const valid = all.filter(f => ALLOWED_TYPES.includes(f.type));
679        const availableSlots = MAX_FILES - files.length;
680
681        if (valid.length > availableSlots) {
682          helpers.showToaster(TEXTS.maxFileText, 'warning');
683        }
684
685        // compress if needed
686        const compressedPromises = valid.slice(0, availableSlots).map(async (file) => {
687          const compressed = await compressImage(file);
688          if (compressed !== file) {
689            console.log(`${file.name} został skompresowany przed wysyłką.`);
690          }
691          return compressed;
692        });
693
694        const finalFiles = await Promise.all(compressedPromises);
695        files = files.concat(finalFiles);
696        drop.dropzoneFiles = files;
697
698        renderList();
699        updateState();
700      }
701
702      /** Event bindings */
703      drop.addEventListener('dragenter', e => {
704        e.preventDefault();
705        drop.classList.add('is-dragover');
706      });
707
708      drop.addEventListener('dragover', e => e.preventDefault());
709
710      drop.addEventListener('dragleave', e => {
711        if (e.target === drop) drop.classList.remove('is-dragover');
712      });
713
714      drop.addEventListener('drop', e => {
715        e.preventDefault();
716        drop.classList.remove('is-dragover');
717        if (e.dataTransfer?.files?.length) {
718          addFiles(e.dataTransfer.files);
719        }
720      });
721
722      qs(drop, '.drop-zone__field')?.addEventListener('click', () => {
723        if (!input.disabled) input.click();
724      });
725
726      externalBtn?.addEventListener('click', () => {
727        if (!input.disabled) input.click();
728      });
729
730      input.addEventListener('change', e => {
731        if (e.target.files.length) {
732          addFiles(e.target.files);
733          input.value = ''; // reset
734        }
735      });
736    });
737  }
738
739  /**
740   * Initialize all list support for keyboard components.
741   * Finds each [component="list"] once.
742   */
743  function initListKeyboardSupport() {
744    qsa(document, '[component="list"]').forEach(list => {
745      if (list.dataset.listInitialized) return;
746
747      list.dataset.listInitialized = 'true';
748      const isListbox = list.matches('[role="listbox"]') ?? false;
749
750      list.addEventListener('keydown', (e) => {
751        const items = Array.from(list.querySelectorAll('li'));
752        if (!items.length) return;
753
754        const current = document.activeElement;
755        const index = items.indexOf(current);
756
757        if (index === -1) index = 0;
758
759        let newIndex = index;
760
761        switch (e.key) {
762          case 'ArrowDown':
763          case 'ArrowRight':
764            newIndex = (index + 1) % items.length;
765            break;
766
767          case 'ArrowUp':
768          case 'ArrowLeft':
769            newIndex = (index - 1 + items.length) % items.length;
770            break;
771
772          case 'Home':
773            newIndex = 0;
774            break;
775
776          case 'End':
777            newIndex = items.length - 1;
778            break;
779
780          case 'Enter':
781            if( isListbox ){
782              current.click();
783            }
784            break;
785
786          default:
787            return;
788        }
789
790        e.preventDefault();
791
792        items.forEach((el, i) => el.tabIndex = i === newIndex ? 0 : -1);
793        items[newIndex].focus();
794      });
795
796    });
797  }
798
799
800  /**
801   * Initialize all components.
802   */
803  function initAll() {
804    initTabs();
805    initAccordion();
806    initTrims();
807    initVdpGallery();
808    initDropdown();
809    initSelect();
810    initDropZone();
811    initListKeyboardSupport();
812  }
813
814  // Public API
815  return {
816    initAll,
817    initTabs,
818    initAccordion,
819    initTrims,
820    initVdpGallery,
821    initDropdown,
822    initSelect,
823    initDropZone,
824    initListKeyboardSupport,
825  };
826})();
827
828window.SP = window.SP || {};
829window.SP.components = components;

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.