Files
Snappy-Carousel/src/snappy.js
T
2026-10-02 21:46:27 +02:00

489 lines
18 KiB
JavaScript

/* 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);
}
}