Add initial project

This commit is contained in:
vollstock committed 2026-10-02 15:55:28 +02:00
1 parent b3ad230739
commit a79d639017
6 files changed
+1084

No files matched your search

+57
View File
@@ -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
View File
@@ -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);
}
}