import debounce from 'lodash/debounce';
import Glide, {
Anchors,
Autoplay,
Breakpoints,
Controls,
Images,
Keyboard,
Swipe,
} from '../../vendor/glide';
/**
* Carousel functionality.
* Initializes `GlideJS` carousel based on settings provided in constructor.
* Adjusts behavior based on `breakpoint` and `enableAtWidth` settings.
*
* @class Carousel
* @prop {object} states - Set of classes that serve as flags for carousel states.
* @prop {object} selectors - Set of selectors that can be used to select carousel features.
* @prop {string} compontentClass - For toggling between init and active carousel style states.
* @prop {string} layoutClass - (e.g. l-videoCarousel, l-editorsPicks), different carousel layouts
* may have their own specific styles to be applied in the init and active states.
* @prop {object} initSettings - Settings applied when carousel is on the first slide.
* @prop {object} activeSettings - Settings applied when carousel is not on the first slide.
* @prop {int} enableAtWidth - Setting defines width at which to enable carousel.
* @prop {bool} hasPicker - Setting defines whether a carousel item picker is present.
* @prop {object} glide - `GlideJS` carousel instance.
* @prop {int} carouselLength - Total number of slides in the carousel.
* @prop {int} desktopWidth - Desktop breakpoint, used for toggling picker hover behavior.
* @prop {element} $elem - The video carousel element itself.
* @prop {element} $picker - Carousel item picker element.
*/
class Carousel {
static states = {
init: '--init',
active: '--active',
};
static selectors = {
slide: '.glide__slide',
clone: '.glide__slide--clone',
arrows: '.glide__arrows',
activeSlide: '.glide__slide--active',
gotoButton: '.c-carousel__goto',
picker: '.c-carousel__picker',
}
componentClass = 'c-carousel';
layoutClass = null;
initSettings = null;
activeSettings = null;
enableAtWidth = false;
hasPicker = false;
glide = null;
carouselLength = null;
desktopWidth = 1312;
$elem = null;
$picker = null;
/**
* @constructs
* @param {object} $elem - The carousel element object.
* @param {object} settings - Carousel settings to be merged with defaults
*/
constructor( $elem, settings ) {
this.$elem = $elem;
this.initSettings = settings.initSettings;
this.activeSettings = settings.activeSettings
? settings.activeSettings : settings.initSettings;
this.enableAtWidth = settings.enableAtWidth;
this.hasPicker = settings.hasPicker;
this.layoutClass = settings.layoutClass;
// carousel will go back to the start after the last perView slides are in view
this.initSettings.bound = true;
this.activeSettings.bound = true;
}
/**
* Initialize carousel, set up class members, and bind event listeners.
*
* @method init
*/
init() {
// initialize carousel length
this.carouselLength = this.$elem.querySelectorAll( Carousel.selectors.slide ).length;
// Only initialize carousel if slides exist.
if ( this.carouselLength > 0 ) {
this.initializeCarousel();
// bind event to capture resize and orientation change
this.resizeHandler = debounce( () => {
this.handleResizeCarousel();
}, 200 );
window.addEventListener( 'orientationchange', this.resizeHandler );
window.addEventListener( 'resize', this.resizeHandler );
// initialize picker if one exists, don't re-init on resize
if ( this.hasPicker && null === this.$picker ) {
this.initializePicker();
}
}
}
/**
* Initialize `GlideJS` carousel object if width is past breakpoint.
* Bind handlers to run on slide change and on resize.
*
* @method initializeCarousel
*/
initializeCarousel() {
if ( window.innerWidth < this.enableAtWidth ) {
return;
}
if ( ! this.$elem.querySelector( '[data-glide-el]' ) ) {
return;
}
this.glide = new Glide( this.$elem, this.initSettings );
this.glide.on( 'run.before', event => this.handleMovement( event ) );
this.glide.on( 'run', () => this.updateActiveSlide() );
this.glide.on( 'run', () => this.updatePicker() );
this.glide.on( 'resize', () => this.handleResizeSettings() );
this.handleResizeSettings();
this.glide.mount({
Anchors,
Autoplay,
Breakpoints,
Controls,
Images,
Keyboard,
Swipe,
});
this.toggleSliderButtons();
// set initial active slide flag
this.updateActiveSlide();
}
/**
* Initialize carousel item picker element and bind event handlers.
* A picker is an optional list of links that lets the user select
* specific carousel slides without swiping through the carousel.
*
* @method initializePicker
*/
initializePicker() {
this.$picker = this.$elem.querySelector( Carousel.selectors.picker );
const $items = this.$picker.querySelectorAll( '[data-slide-index]' );
[].forEach.call( $items, ( $item ) => {
$item.addEventListener( 'click', e => this.handlePickerClick( e, $item ) );
$item.addEventListener( 'mouseenter', () => this.handlePickerHover( $item ) );
});
}
/**
* Change carousel settings is carousel is entering or exiting the first slide.
* This is because the design differs between the first and successive slides.
* Handler is run when slider is run before slider switches to the next slide.
*
* @method handleMovement
* @param {object} event - `run.before` event object instance.
*/
handleMovement( event ) {
if ( this.isGoingToStart( event ) ) {
this.applySettings( this.initSettings );
this.$elem.classList.remove( `${this.componentClass}${Carousel.states.active}` );
this.$elem.classList.add( `${this.componentClass}${Carousel.states.init}` );
if ( this.layoutClass ) {
this.$elem.classList.remove( `${this.layoutClass}${Carousel.states.active}` );
this.$elem.classList.add( `${this.layoutClass}${Carousel.states.init}` );
}
} else if ( 0 === this.glide.index ) {
this.applySettings( this.activeSettings );
this.$elem.classList.remove( `${this.componentClass}${Carousel.states.init}` );
this.$elem.classList.add( `${this.componentClass}${Carousel.states.active}` );
if ( this.layoutClass ) {
this.$elem.classList.remove( `${this.layoutClass}${Carousel.states.init}` );
this.$elem.classList.add( `${this.layoutClass}${Carousel.states.active}` );
}
}
}
/**
* Determines whether or not carousel is heading towards the initial slide.
* If so, carousel display settings will need to change to accommodate design.
* Complicated logic because these settings need to be changed before the new
* slide position is calculated. Otherwise the style transition appears jarring.
*
* @method isGoingToStart
* @param {object} event - `run.before` event object instance.
* @returns {boolean} True / False depending on next slide position.
*/
isGoingToStart( event ) {
const { index, settings } = this.glide;
const length = this.carouselLength;
// on initial node
if ( 0 === index ) {
return false;
}
// user is on the second slide and clicking backwards
if ( 1 === index && '<' === event.direction ) {
return true;
}
// user is on or beyond the last perView slides and moving forwards
// we set bound to true in the constructor,
// so carousel refreshes when last perView slides are in view
if ( length - index <= settings.perView && '>' === event.direction ) {
return true;
}
// user is on or up to first perView and swiping backwards
if ( settings.perView >= index && '|' === event.direction && '<' === event.steps ) {
return true;
}
// user is on on or beyond the last perView slides and swiping forwards
// we set bound to true in the constructor,
// so carousel refreshes when last perView slides are in view
if ( length - index <= settings.perView && '|' === event.direction && '>' === event.steps ) {
return true;
}
return false;
}
/**
* Updates the active element flag on the carousel slide.
* Only operates if a layout class is defined since active
* slide styles are not part of the default design
*
* @method updateActiveSlide
*/
updateActiveSlide() {
if ( this.layoutClass ) {
const $slides = this.$elem.querySelectorAll( `${Carousel.selectors.slide}` );
[].forEach.call( $slides, ( $slide ) => {
$slide.classList.remove( `${this.layoutClass}__slide${Carousel.states.active}` );
});
// remove slide clones as they are irrelevant for us
const $activeSlides = [].filter.call( $slides,
$slide => ! $slide.classList.contains( Carousel.selectors.clone.slice( 1 ) ) );
$activeSlides[this.glide.index].classList.add( `${this.layoutClass}__slide${Carousel.states.active}` );
}
}
/**
* Updates the active element flag on the item picker after a carousel slide transition.
*
* @method updatePicker
*/
updatePicker() {
if ( this.hasPicker && null !== this.$picker && null !== this.$picker.children ) {
[].forEach.call( this.$picker.children, ( $item ) => {
$item.classList.remove( `${this.componentClass}__item${Carousel.states.active}` );
if ( this.layoutClass ) {
$item.classList.remove( `${this.layoutClass}__item${Carousel.states.active}` );
}
});
this.$picker.children[this.glide.index]
.classList.add( `${this.componentClass}__item${Carousel.states.active}` );
if ( this.layoutClass ) {
this.$picker.children[this.glide.index]
.classList.add( `${this.layoutClass}__item${Carousel.states.active}` );
}
}
}
/**
* Toggle the appropriate slide when a picker item is clicked on
* If a picker item is already selected, then allow the link to take the user to the content
*
* @method handlePickerClick
* @param {object} event - Reference to JS event object
* @param {element} $item - DOM reference to picker item that is being hovered over
*/
handlePickerClick( event, $item ) {
const targetIndex = parseInt( $item.dataset.slideIndex, 10 );
if ( targetIndex !== this.glide.index ) {
event.preventDefault();
this.glide.go( `=${targetIndex}` );
}
}
/**
* On desktop we let the user toggle between slides by simply hovering over a picker item.
*
* @method handlePickerHover
* @param {element} $item - DOM reference to picker item that is being hovered over
*/
handlePickerHover( $item ) {
if ( window.innerWidth >= this.desktopWidth ) {
const targetIndex = parseInt( $item.dataset.slideIndex, 10 );
let transitionComplete = false;
this.glide.go( `=${targetIndex}` );
// slide target will not change if another transition is in progress
// so we keep checking and trying until it is, or until the mouse leaves the click area
const transitionChecker = () => {
if ( true === transitionComplete ) {
return;
}
if ( targetIndex !== this.glide.index ) {
this.glide.go( `=${targetIndex}` );
setTimeout( () => transitionChecker(), 200 );
}
};
setTimeout( () => transitionChecker(), 200 );
// if timeout is still running, we stop it
const leaveHandler = () => {
transitionComplete = true;
$item.removeEventListener( 'mouseleave', leaveHandler );
};
$item.addEventListener( 'mouseleave', leaveHandler );
}
}
/**
* Create or destroy carousel based on `enableAtWidth` setting.
* Relies on the internal `GlideJS` resize event which is throttled.
*
* @method handleResizeCarousel
*/
handleResizeCarousel() {
if ( ! this.enableAtWidth ) {
return;
}
if ( null == this.glide && window.innerWidth >= this.enableAtWidth ) {
this.initializeCarousel();
}
if ( null !== this.glide && window.innerWidth < this.enableAtWidth ) {
this.glide.destroy();
this.glide = null;
}
if ( null !== this.glide ) {
this.toggleSliderButtons();
}
}
/**
* Update `GlideJS` carousel after browser resize.
* Relies on the internal `GlideJS` resize event which is throttled.
*
* @method handleResizeSettings
*/
handleResizeSettings() {
if ( 0 === this.glide.index ) {
this.applySettings( this.initSettings );
} else {
this.applySettings( this.activeSettings );
}
}
/**
* Checks carousel breakpoint settings and applies settings if breakpoint is matched
* Technically this should be done automatically by `GlideJS` but it appear to not
* work on resize, so we are applying them manually here.
*
* @method applySettings
* @param {object} settings - Currently applied carousel settings. Either `initSettings` or
* `activeSettings` depending on carousel position.
*/
applySettings( settings ) {
let localSettings = Object.assign({}, settings );
const breakpointSettings = Carousel.matchBreakpoint( settings );
if ( false !== breakpointSettings ) {
localSettings = Object.assign( localSettings, breakpointSettings );
}
this.glide.update( localSettings );
this.$elem.dataset.carouselPerView = localSettings.perView;
}
/**
* Iterates through carousel breakpoint settings to determine if one matches the current
* screen width.
*
* @method matchBreakpoint
* @param {object} settings - Currently applied carousel settings. Either `initSettings` or
* `activeSettings` depending on carousel position.
* @return {object} Matched breakpoint setting if one is found.
*/
static matchBreakpoint( settings ) {
if ( settings.breakpoints ) {
const points = Object.keys( settings.breakpoints );
for ( let i = 0; i < points.length; i += 1 ) {
if ( window.matchMedia( `(max-width: ${points[i]}px)` ).matches ) {
return settings.breakpoints[points[i]];
}
}
}
return false;
}
/**
* Adjusts carousel position to target slide.
*
* @static
* @method goToSlide
* @param {object} $slide - Target slide to scroll to.
* @param {object} $carousel - Carousel element object.
*/
static goToSlide( $slide, $carousel ) {
const $activeSlide = $carousel.querySelector( Carousel.selectors.activeSlide );
// Bail when there's no active slide
if ( ! $activeSlide ) {
return;
}
const $targetSlide = $slide.parentElement;
const $slides = $activeSlide.parentElement.children;
let activeIndex = 0;
let targetIndex = 0;
for ( let i = 0; i < $slides.length; i += 1 ) {
if ( $slides[i] === $activeSlide ) {
activeIndex = i;
}
if ( $slides[i] === $targetSlide ) {
targetIndex = i;
}
}
const perView = parseInt( $carousel.dataset.carouselPerView, 10 );
// Adjust carousel position if active slide is out of view
if ( ( targetIndex - activeIndex ) > ( perView - 1 )
|| ( targetIndex - activeIndex ) < 0 ) {
const $goto = $carousel.querySelector( Carousel.selectors.gotoButton );
$goto.dataset.glideDir = `=${targetIndex}`;
$goto.click();
}
}
/**
* Toggles carousel buttons visibility based on current breakpoint preview number
*
* @method toggleSliderButtons
*/
toggleSliderButtons() {
const carouselSlides = this.$elem.querySelectorAll( Carousel.selectors.slide ).length;
const showingSlides = this.glide.settings.perView; // Current preview number of slides
const carouselButtons = this.$elem.querySelector( Carousel.selectors.arrows );
if ( carouselSlides <= showingSlides ) {
carouselButtons.style.display = 'none';
} else {
carouselButtons.style.display = 'block';
}
}
}
export default Carousel;