diff --git a/src/components/carousel/carousel.component.ts b/src/components/carousel/carousel.component.ts index 5c200305..5e186fc9 100644 --- a/src/components/carousel/carousel.component.ts +++ b/src/components/carousel/carousel.component.ts @@ -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'; @@ -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(); @@ -216,8 +219,80 @@ export default class SlCarousel extends ShoelaceElement { } } + private handleMouseDragStart(event: PointerEvent) { + const canDrag = this.mouseDragging && event.button === 0; + if (canDrag) { + event.preventDefault(); + + document.addEventListener('pointermove', this.handleMouseDrag, { capture: true, passive: true }); + document.addEventListener('pointerup', this.handleMouseDragEnd, { capture: true, once: true }); + } + } + + private handleMouseDrag = (event: PointerEvent) => { + if (!this.dragging) { + // Start dragging if it hasn't yet + this.scrollContainer.style.setProperty('scroll-snap-type', 'none'); + this.dragging = true; + } + + this.scrollContainer.scrollBy({ + left: -event.movementX, + top: -event.movementY, + behavior: 'instant' + }); + }; + + private handleMouseDragEnd = () => { + const scrollContainer = this.scrollContainer; + + document.removeEventListener('pointermove', this.handleMouseDrag, { capture: true }); + + // get the current scroll position + const startLeft = scrollContainer.scrollLeft; + const startTop = scrollContainer.scrollTop; + + // remove the scroll-snap-type property so that the browser will snap the slide to the correct position + scrollContainer.style.removeProperty('scroll-snap-type'); + + // fix(safari): forcing a style recalculation doesn't seem to immediately update the scroll + // position in Safari. Setting "overflow" to "hidden" should force this behavior. + scrollContainer.style.setProperty('overflow', 'hidden'); + + // get the final scroll position to the slide snapped by the browser + const finalLeft = scrollContainer.scrollLeft; + const finalTop = scrollContainer.scrollTop; + + // restore the scroll position to the original one, so that it can be smoothly animated if needed + scrollContainer.style.removeProperty('overflow'); + scrollContainer.style.setProperty('scroll-snap-type', 'none'); + scrollContainer.scrollTo({ left: startLeft, top: startTop, behavior: 'instant' }); + + requestAnimationFrame(async () => { + if (startLeft !== finalLeft || startTop !== finalTop) { + scrollContainer.scrollTo({ + left: finalLeft, + top: finalTop, + behavior: prefersReducedMotion() ? 'auto' : 'smooth' + }); + await waitForEvent(scrollContainer, 'scrollend'); + } + + scrollContainer.style.removeProperty('scroll-snap-type'); + + this.dragging = false; + this.handleScrollEnd(); + }); + }; + + @eventOptions({ passive: true }) + private handleScroll() { + this.scrolling = true; + } + private handleScrollEnd() { - const slides = this.getSlides(); + if (!this.scrolling || this.dragging) return; + const entries = [...this.intersectionObserverEntries.values()]; const firstIntersecting: IntersectionObserverEntry | undefined = entries.find(entry => entry.isIntersecting); @@ -226,13 +301,17 @@ export default class SlCarousel extends ShoelaceElement { const clonePosition = Number(firstIntersecting.target.getAttribute('data-clone')); // Scrolls to the original slide without animating, so the user won't notice that the position has changed - this.goToSlide(clonePosition, 'auto'); + this.goToSlide(clonePosition, 'instant'); } else if (firstIntersecting) { + const slides = this.getSlides(); + // Update the current index based on the first visible slide const slideIndex = slides.indexOf(firstIntersecting.target as SlCarouselItem); // 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 +429,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 +454,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 +473,31 @@ export default class SlCarousel extends ShoelaceElement { const nextSlideIndex = clamp(index + (loop ? slidesPerPage : 0), 0, slidesWithClones.length - 1); const nextSlide = slidesWithClones[nextSlideIndex]; - const scrollContainerRect = scrollContainer.getBoundingClientRect(); - const nextSlideRect = nextSlide.getBoundingClientRect(); + this.scrollToSlide(nextSlide, prefersReducedMotion() ? 'auto' : behavior); + } - scrollContainer.scrollTo({ - left: nextSlideRect.left - scrollContainerRect.left + scrollContainer.scrollLeft, - top: nextSlideRect.top - scrollContainerRect.top + scrollContainer.scrollTop, - behavior: prefersReducedMotion() ? 'auto' : behavior - }); + private async scrollToSlide(slide: HTMLElement, behavior: ScrollBehavior = 'smooth') { + const scrollContainer = this.scrollContainer; + const scrollContainerRect = scrollContainer.getBoundingClientRect(); + const nextSlideRect = slide.getBoundingClientRect(); + + const nextLeft = nextSlideRect.left - scrollContainerRect.left; + const nextTop = nextSlideRect.top - scrollContainerRect.top; + + // If the slide is already in view, don't need to scroll + if (nextLeft !== scrollContainer.scrollLeft || nextTop !== scrollContainer.scrollTop) { + scrollContainer.scrollTo({ + left: nextLeft + scrollContainer.scrollLeft, + top: nextTop + scrollContainer.scrollTop, + behavior + }); + + await 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 +512,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.handleMouseDragStart}" + @scroll="${this.handleScroll}" @scrollend=${this.handleScrollEnd} > diff --git a/src/components/carousel/carousel.styles.ts b/src/components/carousel/carousel.styles.ts index 569fd9cf..a26c9446 100644 --- a/src/components/carousel/carousel.styles.ts +++ b/src/components/carousel/carousel.styles.ts @@ -79,9 +79,7 @@ export default css` overflow-x: hidden; } - .carousel__slides--dragging, - .carousel__slides--dropping { - scroll-snap-type: unset; + .carousel__slides--dragging { } :host([vertical]) ::slotted(sl-carousel-item) { diff --git a/src/components/carousel/carousel.test.ts b/src/components/carousel/carousel.test.ts index bd581b37..42d693a7 100644 --- a/src/components/carousel/carousel.test.ts +++ b/src/components/carousel/carousel.test.ts @@ -1,12 +1,17 @@ import '../../../dist/shoelace.js'; -import { clickOnElement } from '../../internal/test.js'; +import { clickOnElement, dragElement, moveMouseOnElement } from '../../internal/test.js'; import { expect, fixture, html, oneEvent } from '@open-wc/testing'; import { map } from 'lit/directives/map.js'; import { range } from 'lit/directives/range.js'; +import { resetMouse } from '@web/test-runner-commands'; import sinon from 'sinon'; import type SlCarousel from './carousel.js'; describe('', () => { + afterEach(async () => { + await resetMouse(); + }); + it('should render a carousel with default configuration', async () => { // Arrange const el = await fixture(html` @@ -409,6 +414,53 @@ describe('', () => { }); }); + describe('when `mouse-dragging` attribute is provided', () => { + // TODO(alenaksu): skipping because failing in webkit, PointerEvent.movementX and PointerEvent.movementY seem to return incorrect values + it.skip('should be possible to drag the carousel using the mouse', async () => { + // Arrange + const el = await fixture(html` + + Node 1 + Node 2 + Node 3 + + `); + + // Act + await dragElement(el, -Math.round(el.offsetWidth * 0.75)); + await oneEvent(el.scrollContainer, 'scrollend'); + await dragElement(el, -Math.round(el.offsetWidth * 0.75)); + await oneEvent(el.scrollContainer, 'scrollend'); + + await el.updateComplete; + + // Assert + expect(el.activeSlide).to.be.equal(2); + }); + + it('should be possible to interact with clickable elements', async () => { + // Arrange + const el = await fixture(html` + + + Node 2 + Node 3 + + `); + const button = el.querySelector('button')!; + + const clickSpy = sinon.spy(); + button.addEventListener('click', clickSpy); + + // Act + await moveMouseOnElement(button); + await clickOnElement(button); + + // Assert + expect(clickSpy).to.have.been.called; + }); + }); + describe('Navigation controls', () => { describe('when the user clicks the next button', () => { it('should scroll to the next slide', async () => { diff --git a/src/components/carousel/scroll-controller.ts b/src/components/carousel/scroll-controller.ts deleted file mode 100644 index 3871f000..00000000 --- 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 8eedc104..b58636c3 100644 --- a/src/internal/scrollend-polyfill.ts +++ b/src/internal/scrollend-polyfill.ts @@ -32,19 +32,24 @@ 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); decorate(EventTarget.prototype, 'addEventListener', function (this: EventTarget, addEventListener, type) { - if (type !== 'scroll') return; + if (type !== 'scrollend') return; const handleScrollEnd = debounce(() => { if (!pointers.size) { @@ -61,7 +66,7 @@ if (!isSupported) { }); decorate(EventTarget.prototype, 'removeEventListener', function (this: EventTarget, removeEventListener, type) { - if (type !== 'scroll') return; + if (type !== 'scrollend') return; const scrollHandler = scrollHandlers.get(this); if (scrollHandler) {