/* use strict; */ /** Initialize the carousel controller after the document has been parsed. */ document.addEventListener('DOMContentLoaded', () => { window.snappy = new Snappy(); }); /** Discovers carousel elements and creates a controller for each one. */ class Snappy { carousels = []; /** Create controllers for all carousel elements in the document. */ constructor() { document.querySelectorAll('.snappy-carousel').forEach(element => { this.carousels.push(new Carousel(element)); }); } } /** Handles input, scrolling, navigation, and pagination for one carousel. */ class Carousel { // DOM references scrollContainer = null; prevButton = null; nextButton = null; pagination = null; slides = []; paginationDots = []; // Behavioral constants inertia = 0.90; // Decay rate (0.9 = slower stop, 0.99 = longer slide) velocityFrameCount = 5; // State variables isDragging = false; dragFrame = null; pendingDragPosition = null; scrollUpdateFrame = null; autoScrolling = false; direction = 'x'; startPosition = 0; startScrollPosition = 0; velocity = 0; velocitySamples = []; pendingPointerPosition = null; lastPosition; lastTime; activeSlideIndex = 0; renderedActiveSlideIndex = null; /** * Bind a carousel element to its controls and initialize its active slide. * @param {HTMLElement} element - The element containing the carousel markup. */ constructor(element) { this.scrollContainer = element.querySelector('.scroll-container'); this.direction = element.dataset.direction === 'y' ? 'y' : 'x'; this.slides = this.scrollContainer.querySelectorAll('.slide'); this.prevButton = document.querySelector(`.prev`); this.nextButton = document.querySelector(`.next`); this.pagination = document.querySelector(`.pagination`); this.scrollContainer.addEventListener('mousedown', this.startDrag.bind(this)); this.scrollContainer.addEventListener('mousemove', this.drag.bind(this)); this.scrollContainer.addEventListener('mouseup', this.endDrag.bind(this)); this.scrollContainer.addEventListener('mouseleave', this.endDrag.bind(this)); this.scrollContainer.addEventListener('touchstart', this.resetDragStates.bind(this)); this.scrollContainer.addEventListener('wheel', this.resetDragStates.bind(this)); this.scrollContainer.addEventListener('scroll', () => { if (this.scrollUpdateFrame !== null) return; // Coalesce bursts of scroll events into one layout read per frame. this.scrollUpdateFrame = requestAnimationFrame(() => { this.scrollUpdateFrame = null; this.updateActiveSlide(); }); }); if (this.prevButton) { this.prevButton.addEventListener('click', () => this.prev()); } if (this.nextButton) { this.nextButton.addEventListener('click', () => this.next()); } if (this.pagination) { this.createPagination(); } this.updateActiveSlide(); } /** * Begin mouse dragging and record the starting pointer and scroll positions. * @param {MouseEvent} e - The mouse-down event. * @returns {void} */ startDrag(e) { this.isDragging = true; this.autoScrolling = false; this.scrollContainer.classList.add('is-dragging'); // Disable scroll snapping this.velocity = 0; this.startPosition = this.getPointerPosition(e); this.lastPosition = this.startPosition; this.lastTime = performance.now(); this.velocitySamples = []; this.pendingPointerPosition = null; this.startScrollPosition = this.getScrollPosition(); e.preventDefault(); // Prevent text selection during drag } /** * Track pointer velocity and schedule the latest drag position for painting. * @param {MouseEvent} e - The mouse-move event. * @returns {void} */ drag(e) { if (!this.isDragging) return; e.preventDefault(); const pointerPosition = this.getPointerPosition(e); this.pendingPointerPosition = pointerPosition; const distance = pointerPosition - this.startPosition; this.pendingDragPosition = this.startScrollPosition - distance; // Keep at most one pending write; the callback applies the newest position. if (this.dragFrame === null) { this.dragFrame = requestAnimationFrame(() => { this.dragFrame = null; this.flushPendingDragPosition(); }); } } /** * Finish dragging and apply inertial motion when the release velocity warrants it. * @returns {void} */ endDrag() { if (!this.isDragging) return; this.flushPendingDragPosition(); this.isDragging = false; // If the drag velocity is low at release, let the native scroll snap take over if (Math.abs(this.velocity) < 0.05) { this.resetDragStates(); return; } const target = this.calculateDragTarget(); const distance = target - this.getScrollPosition(); // Scale the initial velocity so its geometric decay converges on the chosen target. this.velocity = -distance * (1 - this.inertia) / 10; /** * Advance the inertial scroll until it reaches the selected snap target. * @returns {void} */ const animateInertia = () => { if (this.isDragging) return; // Stop the animation if the user starts dragging again const current = this.getScrollPosition(); const remaining = target - current; if (Math.abs(remaining) <= 0.1) { this.finishDrag(target); return; } const delta = -this.velocity * 10; const next = Math.abs(delta) >= Math.abs(remaining) ? target : current + delta; this.setScrollPosition(next); // Stop if the browser cannot represent a smaller scroll-position change. if (this.getScrollPosition() === current || this.getScrollPosition() === target) { this.finishDrag(target); return; } this.velocity *= this.inertia; requestAnimationFrame(animateInertia); }; animateInertia(); } /** * Set the final drag position and restore native snapping behavior. * @param {number} target - The final scroll position. * @returns {void} */ finishDrag(target) { this.setScrollPosition(target); this.resetDragStates(); } /** * Read the pointer coordinate on the carousel's scrolling axis. * @param {MouseEvent} event - The current pointer event. * @returns {number} The pointer coordinate in viewport pixels. */ getPointerPosition(event) { return this.direction === 'y' ? event.clientY : event.clientX; } /** * Read the scroll offset on the carousel's scrolling axis. * @returns {number} The current scroll offset in pixels. */ getScrollPosition() { return this.direction === 'y' ? this.scrollContainer.scrollTop : this.scrollContainer.scrollLeft; } /** * Set the scroll offset on the carousel's scrolling axis. * @param {number} position - The target scroll offset in pixels. * @returns {void} */ setScrollPosition(position) { if (this.direction === 'y') { this.scrollContainer.scrollTop = position; } else { this.scrollContainer.scrollLeft = position; } } /** * Choose the reachable snap target closest to the inertia-projected stopping point. * @returns {number} The target scroll offset in pixels. */ calculateDragTarget() { const element = this.scrollContainer; const isVertical = this.direction === 'y'; const maxScrollPosition = isVertical ? element.scrollHeight - element.clientHeight : element.scrollWidth - element.clientWidth; const clamp = value => Math.max(0, Math.min(value, maxScrollPosition)); const cssPixels = value => Number.parseFloat(value) || 0; const startEdge = isVertical ? 'top' : 'left'; const endEdge = isVertical ? 'bottom' : 'right'; const paddingStart = isVertical ? 'scrollPaddingTop' : 'scrollPaddingLeft'; const paddingEnd = isVertical ? 'scrollPaddingBottom' : 'scrollPaddingRight'; const marginStart = isVertical ? 'scrollMarginTop' : 'scrollMarginLeft'; const marginEnd = isVertical ? 'scrollMarginBottom' : 'scrollMarginRight'; // Project the stopping point from velocity, then constrain it to the scroll range. const projectedStop = clamp( this.getScrollPosition() - this.velocity * 10 / (1 - this.inertia) ); if (!this.slides.length) return clamp(projectedStop); const elementRect = element.getBoundingClientRect(); const elementStyle = getComputedStyle(element); // Scroll padding defines the snapport; scroll margins define each slide's snap area. const snapportStartEdge = elementRect[startEdge] + (isVertical ? element.clientTop : element.clientLeft); const clientExtent = isVertical ? element.clientHeight : element.clientWidth; const snapportStart = snapportStartEdge + cssPixels(elementStyle[paddingStart]); const snapportEnd = snapportStartEdge + clientExtent - cssPixels(elementStyle[paddingEnd]); const targets = [...this.slides].map(slide => { const slideRect = slide.getBoundingClientRect(); const slideStyle = getComputedStyle(slide); const snapAreaStart = slideRect[startEdge] - cssPixels(slideStyle[marginStart]); const snapAreaEnd = slideRect[endEdge] + cssPixels(slideStyle[marginEnd]); const alignments = slideStyle.scrollSnapAlign.trim().split(/\s+/); const align = isVertical ? alignments[0] : alignments[1] ?? alignments[0]; const scrollPosition = this.getScrollPosition(); let target; if (align === 'start') { target = scrollPosition + snapAreaStart - snapportStart; } else if (align === 'end') { target = scrollPosition + snapAreaEnd - snapportEnd; } else { const snapAreaCenter = (snapAreaStart + snapAreaEnd) / 2; const snapportCenter = (snapportStart + snapportEnd) / 2; target = scrollPosition + snapAreaCenter - snapportCenter; } return clamp(target); }); // Select the snap target nearest the projected stopping point. return targets.reduce((nearest, target) => Math.abs(target - projectedStop) < Math.abs(nearest - projectedStop) ? target : nearest ); } /** * Flush pending drag movement and restore non-dragging state. * @returns {void} */ resetDragStates() { this.flushPendingDragPosition(); this.isDragging = false; this.autoScrolling = false; this.scrollContainer.classList.remove('is-dragging'); this.velocity = 0 } /** * Cancel a queued drag frame and apply its latest position immediately. * @returns {void} */ flushPendingDragPosition() { if (this.dragFrame !== null) { cancelAnimationFrame(this.dragFrame); this.dragFrame = null; } if (this.pendingPointerPosition !== null) { const currentTime = performance.now(); if (currentTime > this.lastTime) { const sample = (this.pendingPointerPosition - this.lastPosition) / (currentTime - this.lastTime); this.velocitySamples.push(sample); if (this.velocitySamples.length > this.velocityFrameCount) { this.velocitySamples.shift(); } this.velocity = this.velocitySamples.reduce((total, value) => total + value, 0) / this.velocitySamples.length; } this.lastPosition = this.pendingPointerPosition; this.lastTime = currentTime; this.pendingPointerPosition = null; } if (this.pendingDragPosition !== null) { this.setScrollPosition(this.pendingDragPosition); this.pendingDragPosition = null; } } /** * Create one clickable pagination dot for each slide. * @returns {void} */ createPagination() { this.slides.forEach((slide, index) => { // Populate the pagination container with dots for each slide let div = document.createElement('div'); div.classList.add('dot'); div.addEventListener('click', () => this.goToSlide(index)); this.pagination.appendChild(div); // Store a reference to the pagination dots in the carousel object for later use if (!this.paginationDots) this.paginationDots = []; this.paginationDots.push(div); }); } /** * Find the slide whose reachable snap position is nearest the current offset. * @returns {number} The active slide index, or -1 when there are no slides. */ getActiveSlideIndex() { if (!this.slides.length) return -1; const isVertical = this.direction === 'y'; const startEdge = isVertical ? 'top' : 'left'; const endEdge = isVertical ? 'bottom' : 'right'; const paddingStart = isVertical ? 'scrollPaddingTop' : 'scrollPaddingLeft'; const paddingEnd = isVertical ? 'scrollPaddingBottom' : 'scrollPaddingRight'; const marginStart = isVertical ? 'scrollMarginTop' : 'scrollMarginLeft'; const marginEnd = isVertical ? 'scrollMarginBottom' : 'scrollMarginRight'; const cssPixels = value => Number.parseFloat(value) || 0; const containerRect = this.scrollContainer.getBoundingClientRect(); const containerStyle = getComputedStyle(this.scrollContainer); // Compare reachable snap positions so end-of-scroll clamping matches browser snapping. const snapportStartEdge = containerRect[startEdge] + (isVertical ? this.scrollContainer.clientTop : this.scrollContainer.clientLeft); const clientExtent = isVertical ? this.scrollContainer.clientHeight : this.scrollContainer.clientWidth; const snapportStart = snapportStartEdge + cssPixels(containerStyle[paddingStart]); const snapportEnd = snapportStartEdge + clientExtent - cssPixels(containerStyle[paddingEnd]); const snapportCenter = (snapportStart + snapportEnd) / 2; const scrollPosition = this.getScrollPosition(); const maxScrollPosition = isVertical ? this.scrollContainer.scrollHeight - this.scrollContainer.clientHeight : this.scrollContainer.scrollWidth - this.scrollContainer.clientWidth; const clamp = value => Math.max(0, Math.min(value, maxScrollPosition)); const isAtEnd = maxScrollPosition > 0 && scrollPosition >= maxScrollPosition - 1; let activeIndex = 0; let closestDistance = Infinity; this.slides.forEach((slide, index) => { const rect = slide.getBoundingClientRect(); const slideStyle = getComputedStyle(slide); const snapAreaStart = rect[startEdge] - cssPixels(slideStyle[marginStart]); const snapAreaEnd = rect[endEdge] + cssPixels(slideStyle[marginEnd]); const alignments = slideStyle.scrollSnapAlign.trim().split(/\s+/); const align = isVertical ? alignments[0] : alignments[1] ?? alignments[0]; let snapPosition; if (align === 'start') { snapPosition = scrollPosition + snapAreaStart - snapportStart; } else if (align === 'end') { snapPosition = scrollPosition + snapAreaEnd - snapportEnd; } else { const snapAreaCenter = (snapAreaStart + snapAreaEnd) / 2; snapPosition = scrollPosition + snapAreaCenter - snapportCenter; } const distance = Math.abs(clamp(snapPosition) - scrollPosition); // At the scroll limit, prefer the later slide when clamping makes targets tie. if (distance < closestDistance || (isAtEnd && distance === closestDistance)) { activeIndex = index; closestDistance = distance; } }); return activeIndex; } /** * Synchronize the active slide and pagination classes when the index changes. * @returns {void} */ updateActiveSlide() { if (!this.autoScrolling) { this.activeSlideIndex = this.getActiveSlideIndex(); } if (this.activeSlideIndex === this.renderedActiveSlideIndex) return; this.renderedActiveSlideIndex = this.activeSlideIndex; this.slides.forEach((slide, index) => { slide.classList.toggle('is-active', index === this.activeSlideIndex); }); this.paginationDots.forEach((dot, index) => { dot.classList.toggle('is-active', index === this.activeSlideIndex); }); } /** * Navigate to a slide by index and mark it active during programmatic scrolling. * @param {number} index - The zero-based slide index. * @returns {void} */ goToSlide(index) { this.activeSlideIndex = Math.max(0, Math.min(index, this.slides.length - 1)); this.autoScrolling = true; this.updateActiveSlide(); this.slides[this.activeSlideIndex].scrollIntoView(); } /** * Navigate to the next slide. * @returns {void} */ next() { this.goToSlide(this.activeSlideIndex + 1); } /** * Navigate to the previous slide. * @returns {void} */ prev() { this.goToSlide(this.activeSlideIndex - 1); } }