1/** 2 * Article Card Carousel Module 3 * Creates a horizontal scrolling carousel for article cards with navigation controls 4 */ 5 6const articleCardCarousel = { 7 /** 8 * Initialize all carousels on the page 9 * @param {string} selector - CSS selector for carousel containers 10 * @param {object} options - Configuration options 11 */ 12 init(selector = '.article-card-carousel', options = {}) { 13 const containers = document.querySelectorAll(selector); 14 15 if (!containers.length) return; 16 17 const defaultOptions = { 18 visibleCards: { 19 mobile: 1.2, // screens < 400px 20 tablet: 2.5, // screens 400-768px 21 desktop: 3.7 // screens > 768px 22 }, 23 ...options 24 }; 25 26 containers.forEach(container => { 27 this.setupCarousel(container, defaultOptions); 28 }); 29 }, 30 31 /** 32 * Set up individual carousel 33 */ 34 setupCarousel(container, options) { 35 const articles = container.querySelectorAll('article'); 36 37 if (!articles.length) return; 38 39 // Add carousel wrapper and navigation
40 this.wrapCarousel(container); 41 42 // Calculate and set dimensions 43 this.calculateDimensions(container, options); 44 45 // Add navigation if needed 46 const shouldShowNav = this.shouldShowNavigation(container, options); 47 if (shouldShowNav) { 48 this.addNavigation(container); 49 } 50 51 // Add touch/swipe support 52 this.addTouchSupport(container); 53 54 // Handle window resize 55 this.handleResize(container, options); 56 }, 57 58 /** 59 * Wrap carousel content for proper styling 60 */ 61 wrapCarousel(container) { 62 // Create wrapper for the entire carousel 63 const wrapper = document.createElement('div'); 64 wrapper.className = 'carousel-wrapper'; 65 66 // Insert wrapper before container 67 container.parentNode.insertBefore(wrapper, container); 68 69 // Move container into wrapper 70 wrapper.appendChild(container); 71 72 // Add scrollable class to container 73 container.classList.add('carousel-scrollable'); 74 }, 75 76 /** 77 * Calculate and set carousel dimensions 78 */ 79 calculateDimensions(container, options) { 80 const articles = container.querySelectorAll('article'); 81 if (!articles.length) return; 82 83 const containerWidth = container.offsetWidth; 84 const visibleCards = this.getVisibleCards(options); 85 86 // Get the actual gap from computed styles 87 const computedStyle = getComputedStyle(container); 88 let gap = parseFloat(computedStyle.gap) || 0; 89 90 // If gap is 0, try to get it from CSS custom property 91 if (gap === 0) { 92 const rootStyles = getComputedStyle(document.documentElement); 93 const cssGap = rootStyles.getPropertyValue('--gap').trim(); 94 // Convert rem to px (assuming 1rem = 10px base font size) 95 if (cssGap.includes('rem')) { 96 gap = parseFloat(cssGap) * 10; 97 } else { 98 gap = parseFloat(cssGap) || 24; // fallback 99 } 100 } 101 102 // Calculate article width 103 const articleWidth = (containerWidth - (gap * (visibleCards - 1))) / visibleCards; 104 105 // Set article widths 106 articles.forEach(article => { 107 article.style.minWidth = `${articleWidth}px`; 108 article.style.maxWidth = `${articleWidth}px`; 109 }); 110 111 // Store dimensions for navigation - use measured distance for accuracy 112 setTimeout(() => { 113 const scrollDistance = this.calculateScrollDistance(articles); 114 container.dataset.articleWidth = articleWidth; 115 container.dataset.gap = gap; 116 container.dataset.scrollDistance = scrollDistance; 117 container.dataset.visibleCards = visibleCards; 118 }, 50); // Small delay to ensure DOM is updated 119 }, 120 121 /** 122 * Calculate the actual scroll distance between articles 123 */ 124 calculateScrollDistance(articles) { 125 if (articles.length < 2) return 0; 126 127 const firstArticle = articles[0]; 128 const secondArticle = articles[1]; 129 130 // Get the actual distance between the left edges of consecutive articles 131 const firstRect = firstArticle.getBoundingClientRect(); 132 const secondRect = secondArticle.getBoundingClientRect(); 133 134 return Math.abs(secondRect.left - firstRect.left); 135 }, 136 137 /** 138 * Get number of visible cards based on screen width 139 */ 140 getVisibleCards(options) { 141 const width = window.innerWidth; 142 143 if (width < 400) { 144 return options.visibleCards.mobile; 145 } else if (width <= 768) { 146 return options.visibleCards.tablet; 147 } else { 148 return options.visibleCards.desktop; 149 } 150 }, 151 152 /** 153 * Check if navigation should be shown 154 */ 155 shouldShowNavigation(container, options) { 156 const articles = container.querySelectorAll('article'); 157 const visibleCards = this.getVisibleCards(options); 158 159 return articles.length > Math.floor(visibleCards); 160 }, 161 162 /** 163 * Add navigation controls 164 */ 165 addNavigation(container) { 166 const wrapper = container.parentNode; 167 168 // Create navigation container 169 const nav = document.createElement('div'); 170 nav.className = 'carousel-nav'; 171 nav.innerHTML = ` 172 <button class="carousel-btn carousel-btn--prev" aria-label="Previous articles"> 173 <svg width="24" height="24" viewBox="0 0 24 24" fill="none"> 174 <path d="M15 18L9 12L15 6" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> 175 </svg> 176 </button> 177 <button class="carousel-btn carousel-btn--next" aria-label="Next articles"> 178 <svg width="24" height="24" viewBox="0 0 24 24" fill="none"> 179 <path d="M9 18L15 12L9 6" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> 180 </svg> 181 </button> 182 `; 183 184 // Insert navigation before carousel
185 wrapper.insertBefore(nav, container); 186 187 // Add event listeners 188 const prevBtn = nav.querySelector('.carousel-btn--prev'); 189 const nextBtn = nav.querySelector('.carousel-btn--next'); 190 191 prevBtn.addEventListener('click', () => this.scrollCarousel(container, 'prev')); 192 nextBtn.addEventListener('click', () => this.scrollCarousel(container, 'next')); 193 194 // Update button states 195 this.updateNavigationState(container); 196 }, 197 198 /** 199 * Scroll carousel in specified direction 200 */ 201 scrollCarousel(container, direction) { 202 // Use the calculated scroll distance (article width + gap) 203 const scrollDistance = parseFloat(container.dataset.scrollDistance) || 204 (parseFloat(container.dataset.articleWidth) + parseFloat(container.dataset.gap)); 205 206 const currentScroll = container.scrollLeft; 207 const newScroll = direction === 'next' 208 ? currentScroll + scrollDistance 209 : currentScroll - scrollDistance; 210 211 container.scrollTo({ 212 left: newScroll, 213 behavior: 'smooth' 214 }); 215 216 // Update navigation state after scroll completes 217 setTimeout(() => this.updateNavigationState(container), 300); 218 }, 219 220 /** 221 * Update navigation button states 222 */ 223 updateNavigationState(container) { 224 const wrapper = container.parentNode; 225 const nav = wrapper.querySelector('.carousel-nav'); 226 227 if (!nav) return; 228 229 const prevBtn = nav.querySelector('.carousel-btn--prev'); 230 const nextBtn = nav.querySelector('.carousel-btn--next'); 231 232 const scrollLeft = container.scrollLeft; 233 const maxScroll = container.scrollWidth - container.offsetWidth; 234 235 // Update prev button 236 prevBtn.disabled = scrollLeft <= 0; 237 prevBtn.classList.toggle('disabled', scrollLeft <= 0); 238 239 // Update next button 240 nextBtn.disabled = scrollLeft >= maxScroll - 1; // -1 for rounding errors 241 nextBtn.classList.toggle('disabled', scrollLeft >= maxScroll - 1); 242 }, 243 244 /** 245 * Add touch/swipe support for mobile devices 246 */ 247 addTouchSupport(container) { 248 let startX = 0; 249 let startY = 0; 250 let isDragging = false; 251 252 container.addEventListener('touchstart', (e) => { 253 startX = e.touches[0].clientX; 254 startY = e.touches[0].clientY; 255 isDragging = true; 256 }); 257 258 container.addEventListener('touchmove', (e) => { 259 if (!isDragging) return; 260 261 const currentX = e.touches[0].clientX; 262 const currentY = e.touches[0].clientY; 263 264 const diffX = Math.abs(currentX - startX); 265 const diffY = Math.abs(currentY - startY); 266 267 // If horizontal swipe is more prominent, prevent vertical scroll 268 if (diffX > diffY) { 269 e.preventDefault(); 270 } 271 }); 272 273 container.addEventListener('touchend', (e) => { 274 if (!isDragging) return; 275 276 const endX = e.changedTouches[0].clientX; 277 const diffX = startX - endX; 278 279 // Minimum swipe distance 280 if (Math.abs(diffX) > 50) { 281 if (diffX > 0) { 282 // Swiped left - go next 283 this.scrollCarousel(container, 'next'); 284 } else { 285 // Swiped right - go prev 286 this.scrollCarousel(container, 'prev'); 287 } 288 } 289 290 isDragging = false; 291 }); 292 293 // Update navigation on scroll (for manual scrolling) 294 container.addEventListener('scroll', () => { 295 this.updateNavigationState(container); 296 }); 297 }, 298 299 /** 300 * Handle window resize 301 */ 302 handleResize(container, options) { 303 let resizeTimeout; 304 305 window.addEventListener('resize', () => { 306 clearTimeout(resizeTimeout); 307 resizeTimeout = setTimeout(() => { 308 this.calculateDimensions(container, options); 309 this.updateNavigationState(container); 310 311 // Recalculate scroll distance after dimensions change 312 const articles = container.querySelectorAll('article'); 313 if (articles.length >= 2) { 314 const scrollDistance = this.calculateScrollDistance(articles); 315 container.dataset.scrollDistance = scrollDistance; 316 } 317 }, 250); 318 }); 319 } 320}; 321 322export default articleCardCarousel;
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.