UNPKG

bootstrap-vue

Version:

With more than 85 components, over 45 available plugins, several directives, and 1000+ icons, BootstrapVue provides one of the most comprehensive implementations of the Bootstrap v4 component and grid system available for Vue.js v2.6, complete with extens

676 lines (663 loc) 20.3 kB
import Vue from '../../vue' import { NAME_CAROUSEL } from '../../constants/components' import { EVENT_OPTIONS_NO_CAPTURE } from '../../constants/events' import { CODE_ENTER, CODE_LEFT, CODE_RIGHT, CODE_SPACE } from '../../constants/key-codes' import noop from '../../utils/noop' import observeDom from '../../utils/observe-dom' import { getComponentConfig } from '../../utils/config' import { addClass, getActiveElement, reflow, removeClass, requestAF, selectAll, setAttr } from '../../utils/dom' import { isBrowser, hasTouchSupport, hasPointerEventSupport } from '../../utils/env' import { eventOn, eventOff, stopEvent } from '../../utils/events' import { isUndefined } from '../../utils/inspect' import { mathAbs, mathFloor, mathMax, mathMin } from '../../utils/math' import { toInteger } from '../../utils/number' import idMixin from '../../mixins/id' import normalizeSlotMixin from '../../mixins/normalize-slot' // Slide directional classes const DIRECTION = { next: { dirClass: 'carousel-item-left', overlayClass: 'carousel-item-next' }, prev: { dirClass: 'carousel-item-right', overlayClass: 'carousel-item-prev' } } // Fallback Transition duration (with a little buffer) in ms const TRANS_DURATION = 600 + 50 // Time for mouse compat events to fire after touch const TOUCH_EVENT_COMPAT_WAIT = 500 // Number of pixels to consider touch move a swipe const SWIPE_THRESHOLD = 40 // PointerEvent pointer types const PointerType = { TOUCH: 'touch', PEN: 'pen' } // Transition Event names const TransitionEndEvents = { WebkitTransition: 'webkitTransitionEnd', MozTransition: 'transitionend', OTransition: 'otransitionend oTransitionEnd', transition: 'transitionend' } // Return the browser specific transitionEnd event name const getTransitionEndEvent = el => { for (const name in TransitionEndEvents) { if (!isUndefined(el.style[name])) { return TransitionEndEvents[name] } } // Fallback /* istanbul ignore next */ return null } // @vue/component export const BCarousel = /*#__PURE__*/ Vue.extend({ name: NAME_CAROUSEL, mixins: [idMixin, normalizeSlotMixin], provide() { return { bvCarousel: this } }, model: { prop: 'value', event: 'input' }, props: { labelPrev: { type: String, default: () => getComponentConfig(NAME_CAROUSEL, 'labelPrev') }, labelNext: { type: String, default: () => getComponentConfig(NAME_CAROUSEL, 'labelNext') }, labelGotoSlide: { type: String, default: () => getComponentConfig(NAME_CAROUSEL, 'labelGotoSlide') }, labelIndicators: { type: String, default: () => getComponentConfig(NAME_CAROUSEL, 'labelIndicators') }, interval: { type: Number, default: 5000 }, indicators: { type: Boolean, default: false }, controls: { type: Boolean, default: false }, noAnimation: { // Disable slide/fade animation type: Boolean, default: false }, fade: { // Enable cross-fade animation instead of slide animation type: Boolean, default: false }, noWrap: { // Disable wrapping/looping when start/end is reached type: Boolean, default: false }, noTouch: { // Sniffed by carousel-slide type: Boolean, default: false }, noHoverPause: { // Disable pause on hover type: Boolean, default: false }, imgWidth: { // Sniffed by carousel-slide type: [Number, String] // default: undefined }, imgHeight: { // Sniffed by carousel-slide type: [Number, String] // default: undefined }, background: { type: String // default: undefined }, value: { type: Number, default: 0 } }, data() { return { index: this.value || 0, isSliding: false, transitionEndEvent: null, slides: [], direction: null, isPaused: !(toInteger(this.interval, 0) > 0), // Touch event handling values touchStartX: 0, touchDeltaX: 0 } }, computed: { numSlides() { return this.slides.length } }, watch: { value(newVal, oldVal) { if (newVal !== oldVal) { this.setSlide(toInteger(newVal, 0)) } }, interval(newVal, oldVal) { if (newVal === oldVal) { /* istanbul ignore next */ return } if (!newVal) { // Pausing slide show this.pause(false) } else { // Restarting or Changing interval this.pause(true) this.start(false) } }, isPaused(newVal, oldVal) { if (newVal !== oldVal) { this.$emit(newVal ? 'paused' : 'unpaused') } }, index(to, from) { if (to === from || this.isSliding) { /* istanbul ignore next */ return } this.doSlide(to, from) } }, created() { // Create private non-reactive props this.$_interval = null this.$_animationTimeout = null this.$_touchTimeout = null this.$_observer = null // Set initial paused state this.isPaused = !(toInteger(this.interval, 0) > 0) }, mounted() { // Cache current browser transitionend event name this.transitionEndEvent = getTransitionEndEvent(this.$el) || null // Get all slides this.updateSlides() // Observe child changes so we can update slide list this.setObserver(true) }, beforeDestroy() { this.clearInterval() this.clearAnimationTimeout() this.clearTouchTimeout() this.setObserver(false) }, methods: { clearInterval() { clearInterval(this.$_interval) this.$_interval = null }, clearAnimationTimeout() { clearTimeout(this.$_animationTimeout) this.$_animationTimeout = null }, clearTouchTimeout() { clearTimeout(this.$_touchTimeout) this.$_touchTimeout = null }, setObserver(on = false) { this.$_observer && this.$_observer.disconnect() this.$_observer = null if (on) { this.$_observer = observeDom(this.$refs.inner, this.updateSlides.bind(this), { subtree: false, childList: true, attributes: true, attributeFilter: ['id'] }) } }, // Set slide setSlide(slide, direction = null) { // Don't animate when page is not visible /* istanbul ignore if: difficult to test */ if (isBrowser && document.visibilityState && document.hidden) { return } const noWrap = this.noWrap const numSlides = this.numSlides // Make sure we have an integer (you never know!) slide = mathFloor(slide) // Don't do anything if nothing to slide to if (numSlides === 0) { return } // Don't change slide while transitioning, wait until transition is done if (this.isSliding) { // Schedule slide after sliding complete this.$once('sliding-end', () => { // Wrap in `requestAF()` to allow the slide to properly finish to avoid glitching requestAF(() => this.setSlide(slide, direction)) }) return } this.direction = direction // Set new slide index // Wrap around if necessary (if no-wrap not enabled) this.index = slide >= numSlides ? noWrap ? numSlides - 1 : 0 : slide < 0 ? noWrap ? 0 : numSlides - 1 : slide // Ensure the v-model is synched up if no-wrap is enabled // and user tried to slide pass either ends if (noWrap && this.index !== slide && this.index !== this.value) { this.$emit('input', this.index) } }, // Previous slide prev() { this.setSlide(this.index - 1, 'prev') }, // Next slide next() { this.setSlide(this.index + 1, 'next') }, // Pause auto rotation pause(evt) { if (!evt) { this.isPaused = true } this.clearInterval() }, // Start auto rotate slides start(evt) { if (!evt) { this.isPaused = false } /* istanbul ignore next: most likely will never happen, but just in case */ this.clearInterval() // Don't start if no interval, or less than 2 slides if (this.interval && this.numSlides > 1) { this.$_interval = setInterval(this.next, mathMax(1000, this.interval)) } }, // Restart auto rotate slides when focus/hover leaves the carousel /* istanbul ignore next */ restart() /* istanbul ignore next: difficult to test */ { if (!this.$el.contains(getActiveElement())) { this.start() } }, doSlide(to, from) { const isCycling = Boolean(this.interval) // Determine sliding direction const direction = this.calcDirection(this.direction, from, to) const overlayClass = direction.overlayClass const dirClass = direction.dirClass // Determine current and next slides const currentSlide = this.slides[from] const nextSlide = this.slides[to] // Don't do anything if there aren't any slides to slide to if (!currentSlide || !nextSlide) { /* istanbul ignore next */ return } // Start animating this.isSliding = true if (isCycling) { this.pause(false) } this.$emit('sliding-start', to) // Update v-model this.$emit('input', this.index) if (this.noAnimation) { addClass(nextSlide, 'active') removeClass(currentSlide, 'active') this.isSliding = false // Notify ourselves that we're done sliding (slid) this.$nextTick(() => this.$emit('sliding-end', to)) } else { addClass(nextSlide, overlayClass) // Trigger a reflow of next slide reflow(nextSlide) addClass(currentSlide, dirClass) addClass(nextSlide, dirClass) // Transition End handler let called = false /* istanbul ignore next: difficult to test */ const onceTransEnd = () => { if (called) { return } called = true /* istanbul ignore if: transition events cant be tested in JSDOM */ if (this.transitionEndEvent) { const events = this.transitionEndEvent.split(/\s+/) events.forEach(evt => eventOff(nextSlide, evt, onceTransEnd, EVENT_OPTIONS_NO_CAPTURE)) } this.clearAnimationTimeout() removeClass(nextSlide, dirClass) removeClass(nextSlide, overlayClass) addClass(nextSlide, 'active') removeClass(currentSlide, 'active') removeClass(currentSlide, dirClass) removeClass(currentSlide, overlayClass) setAttr(currentSlide, 'aria-current', 'false') setAttr(nextSlide, 'aria-current', 'true') setAttr(currentSlide, 'aria-hidden', 'true') setAttr(nextSlide, 'aria-hidden', 'false') this.isSliding = false this.direction = null // Notify ourselves that we're done sliding (slid) this.$nextTick(() => this.$emit('sliding-end', to)) } // Set up transitionend handler /* istanbul ignore if: transition events cant be tested in JSDOM */ if (this.transitionEndEvent) { const events = this.transitionEndEvent.split(/\s+/) events.forEach(event => eventOn(nextSlide, event, onceTransEnd, EVENT_OPTIONS_NO_CAPTURE)) } // Fallback to setTimeout() this.$_animationTimeout = setTimeout(onceTransEnd, TRANS_DURATION) } if (isCycling) { this.start(false) } }, // Update slide list updateSlides() { this.pause(true) // Get all slides as DOM elements this.slides = selectAll('.carousel-item', this.$refs.inner) const numSlides = this.slides.length // Keep slide number in range const index = mathMax(0, mathMin(mathFloor(this.index), numSlides - 1)) this.slides.forEach((slide, idx) => { const n = idx + 1 if (idx === index) { addClass(slide, 'active') setAttr(slide, 'aria-current', 'true') } else { removeClass(slide, 'active') setAttr(slide, 'aria-current', 'false') } setAttr(slide, 'aria-posinset', String(n)) setAttr(slide, 'aria-setsize', String(numSlides)) }) // Set slide as active this.setSlide(index) this.start(this.isPaused) }, calcDirection(direction = null, curIndex = 0, nextIndex = 0) { if (!direction) { return nextIndex > curIndex ? DIRECTION.next : DIRECTION.prev } return DIRECTION[direction] }, handleClick(evt, fn) { const keyCode = evt.keyCode if (evt.type === 'click' || keyCode === CODE_SPACE || keyCode === CODE_ENTER) { stopEvent(evt) fn() } }, /* istanbul ignore next */ handleSwipe() /* istanbul ignore next: JSDOM doesn't support touch events */ { const absDeltaX = mathAbs(this.touchDeltaX) if (absDeltaX <= SWIPE_THRESHOLD) { return } const direction = absDeltaX / this.touchDeltaX // Reset touch delta X // https://github.com/twbs/bootstrap/pull/28558 this.touchDeltaX = 0 if (direction > 0) { // Swipe left this.prev() } else if (direction < 0) { // Swipe right this.next() } }, /* istanbul ignore next */ touchStart(evt) /* istanbul ignore next: JSDOM doesn't support touch events */ { if (hasPointerEventSupport && PointerType[evt.pointerType.toUpperCase()]) { this.touchStartX = evt.clientX } else if (!hasPointerEventSupport) { this.touchStartX = evt.touches[0].clientX } }, /* istanbul ignore next */ touchMove(evt) /* istanbul ignore next: JSDOM doesn't support touch events */ { // Ensure swiping with one touch and not pinching if (evt.touches && evt.touches.length > 1) { this.touchDeltaX = 0 } else { this.touchDeltaX = evt.touches[0].clientX - this.touchStartX } }, /* istanbul ignore next */ touchEnd(evt) /* istanbul ignore next: JSDOM doesn't support touch events */ { if (hasPointerEventSupport && PointerType[evt.pointerType.toUpperCase()]) { this.touchDeltaX = evt.clientX - this.touchStartX } this.handleSwipe() // 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(false) this.clearTouchTimeout() this.$_touchTimeout = setTimeout( this.start, TOUCH_EVENT_COMPAT_WAIT + mathMax(1000, this.interval) ) } }, render(h) { // Wrapper for slides const inner = h( 'div', { ref: 'inner', class: ['carousel-inner'], attrs: { id: this.safeId('__BV_inner_'), role: 'list' } }, [this.normalizeSlot()] ) // Prev and next controls let controls = h() if (this.controls) { const prevHandler = evt => { /* istanbul ignore next */ if (!this.isSliding) { this.handleClick(evt, this.prev) } else { stopEvent(evt, { propagation: false }) } } const nextHandler = evt => { /* istanbul ignore next */ if (!this.isSliding) { this.handleClick(evt, this.next) } else { stopEvent(evt, { propagation: false }) } } controls = [ h( 'a', { class: ['carousel-control-prev'], attrs: { href: '#', role: 'button', 'aria-controls': this.safeId('__BV_inner_'), 'aria-disabled': this.isSliding ? 'true' : null }, on: { click: prevHandler, keydown: prevHandler } }, [ h('span', { class: ['carousel-control-prev-icon'], attrs: { 'aria-hidden': 'true' } }), h('span', { class: ['sr-only'] }, [this.labelPrev]) ] ), h( 'a', { class: ['carousel-control-next'], attrs: { href: '#', role: 'button', 'aria-controls': this.safeId('__BV_inner_'), 'aria-disabled': this.isSliding ? 'true' : null }, on: { click: nextHandler, keydown: nextHandler } }, [ h('span', { class: ['carousel-control-next-icon'], attrs: { 'aria-hidden': 'true' } }), h('span', { class: ['sr-only'] }, [this.labelNext]) ] ) ] } // Indicators const indicators = h( 'ol', { class: ['carousel-indicators'], directives: [ { name: 'show', rawName: 'v-show', value: this.indicators, expression: 'indicators' } ], attrs: { id: this.safeId('__BV_indicators_'), 'aria-hidden': this.indicators ? 'false' : 'true', 'aria-label': this.labelIndicators, 'aria-owns': this.safeId('__BV_inner_') } }, this.slides.map((slide, n) => { return h('li', { key: `slide_${n}`, class: { active: n === this.index }, attrs: { role: 'button', id: this.safeId(`__BV_indicator_${n + 1}_`), tabindex: this.indicators ? '0' : '-1', 'aria-current': n === this.index ? 'true' : 'false', 'aria-label': `${this.labelGotoSlide} ${n + 1}`, 'aria-describedby': this.slides[n].id || null, 'aria-controls': this.safeId('__BV_inner_') }, on: { click: evt => { this.handleClick(evt, () => { this.setSlide(n) }) }, keydown: evt => { this.handleClick(evt, () => { this.setSlide(n) }) } } }) }) ) const on = { mouseenter: this.noHoverPause ? noop : this.pause, mouseleave: this.noHoverPause ? noop : this.restart, focusin: this.pause, focusout: this.restart, keydown: evt => { if (/input|textarea/i.test(evt.target.tagName)) { /* istanbul ignore next */ return } const keyCode = evt.keyCode if (keyCode === CODE_LEFT || keyCode === CODE_RIGHT) { stopEvent(evt) this[keyCode === CODE_LEFT ? 'prev' : 'next']() } } } // Touch support event handlers for environment if (!this.noTouch && hasTouchSupport) { // Attach appropriate listeners (prepend event name with '&' for passive mode) /* istanbul ignore next: JSDOM doesn't support touch events */ if (hasPointerEventSupport) { on['&pointerdown'] = this.touchStart on['&pointerup'] = this.touchEnd } else { on['&touchstart'] = this.touchStart on['&touchmove'] = this.touchMove on['&touchend'] = this.touchEnd } } // Return the carousel return h( 'div', { staticClass: 'carousel', class: { slide: !this.noAnimation, 'carousel-fade': !this.noAnimation && this.fade, 'pointer-event': !this.noTouch && hasTouchSupport && hasPointerEventSupport }, style: { background: this.background }, attrs: { role: 'region', id: this.safeId(), 'aria-busy': this.isSliding ? 'true' : 'false' }, on }, [inner, controls, indicators] ) } })