Instantly share code, notes, and snippets.
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
Save saltcod/e030e13c9af5bc5a5dd26438f3470131 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @module TenUpNavigation | |
* | |
* @description | |
* | |
* Create responsive navigation. | |
*/ | |
export default class Navigation { | |
/** | |
* constructor method | |
* | |
* @param {string} element Element selector for navigation container. | |
* @param {object} options Object of optional callbacks. | |
*/ | |
constructor(element, options = {}) { | |
// Defaults | |
const defaults = { | |
action: 'hover', | |
breakpoint: '(min-width: 48em)', | |
// Event callbacks | |
onCreate: null, | |
onOpen: null, | |
onClose: null, | |
onSubmenuOpen: null, | |
onSubmenuClose: null, | |
}; | |
if (!element || typeof element !== 'string') { | |
console.error( '10up Navigation: No target supplied. A valid target (menu) must be used.' ); // eslint-disable-line | |
return; | |
} | |
this.evtCallbacks = {}; | |
// bind methods | |
this.setMQ = this.setMQ.bind(this); | |
this.listenerMenuToggleClick = this.listenerMenuToggleClick.bind(this); | |
this.listenerSubmenuAnchorFocus = this.listenerSubmenuAnchorFocus.bind(this); | |
this.listenerSubmenuAnchorClick = this.listenerSubmenuAnchorClick.bind(this); | |
this.listenerDocumentClick = this.listenerDocumentClick.bind(this); | |
this.listenerDocumentKeyup = this.listenerDocumentKeyup.bind(this); | |
// Settings | |
this.settings = { ...defaults, ...options }; | |
// Set media queries. | |
this.mq = window.matchMedia(this.settings.breakpoint); | |
// Menu container selector. | |
this.$menu = document.querySelector(element); | |
// Bail out if there's no menu. | |
if (!this.$menu) { | |
console.error( '10up Navigation: Target not found. A valid target (menu) must be used.' ); // eslint-disable-line | |
return; | |
} | |
this.$menuToggle = document.querySelector( | |
`[aria-controls="${this.$menu.getAttribute('id')}"]`, | |
); | |
// Also bail early if the toggle isn't set. | |
if (!this.$menuToggle) { | |
console.error( '10up Navigation: No menu toggle found. A valid menu toggle must be used.' ); // eslint-disable-line | |
return; | |
} | |
// Set all submenus and menu items. | |
this.$submenus = this.$menu.querySelectorAll('ul'); | |
this.$menuItems = this.$menu.querySelectorAll('li'); | |
// Update the html element classes for styles. | |
// Otherwise it'll fallback to :target. | |
document.querySelector('html').classList.remove('no-js'); | |
document.querySelector('html').classList.add('js'); | |
// Setup tasks | |
this.setupMenu(); | |
this.setupSubMenus(); | |
this.setupListeners(); | |
/** | |
* Called after the component is initialized on page load. | |
* | |
* @callback onCreate | |
*/ | |
if (this.settings.onCreate && typeof this.settings.onCreate === 'function') { | |
this.settings.onCreate.call(); | |
} | |
} | |
/** | |
* Handle destroying tabs | |
* | |
* @param options Optional options | |
*/ | |
destroy(options = {}) { | |
this.removeAllEventListeners(); | |
this.mq.removeListener(this.setMQ); | |
const defaults = { | |
removeAttributes: true, | |
}; | |
const settings = { | |
...defaults, | |
...options, | |
}; | |
if (settings.removeAttributes) { | |
this.$menu.removeAttribute('aria-hidden'); | |
this.$menu.removeAttribute('data-action'); | |
this.$menuToggle.removeAttribute('aria-expanded'); | |
this.$menuToggle.removeAttribute('aria-hidden'); | |
this.$submenus.forEach(($submenu) => { | |
const $anchor = $submenu.previousElementSibling; | |
$submenu.removeAttribute('id'); | |
$submenu.removeAttribute('aria-hidden'); | |
// Update ARIA. | |
$submenu.removeAttribute('aria-label'); | |
$anchor.removeAttribute('aria-controls'); | |
$anchor.removeAttribute('aria-haspopup'); | |
$anchor.removeAttribute('aria-expanded'); | |
}); | |
} | |
} | |
/** | |
* Adds an event listener and caches the callback for later removal | |
* | |
* @param {element} element The element associaed with the event listener | |
* @param {string} evtName The event name | |
* @param {Function} callback The callback function | |
*/ | |
addEventListener(element, evtName, callback) { | |
if (typeof this.evtCallbacks[evtName] === 'undefined') { | |
this.evtCallbacks[evtName] = []; | |
} | |
this.evtCallbacks[evtName].push({ | |
element, | |
callback, | |
}); | |
element.addEventListener(evtName, callback); | |
} | |
/** | |
* Removes all event listeners | |
*/ | |
removeAllEventListeners() { | |
Object.keys(this.evtCallbacks).forEach((evtName) => { | |
const events = this.evtCallbacks[evtName]; | |
events.forEach(({ element, callback }) => { | |
element.removeEventListener(evtName, callback); | |
}); | |
}); | |
} | |
/** | |
* Sets up the main menu for the navigation. | |
* Includes adding classes and ARIA. | |
* We use "scoped" classes so we can be more confident that there will be no collisions. | |
* | |
*/ | |
setupMenu() { | |
const id = this.$menu.getAttribute('id'); | |
const href = this.$menuToggle.getAttribute('href'); | |
const hrefTarget = href.replace('#', ''); | |
this.$menu.dataset.action = this.settings.action; | |
// Check for a valid ID on the menu. | |
if (!id || id === '') { | |
console.error( '10up Navigation: Target (menu) must have a valid ID attribute.' ); // eslint-disable-line | |
return; | |
} | |
// Check that the menu toggle is set to use the menu for fallback. | |
if (hrefTarget !== id) { | |
console.warn( '10up Navigation: The menu toggle href and menu ID are not equal.' ); // eslint-disable-line | |
} | |
// Update ARIA. | |
this.$menuToggle.setAttribute('aria-controls', hrefTarget); | |
// Sets up ARIA tags related to screen size based on our media query. | |
this.setMQMenuA11y(); | |
} | |
/** | |
* Sets up the submenus. | |
* Adds JS classes and initial AIRA attributes. | |
*/ | |
setupSubMenus() { | |
this.$submenus.forEach(($submenu, index) => { | |
const $anchor = $submenu.previousElementSibling; | |
const submenuID = `tenUp-submenu-${index}`; | |
$submenu.setAttribute('id', submenuID); | |
// Update ARIA. | |
$submenu.setAttribute('aria-label', 'Submenu'); | |
$anchor.setAttribute('aria-controls', submenuID); | |
$anchor.setAttribute('aria-haspopup', true); | |
$anchor.setAttribute('aria-expanded', false); | |
// Sets up ARIA tags related to screen size based on our media query. | |
this.setMQSubbmenuA11y(); | |
}); | |
} | |
/** | |
* Binds our various listeners for the plugin. | |
* Includes specific element listeners as well as media query. | |
*/ | |
setupListeners() { | |
// Media query listener. | |
// We're using this instead of resize + debounce because it should be more efficient than that combo. | |
this.mq.addListener(this.setMQ); | |
// Menu toggle listener. | |
this.addEventListener(this.$menuToggle, 'click', this.listenerMenuToggleClick); | |
// Submenu listeners. | |
// Mainly applies to the anchors of submenus. | |
this.$submenus.forEach(($submenu) => { | |
const $anchor = $submenu.previousElementSibling; | |
if (this.settings.action === 'hover') { | |
this.addEventListener($anchor, 'focus', this.listenerSubmenuAnchorFocus); | |
} | |
this.addEventListener($anchor, 'click', this.listenerSubmenuAnchorClick); | |
}); | |
// Document specific listeners. | |
// Mainly used to close any open menus. | |
this.addEventListener(document, 'click', this.listenerDocumentClick); | |
this.addEventListener(document, 'keyup', this.listenerDocumentKeyup); | |
} | |
/** | |
* Set | |
*/ | |
/** | |
* Sets an media query related functions when the query boundry is reached. | |
* | |
*/ | |
setMQ() { | |
this.setMQMenuA11y(); | |
this.setMQSubbmenuA11y(); | |
} | |
/** | |
* Sets any ARIA that changes as a result of the media query boundry being passed. | |
* Specifically for the toggle and main menu. | |
* | |
*/ | |
setMQMenuA11y() { | |
// Large | |
if (this.mq.matches) { | |
this.$menu.setAttribute('aria-hidden', false); | |
this.$menuToggle.setAttribute('aria-expanded', true); | |
this.$menuToggle.setAttribute('aria-hidden', true); | |
// Small | |
} else { | |
this.$menu.setAttribute('aria-hidden', true); | |
this.$menuToggle.setAttribute('aria-expanded', false); | |
this.$menuToggle.setAttribute('aria-hidden', false); | |
} | |
} | |
/** | |
* Sets an media query related functions when the query boundry is reached. | |
* Specifically for submenus. | |
* | |
*/ | |
setMQSubbmenuA11y() { | |
this.$submenus.forEach(($submenu) => { | |
$submenu.setAttribute('aria-hidden', true); | |
}); | |
} | |
/** | |
* Opens the passed submenu. | |
* | |
* @param {element} $submenu The submenu to open. Required. | |
*/ | |
openSubmenu($submenu) { | |
// Open the submenu by updating ARIA and class. | |
$submenu.setAttribute('aria-hidden', false); | |
/** | |
* Called when a submenu item is opened. | |
* | |
* @callback onSubmenuOpen - optional. | |
*/ | |
if (this.settings.onSubmenuOpen && typeof this.settings.onSubmenuOpen === 'function') { | |
this.settings.onSubmenuOpen.call(); | |
} | |
} | |
/** | |
* Closes the passed submenu. | |
* | |
* @param {element} $submenu The submenu to close. Required. | |
*/ | |
closeSubmenu($submenu) { | |
const $anchor = $submenu.previousElementSibling; | |
const $childSubmenus = $submenu.querySelectorAll('li > .sub-menu[aria-hidden="false"]'); | |
// Close the submenu by updating ARIA and class. | |
$submenu.setAttribute('aria-hidden', true); | |
if ($childSubmenus) { | |
// Close any children as well. | |
// Update their ARIA and class. | |
this.closeSubmenus($childSubmenus); | |
} | |
if (!this.mq.matches) { | |
$anchor.focus(); | |
} | |
/** | |
* Called when a submenu item is closed. | |
* | |
* @callback onSubmenuClose - optional. | |
*/ | |
if (this.settings.onSubmenuClose && typeof this.settings.onSubmenuClose === 'function') { | |
this.settings.onSubmenuClose.call(); | |
} | |
} | |
/** | |
* Closes all submenus in the node list. | |
* | |
* @param {Array} $submenus The node list of submenus to close. Required. | |
*/ | |
closeSubmenus($submenus) { | |
$submenus.forEach(($submenu) => { | |
this.closeSubmenu($submenu); | |
}); | |
} | |
/** | |
* Listeners | |
*/ | |
/** | |
* Menu toggle handler. | |
* Opens or closes the menu according to current state. | |
* | |
* @param {object} event The event object. | |
*/ | |
listenerMenuToggleClick(event) { | |
const isExpanded = this.$menuToggle.getAttribute('aria-expanded') === 'true'; | |
// Don't act like a link. | |
event.preventDefault(); | |
// Don't bubble. | |
event.stopPropagation(); | |
// Is the menu currently open? | |
if (isExpanded) { | |
// Update ARIA | |
this.$menu.setAttribute('aria-hidden', true); | |
this.$menuToggle.setAttribute('aria-expanded', false); | |
/** | |
* Called when a menu item is closed. | |
* | |
* @callback onClose - optional | |
*/ | |
if (this.settings.onClose && typeof this.settings.onClose === 'function') { | |
this.settings.onClose.call(); | |
} | |
} else { | |
// Update ARIA | |
this.$menu.setAttribute('aria-hidden', false); | |
this.$menuToggle.setAttribute('aria-expanded', true); | |
// Focus the first link in the menu | |
this.$menu.querySelectorAll('a')[0].focus(); | |
/** | |
* Called when a menu item is opened. | |
* | |
* @callback onOpen - optional | |
*/ | |
if (this.settings.onOpen && typeof this.settings.onOpen === 'function') { | |
this.settings.onOpen.call(); | |
} | |
} | |
} | |
/** | |
* Document click handler. | |
* Closes all open submenus on a click outside of the menu. | |
* | |
*/ | |
listenerDocumentClick() { | |
const $openSubmenus = this.$menu.querySelectorAll('.sub-menu[aria-hidden="false"]'); | |
// Bail if no submenus are found. | |
if ($openSubmenus.length === 0) { | |
return; | |
} | |
// Close the submenus. | |
this.closeSubmenus($openSubmenus); | |
} | |
/** | |
* Document keyup handler. | |
* Closes all open menus on a escape key. | |
* Refocuses after closing submenus. | |
* | |
* @param {object} event The event object. | |
*/ | |
listenerDocumentKeyup(event) { | |
const $openSubmenus = this.$menu.querySelectorAll('.sub-menu[aria-hidden="false"]'); | |
// Bail early if not using the escape key or if no submenus are found. | |
if ($openSubmenus.length === 0 || event.keyCode !== 27) { | |
return; | |
} | |
// Close submenus | |
this.closeSubmenus($openSubmenus); | |
// If we're set to click, set the focus back. | |
if (this.settings.action === 'click') { | |
$openSubmenus[0].previousElementSibling.focus(); | |
} | |
} | |
/** | |
* Submenu anchor click handler. | |
* Opens or closes the submenu accordingly. | |
* Only fires based on settings and if the media query is appropriate. | |
* | |
* @param {object} event The event object. Required. | |
*/ | |
listenerSubmenuAnchorClick(event) { | |
const $anchor = event.target; | |
const $submenu = $anchor.nextElementSibling; | |
const isHidden = $submenu.getAttribute('aria-hidden') === 'true'; | |
let $openSubmenus = this.$menu.querySelectorAll('.sub-menu[aria-hidden="false"]'); | |
$openSubmenus = Array.from($openSubmenus).filter((menu) => !menu.contains($anchor)); | |
// Close the submenus. | |
this.closeSubmenus($openSubmenus); | |
// Bail if set to hover and we're on a large screen. | |
if (this.settings.action === 'hover' && this.mq.matches) { | |
return; | |
} | |
// Don't let the link act like a link. | |
event.preventDefault(); | |
// Don't bubble. | |
event.stopPropagation(); | |
// Is the submenu hidden? | |
if (isHidden) { | |
// Yes, open it. | |
this.openSubmenu($submenu); | |
$anchor.setAttribute('aria-expanded', true); | |
} else { | |
// No, close it. | |
this.closeSubmenu($submenu); | |
$anchor.setAttribute('aria-expanded', false); | |
} | |
} | |
/** | |
* Submenu anchor focus handler. | |
* Opens or closes the submenu accordingly. | |
* Only fires based on settings and if the media query is appropriate. | |
* | |
* @param {object} event The event object. | |
*/ | |
listenerSubmenuAnchorFocus(event) { | |
const $anchor = event.target; | |
const $menuItem = $anchor.parentNode; | |
const $submenu = $anchor.nextElementSibling; | |
const $childSubmenus = $menuItem.parentNode.querySelectorAll('.sub-menu'); | |
// Bail early if no submenu is found or if we're on a small screen. | |
if (!$submenu || !this.mq.matches) { | |
return; | |
} | |
// Close all sibling menus | |
this.closeSubmenus($childSubmenus); | |
// Open this menu | |
this.openSubmenu($submenu); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment