]> git.ipfire.org Git - thirdparty/bootstrap.git/commitdiff
Extract Carousel's swipe functionality to a separate Class (#32999)
authorGeoSot <geo.sotis@gmail.com>
Mon, 11 Oct 2021 14:04:43 +0000 (17:04 +0300)
committerGitHub <noreply@github.com>
Mon, 11 Oct 2021 14:04:43 +0000 (17:04 +0300)
.bundlewatch.config.json
js/src/carousel.js
js/src/util/swipe.js [new file with mode: 0644]
js/tests/unit/carousel.spec.js
js/tests/unit/util/swipe.spec.js [new file with mode: 0644]

index 371a7b459bc932284aba8d5886f36e0ba798ab7d..316976ee9c11ff6e047356bd8d10cb7bbb264988 100644 (file)
@@ -54,7 +54,7 @@
     },
     {
       "path": "./dist/js/bootstrap.min.js",
-      "maxSize": "16 kB"
+      "maxSize": "16.25 kB"
     }
   ],
   "ci": {
index 161e980c61c3ea5962c849a698bab84a09087a38..f28ee259b3e46b085dd0c43c51d31d11a473a155 100644 (file)
@@ -8,9 +8,9 @@
 import {
   defineJQueryPlugin,
   getElementFromSelector,
+  getNextActiveElement,
   isRTL,
   isVisible,
-  getNextActiveElement,
   reflow,
   triggerTransitionEnd,
   typeCheckConfig
@@ -18,6 +18,7 @@ import {
 import EventHandler from './dom/event-handler'
 import Manipulator from './dom/manipulator'
 import SelectorEngine from './dom/selector-engine'
+import Swipe from './util/swipe'
 import BaseComponent from './base-component'
 
 /**
@@ -34,7 +35,6 @@ const DATA_API_KEY = '.data-api'
 const ARROW_LEFT_KEY = 'ArrowLeft'
 const ARROW_RIGHT_KEY = 'ArrowRight'
 const TOUCHEVENT_COMPAT_WAIT = 500 // Time for mouse compat events to fire after touch
-const SWIPE_THRESHOLD = 40
 
 const Default = {
   interval: 5000,
@@ -69,11 +69,6 @@ const EVENT_SLID = `slid${EVENT_KEY}`
 const EVENT_KEYDOWN = `keydown${EVENT_KEY}`
 const EVENT_MOUSEENTER = `mouseenter${EVENT_KEY}`
 const EVENT_MOUSELEAVE = `mouseleave${EVENT_KEY}`
-const EVENT_TOUCHSTART = `touchstart${EVENT_KEY}`
-const EVENT_TOUCHMOVE = `touchmove${EVENT_KEY}`
-const EVENT_TOUCHEND = `touchend${EVENT_KEY}`
-const EVENT_POINTERDOWN = `pointerdown${EVENT_KEY}`
-const EVENT_POINTERUP = `pointerup${EVENT_KEY}`
 const EVENT_DRAG_START = `dragstart${EVENT_KEY}`
 const EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}`
 const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
@@ -85,7 +80,6 @@ const CLASS_NAME_END = 'carousel-item-end'
 const CLASS_NAME_START = 'carousel-item-start'
 const CLASS_NAME_NEXT = 'carousel-item-next'
 const CLASS_NAME_PREV = 'carousel-item-prev'
-const CLASS_NAME_POINTER_EVENT = 'pointer-event'
 
 const SELECTOR_ACTIVE = '.active'
 const SELECTOR_ACTIVE_ITEM = '.active.carousel-item'
@@ -97,9 +91,6 @@ const SELECTOR_INDICATOR = '[data-bs-target]'
 const SELECTOR_DATA_SLIDE = '[data-bs-slide], [data-bs-slide-to]'
 const SELECTOR_DATA_RIDE = '[data-bs-ride="carousel"]'
 
-const POINTER_TYPE_TOUCH = 'touch'
-const POINTER_TYPE_PEN = 'pen'
-
 /**
  * ------------------------------------------------------------------------
  * Class Definition
@@ -115,14 +106,10 @@ class Carousel extends BaseComponent {
     this._isPaused = false
     this._isSliding = false
     this.touchTimeout = null
-    this.touchStartX = 0
-    this.touchDeltaX = 0
+    this._swipeHelper = null
 
     this._config = this._getConfig(config)
     this._indicatorsElement = SelectorEngine.findOne(SELECTOR_INDICATORS, this._element)
-    this._touchSupported = 'ontouchstart' in document.documentElement || navigator.maxTouchPoints > 0
-    this._pointerEvent = Boolean(window.PointerEvent)
-
     this._addEventListeners()
   }
 
@@ -214,6 +201,14 @@ class Carousel extends BaseComponent {
     this._slide(order, this._items[index])
   }
 
+  dispose() {
+    if (this._swipeHelper) {
+      this._swipeHelper.dispose()
+    }
+
+    super.dispose()
+  }
+
   // Private
 
   _getConfig(config) {
@@ -226,24 +221,6 @@ class Carousel extends BaseComponent {
     return config
   }
 
-  _handleSwipe() {
-    const absDeltax = Math.abs(this.touchDeltaX)
-
-    if (absDeltax <= SWIPE_THRESHOLD) {
-      return
-    }
-
-    const direction = absDeltax / this.touchDeltaX
-
-    this.touchDeltaX = 0
-
-    if (!direction) {
-      return
-    }
-
-    this._slide(direction > 0 ? DIRECTION_RIGHT : DIRECTION_LEFT)
-  }
-
   _addEventListeners() {
     if (this._config.keyboard) {
       EventHandler.on(this._element, EVENT_KEYDOWN, event => this._keydown(event))
@@ -254,38 +231,17 @@ class Carousel extends BaseComponent {
       EventHandler.on(this._element, EVENT_MOUSELEAVE, event => this.cycle(event))
     }
 
-    if (this._config.touch && this._touchSupported) {
+    if (this._config.touch && Swipe.isSupported()) {
       this._addTouchEventListeners()
     }
   }
 
   _addTouchEventListeners() {
-    const hasPointerPenTouch = event => {
-      return this._pointerEvent &&
-        (event.pointerType === POINTER_TYPE_PEN || event.pointerType === POINTER_TYPE_TOUCH)
-    }
-
-    const start = event => {
-      if (hasPointerPenTouch(event)) {
-        this.touchStartX = event.clientX
-      } else if (!this._pointerEvent) {
-        this.touchStartX = event.touches[0].clientX
-      }
-    }
-
-    const move = event => {
-      // ensure swiping with one touch and not pinching
-      this.touchDeltaX = event.touches && event.touches.length > 1 ?
-        0 :
-        event.touches[0].clientX - this.touchStartX
+    for (const itemImg of SelectorEngine.find(SELECTOR_ITEM_IMG, this._element)) {
+      EventHandler.on(itemImg, EVENT_DRAG_START, event => event.preventDefault())
     }
 
-    const end = event => {
-      if (hasPointerPenTouch(event)) {
-        this.touchDeltaX = event.clientX - this.touchStartX
-      }
-
-      this._handleSwipe()
+    const endCallBack = () => {
       if (this._config.pause === 'hover') {
         // If it's a touch-enabled device, mouseenter/leave are fired as
         // part of the mouse compatibility events on first tap - the carousel
@@ -304,20 +260,13 @@ class Carousel extends BaseComponent {
       }
     }
 
-    for (const itemImg of SelectorEngine.find(SELECTOR_ITEM_IMG, this._element)) {
-      EventHandler.on(itemImg, EVENT_DRAG_START, event => event.preventDefault())
+    const swipeConfig = {
+      leftCallback: () => this._slide(DIRECTION_LEFT),
+      rightCallback: () => this._slide(DIRECTION_RIGHT),
+      endCallback: endCallBack
     }
 
-    if (this._pointerEvent) {
-      EventHandler.on(this._element, EVENT_POINTERDOWN, event => start(event))
-      EventHandler.on(this._element, EVENT_POINTERUP, event => end(event))
-
-      this._element.classList.add(CLASS_NAME_POINTER_EVENT)
-    } else {
-      EventHandler.on(this._element, EVENT_TOUCHSTART, event => start(event))
-      EventHandler.on(this._element, EVENT_TOUCHMOVE, event => move(event))
-      EventHandler.on(this._element, EVENT_TOUCHEND, event => end(event))
-    }
+    this._swipeHelper = new Swipe(this._element, swipeConfig)
   }
 
   _keydown(event) {
diff --git a/js/src/util/swipe.js b/js/src/util/swipe.js
new file mode 100644 (file)
index 0000000..321572e
--- /dev/null
@@ -0,0 +1,122 @@
+import EventHandler from '../dom/event-handler'
+import { execute, typeCheckConfig } from './index'
+
+const EVENT_KEY = '.bs.swipe'
+const EVENT_TOUCHSTART = `touchstart${EVENT_KEY}`
+const EVENT_TOUCHMOVE = `touchmove${EVENT_KEY}`
+const EVENT_TOUCHEND = `touchend${EVENT_KEY}`
+const EVENT_POINTERDOWN = `pointerdown${EVENT_KEY}`
+const EVENT_POINTERUP = `pointerup${EVENT_KEY}`
+const POINTER_TYPE_TOUCH = 'touch'
+const POINTER_TYPE_PEN = 'pen'
+const CLASS_NAME_POINTER_EVENT = 'pointer-event'
+const SWIPE_THRESHOLD = 40
+const NAME = 'swipe'
+
+const Default = {
+  leftCallback: null,
+  rightCallback: null,
+  endCallback: null
+}
+
+const DefaultType = {
+  leftCallback: '(function|null)',
+  rightCallback: '(function|null)',
+  endCallback: '(function|null)'
+}
+
+class Swipe {
+  constructor(element, config) {
+    this._element = element
+
+    if (!element || !Swipe.isSupported()) {
+      return
+    }
+
+    this._config = this._getConfig(config)
+    this._deltaX = 0
+    this._supportPointerEvents = Boolean(window.PointerEvent)
+    this._initEvents()
+  }
+
+  dispose() {
+    EventHandler.off(this._element, EVENT_KEY)
+  }
+
+  _start(event) {
+    if (!this._supportPointerEvents) {
+      this._deltaX = event.touches[0].clientX
+
+      return
+    }
+
+    if (this._eventIsPointerPenTouch(event)) {
+      this._deltaX = event.clientX
+    }
+  }
+
+  _end(event) {
+    if (this._eventIsPointerPenTouch(event)) {
+      this._deltaX = event.clientX - this._deltaX
+    }
+
+    this._handleSwipe()
+    execute(this._config.endCallback)
+  }
+
+  _move(event) {
+    this._deltaX = event.touches && event.touches.length > 1 ?
+      0 :
+      event.touches[0].clientX - this._deltaX
+  }
+
+  _handleSwipe() {
+    const absDeltaX = Math.abs(this._deltaX)
+
+    if (absDeltaX <= SWIPE_THRESHOLD) {
+      return
+    }
+
+    const direction = absDeltaX / this._deltaX
+
+    this._deltaX = 0
+
+    if (!direction) {
+      return
+    }
+
+    execute(direction > 0 ? this._config.rightCallback : this._config.leftCallback)
+  }
+
+  _initEvents() {
+    if (this._supportPointerEvents) {
+      EventHandler.on(this._element, EVENT_POINTERDOWN, event => this._start(event))
+      EventHandler.on(this._element, EVENT_POINTERUP, event => this._end(event))
+
+      this._element.classList.add(CLASS_NAME_POINTER_EVENT)
+    } else {
+      EventHandler.on(this._element, EVENT_TOUCHSTART, event => this._start(event))
+      EventHandler.on(this._element, EVENT_TOUCHMOVE, event => this._move(event))
+      EventHandler.on(this._element, EVENT_TOUCHEND, event => this._end(event))
+    }
+  }
+
+  _getConfig(config) {
+    config = {
+      ...Default,
+      ...(typeof config === 'object' ? config : {})
+    }
+    typeCheckConfig(NAME, config, DefaultType)
+    return config
+  }
+
+  _eventIsPointerPenTouch(event) {
+    return this._supportPointerEvents && (event.pointerType === POINTER_TYPE_PEN || event.pointerType === POINTER_TYPE_TOUCH)
+  }
+
+  static isSupported() {
+    return 'ontouchstart' in document.documentElement || navigator.maxTouchPoints > 0
+  }
+}
+
+export default Swipe
index 70b9b8f0f561ca3590ca3c93dfeccb403c2fd5dc..b048f3a88240ef221057aab660c56c87f43167e6 100644 (file)
@@ -2,6 +2,7 @@ import Carousel from '../../src/carousel'
 import EventHandler from '../../src/dom/event-handler'
 import { clearFixture, createEvent, getFixture, jQueryMock } from '../helpers/fixture'
 import { isRTL, noop } from '../../src/util/index'
+import Swipe from '../../src/util/swipe'
 
 describe('Carousel', () => {
   const { Simulator, PointerEvent } = window
@@ -301,23 +302,24 @@ describe('Carousel', () => {
       })
 
       expect(carousel._addTouchEventListeners).not.toHaveBeenCalled()
+      expect(carousel._swipeHelper).toBeNull()
     })
 
     it('should not add touch event listeners if touch supported = false', () => {
       fixtureEl.innerHTML = '<div></div>'
 
       const carouselEl = fixtureEl.querySelector('div')
+      spyOn(Swipe, 'isSupported').and.returnValue(false)
 
       const carousel = new Carousel(carouselEl)
-
-      EventHandler.off(carouselEl, '.bs-carousel')
-      carousel._touchSupported = false
+      EventHandler.off(carouselEl, Carousel.EVENT_KEY)
 
       spyOn(carousel, '_addTouchEventListeners')
 
       carousel._addEventListeners()
 
       expect(carousel._addTouchEventListeners).not.toHaveBeenCalled()
+      expect(carousel._swipeHelper).toBeNull()
     })
 
     it('should add touch event listeners by default', () => {
@@ -566,7 +568,7 @@ describe('Carousel', () => {
       }, () => {
         restorePointerEvents()
         delete document.documentElement.ontouchstart
-        expect(carousel.touchDeltaX).toEqual(0)
+        expect(carousel._swipeHelper._deltaX).toEqual(0)
         done()
       })
     })
@@ -1237,19 +1239,20 @@ describe('Carousel', () => {
 
       const carouselEl = fixtureEl.querySelector('#myCarousel')
       const addEventSpy = spyOn(carouselEl, 'addEventListener').and.callThrough()
-      const removeEventSpy = spyOn(carouselEl, 'removeEventListener').and.callThrough()
+      const removeEventSpy = spyOn(EventHandler, 'off').and.callThrough()
 
       // Headless browser does not support touch events, so need to fake it
       // to test that touch events are add/removed properly.
       document.documentElement.ontouchstart = noop
 
       const carousel = new Carousel(carouselEl)
+      const swipeHelperSpy = spyOn(carousel._swipeHelper, 'dispose').and.callThrough()
 
       const expectedArgs = [
         ['keydown', jasmine.any(Function), jasmine.any(Boolean)],
         ['mouseover', jasmine.any(Function), jasmine.any(Boolean)],
         ['mouseout', jasmine.any(Function), jasmine.any(Boolean)],
-        ...(carousel._pointerEvent ?
+        ...(carousel._swipeHelper._supportPointerEvents ?
           [
             ['pointerdown', jasmine.any(Function), jasmine.any(Boolean)],
             ['pointerup', jasmine.any(Function), jasmine.any(Boolean)]
@@ -1265,7 +1268,8 @@ describe('Carousel', () => {
 
       carousel.dispose()
 
-      expect(removeEventSpy.calls.allArgs()).toEqual(expectedArgs)
+      expect(removeEventSpy).toHaveBeenCalledWith(carouselEl, Carousel.EVENT_KEY)
+      expect(swipeHelperSpy).toHaveBeenCalled()
 
       delete document.documentElement.ontouchstart
     })
diff --git a/js/tests/unit/util/swipe.spec.js b/js/tests/unit/util/swipe.spec.js
new file mode 100644 (file)
index 0000000..5690319
--- /dev/null
@@ -0,0 +1,263 @@
+import { clearFixture, getFixture } from '../../helpers/fixture'
+import EventHandler from '../../../src/dom/event-handler'
+import Swipe from '../../../src/util/swipe'
+import { noop } from '../../../src/util'
+
+describe('Swipe', () => {
+  const { Simulator, PointerEvent } = window
+  const originWinPointerEvent = PointerEvent
+  const supportPointerEvent = Boolean(PointerEvent)
+
+  let fixtureEl
+  let swipeEl
+  const clearPointerEvents = () => {
+    window.PointerEvent = null
+  }
+
+  const restorePointerEvents = () => {
+    window.PointerEvent = originWinPointerEvent
+  }
+
+  // The headless browser does not support touch events, so we need to fake it
+  // in order to test that touch events are added properly
+  const defineDocumentElementOntouchstart = () => {
+    document.documentElement.ontouchstart = noop
+  }
+
+  const deleteDocumentElementOntouchstart = () => {
+    delete document.documentElement.ontouchstart
+  }
+
+  const mockSwipeGesture = (element, options = {}, type = 'touch') => {
+    Simulator.setType(type)
+    const _options = { deltaX: 0, deltaY: 0, ...options }
+
+    Simulator.gestures.swipe(element, _options)
+  }
+
+  beforeEach(() => {
+    fixtureEl = getFixture()
+    const cssStyle = [
+      '<style>',
+      '   #fixture .pointer-event {',
+      '     touch-action: pan-y;',
+      '  }',
+      '   #fixture div {',
+      '     width: 300px;',
+      '     height: 300px;',
+      '  }',
+      '</style>'
+    ].join('')
+
+    fixtureEl.innerHTML = '<div id="swipeEl"></div>' + cssStyle
+    swipeEl = fixtureEl.querySelector('div')
+  })
+
+  afterEach(() => {
+    clearFixture()
+    deleteDocumentElementOntouchstart()
+  })
+
+  describe('constructor', () => {
+    it('should add touch event listeners by default', () => {
+      defineDocumentElementOntouchstart()
+
+      spyOn(Swipe.prototype, '_initEvents').and.callThrough()
+      const swipe = new Swipe(swipeEl)
+      expect(swipe._initEvents).toHaveBeenCalled()
+    })
+
+    it('should not add touch event listeners if touch is not supported', () => {
+      spyOn(Swipe, 'isSupported').and.returnValue(false)
+
+      spyOn(Swipe.prototype, '_initEvents').and.callThrough()
+      const swipe = new Swipe(swipeEl)
+
+      expect(swipe._initEvents).not.toHaveBeenCalled()
+    })
+  })
+
+  describe('Config', () => {
+    it('Test leftCallback', done => {
+      const spyRight = jasmine.createSpy('spy')
+      clearPointerEvents()
+      defineDocumentElementOntouchstart()
+      // eslint-disable-next-line no-new
+      new Swipe(swipeEl, {
+        leftCallback: () => {
+          expect(spyRight).not.toHaveBeenCalled()
+          restorePointerEvents()
+          done()
+        },
+        rightCallback: spyRight
+      })
+
+      mockSwipeGesture(swipeEl, {
+        pos: [300, 10],
+        deltaX: -300
+      })
+    })
+
+    it('Test rightCallback', done => {
+      const spyLeft = jasmine.createSpy('spy')
+      clearPointerEvents()
+      defineDocumentElementOntouchstart()
+      // eslint-disable-next-line no-new
+      new Swipe(swipeEl, {
+        rightCallback: () => {
+          expect(spyLeft).not.toHaveBeenCalled()
+          restorePointerEvents()
+          done()
+        },
+        leftCallback: spyLeft
+      })
+
+      mockSwipeGesture(swipeEl, {
+        pos: [10, 10],
+        deltaX: 300
+      })
+    })
+
+    it('Test endCallback', done => {
+      clearPointerEvents()
+      defineDocumentElementOntouchstart()
+      let isFirstTime = true
+
+      const callback = () => {
+        if (isFirstTime) {
+          isFirstTime = false
+          return
+        }
+
+        expect().nothing()
+        restorePointerEvents()
+        done()
+      }
+
+      // eslint-disable-next-line no-new
+      new Swipe(swipeEl, {
+        endCallback: callback
+      })
+      mockSwipeGesture(swipeEl, {
+        pos: [10, 10],
+        deltaX: 300
+      })
+
+      mockSwipeGesture(swipeEl, {
+        pos: [300, 10],
+        deltaX: -300
+      })
+    })
+  })
+
+  describe('Functionality on PointerEvents', () => {
+    it('should allow swipeRight and call "rightCallback" with pointer events', done => {
+      if (!supportPointerEvent) {
+        expect().nothing()
+        done()
+        return
+      }
+
+      const style = '#fixture .pointer-event { touch-action: none !important; }'
+      fixtureEl.innerHTML += style
+
+      defineDocumentElementOntouchstart()
+      // eslint-disable-next-line no-new
+      new Swipe(swipeEl, {
+        rightCallback: () => {
+          deleteDocumentElementOntouchstart()
+          expect().nothing()
+          done()
+        }
+      })
+
+      mockSwipeGesture(swipeEl, { deltaX: 300 }, 'pointer')
+    })
+
+    it('should allow swipeLeft and call "leftCallback" with pointer events', done => {
+      if (!supportPointerEvent) {
+        expect().nothing()
+        done()
+        return
+      }
+
+      const style = '#fixture .pointer-event { touch-action: none !important; }'
+      fixtureEl.innerHTML += style
+
+      defineDocumentElementOntouchstart()
+      // eslint-disable-next-line no-new
+      new Swipe(swipeEl, {
+        leftCallback: () => {
+          expect().nothing()
+          deleteDocumentElementOntouchstart()
+          done()
+        }
+      })
+
+      mockSwipeGesture(swipeEl, {
+        pos: [300, 10],
+        deltaX: -300
+      }, 'pointer')
+    })
+  })
+
+  describe('Dispose', () => {
+    it('should call EventHandler.off', () => {
+      defineDocumentElementOntouchstart()
+      spyOn(EventHandler, 'off').and.callThrough()
+      const swipe = new Swipe(swipeEl)
+
+      swipe.dispose()
+      expect(EventHandler.off).toHaveBeenCalledWith(swipeEl, '.bs.swipe')
+    })
+
+    it('should destroy', () => {
+      const addEventSpy = spyOn(fixtureEl, 'addEventListener').and.callThrough()
+      const removeEventSpy = spyOn(fixtureEl, 'removeEventListener').and.callThrough()
+      defineDocumentElementOntouchstart()
+
+      const swipe = new Swipe(fixtureEl)
+
+      const expectedArgs =
+        swipe._supportPointerEvents ?
+          [
+            ['pointerdown', jasmine.any(Function), jasmine.any(Boolean)],
+            ['pointerup', jasmine.any(Function), jasmine.any(Boolean)]
+          ] :
+          [
+            ['touchstart', jasmine.any(Function), jasmine.any(Boolean)],
+            ['touchmove', jasmine.any(Function), jasmine.any(Boolean)],
+            ['touchend', jasmine.any(Function), jasmine.any(Boolean)]
+          ]
+
+      expect(addEventSpy.calls.allArgs()).toEqual(expectedArgs)
+
+      swipe.dispose()
+
+      expect(removeEventSpy.calls.allArgs()).toEqual(expectedArgs)
+
+      delete document.documentElement.ontouchstart
+    })
+  })
+
+  describe('"isSupported" static', () => {
+    it('should return "true" if "touchstart" exists in document element)', () => {
+      Object.defineProperty(window.navigator, 'maxTouchPoints', () => 0)
+      defineDocumentElementOntouchstart()
+
+      expect(Swipe.isSupported()).toBeTrue()
+    })
+
+    it('should return "false" if "touchstart" not exists in document element and "navigator.maxTouchPoints" are  zero (0)', () => {
+      Object.defineProperty(window.navigator, 'maxTouchPoints', () => 0)
+      deleteDocumentElementOntouchstart()
+
+      if ('ontouchstart' in document.documentElement) {
+        expect().nothing()
+        return
+      }
+
+      expect(Swipe.isSupported()).toBeFalse()
+    })
+  })
+})