1import {getHorizontalItemStep} from '../core/measure.js'; 2 3const getCarouselStep = (track, itemSelector) => { 4 return getHorizontalItemStep(track, itemSelector); 5}; 6 7const getOriginalItems = (track, itemSelector, cloneAttr) => 8 Array.from(track.querySelectorAll(itemSelector)).filter( 9 (item) => !item.hasAttribute(cloneAttr) 10 ); 11 12const removeClones = (track, cloneAttr) => { 13 track.querySelectorAll(`[${cloneAttr}]`).forEach((clone) => clone.remove()); 14}; 15 16const syncLoopScrollBounds = (scrollContainer, state) => { 17 if (!state.isEnabled || state.originalSetWidth <= 0) { 18 return; 19 } 20 21 const scrollLeft = scrollContainer.scrollLeft; 22 if (scrollLeft < state.originalSetWidth * 0.5) { 23 state.isSyncing = true; 24 scrollContainer.scrollLeft = scrollLeft + state.originalSetWidth; 25 state.isSyncing = false; 26 return; 27 } 28 29 if (scrollLeft > state.originalSetWidth * 1.5) { 30 state.isSyncing = true; 31 scrollContainer.scrollLeft = scrollLeft - state.originalSetWidth; 32 state.isSyncing = false; 33 } 34}; 35 36const createCarouselHandlers = (scrollContainer, track, state) => { 37 const handlePointerDown = (event) => { 38 if (!state.isEnabled || event.button > 0) { 39 return; 40 } 41 42 // Keep touch scrolling native for smooth momentum on mobile. 43 if (event.pointerType === 'touch' || event.pointerType === 'pen') { 44 return; 45 } 46 47 state.activePointerId = event.pointerId; 48 state.pointerStartX = event.clientX; 49 state.pointerStartY = event.clientY; 50 state.scrollStartX = scrollContainer.scrollLeft; 51 state.isPointerDragging = false; 52 state.suppressNextClick = false; 53 scrollContainer.setPointerCapture(event.pointerId); 54 }; 55 56 const handlePointerMove = (event) => { 57 if (!state.isEnabled || state.activePointerId !== event.pointerId) { 58 return; 59 } 60 61 const deltaX = event.clientX - state.pointerStartX; 62 const deltaY = event.clientY - state.pointerStartY; 63 if (!state.isPointerDragging) { 64 if (Math.abs(deltaX) < 5 || Math.abs(deltaX) <= Math.abs(deltaY)) { 65 return; 66 } 67 state.isPointerDragging = true; 68 } 69 70 event.preventDefault(); 71 state.suppressNextClick = true; 72 scrollContainer.scrollLeft = state.scrollStartX - deltaX; 73 syncLoopScrollBounds(scrollContainer, state); 74 }; 75 76 const handlePointerEnd = (event) => { 77 if (state.activePointerId !== event.pointerId) { 78 return; 79 } 80 81 if (scrollContainer.hasPointerCapture(event.pointerId)) { 82 scrollContainer.releasePointerCapture(event.pointerId); 83 } 84 85 state.activePointerId = null; 86 state.isPointerDragging = false; 87 }; 88 89 const handleTrackClickCapture = (event) => { 90 if (!state.suppressNextClick) { 91 return; 92 } 93 94 event.preventDefault(); 95 event.stopPropagation(); 96 state.suppressNextClick = false; 97 }; 98 99 const handleScroll = () => { 100 if (state.isSyncing) { 101 return; 102 } 103 syncLoopScrollBounds(scrollContainer, state); 104 }; 105 106 return { 107 handlePointerDown, 108 handlePointerMove, 109 handlePointerEnd, 110 handleTrackClickCapture, 111 handleScroll 112 }; 113}; 114 115const attachHandlers = (scrollContainer, track, state, enablePointerDrag) => { 116 state.handlers = createCarouselHandlers(scrollContainer, track, state); 117 118 const { 119 handlePointerDown, 120 handlePointerMove, 121 handlePointerEnd, 122 handleTrackClickCapture, 123 handleScroll 124 } = state.handlers; 125 126 scrollContainer.addEventListener('scroll', handleScroll, {passive: true}); 127 128 if (enablePointerDrag) { 129 scrollContainer.addEventListener('pointerdown', handlePointerDown); 130 scrollContainer.addEventListener('pointermove', handlePointerMove, {passive: false}); 131 scrollContainer.addEventListener('pointerup', handlePointerEnd); 132 scrollContainer.addEventListener('pointercancel', handlePointerEnd); 133 track.addEventListener('click', handleTrackClickCapture, true); 134 } 135}; 136 137const detachHandlers = (scrollContainer, track, state, enablePointerDrag) => { 138 const { 139 handlePointerDown, 140 handlePointerMove, 141 handlePointerEnd, 142 handleTrackClickCapture, 143 handleScroll 144 } = state.handlers; 145 146 if (!handleScroll) { 147 return; 148 } 149 150 scrollContainer.removeEventListener('scroll', handleScroll); 151 152 if (enablePointerDrag) { 153 scrollContainer.removeEventListener('pointerdown', handlePointerDown); 154 scrollContainer.removeEventListener('pointermove', handlePointerMove); 155 scrollContainer.removeEventListener('pointerup', handlePointerEnd); 156 scrollContainer.removeEventListener('pointercancel', handlePointerEnd);
157 track.removeEventListener('click', handleTrackClickCapture, true); 158 } 159 160 state.handlers = {}; 161}; 162 163/** 164 * Creates an infinite-loop carousel with clone-based seamless scrolling. 165 * 166 * @param {object} config 167 * @param {HTMLElement} config.scrollContainer - Element with overflow (the one that scrolls) 168 * @param {HTMLElement} config.track - Element containing items (can be same as scrollContainer) 169 * @param {string} config.itemSelector - CSS selector for original items 170 * @param {string} [config.cloneAttr='data-carousel-clone'] - Data attribute to mark clones 171 * @param {HTMLElement|null} [config.prevButton] 172 * @param {HTMLElement|null} [config.nextButton] 173 * @param {() => boolean} [config.shouldEnable] - When to enable infinite mode 174 * @param {(container: HTMLElement) => number} [config.getScrollAmount] - Scroll distance for chevron clicks 175 * @param {boolean} [config.enablePointerDrag=true] - Enable mouse/stylus drag scrolling 176 */ 177export function createInfiniteCarousel({ 178 scrollContainer, 179 track, 180 itemSelector, 181 cloneAttr = 'data-carousel-clone', 182 prevButton = null, 183 nextButton = null, 184 shouldEnable = () => true, 185 getScrollAmount = (container) => Math.max(120, Math.round(container.clientWidth * 0.55)), 186 enablePointerDrag = true 187 }) { 188 if (!scrollContainer || !track) { 189 return { 190 syncToViewport() { 191 } 192 }; 193 } 194 195 const focusableDescendantSelector = [ 196 'a[href]', 197 'area[href]', 198 'button:not([disabled])', 199 'input:not([disabled])', 200 'select:not([disabled])', 201 'textarea:not([disabled])', 202 'iframe', 203 'audio[controls]', 204 'video[controls]', 205 '[contenteditable]:not([contenteditable="false"])', 206 '[tabindex]:not([tabindex="-1"])' 207 ].join(','); 208 209 const hideCarouselClone = (clone) => { 210 clone.setAttribute('aria-hidden', 'true'); 211 clone.setAttribute('inert', ''); 212 clone.tabIndex = -1; 213 214 clone.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach((heading) => { 215 const neutralHeading = document.createElement('div'); 216 Array.from(heading.attributes).forEach((attribute) => { 217 neutralHeading.setAttribute(attribute.name, attribute.value); 218 }); 219 neutralHeading.replaceChildren(...heading.childNodes); 220 heading.replaceWith(neutralHeading); 221 }); 222 223 clone.querySelectorAll(focusableDescendantSelector).forEach((element) => { 224 element.setAttribute('tabindex', '-1'); 225 }); 226 }; 227 228 const state = { 229 isEnabled: false, 230 originalSetWidth: 0, 231 isSyncing: false, 232 activePointerId: null, 233 pointerStartX: 0, 234 pointerStartY: 0, 235 scrollStartX: 0, 236 isPointerDragging: false, 237 suppressNextClick: false, 238 handlers: {} 239 }; 240 241 const moveByChevron = (direction) => { 242 if (!state.isEnabled) { 243 return; 244 } 245 246 const amount = getScrollAmount(scrollContainer); 247 syncLoopScrollBounds(scrollContainer, state); 248 scrollContainer.scrollTo({
249 left: scrollContainer.scrollLeft + amount * direction, 250 behavior: 'smooth' 251 }); 252 }; 253 254 if (prevButton) { 255 prevButton.addEventListener('click', () => moveByChevron(-1)); 256 } 257 258 if (nextButton) { 259 nextButton.addEventListener('click', () => moveByChevron(1)); 260 } 261 262 const enable = () => { 263 if (state.isEnabled) { 264 return; 265 } 266 267 const originals = getOriginalItems(track, itemSelector, cloneAttr); 268 if (originals.length <= 1) { 269 return; 270 } 271 272 const prependFragment = document.createDocumentFragment(); 273 const appendFragment = document.createDocumentFragment(); 274 275 originals.forEach((item) => { 276 const prependClone = item.cloneNode(true); 277 prependClone.setAttribute(cloneAttr, 'true'); 278 hideCarouselClone(prependClone); 279 prependFragment.appendChild(prependClone); 280 281 const appendClone = item.cloneNode(true); 282 appendClone.setAttribute(cloneAttr, 'true'); 283 hideCarouselClone(appendClone); 284 appendFragment.appendChild(appendClone); 285 }); 286 287 track.insertBefore(prependFragment, track.firstChild); 288 track.appendChild(appendFragment); 289 290 const step = getCarouselStep(track, itemSelector); 291 state.originalSetWidth = step * originals.length; 292 if (state.originalSetWidth <= 0) { 293 removeClones(track, cloneAttr); 294 return; 295 } 296 297 state.isEnabled = true; 298 attachHandlers(scrollContainer, track, state, enablePointerDrag); 299 scrollContainer.scrollLeft = state.originalSetWidth; 300 }; 301 302 const disable = () => { 303 if (!state.isEnabled) { 304 return; 305 } 306 307 state.isEnabled = false; 308 detachHandlers(scrollContainer, track, state, enablePointerDrag); 309 removeClones(track, cloneAttr); 310 scrollContainer.scrollLeft = 0; 311 state.activePointerId = null; 312 state.isPointerDragging = false; 313 state.suppressNextClick = false; 314 }; 315 316 const syncToViewport = () => { 317 if (!shouldEnable()) { 318 disable(); 319 return; 320 } 321 322 if (!state.isEnabled) { 323 enable(); 324 return; 325 } 326 327 const originals = getOriginalItems(track, itemSelector, cloneAttr); 328 const step = getCarouselStep(track, itemSelector); 329 state.originalSetWidth = step * originals.length; 330 if (state.originalSetWidth > 0) { 331 const normalizedOffset = scrollContainer.scrollLeft % state.originalSetWidth; 332 scrollContainer.scrollLeft = state.originalSetWidth + normalizedOffset; 333 } 334 }; 335 336 return {syncToViewport, enable, disable}; 337}
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.