Skip to content
This repository has been archived by the owner on Jul 11, 2024. It is now read-only.

Menus flyout simplified #327

Merged
merged 7 commits into from
Nov 3, 2016
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
254 changes: 42 additions & 212 deletions source/menus/flyout.html.erb.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ wcag_techniques:
- H4
---

Fly-out menus (also: dropdown menus) are frequently used to give users the ability to access pages deep in the website’s structure.
Some websites use fly-out menus to allow users to access additional pages further down the page hierarchy. This removes the need for multiple page loads if the user knows where to find the specific content. It also allows for getting an overview of the content of the website.

As interactive components, fly-out menus need to be developed with accessibility in mind to make sure that they are operable using assistive technologies and the keyboard. For people with reduced dexterity it is also important that submenus don’t snap back immediately when the mouse leaves the clickable area.
## Indicate the presence of a submenu

To make navigating a fly-out menu easier, hidden links should not be part of the keyboard navigation by default and also hidden from assistive technologies. They should, however, show up when requested by the user.
Users need to be aware which menu items have a submenu and which don’t. In the following example, only the “SpaceBears” item has one. An arrow icon is shown to inform visual users about the submenu, but for other uses, like assistive technologies, the following ARIA attributes provide the information programatically:

Usually the first-level menu items are links to individual pages whether they have a submenu or not. Items containing a submenu should be marked in a way that is obvious. In the following example, only the SpaceBears menu item has a submenu:
* `aria-haspopup="true"` prompts assistive technologies to give the information that a submenu is present to the user
* `aria-expanded` conveys the state of the submenu: It is initially set to “false” as the submenu is not visible.

{::nomarkdown}
<%= sample_start('show-overflow') %>
Expand All @@ -23,7 +24,7 @@ Usually the first-level menu items are links to individual pages whether they ha
<li><a href="#flyoutnav">Home</a></li>
<li><a href="#flyoutnav">Shop</a></li>
<li class="has-submenu">
<a href="#flyoutnav">SpaceBears</a>
<a href="#flyoutnav" aria-haspopup="true" aria-expanded="false">SpaceBears</a>
<ul>
<li><a href="#flyoutnav">SpaceBear 6</a></li>
<li><a href="#flyoutnav">SpaceBear 6 Plus</a></li>
Expand Down Expand Up @@ -125,7 +126,9 @@ Usually the first-level menu items are links to individual pages whether they ha
<li><a href="…">Home</a></li>
<li><a href="…">Shop</a></li>
<li class="has-submenu">
<a href="…">SpaceBears</a>
<a href="…" aria-haspopup="true" aria-expanded="false">
SpaceBears
</a>
<ul>
<li><a href="…">SpaceBear 6</a></li>
<li><a href="…">SpaceBear 6 Plus</a></li>
Expand All @@ -141,7 +144,11 @@ Usually the first-level menu items are links to individual pages whether they ha
<%= code_end %>
{:/nomarkdown}

For mouse users, hiding the submenu until the mouse hovers over the first-level menu item is quite easy to do in CSS only, but this method has the disadvantage that the menu immediately closes once the mouse leaves the list item (and the containing submenu).
## Improving the menu

### Mouse users

CSS can be used to show submenus when their parent menu is hovered. However, this method has the disadvantage that the menu immediately snaps back once the cursor leaves the hovered element.

