Fixed links being clicked, when draggind a slide
This commit is contained in:
1 parent
586fef4fb7
commit
53238d2a1b
2 files changed
+113
-99
No files matched your search
@@ -8,10 +8,10 @@ It does not implement every feature found in other carousel libraries. Instead,
|
||||
Mouse-drag behavior closely follows native touch scrolling in the browser, including inertia and snapping to a slide. Thus dragging with the mouse feels pretty identical to native scrolling on a touch device or using a trackpad.
|
||||
|
||||
|
||||
| File | Size | gzip |
|
||||
| ---------- | ------: | -----: |
|
||||
| snappy.js | 7.5k kB | 2.2 KB |
|
||||
| snappy.css | 0.9 B | 0.4 KB |
|
||||
| File | Size | Ggzip | Brotli |
|
||||
| ---------- | ------: | ------: | ------: |
|
||||
| `snappy.css` | 0.91 kB | 0.43 kB | 0.36 kB |
|
||||
| `snappy.js` | 7.65 kB | 2.20 kB | 1.95 kB |
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
+109
-95
@@ -30,9 +30,12 @@ class Carousel {
|
||||
// Behavioral constants
|
||||
inertia = 0.92; // Decay rate (0.9 = slower stop, 0.99 = longer slide)
|
||||
velocityFrameCount = 2;
|
||||
dragThreshold = 5;
|
||||
|
||||
// State variables
|
||||
isDragging = false;
|
||||
hasDragged = false;
|
||||
suppressNextClick = false;
|
||||
dragFrame = null;
|
||||
pendingDragPosition = null;
|
||||
scrollUpdateFrame = null;
|
||||
@@ -65,8 +68,20 @@ class Carousel {
|
||||
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('click', event => {
|
||||
if (!this.suppressNextClick) return;
|
||||
|
||||
this.scrollContainer.addEventListener('touchstart', this.resetDragStates.bind(this));
|
||||
this.suppressNextClick = false;
|
||||
if (event.detail === 0) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, true);
|
||||
|
||||
this.scrollContainer.addEventListener('touchstart', () => {
|
||||
this.suppressNextClick = false;
|
||||
this.resetDragStates();
|
||||
});
|
||||
this.scrollContainer.addEventListener('wheel', this.resetDragStates.bind(this));
|
||||
|
||||
this.scrollContainer.addEventListener('scroll', () => {
|
||||
@@ -100,6 +115,8 @@ class Carousel {
|
||||
* @returns {void}
|
||||
*/
|
||||
startDrag(e) {
|
||||
this.suppressNextClick = false;
|
||||
this.hasDragged = false;
|
||||
this.isDragging = true;
|
||||
this.autoScrolling = false;
|
||||
this.scrollContainer.classList.add('is-dragging'); // Disable scroll snapping
|
||||
@@ -126,6 +143,7 @@ class Carousel {
|
||||
this.pendingPointerPosition = pointerPosition;
|
||||
|
||||
const distance = pointerPosition - this.startPosition;
|
||||
if (Math.abs(distance) >= this.dragThreshold) this.hasDragged = true;
|
||||
this.pendingDragPosition = this.startScrollPosition - distance;
|
||||
|
||||
// Keep at most one pending write; the callback applies the newest position.
|
||||
@@ -145,6 +163,7 @@ class Carousel {
|
||||
if (!this.isDragging) return;
|
||||
this.flushPendingDragPosition();
|
||||
this.isDragging = false;
|
||||
this.suppressNextClick = this.hasDragged;
|
||||
|
||||
// If the drag velocity is low at release, let the native scroll snap take over
|
||||
if (Math.abs(this.velocity) < 0.05) {
|
||||
@@ -233,68 +252,88 @@ class Carousel {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current snapport geometry and reachable scroll range.
|
||||
* @returns {object} The snapport metrics for the scrolling axis.
|
||||
*/
|
||||
getSnapport() {
|
||||
const container = this.scrollContainer;
|
||||
const isVertical = this.direction === 'y';
|
||||
const startEdge = isVertical ? 'top' : 'left';
|
||||
const paddingStart = isVertical ? 'scrollPaddingTop' : 'scrollPaddingLeft';
|
||||
const paddingEnd = isVertical ? 'scrollPaddingBottom' : 'scrollPaddingRight';
|
||||
const cssPixels = value => Number.parseFloat(value) || 0;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const containerStyle = getComputedStyle(container);
|
||||
const snapportStartEdge = containerRect[startEdge] +
|
||||
(isVertical ? container.clientTop : container.clientLeft);
|
||||
const clientExtent = isVertical ? container.clientHeight : container.clientWidth;
|
||||
const snapportStart = snapportStartEdge + cssPixels(containerStyle[paddingStart]);
|
||||
const snapportEnd = snapportStartEdge + clientExtent - cssPixels(containerStyle[paddingEnd]);
|
||||
const maxScrollPosition = isVertical
|
||||
? container.scrollHeight - container.clientHeight
|
||||
: container.scrollWidth - container.clientWidth;
|
||||
|
||||
return {
|
||||
isVertical,
|
||||
snapportStart,
|
||||
snapportEnd,
|
||||
scrollPosition: this.getScrollPosition(),
|
||||
maxScrollPosition,
|
||||
clamp: value => Math.max(0, Math.min(value, maxScrollPosition))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate a slide's snap position within the current snapport.
|
||||
* @param {HTMLElement} slide - The slide to measure.
|
||||
* @param {object} snapport - The current snapport metrics.
|
||||
* @returns {number} The target scroll offset before range clamping.
|
||||
*/
|
||||
getSlideSnapPosition(slide, snapport) {
|
||||
const { isVertical, snapportStart, snapportEnd, scrollPosition } = snapport;
|
||||
const startEdge = isVertical ? 'top' : 'left';
|
||||
const endEdge = isVertical ? 'bottom' : 'right';
|
||||
const marginStart = isVertical ? 'scrollMarginTop' : 'scrollMarginLeft';
|
||||
const marginEnd = isVertical ? 'scrollMarginBottom' : 'scrollMarginRight';
|
||||
const cssPixels = value => Number.parseFloat(value) || 0;
|
||||
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];
|
||||
|
||||
if (align === 'start') {
|
||||
return scrollPosition + snapAreaStart - snapportStart;
|
||||
}
|
||||
|
||||
if (align === 'end') {
|
||||
return scrollPosition + snapAreaEnd - snapportEnd;
|
||||
}
|
||||
|
||||
const snapAreaCenter = (snapAreaStart + snapAreaEnd) / 2;
|
||||
const snapportCenter = (snapportStart + snapportEnd) / 2;
|
||||
return scrollPosition + snapAreaCenter - snapportCenter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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';
|
||||
const snapport = this.getSnapport();
|
||||
|
||||
// Project the stopping point from velocity, then constrain it to the scroll range.
|
||||
const projectedStop = clamp(
|
||||
const projectedStop = snapport.clamp(
|
||||
this.getScrollPosition() - this.velocity * 10 / (1 - this.inertia)
|
||||
);
|
||||
|
||||
if (!this.slides.length) return clamp(projectedStop);
|
||||
if (!this.slides.length) return 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);
|
||||
});
|
||||
const targets = [...this.slides].map(slide =>
|
||||
snapport.clamp(this.getSlideSnapPosition(slide, snapport))
|
||||
);
|
||||
|
||||
// Select the snap target nearest the projected stopping point.
|
||||
return targets.reduce((nearest, target) =>
|
||||
@@ -386,53 +425,17 @@ class Carousel {
|
||||
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;
|
||||
const snapport = this.getSnapport();
|
||||
// At the scroll limit, prefer the later slide when clamping makes targets tie.
|
||||
const isAtEnd = snapport.maxScrollPosition > 0 &&
|
||||
snapport.scrollPosition >= snapport.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);
|
||||
const snapPosition = this.getSlideSnapPosition(slide, snapport);
|
||||
const distance = Math.abs(snapport.clamp(snapPosition) - snapport.scrollPosition);
|
||||
|
||||
// At the scroll limit, prefer the later slide when clamping makes targets tie.
|
||||
if (distance < closestDistance || (isAtEnd && distance === closestDistance)) {
|
||||
@@ -475,10 +478,21 @@ class Carousel {
|
||||
* @returns {void}
|
||||
*/
|
||||
goToSlide(index) {
|
||||
if (!this.slides.length) return;
|
||||
|
||||
this.activeSlideIndex = Math.max(0, Math.min(index, this.slides.length - 1));
|
||||
this.autoScrolling = true;
|
||||
this.updateActiveSlide();
|
||||
this.slides[this.activeSlideIndex].scrollIntoView();
|
||||
|
||||
const container = this.scrollContainer;
|
||||
const slide = this.slides[this.activeSlideIndex];
|
||||
const snapport = this.getSnapport();
|
||||
const targetPosition = snapport.clamp(this.getSlideSnapPosition(slide, snapport));
|
||||
|
||||
container.scrollTo({
|
||||
[snapport.isVertical ? 'top' : 'left']: targetPosition,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user