main/classes/Snackbar.js

import populateTemplate from '../../utils/populateTemplate';
import customEvent from '../../utils/customEvent';

/**
 * Handles Material-UI style Snackbar prompts that slide in at the bottom of the page
 *
 * @class Snackbar
 * @prop {object} selectors - Set of selectors that can be used to select snackbar features.
 * @prop {object} states - Set of classes that serve as flags for snackbar states.
 * @prop {string} template - Snackbar markup template.
 * @prop {object} settings - Set of settings that can be used to customize the snackbar.
 * @prop {element} $element - DOM reference to the snackbar element.
 * @prop {element} $element - DOM reference to the snackbar container (i.e. the body element).
 * @prop {int} height - Snackbar height (used to compute transitions).
 * @prop {int} startY - Initial cursor Y position when swiping (to compute swipe dismiss gesture).
 */
class Snackbar {
	static selectors = {
		container: 'body',
		element: '.c-snackbar',
		dismiss: '.c-snackbar__dismiss',
		accept: '.c-snackbar__accept',
	};

	static states = {
		active: 'c-snackbar--active',
	};

	static template = `
		<div class="c-snackbar__inner">
			<div class="c-snackbar__text">{{prompt}}</div>
			<div class="c-snackbar__buttons">
				<button class="c-button c-button--tertiary c-snackbar__button c-snackbar__dismiss">{{dismiss}}</button>
				<button class="c-button c-snackbar__button c-snackbar__accept">{{accept}}</button>
			</div>
		</div>
	`;

	settings = {
		text: {
			prompt: '',
			dismiss: 'Dismiss',
			accept: 'Accept',
		},
	};

	$container = null;

	$element = null;

	height = null;

	startY = null;

	/**
	 * @constructs
	 * @param {object} settings - Snackbar settings to be merged with defaults
	 */
	constructor( settings ) {
		// custom snackbar text
		if ( settings && settings.text ) {
			this.settings.text.prompt = settings.text.prompt;
			this.settings.text.dismiss = settings.text.dismiss;
			this.settings.text.accept = settings.text.accept;
		}
	}

	/**
	 * Initialize snackbar, and add to page.
	 *
	 * @method init
	 */
	init() {
		// remove existing Snackbar(s)
		const $elements = document.querySelectorAll( Snackbar.selectors.element );
		[].forEach.call( $elements, ( $element ) => {
			$element.parentNode.removeChild( $element );
		});

		// create element
		this.$element = document.createElement( 'div' );
		this.$element.innerHTML = populateTemplate( Snackbar.template, this.settings.text, true );
		this.$element.classList.add( Snackbar.selectors.element.replace( '.', '' ) );

		// add event listeners
		this.listeners();

		// append to markup
		this.$container = document.querySelector( Snackbar.selectors.container );
		this.$container.appendChild( this.$element );

		// set height
		this.height = this.$element.getBoundingClientRect().height;
		this.$element.style.setProperty( '--snackbarHeight', `${this.height}px` );

		// fade in
		setTimeout( () => {
			this.$element.classList.add( Snackbar.states.active );
		}, 300 );
	}

	/**
	 * Add snackbar event listeners for snackbar actions.
	 *
	 * @method listeners
	 */
	listeners() {
		// dismiss button
		const $dismiss = this.$element.querySelector( `${Snackbar.selectors.dismiss}` );
		$dismiss.addEventListener( 'click', () => { this.dismiss(); });

		// accept button
		const $accept = this.$element.querySelector( `${Snackbar.selectors.accept}` );
		$accept.addEventListener( 'click', () => { this.accept(); });

		this.$element.addEventListener( 'pointerdown', ( e ) => { this.startSwipe( e ); });
	}

	/**
	 * Setup and bind listeners to handle the swipe to dismiss gesture.
	 *
	 * @method startSwipe
	 * @param {object} event - Pointerdown event instance.
	 */
	startSwipe( event ) {
		this.startY = event.clientY;

		this.boundPointerMove = e => this.handleSwipe( e );
		this.boundPointerUp = e => this.endSwipe( e );
		this.boundPointerCancel = e => this.endSwipe( e );
		this.boundToggleScroll = ( e ) => {
			if ( e.cancelable ) {
				e.preventDefault();
			}
		};

		window.addEventListener( 'pointermove', this.boundPointerMove );
		window.addEventListener( 'pointerup', this.boundPointerUp );
		window.addEventListener( 'pointercancel', this.boundPointerCancel );
		window.addEventListener( 'touchmove', this.boundToggleScroll, { passive: false });

		this.$element.style.transition = 'none';
	}

	/**
	 * Move snackbar with pointer if user is moving downwards to dismiss.
	 *
	 * @method handleSwipe
	 * @param {object} event - Pointermove event instance.
	 */
	handleSwipe( event ) {
		const diff = this.startY - event.clientY;

		if ( diff <= 0 ) {
			const offset = Math.abs( diff );
			this.$element.style.transform = `translate3d( 0, ${offset}px, 0 )`;
		} else {
			const offset = 8 * Math.log( diff );
			this.$element.style.transform = `translate3d( 0, -${offset}px, 0 )`;
		}

		event.preventDefault();
	}

	/**
	 * Once the user has lifted the pointer, determine if a swipe gestures was completed.
	 * If so, get rid of the snackbar.
	 *
	 * @method endSwipe
	 * @param {object} event - Pointerup or pointercancel event instance.
	 */
	endSwipe( event ) {
		const diff = this.startY - event.clientY;

		window.removeEventListener( 'pointermove', this.boundPointerMove );
		window.removeEventListener( 'pointerup', this.boundPointerUp );
		window.removeEventListener( 'pointercancel', this.boundPointerCancel );
		window.removeEventListener( 'touchmove', this.boundToggleScroll );

		this.$element.style.transform = '';
		this.$element.style.transition = '';

		// remove snackbar if user has swiped 10% or more of the elements height
		if ( diff <= ( this.height * - 0.1 ) ) {
			this.dismiss();
		}
	}

	/**
	 * Dismiss the snackbar and fire dismissal event.
	 *
	 * @method dismiss
	 */
	dismiss() {
		customEvent.fire( this.$element, customEvent.SNACKBAR_DISMISSED );
		this.destroy();
	}

	/**
	 * Fire acceptance event and get rid of the snackbar.
	 *
	 * @method accept
	 */
	accept() {
		customEvent.fire( this.$element, customEvent.SNACKBAR_ACCEPTED );
		this.destroy();
	}

	/**
	 * Slide the snackbar out and remove the element from the DOM.
	 *
	 * @method @destroy
	 */
	destroy() {
		this.$element.classList.remove( Snackbar.states.active );

		setTimeout( () => {
			this.$element.parentNode.removeChild( this.$element );
		}, 500 );
	}
}

export default Snackbar;