main/ToolTip.js

import populateElement from '../utils/populateElement';

/**
 * Module for showing a tool tip when user hovers over an info button
 *
 * @module ToolTip
 * @prop {string} selector - Selector for buttons that trigger tool tip.
 * @prop {object} states - Set of flags that can be used to toggle various tool tip states.
 * @prop {string} template - HTML template of tool tip, supporting a title, main body content, link.
 */

const ToolTip = {
	selector: '[data-tip]',
	buttonSelector: '.c-toolTip__button',

	states: {
		main: 'c-toolTip',
		mobile: 'c-toolTip--mobile',
		desktop: 'c-toolTip--desktop',
		active: 'c-toolTip--active',
		animateIn: 'is-faded-in',
		animateOut: 'is-faded-out',
		right: 'c-toolTip--right',
		flipped: 'c-toolTip--flipped',
	},

	template: `
		<div class="c-toolTip__pointer"></div>
		<div class="c-toolTip__inner">
			<div class="c-toolTip__title" data-map="tipTitle"></div>
			<div class="c-toolTip__content" data-map="tipContent"></div>
			<a class="c-toolTip__link" data-map="tipLink" href="" target="_blank">
				<span class="c-toolTip__linkLabel" data-map="tipLinkLabel"></span>
			</a>
		</div>
		<div class="c-toolTip__button">
			<svg class="c-toolTip__icon c-toolTip__icon--close"><use xlink:href="/wp-content/themes/shaw-globalnews/assets/dist/icons/out/symbol/svg/sprite.symbol.svg?#close"></svg>
		</div>
	`,

	/**
	 * Set up tool tips for designated info buttons
	 * and set up mouse event listeners for show / hiding tool tips
	 *
	 * @method init
	 */
	init() {
		const $targets = document.querySelectorAll( this.selector );
		[].forEach.call( $targets, ( $elem, index ) => {
			this.initItem( $elem, index );
		});
	},

	/**
	 * Set up tool tips for designated a specific button
	 *
	 * @method initItem
	 * @param {element} $elem - button element
	 * @param {string|number} itemId - id for identifying the button element
	 * @param {element} $context - optional context for selecting mobile container for tool tip
	 */
	initItem( $elem, itemId, $context = document ) {
		const id = `${this.states.main}${itemId}`;
		$elem.dataset.tipId = id; /* eslint-disable-line no-param-reassign */

		let $tip = this.addTip( $elem, $elem.dataset, id, this.states.desktop, $context );

		if ( $elem.dataset.tipMobileContainer ) {
			const $container = $context.querySelector( $elem.dataset.tipMobileContainer );
			$tip = this.addTip( $container, $elem.dataset, id, this.states.mobile );
			$container.dataset.tipId = id;
			$container.addEventListener( 'mouseleave', evt => this.handleMouseLeave( evt ) );

			if ( $tip ) {
				const $button = $tip.querySelector( this.buttonSelector );
				$button.dataset.tipId = id;
				$button.addEventListener( 'click', evt => this.handleMouseLeave( evt ) );
			}
		}

		$elem.addEventListener( 'mouseover', evt => this.handleMouseEnter( evt ) );
		$elem.addEventListener( 'mouseleave', evt => this.handleMouseLeave( evt ) );
	},

	/**
	 * Create a tool tip displaying provided data
	 * and append the tool tip to the designated container element
	 *
	 * @method addTip
	 * @param {element} $container - container element
	 * @param {object} data - data to be populated into tool tip
	 * @param {string} id - tool tip id
	 * @param {string} customCss - custom css class
	 * @param {element} $boundary - optional. Apply custom css to ensure tool tip is within bound
	 */
	addTip( $container, data, id, customCss, $boundary = null ) {
		if ( ! $container ) {
			return false;
		}

		let $tip = document.createElement( 'div' );
		$tip.classList.add( this.states.main );
		$tip.dataset.tipId = id;

		let selector = `.${this.states.main}`;
		if ( customCss ) {
			$tip.classList.add( customCss );
			selector = `${selector}.${customCss}`;
		}

		$tip.innerHTML = this.template;
		populateElement( $tip, data );
		$container.appendChild( $tip );

		// Get the $tip element added to $container
		$tip = $container.querySelector( selector );

		// Set custom css if $tip is out of bound
		if ( $boundary && document !== $boundary ) {
			const rect = $tip.getBoundingClientRect();
			const boundaryRect = $boundary.getBoundingClientRect();
			const right = boundaryRect.x + boundaryRect.width;
			const bottom = boundaryRect.y + boundaryRect.height;

			if ( rect.x + rect.width > right - 20 ) {
				$tip.classList.add( this.states.right );
			}

			if ( rect.y + rect.height > bottom - 20 ) {
				$tip.classList.add( this.states.flipped );
			}
		}

		return $tip;
	},

	/**
	 * Show tool tip on mouse enter
	 *
	 * @method handleMouseEnter
	 */
	handleMouseEnter( evt ) {
		const $tips = document.querySelectorAll( `.${this.states.main}[data-tip-id="${evt.currentTarget.dataset.tipId}"]` );
		[].forEach.call( $tips, ( $tip ) => {
			$tip.classList.add( this.states.active );
			$tip.classList.add( this.states.animateIn );
		});
	},

	/**
	 * Hide tool tip on mouse leave
	 *
	 * @method handleMouseLeave
	 */
	handleMouseLeave( evt ) {
		const $tips = document.querySelectorAll( `.${this.states.main}[data-tip-id="${evt.currentTarget.dataset.tipId}"]` );
		let mobileTipVisible = false;

		[].forEach.call( $tips, ( $tip ) => {
			if ( $tip.classList.contains( this.states.mobile ) ) {
				const style = window.getComputedStyle( $tip );
				mobileTipVisible = 'block' === style.getPropertyValue( 'display' );
			}

			// If mobile view of tool tip is visible,
			// only hide the tip when mouse leave triggered by mobile container
			if ( ! evt.currentTarget.dataset.tipMobileContainer || ! mobileTipVisible ) {
				$tip.classList.remove( this.states.active );
				$tip.classList.remove( this.states.animateIn );
				$tip.classList.add( this.states.animateOut );
			}
		});
	},
};

export default ToolTip;