bootstrap-vue
Version:
BootstrapVue, with over 40 plugins and more than 80 custom components, custom directives, and over 300 icons, provides one of the most comprehensive implementations of Bootstrap v4 components and grid system for Vue.js. With extensive and automated WAI-AR
660 lines (645 loc) • 19.5 kB
JavaScript
import Vue from '../../utils/vue'
import KeyCodes from '../../utils/key-codes'
import noop from '../../utils/noop'
import observeDom from '../../utils/observe-dom'
import { getComponentConfig } from '../../utils/config'
import {
selectAll,
reflow,
addClass,
removeClass,
setAttr,
eventOn,
eventOff
} from '../../utils/dom'
import { isBrowser, hasTouchSupport, hasPointerEventSupport } from '../../utils/env'
import { isUndefined } from '../../utils/inspect'
import { toInteger } from '../../utils/number'
import idMixin from '../../mixins/id'
import normalizeSlotMixin from '../../mixins/normalize-slot'
const NAME = 'BCarousel'
// 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'
}
const EventOptions = { passive: true, capture: false }
// 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,
mixins: [idMixin, normalizeSlotMixin],
provide() {
return { bvCarousel: this }
},
model: {
prop: 'value',
event: 'input'
},
props: {
labelPrev: {
type: String,
default: () => getComponentConfig(NAME, 'labelPrev')
},
labelNext: {
type: String,
default: () => getComponentConfig(NAME, 'labelNext')
},
labelGotoSlide: {
type: String,
default: () => getComponentConfig(NAME, 'labelGotoSlide')
},
labelIndicators: {
type: String,
default: () => getComponentConfig(NAME, '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),
// 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._intervalId = null
this._animationTimeout = null
this._touchTimeout = null
// Set initial paused state
this.isPaused = !(toInteger(this.interval) > 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
observeDom(this.$refs.inner, this.updateSlides.bind(this), {
subtree: false,
childList: true,
attributes: true,
attributeFilter: ['id']
})
},
beforeDestroy() {
clearTimeout(this._animationTimeout)
clearTimeout(this._touchTimeout)
clearInterval(this._intervalId)
this._intervalId = null
this._animationTimeout = null
this._touchTimeout = null
},
methods: {
// 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 = Math.floor(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', () => 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
}
if (this._intervalId) {
clearInterval(this._intervalId)
this._intervalId = null
}
},
// Start auto rotate slides
start(evt) {
if (!evt) {
this.isPaused = false
}
/* istanbul ignore next: most likely will never happen, but just in case */
if (this._intervalId) {
clearInterval(this._intervalId)
this._intervalId = null
}
// Don't start if no interval, or less than 2 slides
if (this.interval && this.numSlides > 1) {
this._intervalId = setInterval(this.next, Math.max(1000, this.interval))
}
},
// Restart auto rotate slides when focus/hover leaves the carousel
restart(evt) /* istanbul ignore next: difficult to test */ {
if (!this.$el.contains(document.activeElement)) {
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 = evt => {
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(currentSlide, evt, onceTransEnd, EventOptions))
}
this._animationTimeout = null
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(currentSlide, event, onceTransEnd, EventOptions))
}
// 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 = Math.max(0, Math.min(Math.floor(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 === KeyCodes.SPACE || keyCode === KeyCodes.ENTER) {
evt.preventDefault()
evt.stopPropagation()
fn()
}
},
handleSwipe() /* istanbul ignore next: JSDOM doesn't support touch events */ {
const absDeltaX = Math.abs(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()
}
},
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
}
},
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
}
},
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)
if (this._touchTimeout) {
clearTimeout(this._touchTimeout)
}
this._touchTimeout = setTimeout(
this.start,
TOUCH_EVENT_COMPAT_WAIT + Math.max(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('default')]
)
// 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 {
evt.preventDefault()
}
}
const nextHandler = evt => {
/* istanbul ignore next */
if (!this.isSliding) {
this.handleClick(evt, this.next)
} else {
evt.preventDefault()
}
}
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 === KeyCodes.LEFT || keyCode === KeyCodes.RIGHT) {
evt.preventDefault()
evt.stopPropagation()
this[keyCode === KeyCodes.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]
)
}
})