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>
This commit is contained in:
Marcy Sutton 2021-01-27 01:39:17 -08:00 committed by GitHub
parent bb59e4b869
commit f072730f0f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 263 additions and 93 deletions

View file

@ -114,8 +114,9 @@ var instantClick
function changePage(title, body, newUrl, scrollY, pop) {
var pageContentDiv = document.getElementById("page-content");
if (document.getElementById("navigation-butt")) {
document.getElementById("navigation-butt").classList.remove('showing')
var memberMenuButton = document.getElementById("member-menu-button")
if (!memberMenuButton) {
memberMenuButton.classList.remove('showing')
}
document.getElementsByTagName("BODY")[0].replaceChild(body, pageContentDiv)

View file

@ -1,7 +1,7 @@
/*
global initializeLocalStorageRender, initializeBodyData,
initializeAllChatButtons, initializeAllTagEditButtons, initializeUserFollowButts,
initializeBaseTracking, initializeTouchDevice, initializeCommentsPage,
initializeBaseTracking, initializeCommentsPage,
initializeArticleDate, initializeArticleReactions, initNotifications,
initializeCommentDate, initializeCommentDropdown, initializeSettings,
initializeCommentPreview,
@ -40,7 +40,6 @@ function callInitializers() {
initializeBaseTracking();
initializePaymentPointers();
initializeTouchDevice();
initializeCommentsPage();
initializeArticleDate();
initializeArticleReactions();

View file

@ -1,57 +0,0 @@
function getById(className) {
return document.getElementById(className);
}
function getClassList(className) {
return getById(className).classList;
}
function blur(event, className) {
setTimeout(() => {
if (document.activeElement !== getById(className)) {
getClassList('crayons-header__menu').remove('showing');
}
}, 10);
}
function removeShowingMenu() {
getClassList('crayons-header__menu').remove('showing');
setTimeout(() => {
getClassList('crayons-header__menu').remove('showing');
}, 5);
setTimeout(() => {
getClassList('crayons-header__menu').remove('showing');
}, 150);
}
function toggleMenu() {
getClassList('crayons-header__menu').toggle('showing');
}
function initializeTouchDevice() {
var isTouchDevice = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|DEV-Native-ios/i.test(
navigator.userAgent,
);
if (navigator.userAgent === 'DEV-Native-ios') {
document.body.classList.add('dev-ios-native-body');
}
if (document.querySelector('.crayons-header__menu')) {
setTimeout(() => {
removeShowingMenu();
if (isTouchDevice) {
// Use a named function instead of anonymous so duplicate event handlers are discarded
getById('navigation-butt').addEventListener('click', toggleMenu);
} else {
getClassList('crayons-header__menu').add('desktop');
getById('navigation-butt').addEventListener('focus', (e) =>
getClassList('crayons-header__menu').add('showing'),
);
getById('last-nav-link').addEventListener('blur', (e) =>
blur(e, 'second-last-nav-link'),
);
getById('navigation-butt').addEventListener('blur', (e) =>
blur(e, 'first-nav-link'),
);
}
}, 10);
}
}

View file

@ -71,6 +71,8 @@
// Dropdown under profile picture / hamburger icon
.crayons-header__menu {
--trigger-color: var(--header-icons-color);
--trigger-border-color: transparent;
height: 100%;
display: flex;
align-items: center;
@ -81,11 +83,13 @@
&__trigger {
all: unset;
padding: var(--su-1);
padding: calc(var(--su-1) / 2);
margin: 0 var(--su-2);
border-radius: 50%;
color: var(--header-icons-color);
color: var(--trigger-color);
cursor: pointer;
// hold space for interaction style
border: 2px solid var(--trigger-border-color);
}
&__dropdown {
@ -103,17 +107,18 @@
}
&.showing,
&.desktop:hover,
&.desktop:focus {
&.desktop:hover {
--trigger-border-color: var(--header-bg-hover);
--trigger-color: var(--header-icons-color-hover);
--dropdown-transform: translateY(0) rotateX(0);
--dropdown-delay: 0;
--dropdown-opacity: 1;
--dropdown-visibility: visible;
}
.crayons-header__menu__trigger {
color: var(--header-icons-color-hover);
background: var(--header-bg-hover);
}
.focus-visible:focus {
--trigger-border-color: var(--header-button-focus-color);
--trigger-color: var(--header-button-focus-color);
}
}

View file

@ -88,6 +88,7 @@
// Header
--header-bg: var(--base-inverted);
--header-bg-hover: var(--base-10);
--header-button-focus-color: var(--accent-brand);
--header-bg-current: var(--base-20);
--header-shadow: rgba(0, 0, 0, 0.1);
--header-icons-color: var(--base-70);

View file

@ -29,9 +29,6 @@
font-size: 1em;
resize: vertical;
}
.focus-visible {
border-color: $black;
}
.tag-area-text {
height: 5em;
}

View file

@ -80,6 +80,7 @@
// Header
--header-bg: var(--base-10);
--header-bg-hover: var(--base-20);
--header-button-focus-color: var(--accent-brand);
--header-bg-current: var(--base-0);
--header-shadow: rgba(0, 0, 0, 0.1);
--header-icons-color: var(--base-70);

View file

@ -78,6 +78,7 @@
// Header
--header-bg: var(--base-0);
--header-bg-hover: var(--base-10);
--header-button-focus-color: var(--accent-brand);
--header-bg-current: var(--base-20);
--header-shadow: rgba(0, 0, 0, 0.1);
--header-icons-color: #0a0a0a;

View file

@ -81,6 +81,7 @@
--header-bg: var(--base-10);
--header-bg-hover: var(--base-0);
--header-bg-current: var(--base-20);
--header-button-focus-color: var(--accent-brand);
--header-shadow: rgba(0, 0, 0, 0.3);
--header-icons-color: rgba(255, 255, 255, 0.8);
--header-icons-color-hover: #fff;

View file

@ -79,6 +79,7 @@
--header-bg: var(--base-40);
--header-bg-hover: var(--base-50);
--header-bg-current: var(--base-50);
--header-button-focus-color: var(--accent-brand);
--header-shadow: rgba(0, 0, 0, 0.1);
--header-icons-color: var(--base-10);
--header-icons-color-hover: var(--base-0);

View file

@ -2,6 +2,7 @@ import {
initializeMobileMenu,
setCurrentPageIconLink,
getInstantClick,
initializeTouchDevice,
} from '../topNavigation/utilities';
function getPageEntries() {
@ -25,6 +26,9 @@ getInstantClick().then((spa) => {
});
const { currentPage } = document.getElementById('page-content').dataset;
const memberMenu = document.getElementById('crayons-header__menu');
const menuNavButton = document.getElementById('member-menu-button');
setCurrentPageIconLink(currentPage, getPageEntries());
initializeMobileMenu(menus, moreMenus);
initializeTouchDevice(memberMenu, menuNavButton);

View file

@ -1,10 +1,17 @@
// TODO: Once this is merged, PR up the removal of this and in other tests and move it to testSetup.js
import '@testing-library/jest-dom';
import { getByLabelText, getByText } from '@testing-library/dom';
import {
getByTestId,
getByLabelText,
getByText,
fireEvent,
waitFor,
} from '@testing-library/dom';
import {
getInstantClick,
initializeMobileMenu,
setCurrentPageIconLink,
initializeTouchDevice,
} from '../utilities';
// TODO: ★★★ These tests should be promoted to E2E tests once we have that in place. ★★★
@ -134,4 +141,107 @@ describe('top navigation utilitities', () => {
);
});
});
describe('initializeTouchDevice', () => {
const initialMenuHTML = `
<div class="crayons-header__menu" id="crayons-header__menu" data-testid="menu-dropdown">
<button type="button" class="crayons-header__menu__trigger" id="member-menu-button" aria-label="Navigation menu">
<span class="crayons-avatar crayons-avatar--l"><img class="crayons-avatar__image" alt="" id="nav-profile-image" src=""></span>
</button>
<div class="crayons-dropdown left-2 right-2 s:right-4 s:left-auto p-0 crayons-header__menu__dropdown inline-block">
<ul class="p-0" id="crayons-header__menu__dropdown__list">
<li id="user-profile-link-placeholder" class="border-0 border-b-1 border-solid border-base-20 p-1 mb-1">
<a id="first-nav-link" class="crayons-link crayons-link--block" href="">
</a>
</li>
<li class="px-1 py-1">
<a href="/signout_confirm" class="crayons-link crayons-link--block" id="last-nav-link">Sign Out</a>
</li>
</ul>
</div>
</div>
`;
it('toggles dropdown menu expanded state', async () => {
document.body.innerHTML = initialMenuHTML;
const menuNavButton = getByLabelText(document.body, 'Navigation menu');
const memberMenu = getByTestId(document.body, 'menu-dropdown');
initializeTouchDevice(memberMenu, menuNavButton);
await waitFor(() =>
expect(menuNavButton.getAttribute('aria-expanded')).toEqual('false'),
);
expect(menuNavButton.getAttribute('aria-expanded')).toEqual('false');
expect(memberMenu).not.toHaveClass('showing');
fireEvent.click(menuNavButton);
expect(menuNavButton.getAttribute('aria-expanded')).toEqual('true');
expect(memberMenu).toHaveClass('showing');
fireEvent.click(menuNavButton);
expect(menuNavButton.getAttribute('aria-expanded')).toEqual('false');
expect(memberMenu).not.toHaveClass('showing');
});
it('closes menu on Escape key', async () => {
document.body.innerHTML = initialMenuHTML;
const menuNavButton = getByLabelText(document.body, 'Navigation menu');
const memberMenu = getByTestId(document.body, 'menu-dropdown');
initializeTouchDevice(memberMenu, menuNavButton);
await waitFor(() =>
expect(menuNavButton.getAttribute('aria-expanded')).toEqual('false'),
);
fireEvent.click(menuNavButton);
expect(menuNavButton.getAttribute('aria-expanded')).toEqual('true');
expect(memberMenu).toHaveClass('showing');
fireEvent.keyUp(memberMenu, { key: 'Escape' });
await waitFor(() =>
expect(menuNavButton.getAttribute('aria-expanded')).toEqual('false'),
);
expect(memberMenu).not.toHaveClass('showing');
});
it('closes the menu on click outside', async () => {
document.body.innerHTML = `
<div data-testid="container">
${initialMenuHTML}
</div>
`;
const menuNavButton = getByLabelText(document.body, 'Navigation menu');
const memberMenu = getByTestId(document.body, 'menu-dropdown');
const outerWrapper = getByTestId(document.body, 'container');
initializeTouchDevice(memberMenu, menuNavButton);
await waitFor(() =>
expect(menuNavButton.getAttribute('aria-expanded')).toEqual('false'),
);
fireEvent.click(menuNavButton);
expect(menuNavButton.getAttribute('aria-expanded')).toEqual('true');
expect(memberMenu).toHaveClass('showing');
fireEvent.click(outerWrapper);
await waitFor(() =>
expect(menuNavButton.getAttribute('aria-expanded')).toEqual('false'),
);
expect(memberMenu).not.toHaveClass('showing');
});
});
});

View file

@ -1,3 +1,106 @@
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');
}

View file

@ -1,22 +1,26 @@
<div class="crayons-dropdown left-2 right-2 s:right-4 s:left-auto p-0 crayons-header__menu__dropdown inline-block">
<div id="user-profile-link-placeholder" class="border-0 border-b-1 border-solid border-base-20 p-1">
<a id="first-nav-link" class="crayons-link crayons-link--block">
<div>
<span class="fw-medium block">...</span>
<small class="fs-s color-base-50"></small>
</div>
</a>
</div>
<ul class="p-0" id="crayons-header__menu__dropdown__list">
<li id="user-profile-link-placeholder" class="border-0 border-b-1 border-solid border-base-20 p-1 mb-1">
<a id="first-nav-link" class="crayons-link crayons-link--block">
<div>
<span class="fw-medium block">...</span>
<small class="fs-s color-base-50"></small>
</div>
</a>
</li>
<div class="border-0 border-b-1 border-solid border-base-20 p-1">
<a href="<%= admin_path %>" class="crayons-link crayons-link--block js-header-menu-admin-link hidden" data-no-instant>Admin</a>
<a href="<%= dashboard_path %>" class="crayons-link crayons-link--block">Dashboard</a>
<a href="<%= new_path %>" class="crayons-link crayons-link--block">Write a Post</a>
<a href="<%= readinglist_path %>" class="crayons-link crayons-link--block">Reading list</a>
<a href="<%= user_settings_path %>" class="crayons-link crayons-link--block">Settings</a>
</div>
<li class="px-1 js-header-menu-admin-link hidden">
<a href="<%= admin_path %>" class="crayons-link crayons-link--block" data-no-instant>Admin</a>
</li>
<li class="px-1"><a href="<%= dashboard_path %>" class="crayons-link crayons-link--block">Dashboard</a></li>
<li class="px-1"><a href="<%= new_path %>" class="crayons-link crayons-link--block">Write a Post</a></li>
<li class="px-1"><a href="<%= readinglist_path %>" class="crayons-link crayons-link--block">Reading list</a></li>
<li class="border-0 border-b-1 border-solid border-base-20 px-1 pb-1">
<a href="<%= user_settings_path %>" class="crayons-link crayons-link--block" id="second-last-nav-link">Settings</a>
</li>
<div class="p-1">
<a href="<%= signout_confirm_path %>" class="crayons-link crayons-link--block" id="last-nav-link">Sign Out</a>
</div>
<li class="px-1 py-1">
<a href="<%= signout_confirm_path %>" class="crayons-link crayons-link--block" id="last-nav-link">Sign Out</a>
</li>
</ul>
</div>

View file

@ -47,7 +47,7 @@
</a>
<div class="crayons-header__menu" id="crayons-header__menu">
<button type="button" class="crayons-header__menu__trigger navigation-butt" id="navigation-butt" aria-label="Navigation menu">
<button type="button" class="crayons-header__menu__trigger" id="member-menu-button" aria-label="Navigation menu">
<span class="crayons-avatar crayons-avatar--l"><img class="crayons-avatar__image" alt="" id="nav-profile-image" /></span>
</button>
<%= render "layouts/nav_menu" %>

View file

@ -33,7 +33,6 @@ that major sections of the websites are bootstrapped, for example:
```javascript
initializeBaseTracking();
initializeTouchDevice();
initializeCommentsPage();
initEditorResize();
initLeaveEditorWarning();