Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(b-carousel): add prop no-wrap for disabling wrapping to start/end (closes #3902) #3905

Merged
merged 12 commits into from Aug 17, 2019
44 changes: 44 additions & 0 deletions src/components/carousel/README.md
Expand Up @@ -209,6 +209,13 @@ Set the `<b-carousel>` `no-animation` prop to `true` to disable slide animation.
<!-- b-carousel-no-animation.vue -->
```

## Slide wrapping

Normally when the carousel reaches one end or the other in the list of slides, it will wrap to the
opposite end of the list of slides and continue cycling.

To disable carousel slide wrapping, set the `no-wrap` prop to true.

## Hide slide text content on small screens

On smaller screens you may want to hide the captions and headings. You can do so via the
Expand All @@ -226,6 +233,43 @@ disable touch control, set the `no-touch` prop to `true`.
Programmatically control which slide is showing via `v-model` (which binds to the `value` prop).
Note, that slides are indexed starting at `0`.

## Programmatic slide control

The `<b-carousel>` instance provides several public methods for controlling sliding:

| Method | Description |
| ----------------- | ------------------------------------------------------- |
| `setSlide(index)` | Go to slide specified by `index` |
| `next()` | Go to next slide |
| `prev()` | Go to previous slide |
| `pause()` | Pause the slide cycling |
| `start()` | Start slide cycling (prop `interval` must have a value) |

You will need a reference (via `this.$refs`) to the carousel instance in order to call these
methods:

```html
<template>
<b-carousel ref="myCarousel" .... >
<!-- slides go here -->
</b-carousel>
</template>

<script>
export default {
// ...
methods: {
prev() {
this.$refs.myCarousel.prev()
},
next() {
this.$refs.myCarousel.next()
}
}
}
</script>
```

## Accessibility

Carousels are generally not fully compliant with accessibility standards, although we try to make
Expand Down
44 changes: 35 additions & 9 deletions src/components/carousel/carousel.js
Expand Up @@ -70,7 +70,7 @@ const getTransitionEndEvent = el => {

// @vue/component
export const BCarousel = /*#__PURE__*/ Vue.extend({
name: 'BCarousel',
name: NAME,
mixins: [idMixin, normalizeSlotMixin],
provide() {
return { bvCarousel: this }
Expand Down Expand Up @@ -118,6 +118,11 @@ export const BCarousel = /*#__PURE__*/ Vue.extend({
type: Boolean,
default: false
},
noWrap: {
// Disable wrapping/looping when start/end is reached
type: Boolean,
default: false
},
noTouch: {
// Sniffed by carousel-slide
type: Boolean,
Expand Down Expand Up @@ -160,10 +165,15 @@ export const BCarousel = /*#__PURE__*/ Vue.extend({
touchDeltaX: 0
}
},
computed: {
numSlides() {
return this.slides.length
}
},
watch: {
value(newVal, oldVal) {
if (newVal !== oldVal) {
this.setSlide(newVal)
this.setSlide(parseInt(newVal, 10) || 0)
}
},
interval(newVal, oldVal) {
Expand Down Expand Up @@ -230,9 +240,12 @@ export const BCarousel = /*#__PURE__*/ Vue.extend({
if (isBrowser && document.visibilityState && document.hidden) {
return
}
const len = this.slides.length
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 (len === 0) {
if (numSlides === 0) {
return
}
// Don't change slide while transitioning, wait until transition is done
Expand All @@ -242,10 +255,23 @@ export const BCarousel = /*#__PURE__*/ Vue.extend({
return
}
this.direction = direction
// 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
// 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() {
Expand Down Expand Up @@ -276,7 +302,7 @@ export const BCarousel = /*#__PURE__*/ Vue.extend({
this._intervalId = null
}
// Don't start if no interval, or less than 2 slides
if (this.interval && this.slides.length > 1) {
if (this.interval && this.numSlides > 1) {
this._intervalId = setInterval(this.next, Math.max(1000, this.interval))
}
},
Expand Down
176 changes: 176 additions & 0 deletions src/components/carousel/carousel.spec.js
Expand Up @@ -14,6 +14,7 @@ const appDef = {
controls: false,
fade: false,
noAnimation: false,
noWrap: false,
value: 0
},
render(h) {
Expand All @@ -26,6 +27,7 @@ const appDef = {
controls: this.controls,
fade: this.fade,
noAnimation: this.noAnimation,
noWrap: this.noWrap,
value: this.value
}
},
Expand Down Expand Up @@ -995,4 +997,178 @@ describe('carousel', () => {

wrapper.destroy()
})

it('Next/Prev slide wraps to end/start when no-wrap is false', async () => {
const wrapper = mount(localVue.extend(appDef), {
localVue: localVue,
attachToDocument: true,
propsData: {
interval: 0,
fade: false,
noAnimation: true,
noWrap: false,
indicators: true,
controls: true,
// Start at last slide
value: 3
}
})

expect(wrapper.isVueInstance()).toBe(true)
const $carousel = wrapper.find(BCarousel)
expect($carousel).toBeDefined()
expect($carousel.isVueInstance()).toBe(true)

await waitNT(wrapper.vm)
await waitRAF()

const $indicators = $carousel.findAll('.carousel-indicators > li')
expect($indicators.length).toBe(4)

expect($carousel.emitted('sliding-start')).not.toBeDefined()
expect($carousel.emitted('sliding-end')).not.toBeDefined()
expect($carousel.emitted('input')).not.toBeDefined()

expect($carousel.vm.index).toBe(3)
expect($carousel.vm.isSliding).toBe(false)

// Transitions (or fallback timers) are not used when no-animation set
// Call vm.next()
$carousel.vm.next()
await waitNT(wrapper.vm)

expect($carousel.emitted('sliding-start')).toBeDefined()
expect($carousel.emitted('sliding-end')).toBeDefined()
expect($carousel.emitted('sliding-start').length).toBe(1)
expect($carousel.emitted('sliding-end').length).toBe(1)
// Should have index of 0
expect($carousel.emitted('sliding-start')[0][0]).toEqual(0)
expect($carousel.emitted('sliding-end')[0][0]).toEqual(0)
expect($carousel.emitted('input')).toBeDefined()
expect($carousel.emitted('input').length).toBe(1)
expect($carousel.emitted('input')[0][0]).toEqual(0)
expect($carousel.vm.index).toBe(0)
expect($carousel.vm.isSliding).toBe(false)

// Call vm.prev()
$carousel.vm.prev()
await waitNT(wrapper.vm)

expect($carousel.emitted('sliding-start').length).toBe(2)
expect($carousel.emitted('sliding-end').length).toBe(2)
// Should have index set to last slide
expect($carousel.emitted('sliding-start')[1][0]).toEqual(3)
expect($carousel.emitted('sliding-end')[1][0]).toEqual(3)
expect($carousel.emitted('input').length).toBe(2)
expect($carousel.emitted('input')[1][0]).toEqual(3)
expect($carousel.vm.index).toBe(3)
expect($carousel.vm.isSliding).toBe(false)

wrapper.destroy()
})

it('Next/Prev slide does not wrap to end/start when no-wrap is true', async () => {
const wrapper = mount(localVue.extend(appDef), {
localVue: localVue,
attachToDocument: true,
propsData: {
interval: 0,
fade: false,
// Transitions (or fallback timers) are not used when no-animation set
noAnimation: true,
noWrap: true,
indicators: true,
controls: true,
// Start at last slide
value: 3
}
})

expect(wrapper.isVueInstance()).toBe(true)
const $carousel = wrapper.find(BCarousel)
expect($carousel).toBeDefined()
expect($carousel.isVueInstance()).toBe(true)

await waitNT(wrapper.vm)
await waitRAF()

const $indicators = $carousel.findAll('.carousel-indicators > li')
expect($indicators.length).toBe(4)

expect($carousel.emitted('sliding-start')).not.toBeDefined()
expect($carousel.emitted('sliding-end')).not.toBeDefined()
expect($carousel.emitted('input')).not.toBeDefined()

expect($carousel.vm.index).toBe(3)
expect($carousel.vm.isSliding).toBe(false)

// Call vm.next()
$carousel.vm.next()
await waitNT(wrapper.vm)

// Should not slide to start
expect($carousel.emitted('sliding-start')).not.toBeDefined()
expect($carousel.emitted('sliding-end')).not.toBeDefined()
// Should have index of 3 (no input event emitted since value set to 3)
expect($carousel.emitted('input')).not.toBeDefined()
expect($carousel.vm.index).toBe(3)
expect($carousel.vm.isSliding).toBe(false)

// Call vm.prev()
$carousel.vm.prev()
await waitNT(wrapper.vm)

expect($carousel.emitted('sliding-start').length).toBe(1)
expect($carousel.emitted('sliding-end').length).toBe(1)
// Should have index set to 2
expect($carousel.emitted('sliding-start')[0][0]).toEqual(2)
expect($carousel.emitted('sliding-end')[0][0]).toEqual(2)
expect($carousel.emitted('input')).toBeDefined()
expect($carousel.emitted('input').length).toBe(1)
expect($carousel.emitted('input')[0][0]).toEqual(2)
expect($carousel.vm.index).toBe(2)
expect($carousel.vm.isSliding).toBe(false)

// Call vm.prev()
$carousel.vm.prev()
await waitNT(wrapper.vm)

expect($carousel.emitted('sliding-start').length).toBe(2)
expect($carousel.emitted('sliding-end').length).toBe(2)
// Should have index set to 1
expect($carousel.emitted('sliding-start')[1][0]).toEqual(1)
expect($carousel.emitted('sliding-end')[1][0]).toEqual(1)
expect($carousel.emitted('input').length).toBe(2)
expect($carousel.emitted('input')[1][0]).toEqual(1)
expect($carousel.vm.index).toBe(1)
expect($carousel.vm.isSliding).toBe(false)

// Call vm.prev()
$carousel.vm.prev()
await waitNT(wrapper.vm)

expect($carousel.emitted('sliding-start').length).toBe(3)
expect($carousel.emitted('sliding-end').length).toBe(3)
// Should have index set to 0
expect($carousel.emitted('sliding-start')[2][0]).toEqual(0)
expect($carousel.emitted('sliding-end')[2][0]).toEqual(0)
expect($carousel.emitted('input').length).toBe(3)
expect($carousel.emitted('input')[2][0]).toEqual(0)
expect($carousel.vm.index).toBe(0)
expect($carousel.vm.isSliding).toBe(false)

// Call vm.prev() (should not wrap)
$carousel.vm.prev()
await waitNT(wrapper.vm)

expect($carousel.emitted('sliding-start').length).toBe(3)
expect($carousel.emitted('sliding-end').length).toBe(3)
// Should have index still set to 0, and emit input to update v-model
expect($carousel.emitted('input').length).toBe(4)
expect($carousel.emitted('input')[3][0]).toEqual(0)
expect($carousel.vm.index).toBe(0)
expect($carousel.vm.isSliding).toBe(false)

wrapper.destroy()
})
})
1 change: 0 additions & 1 deletion src/components/carousel/index.d.ts
Expand Up @@ -14,7 +14,6 @@ export declare class BCarousel extends BvComponent {
next: () => void
start: () => void
pause: () => void
restart: () => void
}

// Component: b-carousel-slide
Expand Down