main/MenuOverflow.js

import debounce from 'lodash/debounce';

/**
 * The module is for menus of variable length, where there may too many menu options to fit
 * the screen. The module will add an arrow that users can click on to reveal the rest of the
 * menu options. This is used to handle long tag meta menus in lieu of a more dropdown. It is
 * only intended for tablet-landscape breakpoints and above.
 *
 * @module MenuOverflow
 * @prop {object} selectors - Set of CSS selectors to grab various components of the overflow menu
 * @prop {object} states - Set of CSS flags to toggle various overflow menu states
 * @prop {int} arrowWidth - The width of an overflow menu arrow (part of the scroll calculation)
 */
const MenuOverflow = {
	selectors: {
		container: '.l-overflow',
		track: '.l-overflow__track',
		list: '.l-overflow__list',
		arrowsContainer: '.l-overflow__arrows',
		arrows: {
			left: '.l-overflow__arrow--left',
			right: '.l-overflow__arrow--right',
		},
	},

	states: {
		active: 'l-overflow--active',
		scrolled: 'l-overflow--scrolled',
		anchoredLeft: 'l-overflow--anchoredLeft',
		anchoredRight: 'l-overflow--anchoredRight',
	},

	arrowWidth: 64,

	/**
	 * Locates and initializes all overflow menus on the page.
	 * Binds click and resize handlers. Enables if appropriate.
	 *
	 * @method init
	 */
	init() {
		// find all overflow containers
		this.$overflowMenus = document.querySelectorAll( this.selectors.container );

		// bind click states
		[].forEach.call( this.$overflowMenus, ( $menu ) => {
			$menu.classList.add( this.states.anchoredLeft );

			// Set data attributes for overflow menu:
			// overflowThreshold - width of the arrow button or gradient area to account for while
			//                     calculating the width of the visible part of overflow container
			// overflowCurrent - index of the currently visible item
			//                   (updates as user scrolls forward/backwards)
			// overflowSize - total number of items in the overflow container

			const $list = $menu.querySelector( this.selectors.list );
			const $listItems = $list ? $list.children : false;
			const size = $listItems ? $listItems.length : 0;

			/* eslint-disable no-param-reassign */
			if ( ! $menu.dataset.overflowThreshold ) {
				$menu.dataset.overflowThreshold = this.arrowWidth; // eslint-disable-line no-param-reassign
			}
			$menu.dataset.overflowCurrent = 0;
			$menu.dataset.overflowSize = size;
			/* eslint-enable no-param-reassign */

			// set up arrow buttons click listeners if there items in the menu list.
			if ( size > 0 ) {
				this.setPageSize( $menu );

				// toggle scroll
				const $rightArrow = $menu.querySelector( this.selectors.arrows.right );
				if ( $rightArrow ) {
					$rightArrow.addEventListener( 'click', () => {
						this.handleScroll( $menu, 1 );
					});
				}

				// return to initial state
				const $leftArrow = $menu.querySelector( this.selectors.arrows.left );
				if ( $leftArrow ) {
					$leftArrow.addEventListener( 'click', () => {
						this.handleScroll( $menu, - 1 );
					});
				}

				// Handle scroll and arrows re-positioning
				// when the user is tabbing through the menu
				const intViewportWidth = window.innerWidth;
				if ( intViewportWidth >= 1024 ) {
					[].forEach.call( $listItems, ( $item ) => {
						$item.addEventListener( 'keydown', ( event ) => {
							// Shift + Tabbing ( going backwards )
							if ( event.shiftKey && 9 === event.keyCode ) {
								const menuLeft = $menu.getBoundingClientRect().left;
								const itemLeft = $item.getBoundingClientRect().left;
								if ( menuLeft > itemLeft ) {
									this.handleScroll( $menu, - 1 );
								}
							} else if ( 9 === event.keyCode ) { // Tabbing ( going forwards )
								const menuRight = $menu.getBoundingClientRect().right;
								const itemRight = $item.getBoundingClientRect().right;
								if ( itemRight > menuRight ) {
									event.preventDefault();
									this.handleScroll( $menu, 1 );
								}
							}
						});
					});
				}
			}
		});

		// activate if overflow is occuring
		this.toggleOverflow();

		// recheck overflow during resize / orientation change
		this.handleResize();
	},

	/**
	 * Toggles any overflow menus that are currently overflowing their container.
	 *
	 * @method toggleOverflow
	 */
	toggleOverflow() {
		// check if list is overflowing it's container
		[].forEach.call( this.$overflowMenus, ( $menu ) => {
			const containerWidth = $menu.getBoundingClientRect().width;

			if ( this.hasOverflow( $menu, containerWidth ) ) {
				this.enableOverflow( $menu );
				this.reset( $menu );
				this.setPageSize( $menu );
			} else {
				this.hideOverflow( $menu );
			}
		});
	},

	/**
	 * Checks to see if specified menu is overflowing its container.
	 *
	 * @method hasOverflow
	 * @param {element} - Overflow menu element
	 * @param {int} - Width of overflow menu container
	 * @return {bool} - True / False depending on whether menu is overflowing container
	 */
	hasOverflow( $menu, containerWidth ) {
		const menuWidth = this.getMenuWidth( $menu );
		return menuWidth > containerWidth;
	},

	/**
	 * Activates overflow menu arrows and scroll functionality.
	 *
	 * @method enableOverflow
	 * @param {element} - Overflow menu element
	 */
	enableOverflow( $menu ) {
		$menu.classList.add( this.states.active );
	},

	/**
	 * Deactivates overflow menu arrows and scroll functionality.
	 *
	 * @method hideOverflow
	 * @param {element} - Overflow menu element
	 */
	hideOverflow( $menu ) {
		$menu.classList.remove( this.states.active );
		this.reset( $menu );
	},

	/**
	 * Binds resize events to re-check (and enable / hide) overflow menus as screen size changes.
	 *
	 * @method handleResize
	 */
	handleResize() {
		// bind event to capture resize and orientation change
		this.resizeHandler = debounce( () => {
			this.toggleOverflow();
		}, 200 );
		window.addEventListener( 'orientationchange', this.resizeHandler );
		window.addEventListener( 'resize', this.resizeHandler );
	},

	/**
	 * Scrolls the overflow menu to the other size to reveal obscured menu items.
	 *
	 * @method handleScroll
	 * @param {element} - Overflow menu element
	 * @param {int} - direction. 1 when srcrolling to the right, -1 to the left
	 */
	handleScroll( $menu, direction ) {
		if ( ! this.isValidScroll( $menu, direction ) ) {
			return;
		}

		// Reset anchor flags
		$menu.classList.remove( this.states.anchoredLeft );
		$menu.classList.remove( this.states.anchoredRight );

		// Calculate the target index to scroll to and respective scroll amount
		const currentIndex = parseInt( $menu.dataset.overflowCurrent, 10 );
		const pageSize = parseInt( $menu.dataset.overflowPageSize, 10 );
		const threshold = parseInt( $menu.dataset.overflowThreshold, 10 );
		let index = Math.max( 0, currentIndex + direction * pageSize );
		let scrollAmount = Math.min( 0, threshold + this.getTargetPosition( $menu, index ) * - 1 );

		// Handle scrolling to the end / beginning of the list and apply proper anchoring
		const menuWidth = this.getMenuWidth( $menu );
		const containerWidth = $menu.getBoundingClientRect().width - threshold;
		if ( scrollAmount + menuWidth < containerWidth ) {
			// reached end of the list, anchored to the right
			index = Math.max( 0, parseInt( $menu.dataset.overflowSize, 10 ) - pageSize );
			scrollAmount = this.calculateScrollAmount( $menu );
			$menu.classList.add( this.states.anchoredRight );
		} else if ( 0 === index ) {
			// reached beginning of the list, anchored to the left
			$menu.classList.add( this.states.anchoredLeft );
		}

		// Update current scroll index
		$menu.dataset.overflowCurrent = index; // eslint-disable-line no-param-reassign

		// Apply scroll amount
		const $track = $menu.querySelector( this.selectors.track );
		if ( $track ) {
			$track.style.transform = `translate3d(${scrollAmount}px,0,0)`;

			// toggle arrows
			$menu.classList.add( this.states.scrolled );
		}
	},

	/**
	 * Return whether or not a scroll is valid
	 *
	 * @method isValidScroll
	 * @param {element} - Overflow menu element
	 * @param {int} - scroll direction, 1 represents scroll to the right, -1 scroll to the left
	 */
	isValidScroll( $menu, direction ) {
		return ( direction > 0 && ! $menu.classList.contains( this.states.anchoredRight ) )
			|| ( direction < 0 && ! $menu.classList.contains( this.states.anchoredLeft ) );
	},

	/**
	 * Retuns the overflow menu to initial state
	 *
	 * @method reset
	 * @param {element} - Overflow menu element
	 */
	reset( $menu ) {
		const $track = $menu.querySelector( this.selectors.track );

		if ( $track ) {
			$track.style.transform = '';

			// toggle arrows
			$menu.classList.remove( this.states.scrolled );
			$menu.classList.remove( this.states.anchoredRight );
			$menu.classList.add( this.states.anchoredLeft );

			// reset current page
			$menu.dataset.overflowPage = 0; // eslint-disable-line no-param-reassign
		}
	},

	/**
	 * Set the number of items to scroll per page
	 *
	 * @method setPageSize
	 * @param {element} - Overflow menu element
	 */
	setPageSize( $menu ) {
		const $list = $menu.querySelector( this.selectors.list );
		const listStyle = window.getComputedStyle( $list );
		const overflowEnabled = 'visible' === listStyle.getPropertyValue( 'overflow' );

		if ( overflowEnabled ) {
			const threshold = parseInt( $menu.dataset.overflowThreshold, 10 );
			const boundry = $menu.getBoundingClientRect().width - threshold;

			// reset size
			delete $menu.dataset.overflowPageSize; // eslint-disable-line no-param-reassign

			// Find the first item that isn't fully visible
			[].forEach.call( $list.children, ( $item, index ) => {
				if ( ! $menu.dataset.overflowPageSize
					&& $item.offsetLeft + $item.offsetWidth > boundry ) {
					$menu.dataset.overflowPageSize = index; // eslint-disable-line no-param-reassign
				}
			});
		}
	},

	/**
	 * Calculate the amount in px that the overflow menu needs to scroll to reveal all elements.
	 *
	 * @method calculateScrollAmount
	 * @param {element} - Overflow menu element
	 * @return {int} - Total amount in px to scroll menu
	 */
	calculateScrollAmount( $menu ) {
		const menuWidth = this.getMenuWidth( $menu );
		const containerWidth = $menu.getBoundingClientRect().width;
		return ( containerWidth - menuWidth ) - parseInt( $menu.dataset.overflowThreshold, 10 );
	},

	/**
	 * Get the total overflow menu width in px, including the obscured portion.
	 *
	 * @method getMenuWidth
	 * @param {element} - Overflow menu element
	 * @return {int} - Total menu width
	 */
	getMenuWidth( $menu ) {
		const $list = $menu.querySelector( this.selectors.list );
		if ( $list && $list.children && $list.children.length > 0 ) {
			const $lastItem = $list.lastElementChild;
			return $lastItem.offsetLeft + $lastItem.offsetWidth;
		}

		return 0;
	},

	/**
	 * Get the x position of the $index-th element in $menu list
	 *
	 * @method getTargetPosition
	 * @param {element} - Overflow menu element
	 * @param {int} - index of target item in the menu list
	 * @return {int} - Total menu width
	 */
	getTargetPosition( $menu, $index = false ) {
		const $list = $menu.querySelector( this.selectors.list );
		if ( $list && $list.children && $list.children.length > 0 ) {
			let $item = $list.lastElementChild;
			if ( $list.children.length > $index ) {
				$item = $list.children[$index];
			}
			return $item.offsetLeft;
		}

		return 0;
	},
};

export default MenuOverflow;