Skip to content

Commit

Permalink
rustdoc: use focus for search navigation
Browse files Browse the repository at this point in the history
Rather than keeping track of highlighted element inside the JS, take
advantage of `.focus()` and the :focus CSS pseudo-class.

This required wrapping each row of results in one big <a> tag (because
anchors can be focused, but table rows cannot). That in turn required
moving from a table layout to a div layout with float.

This makes it so Ctrl+Enter opens links in new tabs, and using the arrow
keys to navigate off the bottom of the page scrolls the rest of the page
into view. It also simplifies the keyboard event handling. It eliminates
the need for click handlers on the search results, and for tracking
mouse movements.

This changes the UI treatment of mouse hovering. A hovered element now
gets a light grey background, but does not change the focused element.
It's possible to have two highlighted search results: one that is
focused (via keyboard) and one that is hovered (via mouse). Pressing
enter will activate the focused link; clicking will activate the hovered
link. This matches up with how Firefox and Chrome handle suggestions in
their URL bar, and avoids stray mouse movements changing the focus.

Selecting tabs is now done with left/right arrows while any search
result is focused. The visibility of results on each search tab is
controlled with the "active" class, rather than by setting display: none
directly. Note that the old code kept track of highlighted search
element when tabbing back and forth. The new code doesn't.
  • Loading branch information
