From 32494e783c36d7ae4f9d81e8a053914e26e9212c Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Mon, 23 Oct 2023 12:03:18 -0400 Subject: [PATCH] backport PR 1605 --- cspell.json | 1 + docs/pages/resources/changelog.md | 4 + .../carousel-item/carousel-item.component.ts | 4 - .../carousel-item/carousel-item.styles.ts | 4 +- src/components/carousel/carousel.component.ts | 131 +++++++++++------- src/components/carousel/carousel.test.ts | 121 +++++++++++++++- src/components/carousel/scroll-controller.ts | 65 +++------ src/components/menu-item/menu-item.test.ts | 8 +- src/internal/scrollend-polyfill.ts | 74 ++++++++++ 9 files changed, 304 insertions(+), 108 deletions(-) create mode 100644 src/internal/scrollend-polyfill.ts diff --git a/cspell.json b/cspell.json index a7bc1be0a..8b66531e1 100644 --- a/cspell.json +++ b/cspell.json @@ -113,6 +113,7 @@ "Numberish", "oklab", "oklch", + "onscrollend", "outdir", "ParamagicDev", "peta", diff --git a/docs/pages/resources/changelog.md b/docs/pages/resources/changelog.md index 61e96cd88..db07c27bf 100644 --- a/docs/pages/resources/changelog.md +++ b/docs/pages/resources/changelog.md @@ -22,6 +22,10 @@ New versions of Web Awesome are released as-needed and generally occur when a cr ## Next +- Improved the experimental `` component [#1605] + +## 2.11.0 + - Added the Croatian translation [#1656] - Fixed a bug that caused the [[Escape]] key to stop propagating when tooltips are disabled [#1607] - Fixed a bug that made it impossible to style placeholders in `` [#1667] diff --git a/src/components/carousel-item/carousel-item.component.ts b/src/components/carousel-item/carousel-item.component.ts index 59a619dbf..8b173818c 100644 --- a/src/components/carousel-item/carousel-item.component.ts +++ b/src/components/carousel-item/carousel-item.component.ts @@ -17,10 +17,6 @@ import type { CSSResultGroup } from 'lit'; export default class WaCarouselItem extends WebAwesomeElement { static styles: CSSResultGroup = styles; - static isCarouselItem(node: Node) { - return node instanceof Element && node.getAttribute('aria-roledescription') === 'slide'; - } - connectedCallback() { super.connectedCallback(); this.setAttribute('role', 'group'); diff --git a/src/components/carousel-item/carousel-item.styles.ts b/src/components/carousel-item/carousel-item.styles.ts index 65327e84a..4a5053746 100644 --- a/src/components/carousel-item/carousel-item.styles.ts +++ b/src/components/carousel-item/carousel-item.styles.ts @@ -19,8 +19,8 @@ export default css` } ::slotted(img) { - width: 100%; - height: 100%; + width: 100% !important; + height: 100% !important; object-fit: cover; } `; diff --git a/src/components/carousel/carousel.component.ts b/src/components/carousel/carousel.component.ts index d63feb759..014a7271a 100644 --- a/src/components/carousel/carousel.component.ts +++ b/src/components/carousel/carousel.component.ts @@ -1,3 +1,5 @@ +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'; @@ -10,10 +12,10 @@ import { range } from 'lit/directives/range.js'; import { ScrollController } from './scroll-controller.js'; import { watch } from '../../internal/watch.js'; import styles from './carousel.styles.js'; -import WaCarouselItem from '../carousel-item/carousel-item.component.js'; import WaIcon from '../icon/icon.component.js'; import WebAwesomeElement from '../../internal/webawesome-element.js'; -import type { CSSResultGroup } from 'lit'; +import type { CSSResultGroup, PropertyValueMap } from 'lit'; +import type WaCarouselItem from '../carousel-item/carousel-item.component.js'; /** * @summary Carousels display an arbitrary number of content slides along a horizontal or vertical axis. @@ -68,7 +70,7 @@ export default class WaCarousel extends WebAwesomeElement { /** * Specifies the number of slides the carousel will advance when scrolling, useful when specifying a `slides-per-page` - * greater than one. + * greater than one. It can't be higher than `slides-per-page`. */ @property({ type: Number, attribute: 'slides-per-move' }) slidesPerMove = 1; @@ -78,7 +80,6 @@ export default class WaCarousel extends WebAwesomeElement { /** When set, it is possible to scroll through the slides by dragging them with the mouse. */ @property({ type: Boolean, reflect: true, attribute: 'mouse-dragging' }) mouseDragging = false; - @query('slot:not([name])') defaultSlot: HTMLSlotElement; @query('.carousel__slides') scrollContainer: HTMLElement; @query('.carousel__pagination') paginationContainer: HTMLElement; @@ -87,7 +88,6 @@ export default class WaCarousel extends WebAwesomeElement { private autoplayController = new AutoplayController(this, () => this.next()); private scrollController = new ScrollController(this); - private readonly slides = this.getElementsByTagName('wa-carousel-item'); private intersectionObserver: IntersectionObserver; // determines which slide is displayed // A map containing the state of all the slides private readonly intersectionObserverEntries = new Map(); @@ -133,19 +133,45 @@ export default class WaCarousel extends WebAwesomeElement { protected firstUpdated(): void { this.initializeSlides(); this.mutationObserver = new MutationObserver(this.handleSlotChange); - this.mutationObserver.observe(this, { childList: true, subtree: false }); + this.mutationObserver.observe(this, { + childList: true, + subtree: true + }); + } + + protected willUpdate(changedProperties: PropertyValueMap | Map): void { + // Ensure the slidesPerMove is never higher than the slidesPerPage + if (changedProperties.has('slidesPerMove') || changedProperties.has('slidesPerPage')) { + this.slidesPerMove = Math.min(this.slidesPerMove, this.slidesPerPage); + } } private getPageCount() { - return Math.ceil(this.getSlides().length / this.slidesPerPage); + const slidesCount = this.getSlides().length; + const { slidesPerPage, slidesPerMove, loop } = this; + + const pages = loop ? slidesCount / slidesPerMove : (slidesCount - slidesPerPage) / slidesPerMove + 1; + + return Math.ceil(pages); } private getCurrentPage() { - return Math.ceil(this.activeSlide / this.slidesPerPage); + return Math.ceil(this.activeSlide / this.slidesPerMove); } + private canScrollNext(): boolean { + return this.loop || this.getCurrentPage() < this.getPageCount() - 1; + } + + private canScrollPrev(): boolean { + return this.loop || this.getCurrentPage() > 0; + } + + /** @internal Gets all carousel items. */ private getSlides({ excludeClones = true }: { excludeClones?: boolean } = {}) { - return [...this.slides].filter(slide => !excludeClones || !slide.hasAttribute('data-clone')); + return [...this.children].filter( + (el: HTMLElement) => this.isCarouselItem(el) && (!excludeClones || !el.hasAttribute('data-clone')) + ) as WaCarouselItem[]; } private handleKeyDown(event: KeyboardEvent) { @@ -201,20 +227,22 @@ export default class WaCarousel extends WebAwesomeElement { // Scrolls to the original slide without animating, so the user won't notice that the position has changed this.goToSlide(clonePosition, 'auto'); - - return; + } else if (firstIntersecting) { + // Update the current index based on the first visible slide + const slideIndex = slides.indexOf(firstIntersecting.target as WaCarouselItem); + // Set the index to the first "snappable" slide + this.activeSlide = Math.ceil(slideIndex / this.slidesPerMove) * this.slidesPerMove; } + } - // Activate the first intersecting slide - if (firstIntersecting) { - this.activeSlide = slides.indexOf(firstIntersecting.target as WaCarouselItem); - } + private isCarouselItem(node: HTMLElement): node is WaCarouselItem { + return node.tagName.toLowerCase() === 'wa-carousel-item'; } private handleSlotChange = (mutations: MutationRecord[]) => { const needsInitialization = mutations.some(mutation => [...mutation.addedNodes, ...mutation.removedNodes].some( - node => WaCarouselItem.isCarouselItem(node) && !(node as HTMLElement).hasAttribute('data-clone') + (el: HTMLElement) => this.isCarouselItem(el) && !el.hasAttribute('data-clone') ) ); @@ -222,13 +250,13 @@ export default class WaCarousel extends WebAwesomeElement { if (needsInitialization) { this.initializeSlides(); } + this.requestUpdate(); }; @watch('loop', { waitUntilFirstUpdate: true }) @watch('slidesPerPage', { waitUntilFirstUpdate: true }) initializeSlides() { - const slides = this.getSlides(); const intersectionObserver = this.intersectionObserver; this.intersectionObserverEntries.clear(); @@ -246,23 +274,11 @@ export default class WaCarousel extends WebAwesomeElement { } }); + this.updateSlidesSnap(); + if (this.loop) { // Creates clones to be placed before and after the original elements to simulate infinite scrolling - const slidesPerPage = this.slidesPerPage; - const lastSlides = slides.slice(-slidesPerPage); - const firstSlides = slides.slice(0, slidesPerPage); - - lastSlides.reverse().forEach((slide, i) => { - const clone = slide.cloneNode(true) as HTMLElement; - clone.setAttribute('data-clone', String(slides.length - i - 1)); - this.prepend(clone); - }); - - firstSlides.forEach((slide, i) => { - const clone = slide.cloneNode(true) as HTMLElement; - clone.setAttribute('data-clone', String(i)); - this.append(clone); - }); + this.createClones(); } this.getSlides({ excludeClones: false }).forEach(slide => { @@ -273,6 +289,26 @@ export default class WaCarousel extends WebAwesomeElement { this.goToSlide(this.activeSlide, 'auto'); } + private createClones() { + const slides = this.getSlides(); + + const slidesPerPage = this.slidesPerPage; + const lastSlides = slides.slice(-slidesPerPage); + const firstSlides = slides.slice(0, slidesPerPage); + + lastSlides.reverse().forEach((slide, i) => { + const clone = slide.cloneNode(true) as HTMLElement; + clone.setAttribute('data-clone', String(slides.length - i - 1)); + this.prepend(clone); + }); + + firstSlides.forEach((slide, i) => { + const clone = slide.cloneNode(true) as HTMLElement; + clone.setAttribute('data-clone', String(i)); + this.append(clone); + }); + } + @watch('activeSlide') handelSlideChange() { const slides = this.getSlides(); @@ -292,12 +328,12 @@ export default class WaCarousel extends WebAwesomeElement { } @watch('slidesPerMove') - handleSlidesPerMoveChange() { - const slides = this.getSlides({ excludeClones: false }); + updateSlidesSnap() { + const slides = this.getSlides(); const slidesPerMove = this.slidesPerMove; slides.forEach((slide, i) => { - const shouldSnap = Math.abs(i - slidesPerMove) % slidesPerMove === 0; + const shouldSnap = (i + slidesPerMove) % slidesPerMove === 0; if (shouldSnap) { slide.style.removeProperty('scroll-snap-align'); } else { @@ -325,15 +361,7 @@ export default class WaCarousel extends WebAwesomeElement { * @param behavior - The behavior used for scrolling. */ previous(behavior: ScrollBehavior = 'smooth') { - let previousIndex = this.activeSlide || this.activeSlide - this.slidesPerMove; - let canSnap = false; - - while (!canSnap && previousIndex > 0) { - previousIndex -= 1; - canSnap = Math.abs(previousIndex - this.slidesPerMove) % this.slidesPerMove === 0; - } - - this.goToSlide(previousIndex, behavior); + this.goToSlide(this.activeSlide - this.slidesPerMove, behavior); } /** @@ -357,8 +385,13 @@ export default class WaCarousel extends WebAwesomeElement { const slides = this.getSlides(); const slidesWithClones = this.getSlides({ excludeClones: false }); + // No need to do anything in case there are no items in the carousel + if (!slides.length) { + return; + } + // Sets the next index without taking into account clones, if any. - const newActiveSlide = (index + slides.length) % slides.length; + const newActiveSlide = loop ? (index + slides.length) % slides.length : clamp(index, 0, slides.length - 1); this.activeSlide = newActiveSlide; // Get the index of the next slide. For looping carousel it adds `slidesPerPage` @@ -377,11 +410,11 @@ export default class WaCarousel extends WebAwesomeElement { } render() { - const { scrollController, slidesPerPage } = this; + const { scrollController, slidesPerMove } = this; const pagesCount = this.getPageCount(); const currentPage = this.getCurrentPage(); - const prevEnabled = this.loop || currentPage > 0; - const nextEnabled = this.loop || currentPage < pagesCount - 1; + const prevEnabled = this.canScrollPrev(); + const nextEnabled = this.canScrollNext(); const isLtr = this.localize.dir() === 'ltr'; return html` @@ -459,7 +492,7 @@ export default class WaCarousel extends WebAwesomeElement { aria-selected="${isActive ? 'true' : 'false'}" aria-label="${this.localize.term('goToSlide', index + 1, pagesCount)}" tabindex=${isActive ? '0' : '-1'} - @click=${() => this.goToSlide(index * slidesPerPage)} + @click=${() => this.goToSlide(index * slidesPerMove)} @keydown=${this.handleKeyDown} > `; diff --git a/src/components/carousel/carousel.test.ts b/src/components/carousel/carousel.test.ts index c7d3df985..5a08d7696 100644 --- a/src/components/carousel/carousel.test.ts +++ b/src/components/carousel/carousel.test.ts @@ -1,6 +1,8 @@ import '../../../dist/webawesome.js'; import { clickOnElement } 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 sinon from 'sinon'; import type WaCarousel from './carousel.js'; @@ -223,6 +225,36 @@ describe('', () => { // Assert expect(el.scrollContainer.style.getPropertyValue('--slides-per-page').trim()).to.be.equal('2'); }); + + [ + [7, 2, 1, false, 6], + [5, 3, 3, false, 2], + [10, 2, 2, false, 5], + [7, 2, 1, true, 7], + [5, 3, 3, true, 2], + [10, 2, 2, true, 5] + ].forEach(([slides, slidesPerPage, slidesPerMove, loop, expected]: [number, number, number, boolean, number]) => { + it(`should display ${expected} pages for ${slides} slides grouped by ${slidesPerPage} and scrolled by ${slidesPerMove}${ + loop ? ' (loop)' : '' + }`, async () => { + // Arrange + const el = await fixture(html` + + ${map(range(slides), i => html`${i}`)} + + `); + + // Assert + const paginationItems = el.shadowRoot!.querySelectorAll('.carousel__pagination-item'); + expect(paginationItems.length).to.equal(expected); + }); + }); }); describe('when `slides-per-move` attribute is provided', () => { @@ -230,7 +262,7 @@ describe('', () => { // Arrange const expectedSnapGranularity = 2; const el = await fixture(html` - + Node 1 Node 2 Node 3 @@ -252,6 +284,89 @@ describe('', () => { } } }); + + it('should be possible to move by the given number of slides at a time', async () => { + // Arrange + const el = await fixture(html` + + Node 1 + Node 2 + Node 3 + Node 4 + Node 5 + Node 6 + + `); + const expectedSlides = el.querySelectorAll('.expected')!; + const nextButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--next')!; + + // Act + await clickOnElement(nextButton); + + await oneEvent(el.scrollContainer, 'scrollend'); + await el.updateComplete; + + // Assert + for (const expectedSlide of expectedSlides) { + expect(expectedSlide).to.have.class('--in-view'); + expect(expectedSlide).to.be.visible; + } + }); + + it('should be possible to move by a number that is less than the displayed number', async () => { + // Arrange + const el = await fixture(html` + + Node 1 + Node 2 + Node 3 + Node 4 + Node 5 + Node 6 + + `); + const expectedSlides = el.querySelectorAll('.expected')!; + const nextButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--next')!; + + // Act + await clickOnElement(nextButton); + await clickOnElement(nextButton); + await clickOnElement(nextButton); + await clickOnElement(nextButton); + await clickOnElement(nextButton); + await clickOnElement(nextButton); + + await oneEvent(el.scrollContainer, 'scrollend'); + await el.updateComplete; + + // Assert + for (const expectedSlide of expectedSlides) { + expect(expectedSlide).to.have.class('--in-view'); + expect(expectedSlide).to.be.visible; + } + }); + + it('should not be possible to move by a number that is greater than the displayed number', async () => { + // Arrange + const expectedSlidesPerMove = 2; + const el = await fixture(html` + + Node 1 + Node 2 + Node 3 + Node 4 + Node 5 + Node 6 + + `); + + // Act + el.slidesPerMove = 3; + await el.updateComplete; + + // Assert + expect(el.slidesPerMove).to.be.equal(expectedSlidesPerMove); + }); }); describe('when `orientation` attribute is provided', () => { @@ -465,7 +580,7 @@ describe('', () => { it('should scroll the carousel to the next slide', async () => { // Arrange const el = await fixture(html` - + Node 1 Node 2 Node 3 @@ -485,7 +600,7 @@ describe('', () => { it('should scroll the carousel to the previous slide', async () => { // Arrange const el = await fixture(html` - + Node 1 Node 2 Node 3 diff --git a/src/components/carousel/scroll-controller.ts b/src/components/carousel/scroll-controller.ts index e8e0b17ca..3871f000b 100644 --- a/src/components/carousel/scroll-controller.ts +++ b/src/components/carousel/scroll-controller.ts @@ -1,4 +1,3 @@ -import { debounce } from '../../internal/debounce.js'; import { prefersReducedMotion } from '../../internal/animate.js'; import { waitForEvent } from '../../internal/event.js'; import type { ReactiveController, ReactiveElement } from 'lit'; @@ -12,7 +11,6 @@ interface ScrollHost extends ReactiveElement { */ export class ScrollController implements ReactiveController { private host: T; - private pointers = new Set(); dragging = false; scrolling = false; @@ -30,11 +28,10 @@ export class ScrollController implements ReactiveControlle 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); - scrollContainer.addEventListener('touchstart', this.handleTouchStart, { passive: true }); - scrollContainer.addEventListener('touchend', this.handleTouchEnd); } hostDisconnected(): void { @@ -42,11 +39,10 @@ export class ScrollController implements ReactiveControlle 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); - scrollContainer.removeEventListener('touchstart', this.handleTouchStart); - scrollContainer.removeEventListener('touchend', this.handleTouchEnd); } handleScroll = () => { @@ -54,35 +50,22 @@ export class ScrollController implements ReactiveControlle this.scrolling = true; this.host.requestUpdate(); } - this.handleScrollEnd(); }; - @debounce(100) - handleScrollEnd() { - if (!this.pointers.size) { - // If no pointer is active in the scroll area then the scroll has ended + handleScrollEnd = () => { + if (this.scrolling && !this.dragging) { this.scrolling = false; - this.host.scrollContainer.dispatchEvent( - new CustomEvent('scrollend', { - bubbles: false, - cancelable: false - }) - ); this.host.requestUpdate(); - } else { - // otherwise let's wait a bit more - this.handleScrollEnd(); } - } + }; handlePointerDown = (event: PointerEvent) => { + // Do not handle drag for touch interactions as scroll is natively supported if (event.pointerType === 'touch') { return; } - this.pointers.add(event.pointerId); - - const canDrag = this.mouseDragging && !this.dragging && event.button === 0; + const canDrag = this.mouseDragging && event.button === 0; if (canDrag) { event.preventDefault(); @@ -105,24 +88,9 @@ export class ScrollController implements ReactiveControlle }; handlePointerUp = (event: PointerEvent) => { - this.pointers.delete(event.pointerId); this.host.scrollContainer.releasePointerCapture(event.pointerId); - if (this.pointers.size === 0) { - this.handleDragEnd(); - } - }; - - handleTouchEnd = (event: TouchEvent) => { - for (const touch of event.changedTouches) { - this.pointers.delete(touch.identifier); - } - }; - - handleTouchStart = (event: TouchEvent) => { - for (const touch of event.touches) { - this.pointers.add(touch.identifier); - } + this.handleDragEnd(); }; handleDragStart() { @@ -140,12 +108,11 @@ export class ScrollController implements ReactiveControlle }); } - async handleDragEnd() { + handleDragEnd() { const host = this.host; const scrollContainer = host.scrollContainer; scrollContainer.removeEventListener('pointermove', this.handlePointerMove); - this.dragging = false; const startLeft = scrollContainer.scrollLeft; const startTop = scrollContainer.scrollTop; @@ -158,12 +125,16 @@ export class ScrollController implements ReactiveControlle scrollContainer.scrollTo({ left: startLeft, top: startTop, behavior: 'auto' }); scrollContainer.scrollTo({ left: finalLeft, top: finalTop, behavior: prefersReducedMotion() ? 'auto' : 'smooth' }); - if (this.scrolling) { - await waitForEvent(scrollContainer, 'scrollend'); - } + // Wait for scroll to be applied + requestAnimationFrame(async () => { + if (startLeft !== finalLeft || startTop !== finalTop) { + await waitForEvent(scrollContainer, 'scrollend'); + } - scrollContainer.style.removeProperty('scroll-snap-type'); + scrollContainer.style.removeProperty('scroll-snap-type'); - host.requestUpdate(); + this.dragging = false; + host.requestUpdate(); + }); } } diff --git a/src/components/menu-item/menu-item.test.ts b/src/components/menu-item/menu-item.test.ts index c0e50982d..bb1a58d20 100644 --- a/src/components/menu-item/menu-item.test.ts +++ b/src/components/menu-item/menu-item.test.ts @@ -155,11 +155,13 @@ describe('', () => { `); const focusHandler = sinon.spy((event: FocusEvent) => { - expect(event.target.value).to.equal('outer-item-1'); - expect(event.relatedTarget.value).to.equal('inner-item-1'); + const target = event.target as WaMenuItem; + const relatedTarget = event.relatedTarget as WaMenuItem; + expect(target.value).to.equal('outer-item-1'); + expect(relatedTarget.value).to.equal('inner-item-1'); }); - const outerItem = menu.querySelector('wa-menu-item'); + const outerItem = menu.querySelector('sl-menu-item')!; outerItem.focus(); await menu.updateComplete; await sendKeys({ press: 'ArrowRight' }); diff --git a/src/internal/scrollend-polyfill.ts b/src/internal/scrollend-polyfill.ts new file mode 100644 index 000000000..8eedc104a --- /dev/null +++ b/src/internal/scrollend-polyfill.ts @@ -0,0 +1,74 @@ +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, + decorateFn: (this: unknown, superFn: T[M], ...args: unknown[]) => unknown +) => { + const superFn = proto[method] as MethodOf; + + proto[method] = function (this: unknown, ...args: unknown[]) { + superFn.call(this, ...args); + decorateFn.call(this, superFn, ...args); + } as MethodOf; +}; + +const isSupported = 'onscrollend' in window; + +if (!isSupported) { + const pointers = new Set(); + const scrollHandlers = new WeakMap(); + + const handlePointerDown = (event: PointerEvent) => { + pointers.add(event.pointerId); + }; + + const handlePointerUp = (event: PointerEvent) => { + pointers.delete(event.pointerId); + }; + + document.addEventListener('pointerdown', handlePointerDown); + document.addEventListener('pointerup', handlePointerUp); + + decorate(EventTarget.prototype, 'addEventListener', function (this: EventTarget, addEventListener, type) { + if (type !== 'scroll') return; + + 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 + 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; + + const scrollHandler = scrollHandlers.get(this); + if (scrollHandler) { + removeEventListener.call(this, 'scroll', scrollHandler, { passive: true } as unknown as EventListenerOptions); + } + }); +} + +// Without an import or export, TypeScript sees vars in this file as global +export {};