bootstrap-vue
Version:
BootstrapVue provides one of the most comprehensive implementations of Bootstrap 4 components and grid system for Vue.js and with extensive and automated WAI-ARIA accessibility markup.
466 lines (456 loc) • 13.3 kB
JavaScript
import observeDom from '../../utils/observe-dom'
import KeyCodes from '../../utils/key-codes'
import { selectAll, reflow, addClass, removeClass, setAttr, eventOn, eventOff } from '../../utils/dom'
import idMixin from '../../mixins/id'
// 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
// Transition Event names
const TransitionEndEvents = {
WebkitTransition: 'webkitTransitionEnd',
MozTransition: 'transitionend',
OTransition: 'otransitionend oTransitionEnd',
transition: 'transitionend'
}
// Return the browser specific transitionEnd event name
function getTransisionEndEvent (el) {
for (const name in TransitionEndEvents) {
if (el.style[name] !== undefined) {
return TransitionEndEvents[name]
}
}
// fallback
return null
}
export default {
mixins: [ idMixin ],
render (h) {
const t = this
// Wrapper for slides
const inner = h(
'div',
{
ref: 'inner',
class: [ 'carousel-inner' ],
attrs: {
id: t.safeId('__BV_inner_'),
role: 'list'
}
},
[ t.$slots.default ]
)
// Prev and Next Controls
let controls = h(false)
if (t.controls) {
controls = [
h(
'a',
{
class: [ 'carousel-control-prev' ],
attrs: { href: '#', role: 'button', 'aria-controls': t.safeId('__BV_inner_') },
on: {
click: (evt) => {
evt.preventDefault()
evt.stopPropagation()
t.prev()
},
keydown: (evt) => {
const keyCode = evt.keyCode
if (keyCode === KeyCodes.SPACE || keyCode === KeyCodes.ENTER) {
evt.preventDefault()
evt.stopPropagation()
t.prev()
}
}
}
},
[
h('span', { class: [ 'carousel-control-prev-icon' ], attrs: { 'aria-hidden': 'true' } }),
h('span', { class: [ 'sr-only' ] }, [ t.labelPrev ])
]
),
h(
'a',
{
class: [ 'carousel-control-next' ],
attrs: { href: '#', role: 'button', 'aria-controls': t.safeId('__BV_inner_') },
on: {
click: (evt) => {
evt.preventDefault()
evt.stopPropagation()
t.next()
},
keydown: (evt) => {
const keyCode = evt.keyCode
if (keyCode === KeyCodes.SPACE || keyCode === KeyCodes.ENTER) {
evt.preventDefault()
evt.stopPropagation()
t.next()
}
}
}
},
[
h('span', { class: [ 'carousel-control-next-icon' ], attrs: { 'aria-hidden': 'true' } }),
h('span', { class: [ 'sr-only' ] }, [ t.labelNext ])
]
)
]
}
// Indicators
const indicators = h(
'ol',
{
class: [ 'carousel-indicators' ],
directives: [
{ name: 'show', rawName: 'v-show', value: t.indicators, expression: 'indicators' }
],
attrs: {
id: t.safeId('__BV_indicators_'),
'aria-hidden': t.indicators ? 'false' : 'true',
'aria-label': t.labelIndicators,
'aria-owns': t.safeId('__BV_inner_')
}
},
t.slides.map((slide, n) => {
return h(
'li',
{
key: `slide_${n}`,
class: { active: n === t.index },
attrs: {
role: 'button',
id: t.safeId(`__BV_indicator_${n + 1}_`),
tabindex: t.indicators ? '0' : '-1',
'aria-current': n === t.index ? 'true' : 'false',
'aria-label': `${t.labelGotoSlide} ${n + 1}`,
'aria-describedby': t.slides[n].id || null,
'aria-controls': t.safeId('__BV_inner_')
},
on: {
click: (evt) => {
t.setSlide(n)
},
keydown: (evt) => {
const keyCode = evt.keyCode
if (keyCode === KeyCodes.SPACE || keyCode === KeyCodes.ENTER) {
evt.preventDefault()
evt.stopPropagation()
t.setSlide(n)
}
}
}
}
)
})
)
// Return the carousel
return h(
'div',
{
class: [ 'carousel', 'slide' ],
style: { background: t.background },
attrs: {
role: 'region',
id: t.safeId(),
'aria-busy': t.isSliding ? 'true' : 'false'
},
on: {
mouseenter: t.pause,
mouseleave: t.restart,
focusin: t.pause,
focusout: t.restart,
keydown: (evt) => {
const keyCode = evt.keyCode
if (keyCode === KeyCodes.LEFT || keyCode === KeyCodes.RIGHT) {
evt.preventDefault()
evt.stopPropagation()
t[keyCode === KeyCodes.LEFT ? 'prev' : 'next']()
}
}
}
},
[ inner, controls, indicators ]
)
},
data () {
return {
index: this.value || 0,
isSliding: false,
intervalId: null,
transitionEndEvent: null,
slides: [],
direction: null
}
},
props: {
labelPrev: {
type: String,
default: 'Previous Slide'
},
labelNext: {
type: String,
default: 'Next Slide'
},
labelGotoSlide: {
type: String,
default: 'Goto Slide'
},
labelIndicators: {
type: String,
default: 'Select a slide to display'
},
interval: {
type: Number,
default: 5000
},
indicators: {
type: Boolean,
default: false
},
controls: {
type: Boolean,
default: false
},
imgWidth: {
// Sniffed by carousel-slide
type: [Number, String]
},
imgHeight: {
// Sniffed by carousel-slide
type: [Number, String]
},
background: {
type: String
},
value: {
type: Number,
default: 0
}
},
computed: {
isCycling () {
return Boolean(this.intervalId)
}
},
methods: {
// Set slide
setSlide (slide) {
// Don't animate when page is not visible
if (typeof document !== 'undefined' && document.visibilityState && document.hidden) {
return
}
const len = this.slides.length
// Don't do anything if nothing to slide to
if (len === 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))
return
}
// Make sure we have an integer (you never know!)
slide = Math.floor(slide)
// Set new slide index. Wrap around if necessary
this.index = slide >= len ? 0 : (slide >= 0 ? slide : len - 1)
},
// Previous slide
prev () {
this.direction = 'prev'
this.setSlide(this.index - 1)
},
// Next slide
next () {
this.direction = 'next'
this.setSlide(this.index + 1)
},
// Pause auto rotation
pause () {
if (this.isCycling) {
clearInterval(this.intervalId)
this.intervalId = null
if (this.slides[this.index]) {
// Make current slide focusable for screen readers
this.slides[this.index].tabIndex = 0
}
}
},
// Start auto rotate slides
start () {
// Don't start if no interval, or if we are already running
if (!this.interval || this.isCycling) {
return
}
this.slides.forEach(slide => {
slide.tabIndex = -1
})
this.intervalId = setInterval(() => {
this.next()
}, Math.max(1000, this.interval))
},
// Re-Start auto rotate slides when focus/hover leaves the carousel
restart (evt) {
if (!this.$el.contains(document.activeElement)) {
this.start()
}
},
// Update slide list
updateSlides () {
this.pause()
// 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')
} else {
removeClass(slide, 'active')
}
setAttr(slide, 'aria-current', idx === index ? 'true' : 'false')
setAttr(slide, 'aria-posinset', String(n))
setAttr(slide, 'aria-setsize', String(numSlides))
slide.tabIndex = -1
})
// Set slide as active
this.setSlide(index)
this.start()
},
calcDirection (direction = null, curIndex = 0, nextIndex = 0) {
if (!direction) {
return (nextIndex > curIndex) ? DIRECTION.next : DIRECTION.prev
}
return DIRECTION[direction]
}
},
watch: {
value (newVal, oldVal) {
if (newVal !== oldVal) {
this.setSlide(newVal)
}
},
interval (newVal, oldVal) {
if (newVal === oldVal) {
return
}
if (!newVal) {
// Pausing slide show
this.pause()
} else {
// Restarting or Changing interval
this.pause()
this.start()
}
},
index (val, oldVal) {
if (val === oldVal || this.isSliding) {
return
}
// Determine sliding direction
let direction = this.calcDirection(this.direction, oldVal, val)
// Determine current and next slides
const currentSlide = this.slides[oldVal]
const nextSlide = this.slides[val]
// Don't do anything if there aren't any slides to slide to
if (!currentSlide || !nextSlide) {
return
}
// Start animating
this.isSliding = true
this.$emit('sliding-start', val)
// Update v-model
this.$emit('input', this.index)
nextSlide.classList.add(direction.overlayClass)
// Trigger a reflow of next slide
reflow(nextSlide)
addClass(currentSlide, direction.dirClass)
addClass(nextSlide, direction.dirClass)
// Transition End handler
let called = false
/* istanbul ignore next: dificult to test */
const onceTransEnd = (evt) => {
if (called) {
return
}
called = true
if (this.transitionEndEvent) {
const events = this.transitionEndEvent.split(/\s+/)
events.forEach(event => {
eventOff(currentSlide, event, onceTransEnd)
})
}
this._animationTimeout = null
removeClass(nextSlide, direction.dirClass)
removeClass(nextSlide, direction.overlayClass)
addClass(nextSlide, 'active')
removeClass(currentSlide, 'active')
removeClass(currentSlide, direction.dirClass)
removeClass(currentSlide, direction.overlayClass)
setAttr(currentSlide, 'aria-current', 'false')
setAttr(nextSlide, 'aria-current', 'true')
setAttr(currentSlide, 'aria-hidden', 'true')
setAttr(nextSlide, 'aria-hidden', 'false')
currentSlide.tabIndex = -1
nextSlide.tabIndex = -1
if (!this.isCycling) {
// Focus the next slide for screen readers if not in play mode
nextSlide.tabIndex = 0
this.$nextTick(() => {
nextSlide.focus()
})
}
this.isSliding = false
this.direction = null
// Notify ourselves that we're done sliding (slid)
this.$nextTick(() => this.$emit('sliding-end', val))
}
// Clear transition classes after transition ends
if (this.transitionEndEvent) {
const events = this.transitionEndEvent.split(/\s+/)
events.forEach(event => {
eventOn(currentSlide, event, onceTransEnd)
})
}
// Fallback to setTimeout
this._animationTimeout = setTimeout(onceTransEnd, TRANS_DURATION)
}
},
created () {
// Create private non-reactive props
this._animationTimeout = null
},
mounted () {
// Cache current browser transitionend event name
this.transitionEndEvent = getTransisionEndEvent(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' ]
})
},
/* istanbul ignore next: dificult to test */
beforeDestroy () {
clearInterval(this.intervalId)
clearTimeout(this._animationTimeout)
this.intervalId = null
this._animationTimeout = null
}
}