{::nomarkdown}
<%= code_start('','CSS') %>
Expand All @@ -160,11 +167,7 @@ nav > ul li:hover ul {
<%= code_end %>
{:/nomarkdown}

## Enhancing the menu using JavaScript

By using JavaScript, it is possible to react to keyboard usage and mouse movements. As soon as the mouse leaves the menu a timer is started which closes the menu after one second. If the mouse re-enters the submenu during that time, the timer is canceled and the submenu doesn’t close.

### Improve mouse support
This behavior can be avoided by adding some scripting. When the cursor leaves the menu item, a timer is started which schedules to close the menu after one second. If the mouse re-enters the submenu during that time, that timer is canceled and the submenu stays open.

{::nomarkdown}
<%= sample_start('show-overflow') %>
Expand All @@ -174,7 +177,7 @@ By using JavaScript, it is possible to react to keyboard usage and mouse movemen
<li><a href="#flyoutnavmousefixed">Home</a></li>
<li><a href="#flyoutnavmousefixed">Shop</a></li>
<li class="has-submenu">
<a href="#flyoutnavmousefixed">SpaceBears</a>
<a href="#flyoutnavmousefixed" aria-haspopup="true" aria-expanded="false">SpaceBears</a>
<ul>
<li><a href="#flyoutnavmousefixed">SpaceBear 6</a></li>
<li><a href="#flyoutnavmousefixed">SpaceBear 6 Plus</a></li>
Expand Down Expand Up @@ -295,21 +298,18 @@ Array.prototype.forEach.call(menuItems, function(el, i){
<%= code_end %>
{:/nomarkdown}

### Improve keyboard support

There are two roles top-level menu items in fly-out menus can have for keyboard users:
### Keyboard users

1. It is just a toggle for the sub menu.
2. It links to a page itself and needs to toggle the sub menu.
Usually submenus are not supposed to open when tabbing through the menu using the keyboard as a keyboard user would need to step through all submenu items to get to the next top-level item.

Usually the submenu should not open when the focus enters the top-level menu item. This would mean a keyboard user needs to step through all the submenu links to get to the next top-level item.
Different approaches address the need of keeping the top-level link accessible or not.

#### Toggle submenu using the top-level menu item
{:.ap}

When JavaScript is available, the top-level menu item won’t link to the page in its `href` attribute but instead showing the sub menu. (If JavaScript is not available, the link will work as usual.)
The submenu is opened by a script when the user activates the top-level menu item. The value of the `href` attribute is ignored.

If the focus leaves the submenu (for example by using the tab key on the last submenu item), the submenu is closed.
When the focus leaves the submenu (for example by using the tab key on the last submenu item), the submenu is closed.

{::nomarkdown}
<%= sample_start('show-overflow') %>
Expand Down Expand Up @@ -454,8 +454,10 @@ Array.prototype.forEach.call(menuItems1, function(el, i){
el.querySelector('a').addEventListener("click", function(event){
if (this.parentNode.className == "has-submenu") {
this.parentNode.className = "has-submenu open";
this.setAttribute('aria-expanded', "true");
} else {
this.parentNode.className = "has-submenu";
this.setAttribute('aria-expanded', "false");
}
event.preventDefault();
});
Expand All @@ -482,7 +484,17 @@ Array.prototype.forEach.call(menuItems1, function(el, i){
<%= sample_end %>
{:/nomarkdown}

The following code iterates through all top-level items that have a submenu (indicated through the class `has-submenu`) and adds click event to the first (top-level) link in each menu item. Despite their name, click events are activated regardless of the input method as soon as the link gets activated. If the submenu is closed at the time the link is activated, the script opens the submenu, and vice versa.
The following code iterates through all top-level items with the class `has-submenu` and adds a click event to it which opens or closes the submenu, depending on its state. In addition, the `aria-expanded` attribute is set to `true` while the submenu is shown and otherwise to `false`.

{::nomarkdown}
<%= notes_start() %>
{:/}

**Note:** Despite the name, click events are activated regardless of the input method as soon as the link gets activated.

{::nomarkdown}
<%= notes_end() %>
{:/}

{::nomarkdown}
<%= code_start('', 'JavaScript') %>
Expand All @@ -494,8 +506,10 @@ Array.prototype.forEach.call(menuItems, function(el, i){
el.querySelector('a').addEventListener("click", function(event){
if (this.parentNode.className == "has-submenu") {
this.parentNode.className = "has-submenu open";
this.setAttribute('aria-expanded', "true");
} else {
this.parentNode.className = "has-submenu";
this.setAttribute('aria-expanded', "false");
}
event.preventDefault();
return false;
Expand All @@ -510,7 +524,7 @@ Array.prototype.forEach.call(menuItems, function(el, i){
#### Toggle submenu using a special “show submenu” button
{:.ap}

If the top-level menu item should stay a link to a page, adding a separate button that toggles the submenu is the most reliable way to address the issue.
If the top-level menu item needs to stay a proper link that links to a page, a separate button can be added to the top-level item that opens/closes the menu. This button can also act as a visual indicator for the presence of a submenu.

{::nomarkdown}
<%= sample_start('show-overflow') %>
Expand Down Expand Up @@ -703,8 +717,10 @@ Array.prototype.forEach.call(menuItems1, function(el, i){
el.querySelector('a button').addEventListener("click", function(event){
if (this.parentNode.parentNode.className == "has-submenu") {
this.parentNode.parentNode.className = "has-submenu open";
this.parentNode.setAttribute('aria-expanded', "true");
} else {
this.parentNode.parentNode.className = "has-submenu";
this.parentNode.setAttribute('aria-expanded', "false");
}
event.preventDefault();
});
Expand All @@ -731,7 +747,7 @@ Array.prototype.forEach.call(menuItems1, function(el, i){
<%= sample_end %>
{:/nomarkdown}

In the following code, a button is attached to every menu item link with a submenu. The click event listener is applied to this button and toggles the menu just like above. The invisible button text is changed from “show submenu” to “hide submenu” reflecting the state of the submenu.
In the following code example, a button is added to every top-level menu item link with a submenu. When the button is activated, it shows or hides the submenu. The invisible label of the button is set to “show submenu” or “hide submenu”, reflecting the state of the submenu.

{::nomarkdown}
<%= code_start('','JavaScript') %>
Expand All @@ -747,9 +763,11 @@ Array.prototype.forEach.call(menuItems, function(el, i){
el.querySelector('a button').addEventListener("click", function(event){
if (this.parentNode.parentNode.className == "has-submenu") {
this.parentNode.parentNode.className = "has-submenu open";
this.parentNode.setAttribute('aria-expanded', "true");
this.querySelector('.visuallyhidden').innerText = 'hide submenu';
} else {
this.parentNode.parentNode.className = "has-submenu";
this.parentNode.setAttribute('aria-expanded', "false");
this.querySelector('.visuallyhidden').innerText = 'show submenu';
}
event.preventDefault();
Expand All @@ -760,191 +778,3 @@ Array.prototype.forEach.call(menuItems, function(el, i){
{::nomarkdown}
<%= code_end %>
{:/nomarkdown}


## Improve screen reader support using WAI-ARIA

Screen reader users need to know if an item has a submenu or not and if that submenu is currently opened. While this can be done with hidden text as above, WAI-ARIA helps to convey this information programmatically, using following two attributes to the menu’s HTML:

* **`aria-haspopup="true"`** is used so screen readers are able to announce that the link has a submenu.
* **`aria-expanded`** is initially set to `false` but changed to `true` when the submenu opens which helps screen readers to announce that this menu item is now expanded.

{::nomarkdown}
<%= sample_start('show-overflow') %>

<nav role="presentation" aria-label="Main Navigation" id="flyoutaria">
<ul>
<li><a href="#flyoutaria">Home</a></li>
<li><a href="#flyoutaria">Shop</a></li>
<li class="has-submenu">
<a href="#" aria-haspopup="true" aria-expanded="false">SpaceBears</a>
<ul>
<li><a href="#flyoutaria">SpaceBear 6</a></li>
<li><a href="#flyoutaria">SpaceBear 6 Plus</a></li>
</ul>
</li>
<li><a href="#flyoutaria">MarsCars</a></li>
<li><a href="#flyoutaria">Contact</a></li>
</ul>
</nav>

<style>
.show-overflow {
overflow: visible !important;
}

.show-overflow .box-content {
overflow: visible !important;
}
#flyoutaria {
display:table;
width:100%;
}
#flyoutaria > ul {
margin: 0;
padding: 0;
display: table-row;
background-color: #036;
color: #fff;
}
#flyoutaria > ul > li {
display:table-cell;
width: 20%;
text-align: center;
position:relative;
}
#flyoutaria a,
#flyoutaria .current {
display: block;
padding: .25em;
border-color: #E8E8E8;
}
#flyoutaria a {
color: #fff;
text-decoration: none;
}
#flyoutaria a:hover,
#flyoutaria a:focus {
background-color: #fff;
color: #036;
border: 1px solid #036;
text-decoration: underline;
}
#flyoutaria .current {
background-color: #bbb;
color: #000;
border-color: #444;
}

#flyoutaria > ul > li > ul {
display: none;
position:absolute;
left:0;
right:0;
top:100%;
padding:0;
margin:0;
background-color: #036;
}

#flyoutaria > ul > li.open > ul {
display:block;
}

#flyoutaria > ul > li > ul a{
border-bottom-width: 1px;
}
</style>

<script>
/* focusin/out event polyfill (firefox) */
!function(){
var w = window,
d = w.document;

if( w.onfocusin === undefined ){
d.addEventListener('focus' ,addPolyfill ,true);
d.addEventListener('blur' ,addPolyfill ,true);
d.addEventListener('focusin' ,removePolyfill ,true);
d.addEventListener('focusout' ,removePolyfill ,true);
}
function addPolyfill(e){
var type = e.type === 'focus' ? 'focusin' : 'focusout';
var event = new CustomEvent(type, { bubbles:true, cancelable:false });
event.c1Generated = true;
e.target.dispatchEvent( event );
}
function removePolyfill(e){
if(!e.c1Generated){ // focus after focusin, so chrome will the first time trigger tow times focusin
d.removeEventListener('focus' ,addPolyfill ,true);
d.removeEventListener('blur' ,addPolyfill ,true);
d.removeEventListener('focusin' ,removePolyfill ,true);
d.removeEventListener('focusout' ,removePolyfill ,true);
}
setTimeout(function(){
d.removeEventListener('focusin' ,removePolyfill ,true);
d.removeEventListener('focusout' ,removePolyfill ,true);
});
}
}();

function hasClass(el, className) {
if (el.classList) {
return el.classList.contains(className);
} else {
return new RegExp('(^| )' + className + '( |$)', 'gi').test(el.className);
}
}

var menuItems1 = document.querySelectorAll('#flyoutaria li.has-submenu');
var timer1, timer2;

Array.prototype.forEach.call(menuItems1, function(el, i){
el.addEventListener("mouseover", function(event){
this.className = "has-submenu open";
this.setAttribute('aria-expanded', "true");
clearTimeout(timer1);
});
el.addEventListener("mouseout", function(event){
timer1 = setTimeout(function(event){
var opennav = document.querySelector("#flyoutaria .has-submenu.open");
if (opennav) {
opennav.className = "has-submenu";
opennav.querySelector("[aria-expanded]").setAttribute('aria-expanded', "false");
}
}, 1000);
});
el.querySelector('a').addEventListener("click", function(event){
if (this.parentNode.className == "has-submenu") {
this.parentNode.className = "has-submenu open";
this.setAttribute('aria-expanded', "true");
} else {
this.parentNode.className = "has-submenu";
this.setAttribute('aria-expanded', "false");
}
event.preventDefault();
return false;
});
var links = el.querySelectorAll('a');
Array.prototype.forEach.call(links, function(el, i){
el.addEventListener("focus", function() {
if (timer2) {
clearTimeout(timer2);
timer2 = null;
}
});
el.addEventListener("blur", function(event) {
timer2 = setTimeout(function () {
var opennav = document.querySelector("#flyoutaria .has-submenu.open")
if (opennav) {
opennav.className = "has-submenu";
opennav.querySelector("[aria-expanded]").setAttribute('aria-expanded', "false");
}
}, 10);
});
});
});
</script>

<%= sample_end %>
{:/nomarkdown}