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.