Add initial project
This commit is contained in:
1 parent
b3ad230739
commit
a79d639017
6 files changed
+1084
No files matched your search
@@ -0,0 +1,57 @@
|
||||
.snappy-carousel {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
user-select: none;
|
||||
cursor: grab;
|
||||
scroll-behavior: smooth;
|
||||
|
||||
&[data-direction="y"] {
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&.is-dragging {
|
||||
scroll-snap-type: none;
|
||||
scroll-behavior: unset;
|
||||
}
|
||||
|
||||
scroll-snap-type: x mandatory;
|
||||
|
||||
&[data-direction="y"] {
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
scroll-snap-type: y mandatory;
|
||||
}
|
||||
|
||||
gap: var(--carousel-gap, 0);
|
||||
gap: attr(data-gap type(*), 0);
|
||||
|
||||
.snappy-slide {
|
||||
flex-shrink: 0;
|
||||
scroll-snap-align: center;
|
||||
scroll-snap-stop: normal;
|
||||
}
|
||||
|
||||
&[data-snap-align="start"] .snappy-slide {
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
&[data-snap-align="end"] .snappy-slide {
|
||||
scroll-snap-align: end;
|
||||
}
|
||||
|
||||
&[data-snap-stop="always"] .snappy-slide {
|
||||
scroll-snap-stop: always;
|
||||
}
|
||||
|
||||
}
|
||||
.snappy-pagination-dot {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background-color: silver;
|
||||
}
|
||||
+334
@@ -0,0 +1,334 @@
|
||||
/* use strict; */
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.snappy = new Snappy();
|
||||
});
|
||||
|
||||
class Snappy {
|
||||
carousels = [];
|
||||
|
||||
constructor() {
|
||||
document.querySelectorAll('.snappy-carousel').forEach(element => {
|
||||
this.carousels.push(new Carousel(element));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class Carousel {
|
||||
// DOM references
|
||||
element = null;
|
||||
prevButton = null;
|
||||
nextButton = null;
|
||||
pagination = null;
|
||||
slides = [];
|
||||
paginationDots = [];
|
||||
|
||||
// Bahavioral constants
|
||||
inertia = 0.90; // Decay rate (0.9 = slower stop, 0.99 = longer slide)
|
||||
|
||||
// State variables
|
||||
isDragging = false;
|
||||
direction = 'x';
|
||||
startPosition = 0;
|
||||
startScrollPosition = 0;
|
||||
velocity = 0;
|
||||
lastPosition;
|
||||
lastTime;
|
||||
activeSlideIndex = 0;
|
||||
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
this.direction = this.element.dataset.direction === 'y' ? 'y' : 'x';
|
||||
|
||||
const gap = this.element.dataset.gap;
|
||||
if (gap) this.element.style.setProperty('--carousel-gap', gap);
|
||||
|
||||
this.slides = this.element.querySelectorAll('.snappy-slide');
|
||||
this.prevButton = document.querySelector(`.snappy-prev[data-carousel="${this.element.id}"]`);
|
||||
this.nextButton = document.querySelector(`.snappy-next[data-carousel="${this.element.id}"]`);
|
||||
this.pagination = document.querySelector(`.snappy-pagination[data-carousel="${this.element.id}"]`);
|
||||
|
||||
this.element.addEventListener('mousedown', this.startDrag.bind(this));
|
||||
this.element.addEventListener('mousemove', this.drag.bind(this));
|
||||
this.element.addEventListener('mouseup', this.endDrag.bind(this));
|
||||
this.element.addEventListener('mouseleave', this.endDrag.bind(this));
|
||||
this.element.addEventListener('touchstart', this.reset.bind(this));
|
||||
this.element.addEventListener('wheel', this.reset.bind(this));
|
||||
this.element.addEventListener('scroll', this.updateActiveSlide.bind(this));
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
startDrag(e) {
|
||||
this.isDragging = true;
|
||||
this.element.classList.add('is-dragging'); // Disable scroll snapping
|
||||
this.velocity = 0;
|
||||
this.lastPosition = undefined;
|
||||
this.lastTime = undefined;
|
||||
this.startPosition = this.getPointerPosition(e);
|
||||
this.startScrollPosition = this.getScrollPosition();
|
||||
e.preventDefault(); // Prevent text selection during drag
|
||||
}
|
||||
|
||||
drag(e) {
|
||||
if (!this.isDragging) return;
|
||||
e.preventDefault();
|
||||
const pointerPosition = this.getPointerPosition(e);
|
||||
const currentTime = Date.now();
|
||||
|
||||
// Calculate velocity (pixels per millisecond)
|
||||
if (this.lastTime !== undefined && currentTime > this.lastTime) {
|
||||
this.velocity = (pointerPosition - this.lastPosition) / (currentTime - this.lastTime);
|
||||
}
|
||||
|
||||
this.lastPosition = pointerPosition;
|
||||
this.lastTime = currentTime;
|
||||
|
||||
const distance = pointerPosition - this.startPosition;
|
||||
requestAnimationFrame(() => {
|
||||
this.setScrollPosition(this.startScrollPosition - distance);
|
||||
});
|
||||
}
|
||||
|
||||
endDrag() {
|
||||
if (!this.isDragging) return;
|
||||
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.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
const target = this.calculateDragTarget();
|
||||
const element = this.element;
|
||||
const distance = target - this.getScrollPosition();
|
||||
|
||||
this.velocity = -distance * (1 - this.inertia) / 10;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
finishDrag(target) {
|
||||
this.setScrollPosition(target);
|
||||
this.reset();
|
||||
}
|
||||
|
||||
getPointerPosition(event) {
|
||||
return this.direction === 'y' ? event.clientY : event.clientX;
|
||||
}
|
||||
|
||||
getScrollPosition() {
|
||||
return this.direction === 'y' ? this.element.scrollTop : this.element.scrollLeft;
|
||||
}
|
||||
|
||||
setScrollPosition(position) {
|
||||
if (this.direction === 'y') {
|
||||
this.element.scrollTop = position;
|
||||
} else {
|
||||
this.element.scrollLeft = position;
|
||||
}
|
||||
}
|
||||
|
||||
calculateDragTarget() {
|
||||
const element = this.element;
|
||||
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 final scroll position based on current velocity and inertia
|
||||
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);
|
||||
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);
|
||||
});
|
||||
|
||||
return targets.reduce((nearest, target) =>
|
||||
Math.abs(target - projectedStop) < Math.abs(nearest - projectedStop)
|
||||
? target
|
||||
: nearest
|
||||
);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.isDragging = false;
|
||||
this.element.classList.remove('is-dragging');
|
||||
this.velocity = 0
|
||||
}
|
||||
|
||||
createPagination() {
|
||||
this.slides.forEach((slide, index) => {
|
||||
// Populate the pagination container with dots for each slide
|
||||
let div = document.createElement('div');
|
||||
div.classList.add('snappy-pagination-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);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
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.element.getBoundingClientRect();
|
||||
const containerStyle = getComputedStyle(this.element);
|
||||
const snapportStartEdge = containerRect[startEdge] +
|
||||
(isVertical ? this.element.clientTop : this.element.clientLeft);
|
||||
const clientExtent = isVertical ? this.element.clientHeight : this.element.clientWidth;
|
||||
const snapportStart = snapportStartEdge + cssPixels(containerStyle[paddingStart]);
|
||||
const snapportEnd = snapportStartEdge + clientExtent - cssPixels(containerStyle[paddingEnd]);
|
||||
const snapportCenter = (snapportStart + snapportEnd) / 2;
|
||||
|
||||
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 distance;
|
||||
|
||||
if (align === 'start') {
|
||||
distance = Math.abs(snapAreaStart - snapportStart);
|
||||
} else if (align === 'end') {
|
||||
distance = Math.abs(snapAreaEnd - snapportEnd);
|
||||
} else {
|
||||
const snapAreaCenter = (snapAreaStart + snapAreaEnd) / 2;
|
||||
distance = Math.abs(snapAreaCenter - snapportCenter);
|
||||
}
|
||||
|
||||
if (distance < closestDistance) {
|
||||
activeIndex = index;
|
||||
closestDistance = distance;
|
||||
}
|
||||
});
|
||||
|
||||
return activeIndex;
|
||||
}
|
||||
|
||||
updateActiveSlide() {
|
||||
this.activeSlideIndex = this.getActiveSlideIndex();
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
goToSlide(index) {
|
||||
this.activeSlideIndex = Math.max(0, Math.min(index, this.slides.length - 1));
|
||||
this.slides[this.activeSlideIndex].scrollIntoView();
|
||||
}
|
||||
|
||||
next() {
|
||||
this.goToSlide(this.activeSlideIndex + 1);
|
||||
}
|
||||
prev() {
|
||||
this.goToSlide(this.activeSlideIndex - 1);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user