From 62730d9afd4e208ab9b490a073ab6426463e41b6 Mon Sep 17 00:00:00 2001 From: Johann-S Date: Wed, 27 Mar 2019 11:58:00 +0100 Subject: rewrite carousel unit tests --- js/index.esm.js | 2 +- js/index.umd.js | 2 +- js/src/carousel.js | 645 ------------------ js/src/carousel/carousel.js | 641 ++++++++++++++++++ js/src/carousel/carousel.spec.js | 1197 +++++++++++++++++++++++++++++++++ js/tests/karma.conf.js | 4 +- js/tests/unit/carousel.js | 1370 -------------------------------------- 7 files changed, 1843 insertions(+), 2018 deletions(-) delete mode 100644 js/src/carousel.js create mode 100644 js/src/carousel/carousel.js create mode 100644 js/src/carousel/carousel.spec.js delete mode 100644 js/tests/unit/carousel.js (limited to 'js') diff --git a/js/index.esm.js b/js/index.esm.js index ca47d7405..4f5058560 100644 --- a/js/index.esm.js +++ b/js/index.esm.js @@ -7,7 +7,7 @@ import Alert from './src/alert/alert' import Button from './src/button/button' -import Carousel from './src/carousel' +import Carousel from './src/carousel/carousel' import Collapse from './src/collapse' import Dropdown from './src/dropdown' import Modal from './src/modal' diff --git a/js/index.umd.js b/js/index.umd.js index 2cb90696d..f3b81377e 100644 --- a/js/index.umd.js +++ b/js/index.umd.js @@ -7,7 +7,7 @@ import Alert from './src/alert/alert' import Button from './src/button/button' -import Carousel from './src/carousel' +import Carousel from './src/carousel/carousel' import Collapse from './src/collapse' import Dropdown from './src/dropdown' import Modal from './src/modal' diff --git a/js/src/carousel.js b/js/src/carousel.js deleted file mode 100644 index 7cd790f85..000000000 --- a/js/src/carousel.js +++ /dev/null @@ -1,645 +0,0 @@ -/** - * -------------------------------------------------------------------------- - * Bootstrap (v4.3.1): carousel.js - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - * -------------------------------------------------------------------------- - */ - -import { - jQuery as $, - TRANSITION_END, - emulateTransitionEnd, - getSelectorFromElement, - getTransitionDurationFromElement, - isVisible, - makeArray, - reflow, - triggerTransitionEnd, - typeCheckConfig -} from './util/index' -import Data from './dom/data' -import EventHandler from './dom/event-handler' -import Manipulator from './dom/manipulator' -import SelectorEngine from './dom/selector-engine' - -/** - * ------------------------------------------------------------------------ - * Constants - * ------------------------------------------------------------------------ - */ - -const NAME = 'carousel' -const VERSION = '4.3.1' -const DATA_KEY = 'bs.carousel' -const EVENT_KEY = `.${DATA_KEY}` -const DATA_API_KEY = '.data-api' -const ARROW_LEFT_KEYCODE = 37 // KeyboardEvent.which value for left arrow key -const ARROW_RIGHT_KEYCODE = 39 // KeyboardEvent.which value for right arrow key -const TOUCHEVENT_COMPAT_WAIT = 500 // Time for mouse compat events to fire after touch -const SWIPE_THRESHOLD = 40 - -const Default = { - interval: 5000, - keyboard: true, - slide: false, - pause: 'hover', - wrap: true, - touch: true -} - -const DefaultType = { - interval: '(number|boolean)', - keyboard: 'boolean', - slide: '(boolean|string)', - pause: '(string|boolean)', - wrap: 'boolean', - touch: 'boolean' -} - -const Direction = { - NEXT: 'next', - PREV: 'prev', - LEFT: 'left', - RIGHT: 'right' -} - -const Event = { - SLIDE: `slide${EVENT_KEY}`, - SLID: `slid${EVENT_KEY}`, - KEYDOWN: `keydown${EVENT_KEY}`, - MOUSEENTER: `mouseenter${EVENT_KEY}`, - MOUSELEAVE: `mouseleave${EVENT_KEY}`, - TOUCHSTART: `touchstart${EVENT_KEY}`, - TOUCHMOVE: `touchmove${EVENT_KEY}`, - TOUCHEND: `touchend${EVENT_KEY}`, - POINTERDOWN: `pointerdown${EVENT_KEY}`, - POINTERUP: `pointerup${EVENT_KEY}`, - DRAG_START: `dragstart${EVENT_KEY}`, - LOAD_DATA_API: `load${EVENT_KEY}${DATA_API_KEY}`, - CLICK_DATA_API: `click${EVENT_KEY}${DATA_API_KEY}` -} - -const ClassName = { - CAROUSEL: 'carousel', - ACTIVE: 'active', - SLIDE: 'slide', - RIGHT: 'carousel-item-right', - LEFT: 'carousel-item-left', - NEXT: 'carousel-item-next', - PREV: 'carousel-item-prev', - ITEM: 'carousel-item', - POINTER_EVENT: 'pointer-event' -} - -const Selector = { - ACTIVE: '.active', - ACTIVE_ITEM: '.active.carousel-item', - ITEM: '.carousel-item', - ITEM_IMG: '.carousel-item img', - NEXT_PREV: '.carousel-item-next, .carousel-item-prev', - INDICATORS: '.carousel-indicators', - DATA_SLIDE: '[data-slide], [data-slide-to]', - DATA_RIDE: '[data-ride="carousel"]' -} - -const PointerType = { - TOUCH: 'touch', - PEN: 'pen' -} - -/** - * ------------------------------------------------------------------------ - * Class Definition - * ------------------------------------------------------------------------ - */ -class Carousel { - constructor(element, config) { - this._items = null - this._interval = null - this._activeElement = null - this._isPaused = false - this._isSliding = false - this.touchTimeout = null - this.touchStartX = 0 - this.touchDeltaX = 0 - - this._config = this._getConfig(config) - this._element = element - this._indicatorsElement = SelectorEngine.findOne(Selector.INDICATORS, this._element) - this._touchSupported = 'ontouchstart' in document.documentElement || navigator.maxTouchPoints > 0 - this._pointerEvent = Boolean(window.PointerEvent || window.MSPointerEvent) - - this._addEventListeners() - Data.setData(element, DATA_KEY, this) - } - - // Getters - - static get VERSION() { - return VERSION - } - - static get Default() { - return Default - } - - // Public - - next() { - if (!this._isSliding) { - this._slide(Direction.NEXT) - } - } - - nextWhenVisible() { - // Don't call next when the page isn't visible - // or the carousel or its parent isn't visible - if (!document.hidden && isVisible(this._element)) { - this.next() - } - } - - prev() { - if (!this._isSliding) { - this._slide(Direction.PREV) - } - } - - pause(event) { - if (!event) { - this._isPaused = true - } - - if (SelectorEngine.findOne(Selector.NEXT_PREV, this._element)) { - triggerTransitionEnd(this._element) - this.cycle(true) - } - - clearInterval(this._interval) - this._interval = null - } - - cycle(event) { - if (!event) { - this._isPaused = false - } - - if (this._interval) { - clearInterval(this._interval) - this._interval = null - } - - if (this._config && this._config.interval && !this._isPaused) { - this._interval = setInterval( - (document.visibilityState ? this.nextWhenVisible : this.next).bind(this), - this._config.interval - ) - } - } - - to(index) { - this._activeElement = SelectorEngine.findOne(Selector.ACTIVE_ITEM, this._element) - const activeIndex = this._getItemIndex(this._activeElement) - - if (index > this._items.length - 1 || index < 0) { - return - } - - if (this._isSliding) { - EventHandler.one(this._element, Event.SLID, () => this.to(index)) - return - } - - if (activeIndex === index) { - this.pause() - this.cycle() - return - } - - const direction = index > activeIndex ? - Direction.NEXT : - Direction.PREV - - this._slide(direction, this._items[index]) - } - - dispose() { - EventHandler.off(this._element, EVENT_KEY) - Data.removeData(this._element, DATA_KEY) - - this._items = null - this._config = null - this._element = null - this._interval = null - this._isPaused = null - this._isSliding = null - this._activeElement = null - this._indicatorsElement = null - } - - // Private - - _getConfig(config) { - config = { - ...Default, - ...config - } - typeCheckConfig(NAME, config, DefaultType) - return config - } - - _handleSwipe() { - const absDeltax = Math.abs(this.touchDeltaX) - - if (absDeltax <= SWIPE_THRESHOLD) { - return - } - - const direction = absDeltax / this.touchDeltaX - - this.touchDeltaX = 0 - - // swipe left - if (direction > 0) { - this.prev() - } - - // swipe right - if (direction < 0) { - this.next() - } - } - - _addEventListeners() { - if (this._config.keyboard) { - EventHandler - .on(this._element, Event.KEYDOWN, event => this._keydown(event)) - } - - if (this._config.pause === 'hover') { - EventHandler - .on(this._element, Event.MOUSEENTER, event => this.pause(event)) - EventHandler - .on(this._element, Event.MOUSELEAVE, event => this.cycle(event)) - } - - if (this._config.touch) { - this._addTouchEventListeners() - } - } - - _addTouchEventListeners() { - if (!this._touchSupported) { - return - } - - const start = event => { - if (this._pointerEvent && PointerType[event.pointerType.toUpperCase()]) { - 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 - if (event.touches && event.touches.length > 1) { - this.touchDeltaX = 0 - } else { - this.touchDeltaX = event.touches[0].clientX - this.touchStartX - } - } - - const end = event => { - if (this._pointerEvent && PointerType[event.pointerType.toUpperCase()]) { - this.touchDeltaX = event.clientX - this.touchStartX - } - - this._handleSwipe() - 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 - // would stop cycling until user tapped out of it; - // here, we listen for touchend, explicitly pause the carousel - // (as if it's the second time we tap on it, mouseenter compat event - // is NOT fired) and after a timeout (to allow for mouse compatibility - // events to fire) we explicitly restart cycling - - this.pause() - if (this.touchTimeout) { - clearTimeout(this.touchTimeout) - } - - this.touchTimeout = setTimeout(event => this.cycle(event), TOUCHEVENT_COMPAT_WAIT + this._config.interval) - } - } - - makeArray(SelectorEngine.find(Selector.ITEM_IMG, this._element)).forEach(itemImg => { - EventHandler.on(itemImg, Event.DRAG_START, e => e.preventDefault()) - }) - - 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(ClassName.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)) - } - } - - _keydown(event) { - if (/input|textarea/i.test(event.target.tagName)) { - return - } - - switch (event.which) { - case ARROW_LEFT_KEYCODE: - event.preventDefault() - this.prev() - break - case ARROW_RIGHT_KEYCODE: - event.preventDefault() - this.next() - break - default: - } - } - - _getItemIndex(element) { - this._items = element && element.parentNode ? - makeArray(SelectorEngine.find(Selector.ITEM, element.parentNode)) : - [] - - return this._items.indexOf(element) - } - - _getItemByDirection(direction, activeElement) { - const isNextDirection = direction === Direction.NEXT - const isPrevDirection = direction === Direction.PREV - const activeIndex = this._getItemIndex(activeElement) - const lastItemIndex = this._items.length - 1 - const isGoingToWrap = isPrevDirection && activeIndex === 0 || - isNextDirection && activeIndex === lastItemIndex - - if (isGoingToWrap && !this._config.wrap) { - return activeElement - } - - const delta = direction === Direction.PREV ? -1 : 1 - const itemIndex = (activeIndex + delta) % this._items.length - - return itemIndex === -1 ? - this._items[this._items.length - 1] : - this._items[itemIndex] - } - - _triggerSlideEvent(relatedTarget, eventDirectionName) { - const targetIndex = this._getItemIndex(relatedTarget) - const fromIndex = this._getItemIndex(SelectorEngine.findOne(Selector.ACTIVE_ITEM, this._element)) - - return EventHandler.trigger(this._element, Event.SLIDE, { - relatedTarget, - direction: eventDirectionName, - from: fromIndex, - to: targetIndex - }) - } - - _setActiveIndicatorElement(element) { - if (this._indicatorsElement) { - const indicators = SelectorEngine.find(Selector.ACTIVE, this._indicatorsElement) - for (let i = 0; i < indicators.length; i++) { - indicators[i].classList.remove(ClassName.ACTIVE) - } - - const nextIndicator = this._indicatorsElement.children[ - this._getItemIndex(element) - ] - - if (nextIndicator) { - nextIndicator.classList.add(ClassName.ACTIVE) - } - } - } - - _slide(direction, element) { - const activeElement = SelectorEngine.findOne(Selector.ACTIVE_ITEM, this._element) - const activeElementIndex = this._getItemIndex(activeElement) - const nextElement = element || activeElement && - this._getItemByDirection(direction, activeElement) - - const nextElementIndex = this._getItemIndex(nextElement) - const isCycling = Boolean(this._interval) - - let directionalClassName - let orderClassName - let eventDirectionName - - if (direction === Direction.NEXT) { - directionalClassName = ClassName.LEFT - orderClassName = ClassName.NEXT - eventDirectionName = Direction.LEFT - } else { - directionalClassName = ClassName.RIGHT - orderClassName = ClassName.PREV - eventDirectionName = Direction.RIGHT - } - - if (nextElement && nextElement.classList.contains(ClassName.ACTIVE)) { - this._isSliding = false - return - } - - const slideEvent = this._triggerSlideEvent(nextElement, eventDirectionName) - if (slideEvent.defaultPrevented) { - return - } - - if (!activeElement || !nextElement) { - // Some weirdness is happening, so we bail - return - } - - this._isSliding = true - - if (isCycling) { - this.pause() - } - - this._setActiveIndicatorElement(nextElement) - - if (this._element.classList.contains(ClassName.SLIDE)) { - nextElement.classList.add(orderClassName) - - reflow(nextElement) - - activeElement.classList.add(directionalClassName) - nextElement.classList.add(directionalClassName) - - const nextElementInterval = parseInt(nextElement.getAttribute('data-interval'), 10) - if (nextElementInterval) { - this._config.defaultInterval = this._config.defaultInterval || this._config.interval - this._config.interval = nextElementInterval - } else { - this._config.interval = this._config.defaultInterval || this._config.interval - } - - const transitionDuration = getTransitionDurationFromElement(activeElement) - - EventHandler - .one(activeElement, TRANSITION_END, () => { - nextElement.classList.remove(directionalClassName) - nextElement.classList.remove(orderClassName) - nextElement.classList.add(ClassName.ACTIVE) - - activeElement.classList.remove(ClassName.ACTIVE) - activeElement.classList.remove(orderClassName) - activeElement.classList.remove(directionalClassName) - - this._isSliding = false - - setTimeout(() => { - EventHandler.trigger(this._element, Event.SLID, { - relatedTarget: nextElement, - direction: eventDirectionName, - from: activeElementIndex, - to: nextElementIndex - }) - }, 0) - }) - - emulateTransitionEnd(activeElement, transitionDuration) - } else { - activeElement.classList.remove(ClassName.ACTIVE) - nextElement.classList.add(ClassName.ACTIVE) - - this._isSliding = false - EventHandler.trigger(this._element, Event.SLID, { - relatedTarget: nextElement, - direction: eventDirectionName, - from: activeElementIndex, - to: nextElementIndex - }) - } - - if (isCycling) { - this.cycle() - } - } - - // Static - - static _carouselInterface(element, config) { - let data = Data.getData(element, DATA_KEY) - let _config = { - ...Default, - ...Manipulator.getDataAttributes(element) - } - - if (typeof config === 'object') { - _config = { - ..._config, - ...config - } - } - - const action = typeof config === 'string' ? config : _config.slide - - if (!data) { - data = new Carousel(element, _config) - } - - if (typeof config === 'number') { - data.to(config) - } else if (typeof action === 'string') { - if (typeof data[action] === 'undefined') { - throw new TypeError(`No method named "${action}"`) - } - - data[action]() - } else if (_config.interval && _config.ride) { - data.pause() - data.cycle() - } - } - - static _jQueryInterface(config) { - return this.each(function () { - Carousel._carouselInterface(this, config) - }) - } - - static _dataApiClickHandler(event) { - const selector = getSelectorFromElement(this) - - if (!selector) { - return - } - - const target = SelectorEngine.findOne(selector) - - if (!target || !target.classList.contains(ClassName.CAROUSEL)) { - return - } - - const config = { - ...Manipulator.getDataAttributes(target), - ...Manipulator.getDataAttributes(this) - } - const slideIndex = this.getAttribute('data-slide-to') - - if (slideIndex) { - config.interval = false - } - - Carousel._carouselInterface(target, config) - - if (slideIndex) { - Data.getData(target, DATA_KEY).to(slideIndex) - } - - event.preventDefault() - } - - static _getInstance(element) { - return Data.getData(element, DATA_KEY) - } -} - -/** - * ------------------------------------------------------------------------ - * Data Api implementation - * ------------------------------------------------------------------------ - */ - -EventHandler - .on(document, Event.CLICK_DATA_API, Selector.DATA_SLIDE, Carousel._dataApiClickHandler) - -EventHandler.on(window, Event.LOAD_DATA_API, () => { - const carousels = makeArray(SelectorEngine.find(Selector.DATA_RIDE)) - for (let i = 0, len = carousels.length; i < len; i++) { - Carousel._carouselInterface(carousels[i], Data.getData(carousels[i], DATA_KEY)) - } -}) - -/** - * ------------------------------------------------------------------------ - * jQuery - * ------------------------------------------------------------------------ - * add .carousel to jQuery only if jQuery is present - */ - -if (typeof $ !== 'undefined') { - const JQUERY_NO_CONFLICT = $.fn[NAME] - $.fn[NAME] = Carousel._jQueryInterface - $.fn[NAME].Constructor = Carousel - $.fn[NAME].noConflict = () => { - $.fn[NAME] = JQUERY_NO_CONFLICT - return Carousel._jQueryInterface - } -} - -export default Carousel diff --git a/js/src/carousel/carousel.js b/js/src/carousel/carousel.js new file mode 100644 index 000000000..0e1bad14a --- /dev/null +++ b/js/src/carousel/carousel.js @@ -0,0 +1,641 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap (v4.3.1): carousel.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + +import { + jQuery as $, + TRANSITION_END, + emulateTransitionEnd, + getSelectorFromElement, + getTransitionDurationFromElement, + isVisible, + makeArray, + reflow, + triggerTransitionEnd, + typeCheckConfig +} from '../util/index' +import Data from '../dom/data' +import EventHandler from '../dom/event-handler' +import Manipulator from '../dom/manipulator' +import SelectorEngine from '../dom/selector-engine' + +/** + * ------------------------------------------------------------------------ + * Constants + * ------------------------------------------------------------------------ + */ + +const NAME = 'carousel' +const VERSION = '4.3.1' +const DATA_KEY = 'bs.carousel' +const EVENT_KEY = `.${DATA_KEY}` +const DATA_API_KEY = '.data-api' +const ARROW_LEFT_KEYCODE = 37 // KeyboardEvent.which value for left arrow key +const ARROW_RIGHT_KEYCODE = 39 // KeyboardEvent.which value for right arrow key +const TOUCHEVENT_COMPAT_WAIT = 500 // Time for mouse compat events to fire after touch +const SWIPE_THRESHOLD = 40 + +const Default = { + interval: 5000, + keyboard: true, + slide: false, + pause: 'hover', + wrap: true, + touch: true +} + +const DefaultType = { + interval: '(number|boolean)', + keyboard: 'boolean', + slide: '(boolean|string)', + pause: '(string|boolean)', + wrap: 'boolean', + touch: 'boolean' +} + +const Direction = { + NEXT: 'next', + PREV: 'prev', + LEFT: 'left', + RIGHT: 'right' +} + +const Event = { + SLIDE: `slide${EVENT_KEY}`, + SLID: `slid${EVENT_KEY}`, + KEYDOWN: `keydown${EVENT_KEY}`, + MOUSEENTER: `mouseenter${EVENT_KEY}`, + MOUSELEAVE: `mouseleave${EVENT_KEY}`, + TOUCHSTART: `touchstart${EVENT_KEY}`, + TOUCHMOVE: `touchmove${EVENT_KEY}`, + TOUCHEND: `touchend${EVENT_KEY}`, + POINTERDOWN: `pointerdown${EVENT_KEY}`, + POINTERUP: `pointerup${EVENT_KEY}`, + DRAG_START: `dragstart${EVENT_KEY}`, + LOAD_DATA_API: `load${EVENT_KEY}${DATA_API_KEY}`, + CLICK_DATA_API: `click${EVENT_KEY}${DATA_API_KEY}` +} + +const ClassName = { + CAROUSEL: 'carousel', + ACTIVE: 'active', + SLIDE: 'slide', + RIGHT: 'carousel-item-right', + LEFT: 'carousel-item-left', + NEXT: 'carousel-item-next', + PREV: 'carousel-item-prev', + ITEM: 'carousel-item', + POINTER_EVENT: 'pointer-event' +} + +const Selector = { + ACTIVE: '.active', + ACTIVE_ITEM: '.active.carousel-item', + ITEM: '.carousel-item', + ITEM_IMG: '.carousel-item img', + NEXT_PREV: '.carousel-item-next, .carousel-item-prev', + INDICATORS: '.carousel-indicators', + DATA_SLIDE: '[data-slide], [data-slide-to]', + DATA_RIDE: '[data-ride="carousel"]' +} + +const PointerType = { + TOUCH: 'touch', + PEN: 'pen' +} + +/** + * ------------------------------------------------------------------------ + * Class Definition + * ------------------------------------------------------------------------ + */ +class Carousel { + constructor(element, config) { + this._items = null + this._interval = null + this._activeElement = null + this._isPaused = false + this._isSliding = false + this.touchTimeout = null + this.touchStartX = 0 + this.touchDeltaX = 0 + + this._config = this._getConfig(config) + this._element = element + this._indicatorsElement = SelectorEngine.findOne(Selector.INDICATORS, this._element) + this._touchSupported = 'ontouchstart' in document.documentElement || navigator.maxTouchPoints > 0 + this._pointerEvent = Boolean(window.PointerEvent || window.MSPointerEvent) + + this._addEventListeners() + Data.setData(element, DATA_KEY, this) + } + + // Getters + + static get VERSION() { + return VERSION + } + + static get Default() { + return Default + } + + // Public + + next() { + if (!this._isSliding) { + this._slide(Direction.NEXT) + } + } + + nextWhenVisible() { + // Don't call next when the page isn't visible + // or the carousel or its parent isn't visible + if (!document.hidden && isVisible(this._element)) { + this.next() + } + } + + prev() { + if (!this._isSliding) { + this._slide(Direction.PREV) + } + } + + pause(event) { + if (!event) { + this._isPaused = true + } + + if (SelectorEngine.findOne(Selector.NEXT_PREV, this._element)) { + triggerTransitionEnd(this._element) + this.cycle(true) + } + + clearInterval(this._interval) + this._interval = null + } + + cycle(event) { + if (!event) { + this._isPaused = false + } + + if (this._interval) { + clearInterval(this._interval) + this._interval = null + } + + if (this._config && this._config.interval && !this._isPaused) { + this._interval = setInterval( + (document.visibilityState ? this.nextWhenVisible : this.next).bind(this), + this._config.interval + ) + } + } + + to(index) { + this._activeElement = SelectorEngine.findOne(Selector.ACTIVE_ITEM, this._element) + const activeIndex = this._getItemIndex(this._activeElement) + + if (index > this._items.length - 1 || index < 0) { + return + } + + if (this._isSliding) { + EventHandler.one(this._element, Event.SLID, () => this.to(index)) + return + } + + if (activeIndex === index) { + this.pause() + this.cycle() + return + } + + const direction = index > activeIndex ? + Direction.NEXT : + Direction.PREV + + this._slide(direction, this._items[index]) + } + + dispose() { + EventHandler.off(this._element, EVENT_KEY) + Data.removeData(this._element, DATA_KEY) + + this._items = null + this._config = null + this._element = null + this._interval = null + this._isPaused = null + this._isSliding = null + this._activeElement = null + this._indicatorsElement = null + } + + // Private + + _getConfig(config) { + config = { + ...Default, + ...config + } + typeCheckConfig(NAME, config, DefaultType) + return config + } + + _handleSwipe() { + const absDeltax = Math.abs(this.touchDeltaX) + + if (absDeltax <= SWIPE_THRESHOLD) { + return + } + + const direction = absDeltax / this.touchDeltaX + + this.touchDeltaX = 0 + + // swipe left + if (direction > 0) { + this.prev() + } + + // swipe right + if (direction < 0) { + this.next() + } + } + + _addEventListeners() { + if (this._config.keyboard) { + EventHandler + .on(this._element, Event.KEYDOWN, event => this._keydown(event)) + } + + if (this._config.pause === 'hover') { + EventHandler + .on(this._element, Event.MOUSEENTER, event => this.pause(event)) + EventHandler + .on(this._element, Event.MOUSELEAVE, event => this.cycle(event)) + } + + if (this._config.touch && this._touchSupported) { + this._addTouchEventListeners() + } + } + + _addTouchEventListeners() { + const start = event => { + if (this._pointerEvent && PointerType[event.pointerType.toUpperCase()]) { + 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 + if (event.touches && event.touches.length > 1) { + this.touchDeltaX = 0 + } else { + this.touchDeltaX = event.touches[0].clientX - this.touchStartX + } + } + + const end = event => { + if (this._pointerEvent && PointerType[event.pointerType.toUpperCase()]) { + this.touchDeltaX = event.clientX - this.touchStartX + } + + this._handleSwipe() + 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 + // would stop cycling until user tapped out of it; + // here, we listen for touchend, explicitly pause the carousel + // (as if it's the second time we tap on it, mouseenter compat event + // is NOT fired) and after a timeout (to allow for mouse compatibility + // events to fire) we explicitly restart cycling + + this.pause() + if (this.touchTimeout) { + clearTimeout(this.touchTimeout) + } + + this.touchTimeout = setTimeout(event => this.cycle(event), TOUCHEVENT_COMPAT_WAIT + this._config.interval) + } + } + + makeArray(SelectorEngine.find(Selector.ITEM_IMG, this._element)).forEach(itemImg => { + EventHandler.on(itemImg, Event.DRAG_START, e => e.preventDefault()) + }) + + 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(ClassName.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)) + } + } + + _keydown(event) { + if (/input|textarea/i.test(event.target.tagName)) { + return + } + + switch (event.which) { + case ARROW_LEFT_KEYCODE: + event.preventDefault() + this.prev() + break + case ARROW_RIGHT_KEYCODE: + event.preventDefault() + this.next() + break + default: + } + } + + _getItemIndex(element) { + this._items = element && element.parentNode ? + makeArray(SelectorEngine.find(Selector.ITEM, element.parentNode)) : + [] + + return this._items.indexOf(element) + } + + _getItemByDirection(direction, activeElement) { + const isNextDirection = direction === Direction.NEXT + const isPrevDirection = direction === Direction.PREV + const activeIndex = this._getItemIndex(activeElement) + const lastItemIndex = this._items.length - 1 + const isGoingToWrap = isPrevDirection && activeIndex === 0 || + isNextDirection && activeIndex === lastItemIndex + + if (isGoingToWrap && !this._config.wrap) { + return activeElement + } + + const delta = direction === Direction.PREV ? -1 : 1 + const itemIndex = (activeIndex + delta) % this._items.length + + return itemIndex === -1 ? + this._items[this._items.length - 1] : + this._items[itemIndex] + } + + _triggerSlideEvent(relatedTarget, eventDirectionName) { + const targetIndex = this._getItemIndex(relatedTarget) + const fromIndex = this._getItemIndex(SelectorEngine.findOne(Selector.ACTIVE_ITEM, this._element)) + + return EventHandler.trigger(this._element, Event.SLIDE, { + relatedTarget, + direction: eventDirectionName, + from: fromIndex, + to: targetIndex + }) + } + + _setActiveIndicatorElement(element) { + if (this._indicatorsElement) { + const indicators = SelectorEngine.find(Selector.ACTIVE, this._indicatorsElement) + for (let i = 0; i < indicators.length; i++) { + indicators[i].classList.remove(ClassName.ACTIVE) + } + + const nextIndicator = this._indicatorsElement.children[ + this._getItemIndex(element) + ] + + if (nextIndicator) { + nextIndicator.classList.add(ClassName.ACTIVE) + } + } + } + + _slide(direction, element) { + const activeElement = SelectorEngine.findOne(Selector.ACTIVE_ITEM, this._element) + const activeElementIndex = this._getItemIndex(activeElement) + const nextElement = element || activeElement && + this._getItemByDirection(direction, activeElement) + + const nextElementIndex = this._getItemIndex(nextElement) + const isCycling = Boolean(this._interval) + + let directionalClassName + let orderClassName + let eventDirectionName + + if (direction === Direction.NEXT) { + directionalClassName = ClassName.LEFT + orderClassName = ClassName.NEXT + eventDirectionName = Direction.LEFT + } else { + directionalClassName = ClassName.RIGHT + orderClassName = ClassName.PREV + eventDirectionName = Direction.RIGHT + } + + if (nextElement && nextElement.classList.contains(ClassName.ACTIVE)) { + this._isSliding = false + return + } + + const slideEvent = this._triggerSlideEvent(nextElement, eventDirectionName) + if (slideEvent.defaultPrevented) { + return + } + + if (!activeElement || !nextElement) { + // Some weirdness is happening, so we bail + return + } + + this._isSliding = true + + if (isCycling) { + this.pause() + } + + this._setActiveIndicatorElement(nextElement) + + if (this._element.classList.contains(ClassName.SLIDE)) { + nextElement.classList.add(orderClassName) + + reflow(nextElement) + + activeElement.classList.add(directionalClassName) + nextElement.classList.add(directionalClassName) + + const nextElementInterval = parseInt(nextElement.getAttribute('data-interval'), 10) + if (nextElementInterval) { + this._config.defaultInterval = this._config.defaultInterval || this._config.interval + this._config.interval = nextElementInterval + } else { + this._config.interval = this._config.defaultInterval || this._config.interval + } + + const transitionDuration = getTransitionDurationFromElement(activeElement) + + EventHandler + .one(activeElement, TRANSITION_END, () => { + nextElement.classList.remove(directionalClassName) + nextElement.classList.remove(orderClassName) + nextElement.classList.add(ClassName.ACTIVE) + + activeElement.classList.remove(ClassName.ACTIVE) + activeElement.classList.remove(orderClassName) + activeElement.classList.remove(directionalClassName) + + this._isSliding = false + + setTimeout(() => { + EventHandler.trigger(this._element, Event.SLID, { + relatedTarget: nextElement, + direction: eventDirectionName, + from: activeElementIndex, + to: nextElementIndex + }) + }, 0) + }) + + emulateTransitionEnd(activeElement, transitionDuration) + } else { + activeElement.classList.remove(ClassName.ACTIVE) + nextElement.classList.add(ClassName.ACTIVE) + + this._isSliding = false + EventHandler.trigger(this._element, Event.SLID, { + relatedTarget: nextElement, + direction: eventDirectionName, + from: activeElementIndex, + to: nextElementIndex + }) + } + + if (isCycling) { + this.cycle() + } + } + + // Static + + static _carouselInterface(element, config) { + let data = Data.getData(element, DATA_KEY) + let _config = { + ...Default, + ...Manipulator.getDataAttributes(element) + } + + if (typeof config === 'object') { + _config = { + ..._config, + ...config + } + } + + const action = typeof config === 'string' ? config : _config.slide + + if (!data) { + data = new Carousel(element, _config) + } + + if (typeof config === 'number') { + data.to(config) + } else if (typeof action === 'string') { + if (typeof data[action] === 'undefined') { + throw new TypeError(`No method named "${action}"`) + } + + data[action]() + } else if (_config.interval && _config.ride) { + data.pause() + data.cycle() + } + } + + static _jQueryInterface(config) { + return this.each(function () { + Carousel._carouselInterface(this, config) + }) + } + + static _dataApiClickHandler(event) { + const selector = getSelectorFromElement(this) + + if (!selector) { + return + } + + const target = SelectorEngine.findOne(selector) + + if (!target || !target.classList.contains(ClassName.CAROUSEL)) { + return + } + + const config = { + ...Manipulator.getDataAttributes(target), + ...Manipulator.getDataAttributes(this) + } + const slideIndex = this.getAttribute('data-slide-to') + + if (slideIndex) { + config.interval = false + } + + Carousel._carouselInterface(target, config) + + if (slideIndex) { + Data.getData(target, DATA_KEY).to(slideIndex) + } + + event.preventDefault() + } + + static _getInstance(element) { + return Data.getData(element, DATA_KEY) + } +} + +/** + * ------------------------------------------------------------------------ + * Data Api implementation + * ------------------------------------------------------------------------ + */ + +EventHandler + .on(document, Event.CLICK_DATA_API, Selector.DATA_SLIDE, Carousel._dataApiClickHandler) + +EventHandler.on(window, Event.LOAD_DATA_API, () => { + const carousels = makeArray(SelectorEngine.find(Selector.DATA_RIDE)) + for (let i = 0, len = carousels.length; i < len; i++) { + Carousel._carouselInterface(carousels[i], Data.getData(carousels[i], DATA_KEY)) + } +}) + +/** + * ------------------------------------------------------------------------ + * jQuery + * ------------------------------------------------------------------------ + * add .carousel to jQuery only if jQuery is present + */ +/* istanbul ignore if */ +if (typeof $ !== 'undefined') { + const JQUERY_NO_CONFLICT = $.fn[NAME] + $.fn[NAME] = Carousel._jQueryInterface + $.fn[NAME].Constructor = Carousel + $.fn[NAME].noConflict = () => { + $.fn[NAME] = JQUERY_NO_CONFLICT + return Carousel._jQueryInterface + } +} + +export default Carousel diff --git a/js/src/carousel/carousel.spec.js b/js/src/carousel/carousel.spec.js new file mode 100644 index 000000000..9f897110d --- /dev/null +++ b/js/src/carousel/carousel.spec.js @@ -0,0 +1,1197 @@ +import Carousel from './carousel' +import EventHandler from '../dom/event-handler' + +/** Test helpers */ +import { getFixture, clearFixture, createEvent, jQueryMock } from '../../tests/helpers/fixture' + +describe('Carousel', () => { + const { Simulator, PointerEvent, MSPointerEvent } = window + const originWinPointerEvent = PointerEvent || MSPointerEvent + const supportPointerEvent = Boolean(PointerEvent || MSPointerEvent) + + window.MSPointerEvent = null + const cssStyleCarousel = '.carousel.pointer-event { -ms-touch-action: none; touch-action: none; }' + + const stylesCarousel = document.createElement('style') + stylesCarousel.type = 'text/css' + stylesCarousel.appendChild(document.createTextNode(cssStyleCarousel)) + + const clearPointerEvents = () => { + window.PointerEvent = null + } + + const restorePointerEvents = () => { + window.PointerEvent = originWinPointerEvent + } + + let fixtureEl + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + }) + + describe('VERSION', () => { + it('should return plugin version', () => { + expect(Carousel.VERSION).toEqual(jasmine.any(String)) + }) + }) + + describe('Default', () => { + it('should return plugin default config', () => { + expect(Carousel.Default).toEqual(jasmine.any(Object)) + }) + }) + + describe('constructor', () => { + it('should go to next item if right arrow key is pressed', done => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const carouselEl = fixtureEl.querySelector('#myCarousel') + const carousel = new Carousel(carouselEl, { + keyboard: true + }) + + spyOn(carousel, '_keydown').and.callThrough() + + carouselEl.addEventListener('slid.bs.carousel', () => { + expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item2')) + expect(carousel._keydown).toHaveBeenCalled() + done() + }) + + const keyDown = createEvent('keydown') + keyDown.which = 39 + + carouselEl.dispatchEvent(keyDown) + }) + + it('should go to previous item if left arrow key is pressed', done => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const carouselEl = fixtureEl.querySelector('#myCarousel') + const carousel = new Carousel(carouselEl, { + keyboard: true + }) + + spyOn(carousel, '_keydown').and.callThrough() + + carouselEl.addEventListener('slid.bs.carousel', () => { + expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item1')) + expect(carousel._keydown).toHaveBeenCalled() + done() + }) + + const keyDown = createEvent('keydown') + keyDown.which = 37 + + carouselEl.dispatchEvent(keyDown) + }) + + it('should not prevent keydown if key is not ARROW_LEFT or ARROW_RIGHT', done => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const carouselEl = fixtureEl.querySelector('#myCarousel') + const carousel = new Carousel(carouselEl, { + keyboard: true + }) + + spyOn(carousel, '_keydown').and.callThrough() + + carouselEl.addEventListener('keydown', event => { + expect(carousel._keydown).toHaveBeenCalled() + expect(event.defaultPrevented).toEqual(false) + done() + }) + + const keyDown = createEvent('keydown') + keyDown.which = 40 + + carouselEl.dispatchEvent(keyDown) + }) + + it('should ignore keyboard events within s and ', + ' ', + ' ', + ' ', + ' ', + '' + ].join('') + + const carouselEl = fixtureEl.querySelector('#myCarousel') + const input = fixtureEl.querySelector('input') + const textarea = fixtureEl.querySelector('textarea') + const carousel = new Carousel(carouselEl, { + keyboard: true + }) + + const spyKeyDown = spyOn(carousel, '_keydown').and.callThrough() + const spyPrev = spyOn(carousel, 'prev') + const spyNext = spyOn(carousel, 'next') + + const keyDown = createEvent('keydown', { bubbles: true, cancelable: true }) + keyDown.which = 39 + Object.defineProperty(keyDown, 'target', { + value: input, + writable: true, + configurable: true + }) + + input.dispatchEvent(keyDown) + + expect(spyKeyDown).toHaveBeenCalled() + expect(spyPrev).not.toHaveBeenCalled() + expect(spyNext).not.toHaveBeenCalled() + + spyKeyDown.calls.reset() + spyPrev.calls.reset() + spyNext.calls.reset() + + Object.defineProperty(keyDown, 'target', { + value: textarea + }) + textarea.dispatchEvent(keyDown) + + expect(spyKeyDown).toHaveBeenCalled() + expect(spyPrev).not.toHaveBeenCalled() + expect(spyNext).not.toHaveBeenCalled() + }) + + it('should wrap around from end to start when wrap option is true', done => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const carouselEl = fixtureEl.querySelector('#myCarousel') + const carousel = new Carousel(carouselEl, { wrap: true }) + const getActiveId = () => { + return carouselEl.querySelector('.carousel-item.active').getAttribute('id') + } + + carouselEl.addEventListener('slid.bs.carousel', e => { + const activeId = getActiveId() + + if (activeId === 'two') { + carousel.next() + return + } + + if (activeId === 'three') { + carousel.next() + return + } + + if (activeId === 'one') { + // carousel wrapped around and slid from 3rd to 1st slide + expect(activeId).toEqual('one') + expect(e.from + 1).toEqual(3) + done() + } + }) + + carousel.next() + }) + + it('should stay at the start when the prev method is called and wrap is false', done => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const carouselEl = fixtureEl.querySelector('#myCarousel') + const firstElement = fixtureEl.querySelector('#one') + const carousel = new Carousel(carouselEl, { wrap: false }) + + carouselEl.addEventListener('slid.bs.carousel', () => { + throw new Error('carousel slid when it should not have slid') + }) + + carousel.prev() + + setTimeout(() => { + expect(firstElement.classList.contains('active')).toEqual(true) + done() + }, 10) + }) + + it('should not add touch event listeners if touch = false', () => { + fixtureEl.innerHTML = '
' + + const carouselEl = fixtureEl.querySelector('div') + + spyOn(Carousel.prototype, '_addTouchEventListeners') + + const carousel = new Carousel(carouselEl, { + touch: false + }) + + expect(carousel._addTouchEventListeners).not.toHaveBeenCalled() + }) + + it('should not add touch event listeners if touch supported = false', () => { + fixtureEl.innerHTML = '
' + + const carouselEl = fixtureEl.querySelector('div') + + const carousel = new Carousel(carouselEl) + + EventHandler.off(carouselEl, '.bs-carousel') + carousel._touchSupported = false + + spyOn(carousel, '_addTouchEventListeners') + + carousel._addEventListeners() + + expect(carousel._addTouchEventListeners).not.toHaveBeenCalled() + }) + + it('should add touch event listeners by default', () => { + fixtureEl.innerHTML = '
' + + const carouselEl = fixtureEl.querySelector('div') + + spyOn(Carousel.prototype, '_addTouchEventListeners') + + document.documentElement.ontouchstart = () => {} + const carousel = new Carousel(carouselEl) + + expect(carousel._addTouchEventListeners).toHaveBeenCalled() + }) + + it('should allow swiperight and call prev with pointer events', done => { + if (!supportPointerEvent) { + expect().nothing() + done() + return + } + + document.documentElement.ontouchstart = () => {} + document.head.appendChild(stylesCarousel) + Simulator.setType('pointer') + + fixtureEl.innerHTML = [ + '' + ].join('') + + const carouselEl = fixtureEl.querySelector('.carousel') + const item = fixtureEl.querySelector('#item') + const carousel = new Carousel(carouselEl) + + spyOn(carousel, 'prev').and.callThrough() + + carouselEl.addEventListener('slid.bs.carousel', () => { + expect(item.classList.contains('active')).toEqual(true) + expect(carousel.prev).toHaveBeenCalled() + document.head.removeChild(stylesCarousel) + delete document.documentElement.ontouchstart + done() + }) + + Simulator.gestures.swipe(carouselEl, { + deltaX: 300, + deltaY: 0 + }) + }) + + it('should allow swipeleft and call next with pointer events', done => { + if (!supportPointerEvent) { + expect().nothing() + done() + return + } + + document.documentElement.ontouchstart = () => {} + document.head.appendChild(stylesCarousel) + Simulator.setType('pointer') + + fixtureEl.innerHTML = [ + '' + ].join('') + + const carouselEl = fixtureEl.querySelector('.carousel') + const item = fixtureEl.querySelector('#item') + const carousel = new Carousel(carouselEl) + + spyOn(carousel, 'next').and.callThrough() + + carouselEl.addEventListener('slid.bs.carousel', () => { + expect(item.classList.contains('active')).toEqual(false) + expect(carousel.next).toHaveBeenCalled() + document.head.removeChild(stylesCarousel) + delete document.documentElement.ontouchstart + done() + }) + + Simulator.gestures.swipe(carouselEl, { + pos: [300, 10], + deltaX: -300, + deltaY: 0 + }) + }) + + it('should allow swiperight and call prev with touch events', done => { + Simulator.setType('touch') + clearPointerEvents() + document.documentElement.ontouchstart = () => {} + + fixtureEl.innerHTML = [ + '' + ].join('') + + const carouselEl = fixtureEl.querySelector('.carousel') + const item = fixtureEl.querySelector('#item') + const carousel = new Carousel(carouselEl) + + spyOn(carousel, 'prev').and.callThrough() + + carouselEl.addEventListener('slid.bs.carousel', () => { + expect(item.classList.contains('active')).toEqual(true) + expect(carousel.prev).toHaveBeenCalled() + delete document.documentElement.ontouchstart + restorePointerEvents() + done() + }) + + Simulator.gestures.swipe(carouselEl, { + deltaX: 300, + deltaY: 0 + }) + }) + + it('should allow swipeleft and call next with touch events', done => { + Simulator.setType('touch') + clearPointerEvents() + document.documentElement.ontouchstart = () => {} + + fixtureEl.innerHTML = [ + '' + ].join('') + + const carouselEl = fixtureEl.querySelector('.carousel') + const item = fixtureEl.querySelector('#item') + const carousel = new Carousel(carouselEl) + + spyOn(carousel, 'next').and.callThrough() + + carouselEl.addEventListener('slid.bs.carousel', () => { + expect(item.classList.contains('active')).toEqual(false) + expect(carousel.next).toHaveBeenCalled() + delete document.documentElement.ontouchstart + restorePointerEvents() + done() + }) + + Simulator.gestures.swipe(carouselEl, { + pos: [300, 10], + deltaX: -300, + deltaY: 0 + }) + }) + + it('should not allow pinch with touch events', done => { + Simulator.setType('touch') + clearPointerEvents() + document.documentElement.ontouchstart = () => {} + + fixtureEl.innerHTML = '' + + const carouselEl = fixtureEl.querySelector('.carousel') + const carousel = new Carousel(carouselEl) + + Simulator.gestures.swipe(carouselEl, { + pos: [300, 10], + deltaX: -300, + deltaY: 0, + touches: 2 + }, () => { + restorePointerEvents() + delete document.documentElement.ontouchstart + expect(carousel.touchDeltaX).toEqual(0) + done() + }) + }) + + it('should call pause method on mouse over with pause equal to hover', done => { + fixtureEl.innerHTML = '' + + const carouselEl = fixtureEl.querySelector('.carousel') + const carousel = new Carousel(carouselEl) + + spyOn(carousel, 'pause') + + const mouseOverEvent = createEvent('mouseover') + carouselEl.dispatchEvent(mouseOverEvent) + + setTimeout(() => { + expect(carousel.pause).toHaveBeenCalled() + done() + }, 10) + }) + + it('should call cycle on mouse out with pause equal to hover', done => { + fixtureEl.innerHTML = '' + + const carouselEl = fixtureEl.querySelector('.carousel') + const carousel = new Carousel(carouselEl) + + spyOn(carousel, 'cycle') + + const mouseOutEvent = createEvent('mouseout') + carouselEl.dispatchEvent(mouseOutEvent) + + setTimeout(() => { + expect(carousel.cycle).toHaveBeenCalled() + done() + }, 10) + }) + }) + + describe('next', () => { + it('should not slide if the carousel is sliding', () => { + fixtureEl.innerHTML = '
' + + const carouselEl = fixtureEl.querySelector('div') + const carousel = new Carousel(carouselEl, {}) + + spyOn(carousel, '_slide') + + carousel._isSliding = true + carousel.next() + + expect(carousel._slide).not.toHaveBeenCalled() + }) + + it('should not fire slid when slide is prevented', done => { + fixtureEl.innerHTML = '
' + + const carouselEl = fixtureEl.querySelector('div') + const carousel = new Carousel(carouselEl, {}) + let slidEvent = false + + const doneTest = () => { + setTimeout(() => { + expect(slidEvent).toEqual(false) + done() + }, 20) + } + + carouselEl.addEventListener('slide.bs.carousel', e => { + e.preventDefault() + doneTest() + }) + + carouselEl.addEventListener('slid.bs.carousel', () => { + slidEvent = true + }) + + carousel.next() + }) + + it('should fire slide event with: direction, relatedTarget, from and to', done => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const carouselEl = fixtureEl.querySelector('#myCarousel') + const carousel = new Carousel(carouselEl, {}) + + const onSlide = e => { + expect(e.direction).toEqual('left') + expect(e.relatedTarget.classList.contains('carousel-item')).toEqual(true) + expect(e.from).toEqual(0) + expect(e.to).toEqual(1) + + carouselEl.removeEventListener('slide.bs.carousel', onSlide) + carouselEl.addEventListener('slide.bs.carousel', onSlide2) + + carousel.prev() + } + + const onSlide2 = e => { + expect(e.direction).toEqual('right') + done() + } + + carouselEl.addEventListener('slide.bs.carousel', onSlide) + carousel.next() + }) + + it('should fire slid event with: direction, relatedTarget, from and to', done => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const carouselEl = fixtureEl.querySelector('#myCarousel') + const carousel = new Carousel(carouselEl, {}) + + const onSlid = e => { + expect(e.direction).toEqual('left') + expect(e.relatedTarget.classList.contains('carousel-item')).toEqual(true) + expect(e.from).toEqual(0) + expect(e.to).toEqual(1) + + carouselEl.removeEventListener('slid.bs.carousel', onSlid) + carouselEl.addEventListener('slid.bs.carousel', onSlid2) + + carousel.prev() + } + + const onSlid2 = e => { + expect(e.direction).toEqual('right') + done() + } + + carouselEl.addEventListener('slid.bs.carousel', onSlid) + carousel.next() + }) + + it('should get interval from data attribute in individual item', () => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const carouselEl = fixtureEl.querySelector('#myCarousel') + const carousel = new Carousel(carouselEl, { + interval: 1814 + }) + + expect(carousel._config.interval).toEqual(1814) + + carousel.next() + + expect(carousel._config.interval).toEqual(7) + }) + + it('should update indicators if present', done => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const carouselEl = fixtureEl.querySelector('#myCarousel') + const secondIndicator = fixtureEl.querySelector('#secondIndicator') + const carousel = new Carousel(carouselEl) + + carouselEl.addEventListener('slid.bs.carousel', () => { + expect(secondIndicator.classList.contains('active')).toEqual(true) + done() + }) + + carousel.next() + }) + }) + + describe('nextWhenVisible', () => { + it('should not call next when the page is not visible', () => { + fixtureEl.innerHTML = '' + + const carouselEl = fixtureEl.querySelector('div') + const carousel = new Carousel(carouselEl) + + spyOn(carousel, 'next') + + carousel.nextWhenVisible() + + expect(carousel.next).not.toHaveBeenCalled() + }) + }) + + describe('prev', () => { + it('should not slide if the carousel is sliding', () => { + fixtureEl.innerHTML = '
' + + const carouselEl = fixtureEl.querySelector('div') + const carousel = new Carousel(carouselEl, {}) + + spyOn(carousel, '_slide') + + carousel._isSliding = true + carousel.prev() + + expect(carousel._slide).not.toHaveBeenCalled() + }) + }) + + describe('pause', () => { + it('should call cycle if the carousel have carousel-item-next and carousel-item-prev class', () => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const carouselEl = fixtureEl.querySelector('#myCarousel') + const carousel = new Carousel(carouselEl) + + spyOn(carousel, 'cycle') + spyOn(window, 'clearInterval') + + carousel.pause() + + expect(carousel.cycle).toHaveBeenCalledWith(true) + expect(window.clearInterval).toHaveBeenCalled() + expect(carousel._isPaused).toEqual(true) + }) + + it('should not call cycle if nothing is in transition', () => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const carouselEl = fixtureEl.querySelector('#myCarousel') + const carousel = new Carousel(carouselEl) + + spyOn(carousel, 'cycle') + spyOn(window, 'clearInterval') + + carousel.pause() + + expect(carousel.cycle).not.toHaveBeenCalled() + expect(window.clearInterval).toHaveBeenCalled() + expect(carousel._isPaused).toEqual(true) + }) + + it('should not set is paused at true if an event is passed', () => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const carouselEl = fixtureEl.querySelector('#myCarousel') + const carousel = new Carousel(carouselEl) + const event = createEvent('mouseenter') + + spyOn(window, 'clearInterval') + + carousel.pause(event) + + expect(window.clearInterval).toHaveBeenCalled() + expect(carousel._isPaused).toEqual(false) + }) + }) + + describe('cycle', () => { + it('should set an interval', () => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const carouselEl = fixtureEl.querySelector('#myCarousel') + const carousel = new Carousel(carouselEl) + + spyOn(window, 'setInterval').and.callThrough() + + carousel.cycle() + + expect(window.setInterval).toHaveBeenCalled() + }) + + it('should not set interval if the carousel is paused', () => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const carouselEl = fixtureEl.querySelector('#myCarousel') + const carousel = new Carousel(carouselEl) + + spyOn(window, 'setInterval').and.callThrough() + + carousel._isPaused = true + carousel.cycle(true) + + expect(window.setInterval).not.toHaveBeenCalled() + }) + + it('should clear interval if there is one', () => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const carouselEl = fixtureEl.querySelector('#myCarousel') + const carousel = new Carousel(carouselEl) + + carousel._interval = setInterval(() => {}, 10) + + spyOn(window, 'setInterval').and.callThrough() + spyOn(window, 'clearInterval').and.callThrough() + + carousel.cycle() + + expect(window.setInterval).toHaveBeenCalled() + expect(window.clearInterval).toHaveBeenCalled() + }) + }) + + describe('to', () => { + it('should go directement to the provided index', done => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const carouselEl = fixtureEl.querySelector('#myCarousel') + const carousel = new Carousel(carouselEl, {}) + + expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item1')) + + carousel.to(2) + + carouselEl.addEventListener('slid.bs.carousel', () => { + expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item3')) + done() + }) + }) + + it('should return to a previous slide if the provided index is lower than the current', done => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const carouselEl = fixtureEl.querySelector('#myCarousel') + const carousel = new Carousel(carouselEl, {}) + + expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item3')) + + carousel.to(1) + + carouselEl.addEventListener('slid.bs.carousel', () => { + expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item2')) + done() + }) + }) + + it('should do nothing if a wrong index is provided', () => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const carouselEl = fixtureEl.querySelector('#myCarousel') + const carousel = new Carousel(carouselEl, {}) + + const spy = spyOn(carousel, '_slide') + + carousel.to(25) + + expect(spy).not.toHaveBeenCalled() + + spy.calls.reset() + + carousel.to(-5) + + expect(spy).not.toHaveBeenCalled() + }) + + it('should call pause and cycle is the provided is the same compare to the current one', () => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const carouselEl = fixtureEl.querySelector('#myCarousel') + const carousel = new Carousel(carouselEl, {}) + + spyOn(carousel, '_slide') + spyOn(carousel, 'pause') + spyOn(carousel, 'cycle') + + carousel.to(0) + + expect(carousel._slide).not.toHaveBeenCalled() + expect(carousel.pause).toHaveBeenCalled() + expect(carousel.cycle).toHaveBeenCalled() + }) + + it('should wait before performing to if a slide is sliding', done => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const carouselEl = fixtureEl.querySelector('#myCarousel') + const carousel = new Carousel(carouselEl, {}) + + spyOn(EventHandler, 'one').and.callThrough() + spyOn(carousel, '_slide') + + carousel._isSliding = true + carousel.to(1) + + expect(carousel._slide).not.toHaveBeenCalled() + expect(EventHandler.one).toHaveBeenCalled() + + spyOn(carousel, 'to') + + EventHandler.trigger(carouselEl, 'slid.bs.carousel') + + setTimeout(() => { + expect(carousel.to).toHaveBeenCalledWith(1) + done() + }) + }) + }) + + describe('dispose', () => { + it('should destroy a carousel', () => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const carouselEl = fixtureEl.querySelector('#myCarousel') + const carousel = new Carousel(carouselEl) + + spyOn(EventHandler, 'off').and.callThrough() + + carousel.dispose() + + expect(EventHandler.off).toHaveBeenCalled() + }) + }) + + describe('_jQueryInterface', () => { + it('should create a carousel', () => { + fixtureEl.innerHTML = '
' + + const div = fixtureEl.querySelector('div') + + jQueryMock.fn.carousel = Carousel._jQueryInterface + jQueryMock.elements = [div] + + jQueryMock.fn.carousel.call(jQueryMock) + + expect(Carousel._getInstance(div)).toBeDefined() + }) + + it('should not re create a carousel', () => { + fixtureEl.innerHTML = '
' + + const div = fixtureEl.querySelector('div') + const carousel = new Carousel(div) + + jQueryMock.fn.carousel = Carousel._jQueryInterface + jQueryMock.elements = [div] + + jQueryMock.fn.carousel.call(jQueryMock) + + expect(Carousel._getInstance(div)).toEqual(carousel) + }) + + it('should call to if the config is a number', () => { + fixtureEl.innerHTML = '
' + + const div = fixtureEl.querySelector('div') + const carousel = new Carousel(div) + const slideTo = 2 + + spyOn(carousel, 'to') + + jQueryMock.fn.carousel = Carousel._jQueryInterface + jQueryMock.elements = [div] + + jQueryMock.fn.carousel.call(jQueryMock, slideTo) + + expect(carousel.to).toHaveBeenCalledWith(slideTo) + }) + + it('should throw error on undefined method', () => { + fixtureEl.innerHTML = '
' + + const div = fixtureEl.querySelector('div') + const action = 'undefinedMethod' + + jQueryMock.fn.carousel = Carousel._jQueryInterface + jQueryMock.elements = [div] + + try { + jQueryMock.fn.carousel.call(jQueryMock, action) + } catch (error) { + expect(error.message).toEqual(`No method named "${action}"`) + } + }) + }) + + describe('data-api', () => { + it('should init carousels with data-ride="carousel" on load', () => { + fixtureEl.innerHTML = '
' + + const carouselEl = fixtureEl.querySelector('div') + const loadEvent = createEvent('load') + + window.dispatchEvent(loadEvent) + + expect(Carousel._getInstance(carouselEl)).toBeDefined() + }) + + it('should create carousel and go to the next slide on click', done => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const next = fixtureEl.querySelector('#next') + const item2 = fixtureEl.querySelector('#item2') + + next.click() + + setTimeout(() => { + expect(item2.classList.contains('active')).toEqual(true) + done() + }, 10) + }) + + it('should create carousel and go to the next slide on click with data-slide-to', done => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const next = fixtureEl.querySelector('#next') + const item2 = fixtureEl.querySelector('#item2') + + next.click() + + setTimeout(() => { + expect(item2.classList.contains('active')).toEqual(true) + done() + }, 10) + }) + + it('should do nothing if no selector on click on arrows', () => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const next = fixtureEl.querySelector('#next') + + next.click() + + expect().nothing() + }) + + it('should do nothing if no carousel class on click on arrows', () => { + fixtureEl.innerHTML = [ + '
', + ' ', + ' ', + ' ', + '
' + ].join('') + + const next = fixtureEl.querySelector('#next') + + next.click() + + expect().nothing() + }) + }) +}) diff --git a/js/tests/karma.conf.js b/js/tests/karma.conf.js index b5b9fb373..00bf5d8d3 100644 --- a/js/tests/karma.conf.js +++ b/js/tests/karma.conf.js @@ -74,7 +74,9 @@ const rollupPreprocessor = { } } -let files = [] +let files = [ + 'node_modules/hammer-simulator/index.js' +] const conf = { basePath: '../..', diff --git a/js/tests/unit/carousel.js b/js/tests/unit/carousel.js deleted file mode 100644 index f92d87c61..000000000 --- a/js/tests/unit/carousel.js +++ /dev/null @@ -1,1370 +0,0 @@ -$(function () { - 'use strict' - - window.Carousel = typeof bootstrap === 'undefined' ? Carousel : bootstrap.Carousel - - var originWinPointerEvent = window.PointerEvent - window.MSPointerEvent = null - var supportPointerEvent = Boolean(window.PointerEvent || window.MSPointerEvent) - - function clearPointerEvents() { - window.PointerEvent = null - } - - function restorePointerEvents() { - window.PointerEvent = originWinPointerEvent - } - - var stylesCarousel = [ - '' - ].join('') - - QUnit.module('carousel plugin') - - QUnit.test('should be defined on jQuery object', function (assert) { - assert.expect(1) - assert.ok($(document.body).carousel, 'carousel method is defined') - }) - - QUnit.module('carousel', { - beforeEach: function () { - // Run all tests in noConflict mode -- it's the only way to ensure that the plugin works in noConflict mode - $.fn.bootstrapCarousel = $.fn.carousel.noConflict() - }, - afterEach: function () { - $('.carousel').bootstrapCarousel('dispose') - $.fn.carousel = $.fn.bootstrapCarousel - delete $.fn.bootstrapCarousel - $('#qunit-fixture').html('') - } - }) - - QUnit.test('should provide no conflict', function (assert) { - assert.expect(1) - assert.strictEqual(typeof $.fn.carousel, 'undefined', 'carousel was set back to undefined (orig value)') - }) - - QUnit.test('should return the version', function (assert) { - assert.expect(1) - assert.strictEqual(typeof Carousel.VERSION, 'string') - }) - - QUnit.test('should return default parameters', function (assert) { - assert.expect(1) - - var defaultConfig = Carousel.Default - - assert.strictEqual(defaultConfig.touch, true) - }) - - QUnit.test('should throw explicit error on undefined method', function (assert) { - assert.expect(1) - var $el = $('
') - $el.bootstrapCarousel() - try { - $el.bootstrapCarousel('noMethod') - } catch (error) { - assert.strictEqual(error.message, 'No method named "noMethod"') - } - }) - - QUnit.test('should return jquery collection containing the element', function (assert) { - assert.expect(2) - var $el = $('
') - var $carousel = $el.bootstrapCarousel() - assert.ok($carousel instanceof $, 'returns jquery collection') - assert.strictEqual($carousel[0], $el[0], 'collection contains element') - }) - - QUnit.test('should type check config options', function (assert) { - assert.expect(2) - - var message - var expectedMessage = 'CAROUSEL: Option "interval" provided type "string" but expected type "(number|boolean)".' - var config = { - interval: 'fat sux' - } - - try { - $('
').bootstrapCarousel(config) - } catch (error) { - message = error.message - } - - assert.ok(message === expectedMessage, 'correct error message') - - config = { - keyboard: document.createElement('div') - } - expectedMessage = 'CAROUSEL: Option "keyboard" provided type "element" but expected type "boolean".' - - try { - $('
').bootstrapCarousel(config) - } catch (error) { - message = error.message - } - - assert.ok(message === expectedMessage, 'correct error message') - }) - - QUnit.test('should not fire slid when slide is prevented', function (assert) { - assert.expect(1) - var done = assert.async() - var $carousel = $('