Skip to content

Commit

Permalink
fix(carousel): refactor mouse dragging
Browse files Browse the repository at this point in the history
  • Loading branch information
alenaksu committed Nov 30, 2023
1 parent 78b2a9c commit aec63c6
Show file tree
Hide file tree
Showing 5 changed files with 99 additions and 170 deletions.
2 changes: 1 addition & 1 deletion docs/pages/components/carousel.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ const App = () => (
The carousel will automatically advance when the `autoplay` attribute is used. To change how long a slide is shown before advancing, set `autoplay-interval` to the desired number of milliseconds. For best results, use the `loop` attribute when autoplay is enabled. Note that autoplay will pause while the user interacts with the carousel.

```html:preview
<sl-carousel autoplay loop pagination>
<sl-carousel loop pagination>
<sl-carousel-item>
<img
alt="The sun shines on the mountains and trees (by Adam Kool on Unsplash)"
Expand Down
83 changes: 69 additions & 14 deletions src/components/carousel/carousel.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import '../../internal/scrollend-polyfill.js';
import { AutoplayController } from './autoplay-controller.js';
import { clamp } from '../../internal/math.js';
import { classMap } from 'lit/directives/class-map.js';
import { eventOptions, property, query, state } from 'lit/decorators.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { map } from 'lit/directives/map.js';
import { prefersReducedMotion } from '../../internal/animate.js';
import { property, query, state } from 'lit/decorators.js';
import { range } from 'lit/directives/range.js';
import { ScrollController } from './scroll-controller.js';
import { waitForEvent } from '../../internal/event.js';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import SlIcon from '../icon/icon.component.js';
Expand Down Expand Up @@ -86,8 +86,11 @@ export default class SlCarousel extends ShoelaceElement {
// The index of the active slide
@state() activeSlide = 0;

@state() scrolling = false;

@state() dragging = false;

private autoplayController = new AutoplayController(this, () => this.next());
private scrollController = new ScrollController(this);
private intersectionObserver: IntersectionObserver; // determines which slide is displayed
// A map containing the state of all the slides
private readonly intersectionObserverEntries = new Map<Element, IntersectionObserverEntry>();
Expand Down Expand Up @@ -216,7 +219,52 @@ export default class SlCarousel extends ShoelaceElement {
}
}

handleDrag = (event: MouseEvent) => {
const hasMoved = !!event.movementX || !!event.movementY;
if (!this.dragging && hasMoved) {
// Start dragging if it hasn't yet
this.dragging = true;
} else {
this.scrollContainer.scrollBy({
left: -event.movementX,
top: -event.movementY
});
}
};

handleDragStart(event: MouseEvent) {
const canDrag = this.mouseDragging && event.button === 0;
if (canDrag) {
event.preventDefault();

document.addEventListener('mousemove', this.handleDrag);
document.addEventListener('mouseup', this.handleDragEnd, { once: true });
}
}

handleDragEnd = async () => {
document.removeEventListener('mousemove', this.handleDrag);

const nearestSlide = [...this.intersectionObserverEntries.values()]
.sort((a, b) => a.intersectionRatio - b.intersectionRatio)
.pop();

if (nearestSlide?.target instanceof HTMLElement) {
await this.scrollToSlide(nearestSlide.target);
}

this.dragging = false;
this.handleScrollEnd();
};

@eventOptions({ passive: true })
private handleScroll() {
this.scrolling = true;
}

private handleScrollEnd() {
if (this.dragging) return;

const slides = this.getSlides();
const entries = [...this.intersectionObserverEntries.values()];

Expand All @@ -233,6 +281,8 @@ export default class SlCarousel extends ShoelaceElement {
// Set the index to the first "snappable" slide
this.activeSlide = Math.ceil(slideIndex / this.slidesPerMove) * this.slidesPerMove;
}

this.scrolling = false;
}

private isCarouselItem(node: Node): node is SlCarouselItem {
Expand Down Expand Up @@ -350,11 +400,6 @@ export default class SlCarousel extends ShoelaceElement {
}
}

@watch('mouseDragging')
handleMouseDraggingChange() {
this.scrollController.mouseDragging = this.mouseDragging;
}

/**
* Move the carousel backward by `slides-per-move` slides.
*
Expand All @@ -380,7 +425,7 @@ export default class SlCarousel extends ShoelaceElement {
* @param behavior - The behavior used for scrolling.
*/
goToSlide(index: number, behavior: ScrollBehavior = 'smooth') {
const { slidesPerPage, loop, scrollContainer } = this;
const { slidesPerPage, loop } = this;

const slides = this.getSlides();
const slidesWithClones = this.getSlides({ excludeClones: false });
Expand All @@ -399,18 +444,25 @@ export default class SlCarousel extends ShoelaceElement {
const nextSlideIndex = clamp(index + (loop ? slidesPerPage : 0), 0, slidesWithClones.length - 1);
const nextSlide = slidesWithClones[nextSlideIndex];

this.scrollToSlide(nextSlide, prefersReducedMotion() ? 'auto' : behavior);
}

scrollToSlide(slide: HTMLElement, behavior: ScrollBehavior = 'smooth') {
const scrollContainer = this.scrollContainer;
const scrollContainerRect = scrollContainer.getBoundingClientRect();
const nextSlideRect = nextSlide.getBoundingClientRect();
const nextSlideRect = slide.getBoundingClientRect();

scrollContainer.scrollTo({
left: nextSlideRect.left - scrollContainerRect.left + scrollContainer.scrollLeft,
top: nextSlideRect.top - scrollContainerRect.top + scrollContainer.scrollTop,
behavior: prefersReducedMotion() ? 'auto' : behavior
behavior
});

return waitForEvent(scrollContainer, 'scrollend');
}

render() {
const { scrollController, slidesPerMove } = this;
const { slidesPerMove, scrolling } = this;
const pagesCount = this.getPageCount();
const currentPage = this.getCurrentPage();
const prevEnabled = this.canScrollPrev();
Expand All @@ -425,13 +477,16 @@ export default class SlCarousel extends ShoelaceElement {
class="${classMap({
carousel__slides: true,
'carousel__slides--horizontal': this.orientation === 'horizontal',
'carousel__slides--vertical': this.orientation === 'vertical'
'carousel__slides--vertical': this.orientation === 'vertical',
'carousel__slides--dragging': this.dragging
})}"
style="--slides-per-page: ${this.slidesPerPage};"
aria-busy="${scrollController.scrolling ? 'true' : 'false'}"
aria-busy="${scrolling ? 'true' : 'false'}"
aria-atomic="true"
tabindex="0"
@keydown=${this.handleKeyDown}
@mousedown="${this.handleDragStart}"
@scroll="${this.handleScroll}"
@scrollend=${this.handleScrollEnd}
>
<slot></slot>
Expand Down
2 changes: 1 addition & 1 deletion src/components/carousel/carousel.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export default css`
.carousel__slides--dragging,
.carousel__slides--dropping {
scroll-snap-type: unset;
scroll-snap-type: unset !important;
}
:host([vertical]) ::slotted(sl-carousel-item) {
Expand Down
140 changes: 0 additions & 140 deletions src/components/carousel/scroll-controller.ts

This file was deleted.

42 changes: 28 additions & 14 deletions src/internal/scrollend-polyfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@ type GenericCallback = (this: unknown, ...args: unknown[]) => unknown;

type MethodOf<T, K extends keyof T> = T[K] extends GenericCallback ? T[K] : never;

const debounce = <T extends GenericCallback>(fn: T, delay: number) => {
let timerId = 0;

return function (this: unknown, ...args: unknown[]) {
window.clearTimeout(timerId);
timerId = window.setTimeout(() => {
fn.call(this, ...args);
}, delay);
};
};

const decorate = <T, M extends keyof T>(
proto: T,
method: M,
Expand All @@ -21,42 +32,45 @@ if (!isSupported) {
const pointers = new Set();
const scrollHandlers = new WeakMap<EventTarget, EventListenerOrEventListenerObject>();

const handlePointerDown = (event: PointerEvent) => {
pointers.add(event.pointerId);
const handlePointerDown = (event: TouchEvent) => {
for (const touch of event.changedTouches) {
pointers.add(touch.identifier);
}
};

const handlePointerUp = (event: PointerEvent) => {
pointers.delete(event.pointerId);
const handlePointerUp = (event: TouchEvent) => {
for (const touch of event.changedTouches) {
pointers.delete(touch.identifier);
}
};

document.addEventListener('pointerdown', handlePointerDown);
document.addEventListener('pointerup', handlePointerUp);
document.addEventListener('touchstart', handlePointerDown, true);
document.addEventListener('touchend', handlePointerUp, true);
document.addEventListener('touchcancel', handlePointerUp, true);

// If the pointer is used for scrolling, the browser fires a pointercancel event because it determines
// that there are unlikely to be any more pointer events.
document.addEventListener('pointercancel', handlePointerUp);
// document.addEventListener('pointercancel', handlePointerUp);

decorate(EventTarget.prototype, 'addEventListener', function (this: EventTarget, addEventListener, type) {
if (type !== 'scroll') return;
if (type !== 'scrollend') return;

const handleScrollEnd = () => {
const handleScrollEnd = debounce(() => {
if (!pointers.size) {
// If no pointer is active in the scroll area then the scroll has ended
this.dispatchEvent(new Event('scrollend'));
} else {
// otherwise let's wait a bit more
setTimeout(() => {
handleScrollEnd();
}, 100);
handleScrollEnd();
}
};
}, 100);

addEventListener.call(this, 'scroll', handleScrollEnd, { passive: true });
scrollHandlers.set(this, handleScrollEnd);
});

decorate(EventTarget.prototype, 'removeEventListener', function (this: EventTarget, removeEventListener, type) {
if (type !== 'scroll') return;
if (type !== 'scrollend') return;

const scrollHandler = scrollHandlers.get(this);
if (scrollHandler) {
Expand Down

0 comments on commit aec63c6

Please sign in to comment.