* 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>
173 lines
4.9 KiB
JavaScript
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');
|
|
}
|
|
});
|
|
}
|