main/classes/Carousel.js

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;