Add keyboard interactions and focus trapping to Navigation
This commit is contained in:
16
css/nav.css
16
css/nav.css
@@ -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;
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<button class="Nav-close">
|
||||||
|
<span class="Nav-closeBar"></span>
|
||||||
|
<span class="Nav-closeBar"></span>
|
||||||
|
<span class="Nav-closeText">Close menu</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="Nav-close">
|
|
||||||
<span class="Nav-closeBar"></span>
|
|
||||||
<span class="Nav-closeBar"></span>
|
|
||||||
<span class="Nav-closeText">Close menu</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
Reference in New Issue
Block a user