Finish keyboard navigation for header & menu. (#3607)

This commit is contained in:
Martin Taillefer 2019-03-10 07:57:42 -07:00 committed by GitHub
parent 3e6c061cd5
commit 74e7ef56b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 346 additions and 119 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -2,7 +2,7 @@
<main class="notfound" role="main">
<svg class="icon">
<use xlink:href="{{ .Site.BaseURL }}/img/icons.svg#exclamation-mark"/>
<use xlink:href="{{ .Site.BaseURL }}img/icons.svg#exclamation-mark"/>
</svg>
<div class="error">

View File

@ -141,40 +141,38 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" defer></script>
{{ end }}
<nav>
<div id="scroll-to-top-container">
<button id="scroll-to-top" aria-hidden="true" title='{{ i18n "button_top"}}'>
{{ partial "icon.html" "top" }}
</button>
</div>
</nav>
<div id="scroll-to-top-container" aria-hidden="true">
<button id="scroll-to-top" title='{{ i18n "button_top"}}'>
{{ partial "icon.html" "top" }}
</button>
</div>
{{ if .Site.Data.args.preliminary }}
<div id="switch-lang-container">
<div id="switch-lang-container" aria-hidden="true">
{{ $home := .Site.GetPage "home" }}
<a id="switch-lang" data-skipendnotes="true" aria-hidden="true" title='{{ i18n "switch_lang"}}'>
<a id="switch-lang" data-skipendnotes="true" title='{{ i18n "switch_lang"}}'>
{{ partial "icon.html" "switch-lang" }}
</a>
</div>
{{ if .Page.Params.source_repo }}
<div id="edit-this-page-container">
<div id="edit-this-page-container" aria-hidden="true">
{{ $msg := i18n "generated_file" }}
{{ $title := printf $msg .Page.Params.source_repo }}
<a tabindex="-1" id="edit-this-page" class="disabled" aria-hidden="true" title='{{ $title }}'>
<a tabindex="-1" id="edit-this-page" class="disabled" title='{{ $title }}'>
{{ partial "icon.html" "pencil" }}
</a>
</div>
{{ else }}
<div id="edit-this-page-container">
<a tabindex="-1" id="edit-this-page" data-skipendnotes="true" href="https://github.com/istio/istio.io/edit/{{ .Site.Data.args.doc_branch_name }}/content/{{ .Path }}" aria-hidden="true" title='{{ i18n "edit_on_github"}}'>
<div id="edit-this-page-container" aria-hidden="true">
<a tabindex="-1" id="edit-this-page" data-skipendnotes="true" href="https://github.com/istio/istio.io/edit/{{ .Site.Data.args.doc_branch_name }}/content/{{ .Path }}" title='{{ i18n "edit_on_github"}}'>
{{ partial "icon.html" "pencil" }}
</a>
</div>
{{ end }}
<div id="report-site-bugs-container">
<div id="report-site-bugs-container" aria-hidden="true">
<a tabindex="-1" id="report-site-bugs" data-skipendnotes="true" href="https://github.com/istio/istio.io/issues/new?title=Issue%20with%20{{ .Path}}" aria-hidden="true" title='{{ i18n "report_site_bugs"}}'>
{{ partial "icon.html" "bugs" }}
</a>

View File

@ -12,14 +12,16 @@
{{ .LinkTitle }}
</div>
<div class="body">
{{ $questions := .Resources.ByType "page" }}
{{ $sorted_questions := sort $questions ".Params.weight" }}
{{ $url := .Permalink }}
{{ range $q := $sorted_questions }}
<a href="{{ $url }}#{{ $q.File.BaseFileName | urlize }}">{{ $q.Title }}</a><br/>
{{ end }}
</div>
<nav class="body">
<ul aria-label="{{ .LinkTitle }}">
{{ $questions := .Resources.ByType "page" }}
{{ $sorted_questions := sort $questions ".Params.weight" }}
{{ $url := .Permalink }}
{{ range $q := $sorted_questions }}
<li role="none"><a href="{{ $url }}#{{ $q.File.BaseFileName | urlize }}">{{ $q.Title }}</a></li>
{{ end }}
</ul>
</nav>
</div>
</div>
{{ end }}

View File

@ -53,38 +53,39 @@
{{ end }}
<div class="menu">
<button id="gearDropdown" class="menu-trigger" title='{{ i18n "options_menu" }}' aria-label="Options and Settings" aria-haspopup="true" aria-expanded="false">
<button id="gearDropdownButton" class="menu-trigger" title='{{ i18n "options_menu" }}'
aria-label="Options and Settings" aria-controls="gearDropdownContent">
{{ partial "icon.html" "gear" }}
</button>
<div class="menu-content" aria-labelledby="gearDropdown">
<a tabindex="0" lang="en" id="switch-lang-en" {{ if eq $home.Lang "en" }}class="active"{{ end }}>English</a>
<a tabindex="0" lang="zh" id="switch-lang-zh" {{ if eq $home.Lang "zh" }}class="active"{{ end }}>中文</a>
<div id="gearDropdownContent" class="menu-content" aria-labelledby="gearDropdownButton" role="menu">
<a tabindex="-1" role="menuitem" lang="en" id="switch-lang-en" {{ if eq $home.Lang "en" }}class="active"{{ end }}>English</a>
<a tabindex="-1" role="menuitem" lang="zh" id="switch-lang-zh" {{ if eq $home.Lang "zh" }}class="active"{{ end }}>中文</a>
<div></div>
<div role="separator"></div>
<a tabindex="0" class="active" id="light-theme-item">{{ i18n "light_theme" }}</a>
<a tabindex="0" id="dark-theme-item">{{ i18n "dark_theme" }}</a>
<a tabindex="-1" role="menuitem" class="active" id="light-theme-item">{{ i18n "light_theme" }}</a>
<a tabindex="-1" role="menuitem" id="dark-theme-item">{{ i18n "dark_theme" }}</a>
<div></div>
<div role="separator"></div>
<a tabindex="0" id="syntax-coloring-item">{{ i18n "syntax_coloring" }}</a>
<a tabindex="-1" role="menuitem" id="syntax-coloring-item">{{ i18n "syntax_coloring" }}</a>
{{ if not .Site.Data.args.archive }}
<div></div>
<div role="separator"></div>
{{ if .Params.source_repo }}
{{ $msg := i18n "generated_file" }}
{{ $title := printf $msg .Page.Params.source_repo }}
<a class="disabled" title="{{ $title }}">{{ i18n "edit_on_github" }}</a>
<a tabindex="-1" role="menuitem" class="disabled" title="{{ $title }}">{{ i18n "edit_on_github" }}</a>
{{ else }}
<a href="https://github.com/istio/istio.io/edit/{{ .Site.Data.args.doc_branch_name }}/content/{{ .Path }}">{{ i18n "edit_on_github" }}</a>
<a tabindex="-1" role="menuitem" href="https://github.com/istio/istio.io/edit/{{ .Site.Data.args.doc_branch_name }}/content/{{ .Path }}">{{ i18n "edit_on_github" }}</a>
{{ end }}
<a href="https://github.com/istio/istio.io/issues/new?title=Issue%20with%20{{ .Path}}">{{ i18n "report_site_bugs" }}</a>
<a tabindex="-1" role="menuitem" href="https://github.com/istio/istio.io/issues/new?title=Issue%20with%20{{ .Path}}">{{ i18n "report_site_bugs" }}</a>
{{ end }}
<div></div>
<div role="separator"></div>
<h6>{{ i18n "other_versions_of_site" }}</h6>
@ -92,20 +93,20 @@
{{ $current := index .Site.Data.releases 1 }}
{{ if .Site.Data.args.archive }}
<a tabindex="0" onclick="navigateToUrlOrRoot('https://istio.io/{{.Dir}}');return false;">{{ printf (i18n "current_release") $current.name }}</a>
<a tabindex="0" onclick="navigateToUrlOrRoot('https://preliminary.istio.io/{{.Dir}}');return false;">{{ printf (i18n "next_release") $next.name }}</a>
<a href="https://archive.istio.io">{{ i18n "archived_releases" }}</a>
<a tabindex="-1" role="menuitem" onclick="navigateToUrlOrRoot('https://istio.io/{{.Dir}}');return false;">{{ printf (i18n "current_release") $current.name }}</a>
<a tabindex="-1" role="menuitem" onclick="navigateToUrlOrRoot('https://preliminary.istio.io/{{.Dir}}');return false;">{{ printf (i18n "next_release") $next.name }}</a>
<a tabidnex="-1" role="menuitem" href="https://archive.istio.io">{{ i18n "archived_releases" }}</a>
{{ else if .Site.Data.args.preliminary }}
<a tabindex="0" onclick="navigateToUrlOrRoot('https://istio.io/{{.Dir}}');return false;">{{ printf (i18n "current_release") $current.name }}</a>
<a href="https://archive.istio.io">{{ i18n "archived_releases" }}</a>
<a tabindex="-1" role="menuitem" onclick="navigateToUrlOrRoot('https://istio.io/{{.Dir}}');return false;">{{ printf (i18n "current_release") $current.name }}</a>
<a tabindex="-1" role="menuitem" href="https://archive.istio.io">{{ i18n "archived_releases" }}</a>
{{ else }}
<a tabindex="0" onclick="navigateToUrlOrRoot('https://preliminary.istio.io/{{.Dir}}');return false;">{{ printf (i18n "next_release") $next.name }}</a>
<a href="https://archive.istio.io">{{ i18n "archived_releases" }}</a>
<a tabindex="-1" role="menuitem" onclick="navigateToUrlOrRoot('https://preliminary.istio.io/{{.Dir}}');return false;">{{ printf (i18n "next_release") $next.name }}</a>
<a tabindex="-1" role="menuitem" href="https://archive.istio.io">{{ i18n "archived_releases" }}</a>
{{ end }}
</div>
</div>
<button tabindex="0" id="search-show" title='{{ i18n "search" }}' aria-label='{{ i18n "search_label" }}'>{{ partial "icon.html" "magnifier" }}</button>
<button id="search-show" title='{{ i18n "search" }}' aria-label='{{ i18n "search_label" }}'>{{ partial "icon.html" "magnifier" }}</button>
</div>
<form id="search-form" name="cse" role="search">
@ -118,7 +119,7 @@
{{ end }}
<input type="hidden" name="ie" value="utf-8" />
<input type="hidden" name="hl" value="en" />
<input type="hidden" id="search-page-url" value="{{ .Site.BaseURL }}/search.html" />
<input type="hidden" id="search-page-url" value="{{ .Site.BaseURL }}search.html" />
<input id="search-textbox" class="form-control" name="q" type="search" aria-label='{{ i18n "search" }}'/>
<button id="search-close" title='{{ i18n "search_cancel" }}' type="reset" aria-label='{{ i18n "search_cancel" }}'>{{ partial "icon.html" "cancel-x" }}</button>
</form>

View File

@ -43,9 +43,9 @@
{{ if and $needTOC (not .Params.force_inline_toc) }}
<div class="toc-container">
<nav class="toc">
<div id="toc" class="directory" role="directory">
{{ $toc }}
<nav class="toc" aria-label="Table of Contents">
<div id="toc">
{{ $toc | safeHTML }}
</div>
</nav>
</div>

View File

@ -103,11 +103,11 @@
</div>
{{ if $needTOC }}
<div class="toc-inlined{{ if .Params.force_inline_toc }} toc-forced{{ end }}">
<hr/>
<div class="directory" role="directory">
{{ replace $toc "TableOfContents" "InlinedTableOfContents" | safeHTML }}
<nav class="toc-inlined{{ if .Params.force_inline_toc }} toc-forced{{ end }}" aria-label="Table of Contents">
<div>
<hr/>
{{ $toc | safeHTML }}
<hr/>
</div>
<hr/>
</div>
</nav>
{{ end }}

View File

@ -21,45 +21,43 @@
{{ if or (gt $len 0) ($page.Scratch.Get "seeAlso") }}
{{ $page.Scratch.Set "needTOC" true }}
<nav id="TableOfContents" aria-label="Table of Contents">
<ul role="tree">
{{ $page.Scratch.Set "level" 50 }}
{{ range $h := $headers }}
{{ $level := index (index (findRE "<h[23456].*?" $h) 0) 2 | int }}
{{ $id := replaceRE "<h[23456].*?id=\"(.*?)\".*?>.*?</h[23456]>" "$1" $h }}
{{ $title := replaceRE "<h[23456].*?>(.*?)</h[23456]>" "$1" $h }}
{{ $current := $page.Scratch.Get "level" | int }}
<ol>
{{ $page.Scratch.Set "level" 50 }}
{{ range $h := $headers }}
{{ $level := index (index (findRE "<h[23456].*?" $h) 0) 2 | int }}
{{ $id := replaceRE "<h[23456].*?id=\"(.*?)\".*?>.*?</h[23456]>" "$1" $h }}
{{ $title := replaceRE "<h[23456].*?>(.*?)</h[23456]>" "$1" $h }}
{{ $current := $page.Scratch.Get "level" | int }}
{{ if gt $level $current }}
{{ $delta := sub $level $current }}
{{ range $index, $num := (seq $delta) }}
<ul role="group">
{{ end }}
{{ else if lt $level $current }}
{{ $delta := sub $current $level }}
{{ range $index, $num := (seq $delta) }}
</ul>
</li>
{{ end }}
{{ if gt $level $current }}
{{ $delta := sub $level $current }}
{{ range $index, $num := (seq $delta) }}
<ol>
{{ end }}
<li role="none" aria-label="{{ $title | safeHTML }}"><a role="treeitem" href="#{{ $id }}">{{ $title | safeHTML }}</a>
{{ $page.Scratch.Set "level" $level }}
{{ end }}
{{ $delta := sub ($page.Scratch.Get "level") 50 }}
{{ range $index, $num := (seq $delta) }}
</ul>
</li>
{{ end }}
{{ if $page.Scratch.Get "seeAlso" }}
{{ with $related }}
<li role="none"><a role="treeitem" href="#see-also">{{ i18n "see_also" }}</a></li>
{{ else if lt $level $current }}
{{ $delta := sub $current $level }}
{{ range $index, $num := (seq $delta) }}
</ol>
</li>
{{ end }}
{{ end }}
</ul>
</nav>
<li role="none" aria-label="{{ $title | safeHTML }}"><a href="#{{ $id }}">{{ $title | safeHTML }}</a>
{{ $page.Scratch.Set "level" $level }}
{{ end }}
{{ $delta := sub ($page.Scratch.Get "level") 50 }}
{{ range $index, $num := (seq $delta) }}
</ol>
</li>
{{ end }}
{{ if $page.Scratch.Get "seeAlso" }}
{{ with $related }}
<li><a href="#see-also">{{ i18n "see_also" }}</a></li>
{{ end }}
{{ end }}
</ol>
{{ end }}
{{ end }}

View File

@ -3,6 +3,7 @@ const mouseenter = 'mouseenter';
const mouseleave = 'mouseleave';
const active = 'active';
const keyup = 'keyup';
const keydown = 'keydown';
const button = 'button';
const ariaLabel = 'aria-label';
const ariaExpanded = 'aria-expanded';

View File

@ -3,9 +3,186 @@
// Attach the event handlers to support menus
function handleMenu() {
queryAll(document, '.menu').forEach(menu => {
listen(query(menu, ".menu-trigger"), click, e => {
e.cancelBubble = true;
const trigger = query(menu, ".menu-trigger");
const content = query(menu, ".menu-content");
// get all the menu items, setting role="menuitem" and tabindex="-1" along the way
let items = [];
for (let i = 0; i < content.children.length; i++) {
const el = content.children[i];
if (el.getAttribute("role") === 'menuitem') {
items.push(el);
}
}
function focusTrigger() {
trigger.focus();
}
function focusFirstItem() {
items[0].focus();
}
function focusLastItem() {
items[items.length - 1].focus();
}
function focusNextItem(current) {
const index = items.indexOf(current);
if (index < items.length - 1) {
items[index+1].focus();
} else {
items[0].focus();
}
}
function focusPrevItem(current) {
const index = items.indexOf(current);
if (index > 0) {
items[index-1].focus();
} else {
items[items.length - 1].focus();
}
}
function getIndexFirstChars(startIndex, ch) {
for (let i = startIndex; i < items.length; i++) {
const firstChar = items[i].textContent.trim().substring(0, 1).toLowerCase();
if (ch === firstChar) {
return i;
}
}
return -1;
}
function focusItemByChar(current, ch) {
ch = ch.toLowerCase();
// Check remaining slots in the menu
let index = getIndexFirstChars(items.indexOf(current) + 1, ch);
// If not found in remaining slots, check from beginning
if (index === -1) {
index = getIndexFirstChars(0, ch);
}
// If match was found...
if (index > -1) {
if (!isActiveOverlay(menu)) {
showOverlay(menu);
toggleAttribute(trigger, ariaExpanded);
}
items[index].focus();
}
}
function isPrintableCharacter(str) {
return str.length === 1 && str.match(/\S/);
}
listen(trigger, click, e => {
toggleOverlay(menu);
toggleAttribute(e.currentTarget, ariaExpanded);
e.cancelBubble = true;
});
listen(trigger, keydown, e => {
const ch = e.key;
if (e.ctrlKey || e.altKey || e.metaKey) {
// nothing
}
else if (e.shiftKey) {
if (isPrintableCharacter(ch)) {
focusItemByChar(items[items.length - 1], ch);
}
} else {
switch (e.keyCode) {
case keyCodes.SPACE:
case keyCodes.RETURN:
case keyCodes.DOWN:
showOverlay(menu);
focusFirstItem();
e.preventDefault();
e.cancelBubble = true;
break;
case keyCodes.UP:
showOverlay(menu);
focusLastItem();
e.preventDefault();
e.cancelBubble = true;
break;
default:
if (isPrintableCharacter(ch)) {
focusItemByChar(items[items.length -1], ch);
}
break;
}
}
});
items.forEach(el => {
listen(el, keydown, e => {
const ch = e.key;
if (e.ctrlKey || e.altKey || e.metaKey) {
// nothing
}
else if (e.shiftKey) {
if (isPrintableCharacter(ch)) {
focusItemByChar(el, ch);
}
} else {
switch (e.keyCode) {
case keyCodes.SPACE:
break;
case keyCodes.RETURN:
const evt = new MouseEvent("click", {
view: window,
bubbles: true,
cancelable: true,
clientX: 20,
});
el.dispatchEvent(evt);
break;
case keyCodes.ESC:
case keyCodes.TAB:
focusTrigger();
closeActiveOverlay();
break;
case keyCodes.UP:
focusPrevItem(el);
break;
case keyCodes.DOWN:
focusNextItem(el);
break;
case keyCodes.HOME:
case keyCodes.PAGEUP:
focusFirstItem();
break;
case keyCodes.END:
case keyCodes.PAGEDOWN:
focusLastItem();
break;
default:
if (isPrintableCharacter(ch)) {
focusItemByChar(el, ch);
}
break;
}
e.preventDefault();
e.cancelBubble = true;
}
});
});
});
}

View File

@ -4,6 +4,10 @@
let overlay = null;
let popper = null;
function isActiveOverlay(element) {
return overlay === element;
}
// show/hide the specific overlay
function toggleOverlay(element) {
if (overlay === element) {
@ -17,6 +21,16 @@ function toggleOverlay(element) {
}
}
// explicitly show the specific overlay
function showOverlay(element) {
if (overlay === element) {
return;
}
closeActiveOverlay();
element.classList.add('show');
overlay = element;
}
// explicitly close the active overlay
function closeActiveOverlay() {
if (overlay !== null) {

View File

@ -13,11 +13,7 @@ function handleSidebar() {
let button = e.currentTarget;
button.classList.toggle("show");
const ul = button.nextElementSibling.nextElementSibling;
if (ul.getAttribute(ariaExpanded) === "true") {
ul.setAttribute(ariaExpanded, "false");
} else {
ul.setAttribute(ariaExpanded, "true");
}
toggleAttribute(ul, ariaExpanded);
let el = ul;
do {

View File

@ -1,5 +1,20 @@
"use strict";
const keyCodes = Object.freeze({
'TAB': 9,
'RETURN': 13,
'ESC': 27,
'SPACE': 32,
'PAGEUP': 33,
'PAGEDOWN': 34,
'END': 35,
'HOME': 36,
'LEFT': 37,
'UP': 38,
'RIGHT': 39,
'DOWN': 40
});
const escapeChars = {
'¢': 'cent',
'£': 'pound',
@ -97,14 +112,22 @@ function getById(id) {
return document.getElementById(id);
}
function query(o, s) {
return o.querySelector(s);
function query(el, s) {
return el.querySelector(s);
}
function queryAll(o, s) {
return o.querySelectorAll(s);
function queryAll(el, s) {
return el.querySelectorAll(s);
}
function listen(o, e, f) {
o.addEventListener(e, f);
}
function toggleAttribute(el, name) {
if (el.getAttribute(name) === "true") {
el.setAttribute(name, "false");
} else {
el.setAttribute(name, "true");
}
}

View File

@ -22,6 +22,12 @@
.body {
padding: 1.25rem;
ul {
list-style: none;
padding: 0;
margin: 0;
}
}
}
}

View File

@ -82,6 +82,12 @@ header {
margin-left: 4.2em;
}
}
&:focus {
span {
color: $textBrandHighlightColor;
}
}
}
a, button {

View File

@ -1,6 +1,9 @@
.menu {
position: relative;
cursor: pointer;
.menu-trigger {
cursor: pointer;
}
.menu-content {
display: block;
@ -16,6 +19,7 @@
text-align: left;
top: -600px;
transition: top .5s;
cursor: default;
a {
display: block;
@ -24,7 +28,7 @@
padding: .25rem 1.5rem;
margin: 0;
&:hover {
&:hover, &:focus {
color: $textBrandColor;
background-color: $mainBrandColor;
text-decoration: none;
@ -39,7 +43,7 @@
background-size: .75rem .75rem;
border: 0;
&:hover {
&:hover, &:focus {
background-image: $dropdownCheckHover;
background-color: $mainBrandColor;
}

View File

@ -12,7 +12,7 @@
top: $headerHeight;
}
.directory {
div {
padding-left: .5em;
border-left: 1px solid $dividerBarColor;
@ -42,13 +42,13 @@
}
}
ul {
ol {
list-style-type: none !important;
padding-left: 0;
padding-bottom: 0;
margin: 0;
ul {
ol {
padding-left: 1em;
}
}
@ -57,6 +57,7 @@
.toc-inlined {
display: block;
margin-bottom: 2rem;
@media print {
display: none;
@ -66,20 +67,20 @@
display: none;
}
.directory {
div {
border-left: 0;
li {
font-size: 1rem;
}
ul {
ol {
list-style-type: none !important;
padding-left: 0;
padding-bottom: 0;
margin: 0;
ul {
ol {
padding-left: 1em;
}