jsha committed May 13, 2021
1 parent 952c573 commit b615c0c
Show file tree
Hide file tree
Showing 6 changed files with 178 additions and 195 deletions.
9 changes: 3 additions & 6 deletions src/librustdoc/html/static/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,8 @@ function hideThemeButtonState() {
// 1 for "In Parameters"
// 2 for "In Return Types"
currentTab: 0,
mouseMovedAfterSearch: true,
// tab and back preserves the element that was focused.
focusedByTab: [null, null, null],
clearInputTimeout: function() {
if (searchState.timeout !== null) {
clearTimeout(searchState.timeout);
Expand Down Expand Up @@ -262,10 +263,6 @@ function hideThemeButtonState() {
search_input.placeholder = searchState.input.origPlaceholder;
});

document.addEventListener("mousemove", function() {
searchState.mouseMovedAfterSearch = true;
});

search_input.removeAttribute('disabled');

// `crates{version}.js` should always be loaded before this script, so we can use it
Expand Down Expand Up @@ -1070,7 +1067,7 @@ function hideThemeButtonState() {
["T", "Focus the theme picker menu"],
["↑", "Move up in search results"],
["↓", "Move down in search results"],
["ctrl + ↑ / ↓", "Switch result tab"],
["← / →", "Switch result tab (when results focused)"],
["&#9166;", "Go to active search result"],
["+", "Expand all sections"],
["-", "Collapse all sections"],
Expand Down
32 changes: 19 additions & 13 deletions src/librustdoc/html/static/rustdoc.css
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ h4.type.trait-impl, h4.associatedconstant.trait-impl, h4.associatedtype.trait-im
}

h1, h2, h3, h4,
.sidebar, a.source, .search-input, .content table td:first-child > a,
.sidebar, a.source, .search-input, .search-results .result-name,
div.item-list .out-of-band,
#source-sidebar, #sidebar-toggle,
details.rustdoc-toggle > summary::before,
Expand Down Expand Up @@ -748,6 +748,15 @@ a {
outline: 0;
}

.search-results {
display: none;
padding-bottom: 2em;
}

.search-results.active {
display: block;
}

.search-results .desc {
white-space: nowrap;
text-overflow: ellipsis;
Expand All @@ -756,22 +765,14 @@ a {
}

.search-results a {
/* A little margin ensures the browser's outlining of focused links has room to display. */
margin-left: 2px;
margin-right: 2px;
display: block;
}

.content .search-results td:first-child {
padding-right: 0;
.result-name {
width: 50%;
}
.content .search-results td:first-child a {
padding-right: 10px;
}
.content .search-results td:first-child a:after {
clear: both;
content: "";
display: block;
}
.content .search-results td:first-child a span {
float: left;
}

Expand Down Expand Up @@ -1134,6 +1135,11 @@ pre.rust {
.search-failed {
text-align: center;
margin-top: 20px;
display: none;
}

.search-failed.active {
display: block;
}

.search-failed > ul {
Expand Down
186 changes: 72 additions & 114 deletions src/librustdoc/html/static/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@ function printTab(nb) {
});
onEachLazy(document.getElementById("results").childNodes, function(elem) {
if (nb === 0) {
elem.style.display = "";
addClass(elem, "active");
} else {
elem.style.display = "none";
removeClass(elem, "active");
}
nb -= 1;
});
Expand Down Expand Up @@ -875,106 +875,22 @@ window.initSearch = function(rawSearchIndex) {
};
}

function initSearchNav() {
var hoverTimeout;

var click_func = function(e) {
var el = e.target;
// to retrieve the real "owner" of the event.
while (el.tagName !== "TR") {
el = el.parentNode;
}
var dst = e.target.getElementsByTagName("a");
if (dst.length < 1) {
return;
}
dst = dst[0];
if (window.location.pathname === dst.pathname) {
searchState.hideResults();
document.location.href = dst.href;
}
};
var mouseover_func = function(e) {
if (searchState.mouseMovedAfterSearch) {
var el = e.target;
// to retrieve the real "owner" of the event.
while (el.tagName !== "TR") {
el = el.parentNode;
}
clearTimeout(hoverTimeout);
hoverTimeout = setTimeout(function() {
onEachLazy(document.getElementsByClassName("search-results"), function(e) {
onEachLazy(e.getElementsByClassName("result"), function(i_e) {
removeClass(i_e, "highlighted");
});
});
addClass(el, "highlighted");
}, 20);
}
};
onEachLazy(document.getElementsByClassName("search-results"), function(e) {
onEachLazy(e.getElementsByClassName("result"), function(i_e) {
i_e.onclick = click_func;
i_e.onmouseover = mouseover_func;
});
});

searchState.input.onkeydown = function(e) {
// "actives" references the currently highlighted item in each search tab.
// Each array in "actives" represents a tab.
var actives = [[], [], []];
// "current" is used to know which tab we're looking into.
var current = 0;
onEachLazy(document.getElementById("results").childNodes, function(e) {
onEachLazy(e.getElementsByClassName("highlighted"), function(h_e) {
actives[current].push(h_e);
});
current += 1;
});
var SHIFT = 16;
var CTRL = 17;
var ALT = 18;
function nextTab(direction) {
var next = (searchState.currentTab + direction + 3) % searchState.focusedByTab.length;
searchState.focusedByTab[searchState.currentTab] = document.activeElement;
printTab(next);
focusSearchResult();
}

var currentTab = searchState.currentTab;
if (e.which === 38) { // up
if (e.ctrlKey) { // Going through result tabs.
printTab(currentTab > 0 ? currentTab - 1 : 2);
} else {
if (!actives[currentTab].length ||
!actives[currentTab][0].previousElementSibling) {
return;
}
addClass(actives[currentTab][0].previousElementSibling, "highlighted");
removeClass(actives[currentTab][0], "highlighted");
}
e.preventDefault();
} else if (e.which === 40) { // down
if (e.ctrlKey) { // Going through result tabs.
printTab(currentTab > 1 ? 0 : currentTab + 1);
} else if (!actives[currentTab].length) {
var results = document.getElementById("results").childNodes;
if (results.length > 0) {
var res = results[currentTab].getElementsByClassName("result");
if (res.length > 0) {
addClass(res[0], "highlighted");
}
}
} else if (actives[currentTab][0].nextElementSibling) {
addClass(actives[currentTab][0].nextElementSibling, "highlighted");
removeClass(actives[currentTab][0], "highlighted");
}
e.preventDefault();
} else if (e.which === 13) { // return
if (actives[currentTab].length) {
var elem = actives[currentTab][0].getElementsByTagName("a")[0];
document.location.href = elem.href;
}
} else if ([SHIFT, CTRL, ALT].indexOf(e.which) !== -1) {
// Does nothing, it's just to avoid losing "focus" on the highlighted element.
} else if (actives[currentTab].length > 0) {
removeClass(actives[currentTab][0], "highlighted");
}
};
// focus the first search result on the active tab, or the result that
// was focused last time this tab was active.
function focusSearchResult() {
var target = searchState.focusedByTab[searchState.currentTab] ||
document.querySelectorAll(".search-results.active a").item(0) ||
document.querySelectorAll("#titles > button").item(searchState.currentTab);
if (target) {
target.focus();
}
}

function buildHrefAndPath(item) {
Expand Down Expand Up @@ -1044,16 +960,16 @@ window.initSearch = function(rawSearchIndex) {
}

function addTab(array, query, display) {
var extraStyle = "";
if (display === false) {
extraStyle = " style=\"display: none;\"";
var extraClass = "";
if (display === true) {
extraClass = " active";
}

var output = "";
var duplicates = {};
var length = 0;
if (array.length > 0) {
output = "<table class=\"search-results\"" + extraStyle + ">";
output = "<div class=\"search-results " + extraClass + "\">";

array.forEach(function(item) {
var name, type;
Expand All @@ -1069,20 +985,19 @@ window.initSearch = function(rawSearchIndex) {
}
length += 1;

output += "<tr class=\"" + type + " result\"><td>" +
"<a href=\"" + item.href + "\">" +
output += "<a class=\"result-" + type + "\" href=\"" + item.href + "\">" +
"<div><div class=\"result-name\">" +
(item.is_alias === true ?
("<span class=\"alias\"><b>" + item.alias + " </b></span><span " +
"class=\"grey\"><i>&nbsp;- see&nbsp;</i></span>") : "") +
item.displayPath + "<span class=\"" + type + "\">" +
name + "</span></a></td><td>" +
"<a href=\"" + item.href + "\">" +
name + "</span></div><div>" +
"<span class=\"desc\">" + item.desc +
"&nbsp;</span></a></td></tr>";
"&nbsp;</span></div></div></a>";
});
output += "</table>";
output += "</div>";
} else {
output = "<div class=\"search-failed\"" + extraStyle + ">No results :(<br/>" +
output = "<div class=\"search-failed\"" + extraClass + ">No results :(<br/>" +
"Try on <a href=\"https://duckduckgo.com/?q=" +
encodeURIComponent("rust " + query.query) +
"\">DuckDuckGo</a>?<br/><br/>" +
Expand Down Expand Up @@ -1118,7 +1033,7 @@ window.initSearch = function(rawSearchIndex) {
{
var elem = document.createElement("a");
elem.href = results.others[0].href;
elem.style.display = "none";
removeClass(elem, "active");
// For firefox, we need the element to be in the DOM so it can be clicked.
document.body.appendChild(elem);
elem.click();
Expand Down Expand Up @@ -1159,7 +1074,6 @@ window.initSearch = function(rawSearchIndex) {

search.innerHTML = output;
searchState.showResults(search);
initSearchNav();
var elems = document.getElementById("titles").childNodes;
elems[0].onclick = function() { printTab(0); };
elems[1].onclick = function() { printTab(1); };
Expand Down Expand Up @@ -1437,6 +1351,50 @@ window.initSearch = function(rawSearchIndex) {
};
searchState.input.onpaste = searchState.input.onchange;

searchState.outputElement().addEventListener("keydown", function(e) {
// We only handle unmodified keystrokes here. We don't want to interfere with,
// for instance, alt-left and alt-right for history navigation.
if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey) {
return;
}
// up and down arrow select next/previous search result, or the
// search box if we're already at the top.
if (e.which === 38) { // up
var previous = document.activeElement.previousElementSibling;
if (previous) {
console.log("previousElementSibling", previous);
previous.focus();
} else {
searchState.focus();
}
e.preventDefault();
} else if (e.which === 40) { // down
var next = document.activeElement.nextElementSibling;
if (next) {
next.focus();
}
var rect = document.activeElement.getBoundingClientRect();
if (window.innerHeight - rect.bottom < rect.height) {
window.scrollBy(0, rect.height);
}
e.preventDefault();
} else if (e.which === 37) { // left
nextTab(-1);
e.preventDefault();
} else if (e.which === 39) { // right
nextTab(1);
e.preventDefault();
}
});

searchState.input.addEventListener("keydown", function(e) {
if (e.which === 40) { // down
focusSearchResult();
e.preventDefault();
}
});


var selectCrate = document.getElementById("crate-search");
if (selectCrate) {
selectCrate.onchange = function() {
Expand Down
Loading

0 comments on commit b615c0c

Please sign in to comment.