1(function() { 2 'use strict'; 3 4 const containers = document.querySelectorAll('.lothian-safer-edinburgh-carousel-posts-container'); 5 if (containers.length === 0) return; 6 7 const CONFIG = { 8 scrollSpeed: 0.5, 9 pauseOnHover: true, 10 }; 11 12 let lightboxOpen = false; 13 let lightboxTriggerEl = null; 14 let lightboxSourceCarousel = null; 15 16 // ===== LIGHTBOX FUNCTIONALITY (shared across all carousels) ===== 17 function createLightbox() { 18 19 const modal = document.createElement('div'); 20 modal.className = 'lse-carousel-lightbox'; 21 modal.innerHTML = ` 22 <div class="lse-carousel-lightbox-overlay"></div> 23 <div class="lse-carousel-lightbox-content" role="dialog" aria-modal="true" aria-label="Image preview"> 24 <button class="lse-carousel-lightbox-close" aria-label="Close lightbox"> 25 <span aria-hidden="true">×</span> 26 </button> 27 <img class="lse-carousel-lightbox-image" src="" alt="" /> 28 <div class="lse-carousel-lightbox-title"></div> 29 </div> 30 `; 31 document.body.appendChild(modal); 32 return modal; 33 34 } 35 36 const lightbox = createLightbox(); 37 const lightboxImage = lightbox.querySelector('.lse-carousel-lightbox-image'); 38 const lightboxTitle = lightbox.querySelector('.lse-carousel-lightbox-title'); 39 const closeBtn = lightbox.querySelector('.lse-carousel-lightbox-close'); 40 const overlay = lightbox.querySelector('.lse-carousel-lightbox-overlay'); 41 42 let lightboxLoadToken = 0; 43 44 function openLightbox(url, title, triggerEl) { 45 46 const token = ++lightboxLoadToken; // guards against rapid successive clicks 47 48 lightboxTriggerEl = triggerEl || null; 49 lightboxSourceCarousel = triggerEl ? triggerEl.closest('.lothian-safer-edinburgh-carousel-posts-container') : null; 50 51 lightboxImage.classList.add('lse-carousel-lightbox-image-loading'); 52 lightboxTitle.textContent = title || ''; 53 lightbox.classList.add('lse-carousel-lightbox-active'); 54 document.body.style.overflow = 'hidden'; 55 lightboxOpen = true; 56 57 closeBtn.focus(); 58 59 const preload = new Image(); 60 61 preload.onload = () => { 62 if (token !== lightboxLoadToken) return; // a newer image was requested while this one was loading 63 lightboxImage.src = url; 64 lightboxImage.alt = title || ''; 65 lightboxImage.classList.remove('lse-carousel-lightbox-image-loading'); 66 }; 67 68 preload.src = url; 69 70 } 71 72 // Trap focus within the lightbox when it's open 73 lightbox.addEventListener('keydown', (e) => { 74 75 if (e.key !== 'Tab' || !lightboxOpen) return; 76 77 // The close button is the only focusable element in this modal, 78 // so Tab and Shift+Tab both just keep focus pinned there. 79 e.preventDefault(); 80 closeBtn.focus(); 81 82 }); 83 84 function closeLightbox() { 85 86 lightbox.classList.remove('lse-carousel-lightbox-active'); 87 document.body.style.overflow = ''; 88 lightboxOpen = false; 89 90 if (lightboxTriggerEl) { 91 lightboxTriggerEl.focus(); 92 lightboxTriggerEl = null; 93 } 94 95 if (lightboxSourceCarousel) { 96 lightboxSourceCarousel.dispatchEvent(new CustomEvent('lse-carousel-lightbox-closed')); 97 lightboxSourceCarousel = null; 98 } 99 100 } 101 102 closeBtn.addEventListener('click', closeLightbox); 103 overlay.addEventListener('click', closeLightbox); 104 105 document.addEventListener('keydown', (e) => { 106 if ( 107 e.key === 'Escape' 108 && 109 lightbox.classList.contains('lse-carousel-lightbox-active') 110 ) { 111 closeLightbox(); 112 } 113 }); 114 115 // ===== PER-CAROUSEL INITIALISATION ===== 116 function initCarousel(container) { 117 118 const items = container.querySelectorAll('.lothian-safer-edinburgh-carousel-post-card'); 119 if (items.length === 0) return; 120 121 let isAutoPlaying = true; 122 let animationFrameId = null; 123 124 const totalItems = items.length; 125 const itemsPerSet = totalItems / 2; 126 127 // Cached layout measurements â recalculated on resize rather than every frame 128 let itemWidthWithGap = 0; 129 let resetPoint = 0; 130 131 function measure() { 132 const itemWidth = items[0].offsetWidth; 133 const gap = parseFloat(window.getComputedStyle(container).gap) || 0; 134 itemWidthWithGap = itemWidth + gap; 135 resetPoint = itemWidthWithGap * itemsPerSet; 136 } 137 138 measure(); 139 window.addEventListener('resize', measure); 140 141 // ===== IMAGE BUTTON HANDLERS (scoped to this container) ===== 142 // Native <button> handles Enter/Space activation automatically â click covers both. 143 container.querySelectorAll('.lothian-safer-edinburgh-carousel-post-image-btn').forEach((btn) => { 144 145 btn.addEventListener('click', () => { 146 const url = btn.dataset.lightboxUrl; 147 if (url) { 148 openLightbox(url, btn.dataset.lightboxTitle, btn); 149 } 150 }); 151 152 // Auto-scroll can carry the carousel on before Tab reaches this 153 // button, leaving the focused item out of view â bring it back 154 // into the visible area whenever it receives keyboard focus 155 btn.addEventListener('focus', () => { 156 btn.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); 157 }); 158 159 }); 160 161 // ===== AUTO-SCROLL ===== 162 function autoScroll() { 163 164 if ( 165 isAutoPlaying 166 && 167 !lightboxOpen 168 ) { 169 170 container.scrollLeft += CONFIG.scrollSpeed; 171 172 if (container.scrollLeft >= resetPoint - 10) { 173 container.scrollLeft = 0; 174 } 175 176 } 177 178 animationFrameId = requestAnimationFrame(autoScroll); 179 } 180 181 // ===== PAUSE ON HOVER / KEYBOARD FOCUS ===== 182 if (CONFIG.pauseOnHover) { 183 container.addEventListener('mouseenter', () => { isAutoPlaying = false; }); 184 container.addEventListener('mouseleave', () => { isAutoPlaying = true; }); 185 } 186
187 // Pause for as long as focus is anywhere inside the carousel â 188 // covers the Tab-to-reach-a-button flow, not just arrow-key scrolling 189 container.addEventListener('focusin', () => { isAutoPlaying = false; }); 190 191 container.addEventListener('focusout', (e) => { 192 193 if (!container.contains(e.relatedTarget)) { 194 isAutoPlaying = true; 195 } 196 197 }); 198 199 container.addEventListener('lse-carousel-lightbox-closed', () => { 200 isAutoPlaying = true; 201 }); 202 203 // ===== KEYBOARD NAVIGATION (scoped to this carousel via bubbling) ===== 204 // Only real (non-duplicate) buttons are focusable â see tabindex="-1" 205 // on duplicate items in the PHP template 206 const focusableButtons = Array.from( 207 container.querySelectorAll('.lothian-safer-edinburgh-carousel-post-image-btn:not([tabindex="-1"])') 208 ); 209 210 container.addEventListener('keydown', (e) => { 211 212 if (lightboxOpen) return; 213 214 if ( 215 e.key === 'ArrowLeft' 216 || 217 e.key === 'ArrowRight' 218 ) { 219 220 const currentIndex = focusableButtons.indexOf(document.activeElement); 221 222 // If focus isn't on one of this carousel's buttons, let the 223 // key do its default thing rather than guessing an index 224 if (currentIndex === -1) return; 225 226 e.preventDefault(); 227 228 let nextIndex; 229 if (e.key === 'ArrowLeft') { 230 nextIndex = Math.max( 0, currentIndex - 1 ); 231 } else { 232 nextIndex = Math.min( focusableButtons.length - 1, currentIndex + 1 ); 233 } 234 235 // .focus() triggers the existing 'focus' listener above, 236 // which handles scrolling the item into view 237 focusableButtons[ nextIndex ].focus(); 238 239 } 240 241 }); 242 243 autoScroll(); 244 245 // Exposed so a future caller (e.g. if a page builder ever tears 246 // this container down dynamically) can stop the animation loop 247 // and remove the resize listener cleanly. 248 return function destroy() { 249 if (animationFrameId !== null) { 250 cancelAnimationFrame(animationFrameId); 251 } 252 window.removeEventListener('resize', measure); 253 }; 254 255 } 256 257 const carouselInstances = Array.from(containers).map(initCarousel); 258 259})();
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.