Skip to content

Make pure menu dropdowns keyboard accessible #568

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Jan 30, 2020
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ RUN touch build.rs
COPY src src/
RUN find src -name "*.rs" -exec touch {} \;
COPY templates/style.scss templates/
COPY templates/menu.js templates/

RUN cargo build --release

Expand Down
9 changes: 8 additions & 1 deletion build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ extern crate git2;

use std::env;
use std::path::Path;
use std::fs::File;
use std::fs::{self, File};
use std::io::Write;
use git2::Repository;


fn main() {
write_git_version();
compile_sass();
copy_js();
}


Expand Down Expand Up @@ -49,3 +50,9 @@ fn compile_sass() {
let mut file = File::create(&dest_path).unwrap();
file.write_all(css.as_bytes()).unwrap();
}

fn copy_js() {
let source_path = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/menu.js"));
let dest_path = Path::new(&env::var("OUT_DIR").unwrap()).join("menu.js");
fs::copy(&source_path, &dest_path).expect("copy template/menu.js to target");
}
9 changes: 9 additions & 0 deletions src/web/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ use std::sync::{Arc, Mutex};
/// Duration of static files for staticfile and DatabaseFileHandler (in seconds)
const STATIC_FILE_CACHE_DURATION: u64 = 60 * 60 * 24 * 30 * 12; // 12 months
const STYLE_CSS: &'static str = include_str!(concat!(env!("OUT_DIR"), "/style.css"));
const MENU_JS: &'static str = include_str!(concat!(env!("OUT_DIR"), "/menu.js"));
const OPENSEARCH_XML: &'static [u8] = include_bytes!("opensearch.xml");

const DEFAULT_BIND: &str = "0.0.0.0:3000";
Expand Down Expand Up @@ -426,6 +427,14 @@ fn style_css_handler(_: &mut Request) -> IronResult<Response> {
Ok(response)
}

fn menu_js_handler(_: &mut Request) -> IronResult<Response> {
let mut response = Response::with((status::Ok, MENU_JS));
let cache = vec![CacheDirective::Public,
CacheDirective::MaxAge(STATIC_FILE_CACHE_DURATION as u32)];
response.headers.set(ContentType("application/javascript".parse().unwrap()));
response.headers.set(CacheControl(cache));
Ok(response)
}

