docbrown/app/javascript/topNavigation/utilities.js
Marcy Sutton f072730f0f
Header navigation dropdown accessibility (#11509)
* Improve keyboard a11y of header menu dropdown

This commit also includes some cleanup of unnecessary functions that seemed to degrade performance.

Closes https://github.com/forem/forem/issues/1154

* Add temporary focus style on navigation-button

* Put menu items in a list

* Adjust menu button based on VoiceOver testing

* Refactor menu logic to be reusable/work on touch

* Preserve admin link visibility

* Focus on first item on menu open

* Clean up some HTML and CSS

* Apply suggestions from code review

Co-authored-by: ludwiczakpawel <ludwiczakpawel@gmail.com>

* Ensure menu hides on click outside

* Rename toggle function and adjust formatting

* Update nav button focus style

* Clean up padding on header avatar focus style

* Make button show focus state for keyboard only

Using .focus-visible:focus targets keyboard focus and eliminates a flash of the blue border on click before focus is moved to the child item (which also has no focus style on mouse click)

* Update app/views/layouts/_top_bar.html.erb

Co-authored-by: ludwiczakpawel <ludwiczakpawel@gmail.com>

* Add some defensive programming

* Removed initializeTouchDevice from base.js.erb

* navigation-butt ID is now member-menu-button

* Moved all the logic from initializeTouchDevice.js into a pack file/utilities.

* committing re-ordered schema after setup

* add tests for initializeTouchDevice

* remove some unneeded html setup

* make sure menu doesn't close if user tabs back from sign out

* Revert "committing re-ordered schema after setup"

This reverts commit a41a1c861cca3b97d8a7b8a99268b8afaae9f028.

* optimized code

* small tweaks, only show outline when focused

Co-authored-by: ludwiczakpawel <ludwiczakpawel@gmail.com>
Co-authored-by: rhymes <rhymes@hey.com>
Co-authored-by: Nick Taylor <nick@dev.to>
Co-authored-by: Suzanne Aitchison <suzanne@forem.com>
2021-01-27 09:39:17 +00:00

173 lines
4.9 KiB
JavaScript

function closeHeaderMenu(memberMenu, menuNavButton) {
if (menuNavButton) {
menuNavButton.setAttribute('aria-expanded', 'false');
}
if (memberMenu) {
setTimeout(() => {
memberMenu.classList.remove('showing');
}, 5);
}
}
function blurHeaderMenu(memberMenu, menuNavButton, potentiallyActiveElement) {
setTimeout(() => {
if (document.activeElement !== potentiallyActiveElement) {
closeHeaderMenu(memberMenu, menuNavButton);
}
}, 10);
}
function toggleHeaderMenu(memberMenu, navigationButton) {
if (!memberMenu || !navigationButton) {
return;
}
let crayonsHeaderMenuClassList = memberMenu.classList;
if (crayonsHeaderMenuClassList.contains('showing')) {
crayonsHeaderMenuClassList.remove('showing');
navigationButton.setAttribute('aria-expanded', 'false');
} else {
crayonsHeaderMenuClassList.add('showing');
navigationButton.setAttribute('aria-expanded', 'true');
let firstNavLink = document.getElementById('first-nav-link');
if (firstNavLink) {
setTimeout(() => {
// focus first item on open
firstNavLink.focus();
}, 100);
}
}
}
export function isTouchDevice() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|DEV-Native-ios/i.test(
navigator.userAgent,
);
}
export function initializeTouchDevice(memberTopMenu, menuNavButton) {
if (navigator.userAgent === 'DEV-Native-ios') {
document.body.classList.add('dev-ios-native-body');
}
if (memberTopMenu) {
let crayonsHeaderMenuClassList = memberTopMenu.classList;
setTimeout(() => {
closeHeaderMenu(memberTopMenu, menuNavButton);
if (isTouchDevice()) {
// Use a named function instead of anonymous so duplicate event handlers are discarded
menuNavButton.addEventListener('click', (_event) => {
toggleHeaderMenu(memberTopMenu, menuNavButton);
});
} else {
crayonsHeaderMenuClassList.add('desktop');
menuNavButton.addEventListener('click', (_event) => {
toggleHeaderMenu(memberTopMenu, menuNavButton);
});
memberTopMenu.addEventListener('keyup', (e) => {
if (
e.key === 'Escape' &&
crayonsHeaderMenuClassList.contains('showing')
) {
crayonsHeaderMenuClassList.remove('showing');
menuNavButton.focus();
}
});
document
.getElementById('last-nav-link')
.addEventListener('blur', (_event) => {
blurHeaderMenu(
memberTopMenu,
menuNavButton,
document.getElementById('second-last-nav-link'),
);
});
document.addEventListener('click', (_event) => {
// if clicking outside of the menu, close it
if (!memberTopMenu.contains(document.activeElement)) {
blurHeaderMenu(
memberTopMenu,
menuNavButton,
document.getElementById('first-nav-link'),
);
}
});
}
}, 10);
}
}
function toggleBurgerMenu() {
document.body.classList.toggle('hamburger-open');
}
function showMoreMenu({ target }) {
target.nextElementSibling.classList.remove('hidden');
target.classList.add('hidden');
}
/**
* Gets a reference to InstantClick
*
* @param {number} [waitTime=2000] The amount of time to wait
* until giving up waiting for InstantClick to exist
*
* @returns {Promise<object>} The global instance of InstantClick.
*/
export async function getInstantClick(waitTime = 2000) {
return new Promise((resolve, reject) => {
const failTimer = setTimeout(() => {
clearInterval(timer);
reject(new Error('Unable to resolve InstantClick'));
}, waitTime);
const timer = setInterval(() => {
if (typeof InstantClick !== 'undefined') {
clearTimeout(failTimer);
clearInterval(timer);
resolve(InstantClick);
}
});
});
}
/**
* Initializes the hamburger menu for mobile navigation
*
* @param {HTMLElement[]} menus
* @param {HTMLElement[]} moreMenus
*/
export function initializeMobileMenu(menus, moreMenus) {
menus.forEach((trigger) => {
trigger.addEventListener('click', toggleBurgerMenu);
});
moreMenus.forEach((trigger) => {
trigger.addEventListener('click', showMoreMenu);
});
}
/**
* Sets the icon link visually for the current page if the current page
* is one of the main icon links of the top navigation.
*
* @param {string} currentPage
* @param {[string, HTMLElement][]} pageEntries
*/
export function setCurrentPageIconLink(currentPage, pageEntries) {
pageEntries
// Filter out nulls (means the user is logged out so most icons are not in the logged out view)
.filter(([, iconLink]) => iconLink)
.forEach(([page, iconLink]) => {
if (currentPage === page) {
iconLink.blur();
iconLink.classList.add('crayons-header__link--current');
} else {
iconLink.classList.remove('crayons-header__link--current');
}
});
}