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
JavaScript
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]
)
}
})