fn opensearch_xml_handler(_: &mut Request) -> IronResult<Response> {
let mut response = Response::with((status::Ok, OPENSEARCH_XML));
Expand Down
1 change: 1 addition & 0 deletions src/web/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub(super) fn build_routes() -> Routes {
let mut routes = Routes::new();

routes.static_resource("/style.css", super::style_css_handler);
routes.static_resource("/menu.js", super::menu_js_handler);
routes.static_resource("/robots.txt", super::sitemap::robots_txt_handler);
routes.static_resource("/sitemap.xml", super::sitemap::sitemap_handler);
routes.static_resource("/opensearch.xml", super::opensearch_xml_handler);
Expand Down
1 change: 1 addition & 0 deletions templates/footer.hbs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{{#if varsb.javascript_highlightjs}}<script type="text/javascript" charset="utf-8">hljs.initHighlighting();</script>{{/if}}
<script type="text/javascript" src="/menu.js?{{cratesfyi_version_safe}}"></script>
</body>
</html>
201 changes: 201 additions & 0 deletions templates/menu.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
// Allow menus to be open and used by keyboard.
(function() {
var currentMenu;
var backdrop = document.createElement("div");
backdrop.style = "display:none;position:fixed;width:100%;height:100%;z-index:1";
document.documentElement.insertBefore(backdrop, document.querySelector("body"));
function previous(allItems, item) {
var i = 1;
var l = allItems.length;
while (i < l) {
if (allItems[i] == item) {
return allItems[i - 1];
}
i += 1;
}
}
function next(allItems, item) {
var i = 0;
var l = allItems.length - 1;
while (i < l) {
if (allItems[i] == item) {
return allItems[i + 1];
}
i += 1;
}
}
function last(allItems) {
return allItems[allItems.length - 1];
}
function closeMenu() {
if (this === backdrop) {
var rustdoc = document.querySelector(".rustdoc");
if (rustdoc) {
rustdoc.focus();
} else {
document.documentElement.focus();
}
} else if (currentMenu.querySelector(".pure-menu-link:focus")) {
currentMenu.firstElementChild.focus();
}
currentMenu.className = currentMenu.className.replace("pure-menu-active", "");
currentMenu = null;
backdrop.style.display = "none";
}
backdrop.onclick = closeMenu;
function openMenu(newMenu) {
currentMenu = newMenu;
newMenu.className += " pure-menu-active";
backdrop.style.display = "block";
}
function menuOnClick(e) {
if (this.getAttribute("href") != "#") {
return;
}
if (this.parentNode === currentMenu) {
closeMenu();
} else {
if (currentMenu) closeMenu();
openMenu(this.parentNode);
}
e.preventDefault();
e.stopPropagation();
};
function menuMouseOver(e) {
if (currentMenu) {
if (e.target.className.indexOf("pure-menu-link") !== -1) {
e.target.focus();
if (e.target.parentNode.className.indexOf("pure-menu-has-children") !== -1 && e.target.parentNode !== currentMenu) {
closeMenu();
openMenu(e.target.parentNode);
}
}
}
}
function menuKeyDown(e) {
if (currentMenu) {
var children = currentMenu.querySelector(".pure-menu-children");
var currentLink = children.querySelector(".pure-menu-link:focus");
var currentItem;
if (currentLink && currentLink.parentNode.className.indexOf("pure-menu-item") !== -1) {
currentItem = currentLink.parentNode;
}
var allItems = [];
if (children) {
allItems = children.querySelectorAll(".pure-menu-item .pure-menu-link");
}
var switchTo = null;
switch (e.key.toLowerCase()) {
case "escape":
case "esc":
closeMenu();
e.preventDefault();
e.stopPropagation();
return;
case "arrowdown":
case "down":
if (currentLink) {
// Arrow down when an item other than the last is focused: focus next item.
// Arrow down when the last item is focused: jump to top.
switchTo = (next(allItems, currentLink) || allItems[0]);
} else {
// Arrow down when a menu is open and nothing is focused: focus first item.
switchTo = allItems[0];
}
break;
case "arrowup":
case "up":
if (currentLink) {
// Arrow up when an item other than the first is focused: focus previous item.
// Arrow up when the first item is focused: jump to bottom.
switchTo = (previous(allItems, currentLink) || last(allItems));
} else {
// Arrow up when a menu is open and nothing is focused: focus last item.
switchTo = last(allItems);
}
break;
case "tab":
if (!currentLink) {
// if the menu is open, we should focus trap into it
// this is the behavior of the WAI example
// it is not the same as GitHub, but GitHub allows you to tab yourself out
// of the menu without closing it (which is horrible behavior)
switchTo = e.shiftKey ? last(allItems) : allItems[0];
} else if (e.shiftKey && currentLink === allItems[0]) {
// if you tab your way out of the menu, close it
// this is neither what GitHub nor the WAI example do,
// but is a rationalization of GitHub's behavior: we don't want users who know how to
// use tab and enter, but don't know that they can close menus with Escape,
// to find themselves completely trapped in the menu
closeMenu();
e.preventDefault();
e.stopPropagation();
} else if (!e.shiftKey && currentLink === last(allItems)) {
// same as above.
// if you tab your way out of the menu, close it
closeMenu();
}
break;
case "enter":
case "return":
case "space":
case " ":
// enter, return, and space have the default browser behavior,
// but they also close the menu
// this behavior is identical between both the WAI example, and GitHub's
setTimeout(function() {
closeMenu();
}, 100);
break;
case "home":
case "pageup":
// home: focus first menu item.
// This is the behavior of WAI, while GitHub scrolls,
// but it's unlikely that a user will try to scroll the page while the menu is open,
// so they won't do it on accident.
switchTo = allItems[0];
break;
case "end":
case "pagedown":
// end: focus last menu item.
// This is the behavior of WAI, while GitHub scrolls,
// but it's unlikely that a user will try to scroll the page while the menu is open,
// so they won't do it on accident.
switchTo = last(allItems);
break;
}
if (switchTo) {
var switchToLink = switchTo.querySelector("a");
if (switchToLink) {
switchToLink.focus();
} else {
switchTo.focus();
}
e.preventDefault();
e.stopPropagation();
}
} else if (e.target.parentNode.className && e.target.parentNode.className.indexOf("pure-menu-has-children") !== -1) {
switch (e.key.toLowerCase()) {
case "arrowdown":
case "down":
case "space":
case " ":
openMenu(e.target.parentNode);
e.preventDefault();
e.stopPropagation();
break;
}
}
};
var menus = Array.prototype.slice.call(document.querySelectorAll(".pure-menu-has-children"));
var menusLength = menus.length;
var menu;
for (var i = 0; i < menusLength; ++i) {
menu = menus[i];
menu.firstElementChild.setAttribute("aria-haspopup", "menu");
menu.firstElementChild.nextElementSibling.setAttribute("role", "menu");
menu.firstElementChild.addEventListener("click", menuOnClick);
menu.addEventListener("mouseover", menuMouseOver);
}
document.documentElement.addEventListener("keydown", menuKeyDown);
})();
1 change: 1 addition & 0 deletions templates/rustdoc.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@
<div class="{{content.rustdoc_body_class}}">
{{{content.rustdoc_body}}}
</div>
<script type="text/javascript" src="/menu.js?{{cratesfyi_version_safe}}"></script>
</body>
</html>