@financial-times/o-header
Version:
Responsive Financial Times page header with primary and secondary navigation, a drop down mega menu, and a collapsible drawer
308 lines (251 loc) • 8.62 kB
JavaScript
/**
* Subnav Dropdown
*
* This script controls the behaviour of subnavigation dropdown options where present.
* Desktop:
* - Shown when hovering over the appropriate subnav item
* - Hidden when the cursor is moved away for a set duration
* - Positioned relative to the designated item
*
* Mobile:
* - Shown when the subnav item is tapped
* - Positioned centrally with a close icon available
* - Hidden when the close icon is tapped
*/
const INTENT_ENTER = 300;
const INTENT_LEAVE = 400;
const DEFAULT_DROPDOWN_WIDTH = 285;
const POSITIONING_OFFSET = 4;
const expandedDropdowns = new Set();
const activeDropdownEventListeners = new WeakMap();
const showHideEventListeners = new WeakMap();
const focusableElements = [
'a[href]',
'button:not([disabled])'
].join(',');
function getButtonForDropdown(dropdown) {
return dropdown.parentNode.querySelector('[data-o-header-subnav-dropdown-button]')
}
function handleScroll() {
expandedDropdowns.forEach(dropdown => {
const button = getButtonForDropdown(dropdown);
positionDropdown(dropdown, button);
});
}
function closeAllDropdowns() {
const dropdownsToClose = new Set(expandedDropdowns);
dropdownsToClose.forEach((dropdown) => {
const button = getButtonForDropdown(dropdown);
hideDropdown(dropdown, button);
});
}
function isDropdownOpen(dropdown) {
return expandedDropdowns.has(dropdown);
}
function getFocusableElementsInDropdown(dropdown) {
return [...dropdown.querySelectorAll(focusableElements)]
.filter(el => !el.hasAttribute('hidden') && el.offsetParent !== null);
}
function addDropdownControlEvents(dropdown, button, isDesktop) {
const currentDropdownEventListeners = activeDropdownEventListeners.get(dropdown);
if (currentDropdownEventListeners) return;
const listeners = new Set();
const registerListener = (target, type, callback) => {
target.addEventListener(type, callback);
listeners.add({ target, type, callback });
};
const keydownHandler = (event) => {
const key = event.key;
if (key === 'Escape' && isDropdownOpen(dropdown)) {
hideDropdown(dropdown, button);
button.focus();
}
if (key !== 'Tab') {
return;
}
const focusElements = getFocusableElementsInDropdown(dropdown);
if (focusElements.length === 0) {
event.preventDefault();
return;
}
const firstElement = focusElements[0];
const lastElement = focusElements[focusElements.length - 1];
const focusAtStart = document.activeElement === firstElement || document.activeElement === dropdown;
const focusAtEnd = document.activeElement === lastElement;
const isShiftTab = event.shiftKey;
if (isDesktop) {
if (isShiftTab && focusAtStart) {
hideDropdown(dropdown, button);
return;
}
if (!isShiftTab && focusAtEnd) {
hideDropdown(dropdown, button);
}
} else {
if (isShiftTab && focusAtStart) {
event.preventDefault();
lastElement.focus();
return;
}
if (!isShiftTab && focusAtEnd) {
event.preventDefault();
firstElement.focus();
}
}
}
registerListener(document, 'keydown', keydownHandler);
if (isDesktop) {
// Dropdowns scroll with the user on desktop
registerListener(window, 'scroll', handleScroll);
} else {
// The close button is only visible on mobile
const closeButton = dropdown.querySelector('[data-o-header-subnav-dropdown-close]');
if (closeButton) {
const closeButtonClickHandler = (event) => {
event.preventDefault();
event.stopPropagation();
hideDropdown(dropdown, button);
button.focus();
};
registerListener(closeButton, 'click', closeButtonClickHandler);
}
}
activeDropdownEventListeners.set(dropdown, listeners);
}
function removeDropdownControlEvents (dropdown) {
const currentDropdownEventListeners = activeDropdownEventListeners.get(dropdown);
currentDropdownEventListeners.forEach((listener) => {
const { target, type, callback } = listener;
target.removeEventListener(type, callback)
})
activeDropdownEventListeners.delete(dropdown);
}
function removeShowHideControlEvents (target) {
const currentDropdownEventListeners = showHideEventListeners.get(target);
currentDropdownEventListeners.forEach((listener) => {
const { target, type, callback } = listener;
target.removeEventListener(type, callback)
})
showHideEventListeners.delete(target);
}
function removeScrollLock () {
document.body.classList.remove('o-header__subnav-dropdown-body-scroll-lock');
}
function addScrollLock () {
document.body.classList.add('o-header__subnav-dropdown-body-scroll-lock');
}
function resetDropdownPosition(dropdown) {
dropdown.style.removeProperty('top');
dropdown.style.removeProperty('left');
dropdown.style.removeProperty('zIndex');
dropdown.style.removeProperty('transform');
dropdown.style.removeProperty('right');
dropdown.style.removeProperty('bottom');
dropdown.style.removeProperty('margin');
}
function positionDropdown(dropdown, hoverTarget) {
if (!hoverTarget) {
return;
}
const targetRect = hoverTarget.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const dropdownWidth = DEFAULT_DROPDOWN_WIDTH;
let left = targetRect.left;
if (left + dropdownWidth > viewportWidth) {
left = Math.max(POSITIONING_OFFSET, viewportWidth - dropdownWidth - POSITIONING_OFFSET);
}
Object.assign(dropdown.style, {
top: `${targetRect.bottom + POSITIONING_OFFSET}px`,
left: `${left}px`,
zIndex: '10000',
transform: 'none',
right: 'auto',
bottom: 'auto',
margin: '0'
});
}
function showDropdown({ button, dropdown, isDesktop }) {
button.setAttribute('aria-expanded', 'true');
dropdown.style.display = 'block';
expandedDropdowns.add(dropdown);
if (isDesktop) {
positionDropdown(dropdown, button)
} else {
addScrollLock();
}
addDropdownControlEvents(dropdown, button, isDesktop)
}
function hideDropdown(dropdown, button) {
button.setAttribute('aria-expanded', 'false');
dropdown.style.display = 'none';
expandedDropdowns.delete(dropdown);
removeScrollLock();
removeDropdownControlEvents(dropdown)
}
function addDropdownShowHideEvents({ button, dropdown, parent, isDesktop }) {
let timeout;
const openDropdown = () => {
if (isDropdownOpen(dropdown)) return;
if (expandedDropdowns.size > 0) {
closeAllDropdowns();
}
showDropdown({ button, dropdown, isDesktop });
}
const listeners = new Set();
const registerListener = (target, type, callback) => {
target.addEventListener(type, callback);
listeners.add({ target, type, callback });
};
if (isDesktop) {
const handleMouseEnter = () => {
clearTimeout(timeout);
timeout = setTimeout(() => {
openDropdown();
}, INTENT_ENTER);
}
registerListener(parent, 'mouseenter', handleMouseEnter);
const handleMouseLeave = () => {
clearTimeout(timeout);
timeout = setTimeout(() => {
if (isDropdownOpen(dropdown)) {
hideDropdown(dropdown, button);
}
}, INTENT_LEAVE);
}
registerListener(parent, 'mouseleave', handleMouseLeave);
}
const clickHandler = () => {
openDropdown();
const dropdownElements = getFocusableElementsInDropdown(dropdown);
if (dropdownElements.length) {
dropdownElements[0].focus();
}
};
registerListener(button, 'click', clickHandler);
showHideEventListeners.set(parent, listeners);
}
function initSubnavDropdowns(subnav) {
const dropdownParents = Array.from(subnav.querySelectorAll('[data-o-header-subnav-dropdown-parent]'));
const isDesktopQuery = window.matchMedia('(min-width: 740px)');
isDesktopQuery.addEventListener('change', () => {
dropdownParents.forEach((parent) => {
removeShowHideControlEvents(parent);
const button = parent.querySelector('[data-o-header-subnav-dropdown-button]')
const dropdown = parent.querySelector('[data-o-header-subnav-dropdown-modal]')
if (button.hasAttribute('aria-expanded')) {
if (button.getAttribute('aria-expanded') === 'true') {
hideDropdown(dropdown, button);
}
}
resetDropdownPosition(dropdown);
addDropdownShowHideEvents({ button, dropdown, parent, isDesktop: isDesktopQuery.matches })
});
});
dropdownParents.forEach((parent) => {
const button = parent.querySelector('[data-o-header-subnav-dropdown-button]')
const dropdown = parent.querySelector('[data-o-header-subnav-dropdown-modal]')
addDropdownShowHideEvents({ button, dropdown, parent, isDesktop: isDesktopQuery.matches })
});
}
export { initSubnavDropdowns };
export default { initSubnavDropdowns };