Add keyboard interactions and focus trapping to Navigation

This commit is contained in:
2018-04-29 10:30:32 +01:00
parent 387d6a770e
commit db70a6cb06
6 changed files with 147 additions and 45 deletions

View File

@@ -42,6 +42,14 @@
margin-right: 62px; margin-right: 62px;
} }
.Nav-titleLink,
.Nav-titleLink:active,
.Nav-titleLink:focus,
.Nav-titleLink:hover,
.Nav-titleLink:visited {
color: #fff;
}
.Nav-close { .Nav-close {
background-color: transparent; background-color: transparent;
border: none; border: none;
@@ -52,9 +60,17 @@
position: absolute; position: absolute;
right: 15px; right: 15px;
top: 15px; top: 15px;
transition: filter var(--Nav-transitionDuration) ease-out;
width: 32px; width: 32px;
} }
.Nav-close:active,
.Nav-close:focus,
.Nav-close:hover {
filter: drop-shadow(0 0 2px #fff);
outline: 0;
}
.Nav-closeBar { .Nav-closeBar {
background-color: #fff; background-color: #fff;
border-radius: 2px; border-radius: 2px;

View File

@@ -12,12 +12,14 @@ export default class Menu {
} }
init() { init() {
if (!this.state) return false;
this.container.setAttribute('aria-haspopup', true); this.container.setAttribute('aria-haspopup', true);
this.container.setAttribute('aria-expanded', false); this.container.setAttribute('aria-expanded', false);
this.attachEvents(); this.attachEvents();
setTimeout(() => { setTimeout(() => {
this.showMenu(); this.showMenu();
}, 1000); }, 1000);
return true;
} }
attachEvents() { attachEvents() {

View File

@@ -12,19 +12,19 @@ export default class Navigation {
} }
init() { init() {
if (!this.state) return false;
this.container.setAttribute('aria-hidden', true); this.container.setAttribute('aria-hidden', true);
this.container.querySelector('.Nav-list').setAttribute('role', 'menubar'); this.attachClickEvents();
this.container.querySelectorAll('.Nav-listItem').forEach((item) => { this.attachKeyEvents();
// Override implied listitem role return true;
item.setAttribute('role', 'none');
});
this.container.querySelectorAll('.Nav-link').forEach((link) => {
link.setAttribute('role', 'menuitem');
});
this.attachEvents();
} }
attachEvents() { attachClickEvents() {
this.container.addEventListener('click', (e) => {
if (this.container === e.target) {
this.closeNav();
}
});
this.menu.container.addEventListener('click', () => { this.menu.container.addEventListener('click', () => {
this.openNav(); this.openNav();
}); });
@@ -33,11 +33,34 @@ export default class Navigation {
}); });
} }
attachKeyEvents() {
const first = this.container.querySelector('.Nav-link');
const last = this.container.querySelector('.Nav-close');
this.container.addEventListener('keydown', (e) => {
if (e.keyCode === 27) {
this.closeNav();
}
});
first.addEventListener('keydown', (e) => {
if (e.shiftKey && e.keyCode === 9) {
e.preventDefault();
last.focus();
}
});
last.addEventListener('keydown', (e) => {
if (!e.shiftKey && e.keyCode === 9) {
e.preventDefault();
first.focus();
}
});
}
openNav() { openNav() {
this.state = 'visible'; this.state = 'visible';
this.menu.hideMenu(); this.menu.hideMenu();
this.container.classList.add(STATES.open); this.container.classList.add(STATES.open);
this.container.setAttribute('aria-hidden', false); this.container.setAttribute('aria-hidden', false);
this.container.querySelector('.Nav-titleLink').focus();
} }
closeNav() { closeNav() {

View File

@@ -1,9 +1,12 @@
import Navigation from './navigation'; import Navigation from './navigation';
const navHtml = `<nav class="Nav"> const navHtml = `<a href="/test">Test link</a>
<nav class="Nav">
<button class="Menu">Menu</button> <button class="Menu">Menu</button>
<div class="Nav-content"> <div class="Nav-content">
<h2 class="Nav-title">WindyCOYS</h2> <h2 class="Nav-title">
<a class="Nav-titleLink" href="/">WindyCOYS</a>
</h2>
<ul class="Nav-list"> <ul class="Nav-list">
<li class="Nav-listItem"> <li class="Nav-listItem">
<a class="Nav-link" href="/">Home</a> <a class="Nav-link" href="/">Home</a>
@@ -14,7 +17,8 @@ const navHtml = `<nav class="Nav">
</ul> </ul>
</div> </div>
<button class="Nav-close">close</button> <button class="Nav-close">close</button>
</nav>`; </nav>
<a href="/another-test">Another test link</a>`;
let nav; let nav;
@@ -43,11 +47,44 @@ describe('Navigation', () => {
expect((new Navigation()).state).toBeUndefined(); expect((new Navigation()).state).toBeUndefined();
}); });
it('Should fail to initialise if the menu is not provided', () => {
const noMenu = new Navigation({
container: document.querySelector('.Nav'),
});
noMenu.init();
expect(noMenu.state).toBeUndefined();
});
it('Should be hidden by default', () => { it('Should be hidden by default', () => {
nav.init(); nav.init();
expect(nav.container.classList.contains('is-open')).toBe(false); expect(nav.container.classList.contains('is-open')).toBe(false);
}); });
it('Should close when the .Nav element is clicked', () => {
nav.init();
nav.openNav();
const event = new Event('click');
nav.container.dispatchEvent(event);
expect(nav.state).toBe('hidden');
});
it('Should close when the user presses ESC', () => {
nav.init();
nav.openNav();
const event = new Event('keydown');
event.keyCode = 27;
nav.container.dispatchEvent(event);
expect(nav.state).toBe('hidden');
});
it('Should not close when the .Nav-content element is clicked', () => {
nav.init();
nav.openNav();
const event = new Event('click');
nav.container.querySelector('.Nav-content').dispatchEvent(event);
expect(nav.state).toBe('visible');
});
it('Should have initial ARIA attributes', () => { it('Should have initial ARIA attributes', () => {
nav.init(); nav.init();
expect(nav.container.getAttribute('aria-hidden')).toBe('true'); expect(nav.container.getAttribute('aria-hidden')).toBe('true');
@@ -67,30 +104,55 @@ describe('Navigation', () => {
}); });
}); });
describe('Navigation list', () => { describe('Navigation menu button', () => {
it('Should have initial ARIA attributes', () => { it('Should open the Navigation when clicked', () => {
nav.init(); nav.init();
const ul = nav.container.querySelector('.Nav-list'); const event = new Event('click');
expect(ul.getAttribute('role')).toBe('menubar'); nav.menu.container.dispatchEvent(event);
expect(nav.state).toBe('visible');
}); });
}); });
describe('Navigation list items', () => { describe('Navigation close button', () => {
it('Should have initial ARIA attributes', () => { it('Should close the Navigation when clicked', () => {
nav.init(); nav.init();
const lis = nav.container.querySelectorAll('.Nav-listItem'); nav.openNav();
lis.forEach((li) => { const event = new Event('click');
expect(li.getAttribute('role')).toBe('none'); nav.container.querySelector('.Nav-close').dispatchEvent(event);
}); expect(nav.state).toBe('hidden');
}); });
}); });
describe('Navigation links', () => { describe('Navigation focus behaviour', () => {
it('Should have initial ARIA attributes', () => { let first;
let last;
beforeEach(() => {
nav.init(); nav.init();
const links = nav.container.querySelectorAll('.Nav-link'); nav.openNav();
links.forEach((link) => { first = nav.container.querySelector('.Nav-link');
expect(link.getAttribute('role')).toBe('menuitem'); last = nav.container.querySelector('.Nav-close');
}); });
it('Should apply focus to .Nav-titleLink when opened', () => {
const link = nav.container.querySelector('.Nav-titleLink');
expect(document.activeElement).toBe(link);
});
test('Focus moves to first element when TAB pressed on last', () => {
last.focus();
const event = new Event('keydown');
event.keyCode = 9;
last.dispatchEvent(event);
expect(document.activeElement).toBe(first);
});
test('Focus moves to last element when SHIFT + TAB pressed on first', () => {
first.focus();
const event = new Event('keydown');
event.shiftKey = true;
event.keyCode = 9;
first.dispatchEvent(event);
expect(document.activeElement).toBe(last);
}); });
}); });

View File

@@ -5,8 +5,8 @@ class Windycoys_Walker extends Walker_Nav_Menu {
$title = $item->post_title; $title = $item->post_title;
$uri = get_site_url() . '/' . $item->post_name; $uri = get_site_url() . '/' . $item->post_name;
$output .= '<li class="Nav-listItem">'; $output .= '<li class="Nav-listItem" role="none">';
$output .= '<a class="Nav-link" href="' . $uri .'">'; $output .= '<a class="Nav-link" href="' . $uri .'" role="menuitem">';
$output .= $title . '</a></li>'; $output .= $title . '</a></li>';
} }
} }

View File

@@ -6,18 +6,17 @@
<span class="Menu-text">Open menu</span> <span class="Menu-text">Open menu</span>
</button> </button>
<div class="Nav-content"> <div class="Nav-content">
<h2 class="Nav-title">WindyCOYS</h2> <h2 class="Nav-title">
<ul class="Nav-list"> <a class="Nav-titleLink" href="<?php get_site_url() ?>/" aria-label="Navigation title" tabindex="-1">
<li class="Nav-listItem"> WindyCOYS
<a class="Nav-link" href="<?php get_site_url() ?>/">Home</a> </a>
</h2>
<ul class="Nav-list" role="menubar">
<li class="Nav-listItem" role="none">
<a class="Nav-link" href="<?php get_site_url() ?>/" role="menuitem">Home</a>
<?php windycoys_nav() ?> <?php windycoys_nav() ?>
</li> </li>
</ul> </ul>
<button class="Nav-close">
<span class="Nav-closeBar"></span>
<span class="Nav-closeBar"></span>
<span class="Nav-closeText">Close menu</span>
</button>
<div class="Nav-social"> <div class="Nav-social">
<a class="Nav-socialLink" href="https://www.facebook.com/WindyCOYS/"> <a class="Nav-socialLink" href="https://www.facebook.com/WindyCOYS/">
<svg class="Icon Icon--large"> <svg class="Icon Icon--large">
@@ -33,10 +32,10 @@
<div class="Nav-search"> <div class="Nav-search">
<?php get_search_form() ?> <?php get_search_form() ?>
</div> </div>
</div>
<button class="Nav-close"> <button class="Nav-close">
<span class="Nav-closeBar"></span> <span class="Nav-closeBar"></span>
<span class="Nav-closeBar"></span> <span class="Nav-closeBar"></span>
<span class="Nav-closeText">Close menu</span> <span class="Nav-closeText">Close menu</span>
</button> </button>
</div>
</nav> </nav>