@financial-times/o-header-services
Version:
Responsive page header for sites with minimal or customised branding, including internal products, customer facing tools, and specialist titles.
243 lines (220 loc) • 7.15 kB
JavaScript
import * as oUtils from '@financial-times/o-utils';
class DropDown {
/**
*
* @typedef Drawer
* @param {HTMLElement} [headerEl] - The component element in the D
*/
/**
* Class constructor
*
* @param {HTMLElement} headerEl - The component element in the DOM
* @param {Drawer|null} drawer - The drawer that this drop down belongs to if any.
*/
constructor(headerEl, drawer = null) {
this.primaryNav = headerEl.querySelector('.o-header-services__primary-nav');
this.drawer = drawer;
this.headerEl = headerEl;
/**
* @type {Element[]} - Nav items with a dropdown.
*/
this.navItems = [...headerEl.querySelectorAll('[data-o-header-services-level="1"]')].filter(item => item.querySelector('ul'));
this.navItems.forEach(item => {
const button = item.querySelector('button');
if (!button) {
return;
}
button.addEventListener('click', this);
const parentElement = item.parentElement;
// Moving focus out of the navigation region also closes an open dropdown.
parentElement.addEventListener('focusout', (event) => {
const menuContainsFocus = parentElement.contains(event.relatedTarget);
if (!menuContainsFocus) {
DropDown.collapse(item);
}
});
});
// the event listener is added to the body here to handle cases where a
// user might click anywhere else on the body to collapse open dropdowns
document.body.addEventListener('click', this);
window.addEventListener('keydown', this);
// When the drawer is enabled or disabled reset the dropdowns.
// The breakpoint the drawer is enabled is customisable with SASS,
// we use the drawer burger icon visibility to decide when it is enabled.
// Use ResizeObserver where supported to detect drawer icon changes where
// possible, otherwise fallback to a debounced resize listener.
if (window.ResizeObserver && this.drawer && this.drawer.burger) {
const resizeObserver = new ResizeObserver(this.reset.bind(this));
resizeObserver.observe(this.drawer.burger);
} else {
window.addEventListener('resize', oUtils.debounce(() => this.reset(), 33));
}
}
/**
* Event Handler
*
* @param {object} event - The event emitted by element/window interactions
* @returns {void}
*/
handleEvent(event) {
if (event.key === 'Escape') {
this.reset();
}
if (event.type === 'click' && event.target) {
// Close dropdown if some non-nav element on the page is clicked.
if (event.target.nodeName !== 'BUTTON' &&
event.target.nodeName !== 'A' &&
event.target !== this.drawer.navList
) {
this.reset();
return;
}
// Bail if there's no parent menu to toggle. Note: IE11 does not
// support `Array.prototype.find()` or `Element.closest()`,
// adding a polyfill requirement is a breaking change
let parentMenu;
for (let itemIndex = 0; itemIndex < this.navItems.length; itemIndex++) {
const navItem = this.navItems[itemIndex];
if (navItem.contains(event.target)) {
parentMenu = navItem;
break; // found the parent menu
}
}
if (!parentMenu) {
return;
}
// Toggle the menu. Close other open menus when not in the drawer.
if (!DropDown.isExpanded(parentMenu)) {
if (!this.isDrawer()) {
DropDown.collapseAll(this.navItems);
}
DropDown.expand(parentMenu);
} else {
DropDown.collapse(parentMenu);
}
event.stopPropagation();
}
}
/**
* Checks if primary nav is in a drawer
* This boolean will change the drop down behaviour.
*
* @returns {boolean} - whether the drawer is enabled or not
*/
isDrawer() {
return this.drawer && this.drawer.enabled;
}
/**
* Returns nav items to their original collapsed state,
* items which contain links with the attribute `aria-current`
* set to true remain expanded.
*
* @returns {void}
*/
reset() {
// Disable transitions immediately. These should only happen on user
// interaction. We do not want the dropdowns to transition when we are
// resetting them i.e. due to the drawer being enabled on a viewport
// change.
this.headerEl.classList.add('o-header-services--disable-transition');
// In the next animation frame...
window.requestAnimationFrame(function () {
// Close all dropdowns except within the drawer only, where the
// dropdown for the current page should be open.
DropDown.collapseAll(this.navItems);
if (this.isDrawer()) {
DropDown.expandAll(DropDown.getCurrent(this.navItems));
}
// Enable transitions again, which should happen on user interaction
this.headerEl.classList.remove('o-header-services--disable-transition');
}.bind(this));
}
/**
* Checks whether nav menu is expanded
*
* @param {HTMLElement} item - the nav menu
* @returns {boolean} - whether the nav menu is expanded
*/
static isExpanded(item) {
return item.getAttribute('dropdown-open') === 'true';
}
/**
* Expands closed nav menu
*
* @param {HTMLElement} item - the nav menu
* @returns {void}
*/
static expand(item) {
const childList = item.querySelector('ul');
requestAnimationFrame(() => {
childList.setAttribute('aria-hidden', false);
DropDown.position(childList);
requestAnimationFrame(() => {
item.setAttribute('dropdown-open', true);
item.querySelector('button').setAttribute('aria-expanded', true);
});
});
}
/**
* Changes nav menu position relative to the window
*
* @param {HTMLElement} item - the nav menu
* @returns {void}
*/
static position(item) {
if (item.getBoundingClientRect().right > window.innerWidth) {
item.classList.add('o-header-services__list--right');
}
}
/**
* Collapses open nav menu
*
* @param {HTMLElement} item - the nav menu
* @returns {void}
*/
static collapse(item) {
const childList = item.querySelector('ul');
item.setAttribute('dropdown-open', false);
item.querySelector('button').setAttribute('aria-expanded', false);
childList.setAttribute('aria-hidden', true);
}
/**
* Collapses all open nav menus
*
* @param {Array<HTMLElement>} items - the menu items to collapse
* @returns {void}
*/
static collapseAll(items) {
items.forEach(DropDown.collapse);
}
/**
* Expands all open nav menus
*
* @param {Array<HTMLElement>} items - the menu items to expand
* @returns {void}
*/
static expandAll(items) {
items.forEach(DropDown.expand);
}
/**
* Returns items which contain an anchor
* with the attribute `aria-current` set to true or "page".
*
* @param {Array<HTMLElement>} items - the menu items to check
* @returns {HTMLElement} - The current menu item
*/
static getCurrent(items) {
return items.filter(item => {
const links = item.querySelectorAll('a');
const hasCurrentLink = Array.from(links).reduce((result, link) => {
// Check against "page" and "true" as o-header-services
// used "true" in its markup before switching to "page".
// https://www.aditus.io/aria/aria-current/#aria-current-page
const ariaCurrent = link.getAttribute('aria-current') ;
return result || (ariaCurrent === 'true' || ariaCurrent === 'page');
}, false);
return hasCurrentLink;
});
}
}
export default DropDown;