import { tagName } from '@ember-decorators/component';
import { observes } from '@ember-decorators/object';
import { action, computed } from '@ember/object';
import { equal, filter, gt, lte, readOnly } from '@ember/object/computed';
import CarouselSlide from 'ember-bootstrap/components/bs-carousel/slide';
import Component from '@ember/component';
import ComponentParent from 'ember-bootstrap/mixins/component-parent';
import { schedule, scheduleOnce } from '@ember/runloop';
import { task, timeout } from 'ember-concurrency';
import RSVP from 'rsvp';
import defaultValue from 'ember-bootstrap/utils/default-decorator';
import deprecateSubclassing from 'ember-bootstrap/utils/deprecate-subclassing';
/**
Ember implementation of Bootstrap's Carousel. Supports all original features but API is partially different:
| Description | Original | Component |
| ------ | ------ | ------ |
| Autoplays after first user event or on page load. | ride='carousel'\|false | autoPlay=false\|true |
| Disable automatic cycle. | interval=false | interval=0 |
| If first slide should follow last slide on "previous" event, the opposite will also be true for "next" event. | wrap=false\|true | wrap=false\|true |
| Jumps into specific slide index | data-slide-to=n | index=n |
| Keyboard events. | keyboard=false\|true | keyboard=false\|true |
| Left-to-right or right-to-left sliding. | N/A | ltr=false\|true |
| Pause current cycle on mouse enter. | pause='hover'\|null | pauseOnMouseEnter=false\|true |
| Show or hide controls | Tag manipulation. | showControls=false\|true |
| Show or hide indicators | Tag manipulation. | showIndicators=false\|true |
| Waiting time of slides in an automatic cycle. | interval=n | interval=n |
Default settings are the same as the original, so you don't have to worry about changing parameters.
```hbs
<BsCarousel as |car|>
<car.slide>
<img alt="First slide" src="slide1.jpg">
</car.slide>
<car.slide>
<img alt="Second slide" src="slide2.jpg">
</car.slide>
<car.slide>
<img alt="Third slide" src="slide3.jpg">
</car.slide>
</BsCarousel>
```
To better understand the whole documentation, you should be aware of the following operations:
| Operation | Description |
| ------ | ------ |
| Transition | Swaps two slides. |
| Interval | Waiting time after a transition. |
| Presentation | Represents a single transition, or a single interval, or the union of both. |
| Cycle | Presents all slides until it reaches first or last slide. |
| Wrap | wrap slides, cycles without stopping at first or last slide. |
*Note that only invoking the component in a template as shown above is considered part of its public API. Extending from it (subclassing) is generally not supported, and may break at any time.*
@class Carousel
@namespace Components
@extends Ember.Component
@public
*/
@tagName('')
@deprecateSubclassing
export default class Carousel extends Component.extend(ComponentParent) {
tabindex = '1';
/**
* @property slideComponent
* @type {String}
* @private
*/
/**
* If a slide can turn to left, including corners.
*
* @private
* @property canTurnToLeft
*/
@computed('wrap', 'currentIndex')
get canTurnToLeft() {
return this.wrap || this.currentIndex > 0;
}
/**
* If a slide can turn to right, including corners.
*
* @private
* @property canTurnToRight
*/
@computed('childSlides.length', 'wrap', 'currentIndex')
get canTurnToRight() {
return this.wrap || this.currentIndex < this.childSlides.length - 1;
}
/**
* All `CarouselSlide` child components.
*
* @private
* @property childSlides
* @readonly
* @type array
*/
@(filter('children', function (view) {
return view instanceof CarouselSlide;
}).readOnly())
childSlides;
/**
* This observer is the entry point for real time insertion and removing of slides.
*
* @private
* @property childSlidesObserver
*/
@observes('childSlides.[]', 'autoPlay')
childSlidesObserver() {
scheduleOnce('actions', this, this._childSlidesObserver);
}
_childSlidesObserver() {
let childSlides = this.childSlides;
if (childSlides.length === 0) {
return;
}
// Sets new current index
let currentIndex = this.currentIndex;
if (currentIndex >= childSlides.length) {
currentIndex = childSlides.length - 1;
this.set('currentIndex', currentIndex);
}
// Automatic sliding
if (this.autoPlay) {
this.waitIntervalToInitCycle.perform();
}
// Initial slide state
this.set('presentationState', null);
}
/**
* Indicates the current index of the current slide.
*
* @property currentIndex
* @private
*/
@defaultValue
currentIndex = this.index;
/**
* The current slide object that is going to be used by the nested slides components.
*
* @property currentSlide
* @private
*
*/
@(computed('childSlides', 'currentIndex').readOnly())
get currentSlide() {
return this.childSlides.objectAt(this.currentIndex);
}
/**
* Bootstrap style to indicate that a given slide should be moving to left/right.
*
* @property directionalClassName
* @private
* @type string
*/
@defaultValue
directionalClassName = null;
/**
* Indicates the next slide index to move into.
*
* @property followingIndex
* @private
* @type number
*/
@defaultValue
followingIndex = null;
/**
* The following slide object that is going to be used by the nested slides components.
*
* @property followingIndex
* @private
*/
@(computed('childSlides', 'followingIndex').readOnly())
get followingSlide() {
return this.childSlides.objectAt(this.followingIndex);
}
/**
* @private
* @property hasInterval
* @type boolean
*/
@(gt('interval', 0).readOnly())
hasInterval;
/**
* This observer is the entry point for programmatically slide changing.
*
* @property indexObserver
* @private
*/
@observes('index')
indexObserver() {
this.send('toSlide', this.index);
}
/**
* @property indicators
* @private
*/
@computed('childSlides.length')
get indicators() {
return [...Array(this.childSlides.length)];
}
/**
* If user is hovering its cursor on component.
* This property is only manipulated when 'pauseOnMouseEnter' is true.
*
* @property isMouseHovering
* @private
* @type boolean
*/
@defaultValue
isMouseHovering = false;
/**
* The class name to append to the next control link element.
*
* @property nextControlClassName
* @type string
* @private
*/
/**
* Bootstrap style to indicate the next/previous slide.
*
* @property orderClassName
* @private
* @type string
*/
/**
* The current state of the current presentation, can be either "didTransition"
* or "willTransit".
*
* @private
* @property presentationState
* @type string
*/
@defaultValue
presentationState = null;
/**
* The class name to append to the previous control link element.
*
* @property prevControlClassName
* @type string
* @private
*/
/**
* @private
* @property shouldNotDoPresentation
* @type boolean
*/
@lte('childSlides.length', 1)
shouldNotDoPresentation;
/**
* @private
* @property shouldRunAutomatically
* @type boolean
*/
@readOnly('hasInterval')
shouldRunAutomatically;
/**
* Starts automatic sliding on page load.
* This parameter has no effect if interval is less than or equal to zero.
*
* @default false
* @property autoPlay
* @public
* @type boolean
*/
@defaultValue
autoPlay = false;
/**
* If false will hard stop on corners, i.e., first slide won't make a transition to the
* last slide and vice versa.
*
* @default true
* @property wrap
* @public
* @type boolean
*/
@defaultValue
wrap = true;
/**
* Index of starting slide.
*
* @default 0
* @property index
* @public
* @type number
*/
@defaultValue
index = 0;
/**
* Waiting time before automatically show another slide.
* Automatic sliding is canceled if interval is less than or equal to zero.
*
* @default 5000
* @property interval
* @public
* @type number
*/
@defaultValue
interval = 5000;
/**
* Should bind keyboard events into sliding.
*
* @default true
* @property keyboard
* @public
* @type boolean
*/
@defaultValue
keyboard = true;
/**
* If automatic sliding should be left-to-right or right-to-left.
* This parameter has no effect if interval is less than or equal to zero.
*
* @default true
* @property ltr
* @public
* @type boolean
*/
@defaultValue
ltr = true;
/**
* The next control icon to be displayed.
*
* @default null
* @property nextControlIcon
* @type string
* @public
*/
/**
* Label for screen readers, defaults to 'Next'.
*
* @default 'Next'
* @property nextControlLabel
* @type string
* @public
*/
@defaultValue
nextControlLabel = 'Next';
/**
* Pauses automatic sliding if mouse cursor is hovering the component.
* This parameter has no effect if interval is less than or equal to zero.
*
* @default true
* @property pauseOnMouseEnter
* @public
* @type boolean
*/
@defaultValue
pauseOnMouseEnter = true;
/**
* The previous control icon to be displayed.
*
* @default null
* @property prevControlIcon
* @type string
* @public
*/
/**
* Label for screen readers, defaults to 'Previous'.
*
* @default 'Previous'
* @property prevControlLabel
* @type string
* @public
*/
@defaultValue
prevControlLabel = 'Previous';
/**
* Show or hide controls.
*
* @default true
* @property showControls
* @public
* @type boolean
*/
@defaultValue
showControls = true;
/**
* Show or hide indicators.
*
* @default true
* @property showIndicators
* @public
* @type boolean
*/
@defaultValue
showIndicators = true;
/**
* The duration of the slide transition.
* You should also change this parameter in Bootstrap CSS file.
*
* @default 600
* @property transitionDuration
* @public
* @type number
*/
@defaultValue
transitionDuration = 600;
/**
* The type slide transition to perform.
* Options are 'fade' or 'slide'. Note: BS4 only
*
* @default 'slide'
* @property transition
* @public
* @type string
*/
@defaultValue
transition = 'slide';
@(equal('transition', 'fade').readOnly())
carouselFade;
/**
* Action called after the slide has changed.
*
* @event onSlideChanged
* @param toIndex
* @public
*/
onSlideChanged(toIndex) {} // eslint-disable-line no-unused-vars
/**
* Do a presentation and calls itself to perform a cycle.
*
* @method cycle
* @private
*/
@(task(function* () {
yield this.transitioner.perform();
yield timeout(this.interval);
this.toAppropriateSlide();
}).restartable())
cycle;
/**
* @method transitioner
* @private
*/
@(task(function* () {
this.set('presentationState', 'willTransit');
yield timeout(this.transitionDuration);
this.set('presentationState', 'didTransition');
// Must change current index after execution of 'presentationStateObserver' method
// from child components.
yield new RSVP.Promise((resolve) => {
schedule('afterRender', this, function () {
this.set('currentIndex', this.followingIndex);
resolve();
});
});
}).drop())
transitioner;
/**
* Waits an interval time to start a cycle.
*
* @method waitIntervalToInitCycle
* @private
*/
@(task(function* () {
if (this.shouldRunAutomatically === false) {
return;
}
yield timeout(this.interval);
this.toAppropriateSlide();
}).restartable())
waitIntervalToInitCycle;
@action
toSlide(toIndex) {
if (this.currentIndex === toIndex || this.shouldNotDoPresentation) {
return;
}
this.assignClassNameControls(toIndex);
this.setFollowingIndex(toIndex);
if (this.shouldRunAutomatically === false || this.isMouseHovering) {
this.transitioner.perform();
} else {
this.cycle.perform();
}
this.onSlideChanged(toIndex);
}
@action
toNextSlide() {
if (this.canTurnToRight) {
this.send('toSlide', this.currentIndex + 1);
}
}
@action
toPrevSlide() {
if (this.canTurnToLeft) {
this.send('toSlide', this.currentIndex - 1);
}
}
/**
* Indicates what class names should be applicable to the current transition slides.
*
* @method assignClassNameControls
* @private
*/
assignClassNameControls(toIndex) {
if (toIndex < this.currentIndex) {
this.set('directionalClassName', 'right');
this.set('orderClassName', 'prev');
} else {
this.set('directionalClassName', 'left');
this.set('orderClassName', 'next');
}
}
/**
* Initial page loading configuration.
*/
// eslint-disable-next-line ember/no-component-lifecycle-hooks
didInsertElement() {
super.didInsertElement(...arguments);
this.triggerChildSlidesObserver();
}
@action
handleMouseEnter() {
if (this.pauseOnMouseEnter) {
this.set('isMouseHovering', true);
this.cycle.cancelAll();
this.waitIntervalToInitCycle.cancelAll();
}
}
@action
handleMouseLeave() {
if (this.pauseOnMouseEnter && (this.transitioner.last !== null || this.waitIntervalToInitCycle.last !== null)) {
this.set('isMouseHovering', false);
this.waitIntervalToInitCycle.perform();
}
}
@action
handleKeyDown(e) {
let code = e.keyCode || e.which;
if (this.keyboard === false || /input|textarea/i.test(e.target.tagName)) {
return;
}
switch (code) {
case 37:
this.send('toPrevSlide');
break;
case 39:
this.send('toNextSlide');
break;
default:
break;
}
}
/**
* Sets the following slide index within the lower and upper bounds.
*
* @method setFollowingIndex
* @private
*/
setFollowingIndex(toIndex) {
let slidesLengthMinusOne = this.childSlides.length - 1;
if (toIndex > slidesLengthMinusOne) {
this.set('followingIndex', 0);
} else if (toIndex < 0) {
this.set('followingIndex', slidesLengthMinusOne);
} else {
this.set('followingIndex', toIndex);
}
}
/**
* Coordinates the correct slide movement direction.
*
* @method toAppropriateSlide
* @private
*/
toAppropriateSlide() {
if (this.ltr) {
this.send('toNextSlide');
} else {
this.send('toPrevSlide');
}
}
/**
* @method triggerChildSlidesObserver
* @private
*/
triggerChildSlidesObserver() {
this.childSlides;
}
}