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;