@financial-times/o-header
Version:
Responsive Financial Times page header with primary and secondary navigation, a drop down mega menu, and a collapsible drawer
212 lines (176 loc) • 6.61 kB
JavaScript
import Toggle from '@financial-times/o-toggle';
const LISTEN_DELAY = 300;
const INTENT_DELAY = 1000;
function handleCloseEvents (scope, callback, allFocusable) {
let timeout;
const handleKeydown = (e) => {
if (e.keyCode === 27) {
callback();
}
};
const handleClick = (e) => {
if (!scope.contains(e.target)) {
callback();
}
};
const handleMouseenter = () => {
clearTimeout(timeout);
};
const handleMouseleave = () => {
// IE 11 mobile fires a mouseleave event when the search box gets focus. This means when the user tries
// to use the search box, it disappears because the drawer closes.
// Mouseout events should only occur when the drawer takes up less than 100% of the window, so we can ignore
// any events triggered if the width of the drawer is equal to or bigger than the window.innerwidth
if (window.innerWidth >= scope.offsetWidth) {
timeout = setTimeout(callback, INTENT_DELAY);
}
};
const handleFocus = (e) => {
const target = e.relatedTarget || e.target;
if (!scope.contains(target)) {
scope.focus();
}
};
const handleTab = (e) => {
if (e.keyCode === 9) {
const firstEl = allFocusable[0];
const lastEl = allFocusable[allFocusable.length - 1];
// Keep focus within the drawer when tabbing for a11y reasons.
if (!e.shiftKey && e.target === lastEl) {
firstEl.focus();
e.preventDefault();
} else if (e.shiftKey && e.target === firstEl) { // loop to the bottom when shift+tabbing.
lastEl.focus();
e.preventDefault();
}
}
};
const handleTransitionEnd = () => {
const isClosed = !scope.classList.contains('o-header__drawer--open');
if (isClosed) {
scope.style.display = 'none';
}
};
const removeEvents = () => {
clearTimeout(timeout);
scope.removeEventListener('mouseenter', handleMouseenter);
scope.removeEventListener('mouseleave', handleMouseleave);
document.removeEventListener('click', handleClick);
document.removeEventListener('touchstart', handleClick);
document.removeEventListener('keydown', handleKeydown);
document.removeEventListener('focusin', handleFocus);
document.removeEventListener('focusout', handleFocus);
scope.removeEventListener('keydown', handleTab);
};
const addEvents = () => {
scope.addEventListener('mouseenter', handleMouseenter);
scope.addEventListener('mouseleave', handleMouseleave);
document.addEventListener('click', handleClick);
document.addEventListener('touchstart', handleClick);
document.addEventListener('keydown', handleKeydown);
// Firefox doesn't support focusin or focusout
// https://bugzilla.mozilla.org/show_bug.cgi?id=687787
document.addEventListener('focusin', handleFocus);
document.addEventListener('focusout', handleFocus);
scope.addEventListener('keydown', handleTab);
scope.addEventListener('transitionend', handleTransitionEnd);
};
return { addEvents, removeEvents, handleMouseleave };
}
function addDrawerToggles (drawerEl, allFocusable) {
const controls = Array.from(document.body.querySelectorAll(`[aria-controls="${drawerEl.id}"]`));
let handleClose;
let openingControl;
function toggleCallback (state, e) {
if (state === 'close') {
toggleTabbing(drawerEl, false, allFocusable);
handleClose.removeEvents();
openingControl.focus();
drawerEl.classList.remove('o-header__drawer--open');
} else {
toggleTabbing(drawerEl, true, allFocusable);
// don't capture the initial click or accidental double taps etc.
// we could use transitionend but scoping is tricky and it needs prefixing and...
setTimeout(handleClose.addEvents, LISTEN_DELAY);
// record the opening control so we can send focus back to it when closing the drawer
openingControl = e.currentTarget;
// aria-controls is only supported by JAWS.
// In a setTimeout callback to avoid flickering transitions in Chrome (v54)
setTimeout(() => {
// Don't focus on the drawer itself or iOS VoiceOver will miss it
// Focus on the first focusable element
const firstFocusable = drawerEl.querySelector('a, button, input, select');
if (firstFocusable) {
firstFocusable.focus();
}
else {
drawerEl.focus();
}
});
}
if (state === 'open') {
drawerEl.style.display = 'block';
setTimeout(() => drawerEl.classList.add('o-header__drawer--open'), 0); // Without the zero-second timeout, the browser will render the box immediately with no transition.
}
}
controls.forEach((control) => {
const drawerToggle = new Toggle(control, {
target: drawerEl,
callback: toggleCallback
});
// Both toggles have the same target, so the toggle function will be the same
// If there's a separate handleClose instance for each toggle, removeEvents doesn't work
// when the close toggle is clicked
if (!handleClose) {
handleClose = handleCloseEvents(drawerEl, drawerToggle.toggle, allFocusable);
}
});
// make the drawer programmatically focusable
drawerEl.tabIndex = -1;
}
function addSubmenuToggles (drawerEl) {
const submenus = drawerEl.querySelectorAll('[id^="o-header-drawer-child-"]');
Array.from(submenus).forEach(submenu => {
const button = drawerEl.querySelector(`[aria-controls="${submenu.id}"]`);
submenu.setAttribute('aria-hidden', 'true');
new Toggle(button, {
target: submenu,
callback: (state) => {
button.textContent = button.textContent.replace(/fewer|more/, state === 'open' ? 'fewer' : 'more');
}
});
});
}
// This function is to solve accessibility issue
// when o-header-drawer is closed => tabbing is disabled.
// when o-header-drawer is open => tabbing is enabled.
function toggleTabbing (drawerEl, isEnabled, allFocusable) {
if (isEnabled) {
allFocusable.forEach(el => {
el.removeAttribute('tabindex');
});
} else {
allFocusable.forEach(el => {
el.setAttribute('tabindex', '-1');
});
}
}
function init () {
const drawerEl = document.body.querySelector('[data-o-header-drawer]');
if (!drawerEl) {
return;
}
const allFocusable = Array.from(drawerEl.querySelectorAll('a, button, input, select'));
toggleTabbing(drawerEl, false, allFocusable);
addSubmenuToggles(drawerEl);
addDrawerToggles(drawerEl, allFocusable);
// Wrap in a timeout to stop page load stall in Chrome v73 on Android
// toggleTabbing and the removal of the no-js attribute spikes the CPU
// and causes the main process to block for around 10 seconds.
setTimeout(() => {
drawerEl.removeAttribute('data-o-header-drawer--no-js');
drawerEl.setAttribute('data-o-header-drawer--js', 'true');
});
}
export default { init, handleCloseEvents };
export { init, handleCloseEvents };