From aec63c68b5ef48ca85bcf2b1e5d5734d5582f0a5 Mon Sep 17 00:00:00 2001 From: Alessandro Date: Thu, 30 Nov 2023 00:45:46 +0000 Subject: [PATCH] fix(carousel): refactor mouse dragging --- docs/pages/components/carousel.md | 2 +- src/components/carousel/carousel.component.ts | 83 +++++++++-- src/components/carousel/carousel.styles.ts | 2 +- src/components/carousel/scroll-controller.ts | 140 ------------------ src/internal/scrollend-polyfill.ts | 42 ++++-- 5 files changed, 99 insertions(+), 170 deletions(-) delete mode 100644 src/components/carousel/scroll-controller.ts diff --git a/docs/pages/components/carousel.md b/docs/pages/components/carousel.md index afc0bc9276..a883a9b183 100644 --- a/docs/pages/components/carousel.md +++ b/docs/pages/components/carousel.md @@ -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 - + The sun shines on the mountains and trees (by Adam Kool on Unsplash) 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(); @@ -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()]; @@ -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 { @@ -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. * @@ -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 }); @@ -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(); @@ -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} > diff --git a/src/components/carousel/carousel.styles.ts b/src/components/carousel/carousel.styles.ts index 569fd9cf28..995b0c50a6 100644 --- a/src/components/carousel/carousel.styles.ts +++ b/src/components/carousel/carousel.styles.ts @@ -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) { diff --git a/src/components/carousel/scroll-controller.ts b/src/components/carousel/scroll-controller.ts deleted file mode 100644 index 3871f000bc..0000000000 --- a/src/components/carousel/scroll-controller.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { prefersReducedMotion } from '../../internal/animate.js'; -import { waitForEvent } from '../../internal/event.js'; -import type { ReactiveController, ReactiveElement } from 'lit'; - -interface ScrollHost extends ReactiveElement { - scrollContainer: HTMLElement; -} - -/** - * A controller for handling scrolling and mouse dragging. - */ -export class ScrollController implements ReactiveController { - private host: T; - - dragging = false; - scrolling = false; - mouseDragging = false; - - constructor(host: T) { - this.host = host; - host.addController(this); - } - - async hostConnected() { - const host = this.host; - await host.updateComplete; - - const scrollContainer = host.scrollContainer; - - scrollContainer.addEventListener('scroll', this.handleScroll, { passive: true }); - scrollContainer.addEventListener('scrollend', this.handleScrollEnd, true); - scrollContainer.addEventListener('pointerdown', this.handlePointerDown); - scrollContainer.addEventListener('pointerup', this.handlePointerUp); - scrollContainer.addEventListener('pointercancel', this.handlePointerUp); - } - - hostDisconnected(): void { - const host = this.host; - const scrollContainer = host.scrollContainer; - - scrollContainer.removeEventListener('scroll', this.handleScroll); - scrollContainer.removeEventListener('scrollend', this.handleScrollEnd, true); - scrollContainer.removeEventListener('pointerdown', this.handlePointerDown); - scrollContainer.removeEventListener('pointerup', this.handlePointerUp); - scrollContainer.removeEventListener('pointercancel', this.handlePointerUp); - } - - handleScroll = () => { - if (!this.scrolling) { - this.scrolling = true; - this.host.requestUpdate(); - } - }; - - handleScrollEnd = () => { - if (this.scrolling && !this.dragging) { - this.scrolling = false; - this.host.requestUpdate(); - } - }; - - handlePointerDown = (event: PointerEvent) => { - // Do not handle drag for touch interactions as scroll is natively supported - if (event.pointerType === 'touch') { - return; - } - - const canDrag = this.mouseDragging && event.button === 0; - if (canDrag) { - event.preventDefault(); - - this.host.scrollContainer.addEventListener('pointermove', this.handlePointerMove); - } - }; - - handlePointerMove = (event: PointerEvent) => { - const scrollContainer = this.host.scrollContainer; - - const hasMoved = !!event.movementX || !!event.movementY; - if (!this.dragging && hasMoved) { - // Start dragging if it hasn't yet - scrollContainer.setPointerCapture(event.pointerId); - this.handleDragStart(); - } else if (scrollContainer.hasPointerCapture(event.pointerId)) { - // Ignore pointers that we are not tracking - this.handleDrag(event); - } - }; - - handlePointerUp = (event: PointerEvent) => { - this.host.scrollContainer.releasePointerCapture(event.pointerId); - - this.handleDragEnd(); - }; - - handleDragStart() { - const host = this.host; - - this.dragging = true; - host.scrollContainer.style.setProperty('scroll-snap-type', 'unset'); - host.requestUpdate(); - } - - handleDrag(event: PointerEvent) { - this.host.scrollContainer.scrollBy({ - left: -event.movementX, - top: -event.movementY - }); - } - - handleDragEnd() { - const host = this.host; - const scrollContainer = host.scrollContainer; - - scrollContainer.removeEventListener('pointermove', this.handlePointerMove); - - const startLeft = scrollContainer.scrollLeft; - const startTop = scrollContainer.scrollTop; - - scrollContainer.style.removeProperty('scroll-snap-type'); - const finalLeft = scrollContainer.scrollLeft; - const finalTop = scrollContainer.scrollTop; - - scrollContainer.style.setProperty('scroll-snap-type', 'unset'); - scrollContainer.scrollTo({ left: startLeft, top: startTop, behavior: 'auto' }); - scrollContainer.scrollTo({ left: finalLeft, top: finalTop, behavior: prefersReducedMotion() ? 'auto' : 'smooth' }); - - // Wait for scroll to be applied - requestAnimationFrame(async () => { - if (startLeft !== finalLeft || startTop !== finalTop) { - await waitForEvent(scrollContainer, 'scrollend'); - } - - scrollContainer.style.removeProperty('scroll-snap-type'); - - this.dragging = false; - host.requestUpdate(); - }); - } -} diff --git a/src/internal/scrollend-polyfill.ts b/src/internal/scrollend-polyfill.ts index c11754c89b..bb4f3218dc 100644 --- a/src/internal/scrollend-polyfill.ts +++ b/src/internal/scrollend-polyfill.ts @@ -2,6 +2,17 @@ type GenericCallback = (this: unknown, ...args: unknown[]) => unknown; type MethodOf = T[K] extends GenericCallback ? T[K] : never; +const debounce = (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 = ( proto: T, method: M, @@ -21,42 +32,45 @@ if (!isSupported) { const pointers = new Set(); const scrollHandlers = new WeakMap(); - 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) {