Rollup merge of #84462 - jsha:focus-search-results2, r=GuillaumeGomez

rustdoc: use focus for search navigation

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 focus. 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.

Demo at https://hoffman-andrews.com/rust/focus-search-results2/std/?search=fn

Fixes #84384
Fixes #79962
Fixes #79872
This commit is contained in:
Ralf Jung 2021-05-18 19:35:35 +02:00 committed by GitHub
commit c9b6bb9279
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 178 additions and 195 deletions

View file

@ -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);
@ -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
@ -1064,7 +1061,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"],

View file

@ -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,
@ -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;
@ -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;
}
@ -1134,6 +1135,11 @@ pre.rust {
.search-failed {
text-align: center;
margin-top: 20px;
display: none;
}
.search-failed.active {
display: block;
}
.search-failed > ul {

View file

@ -51,9 +51,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;
});
@ -878,106 +878,22 @@ window.initSearch = function(rawSearchIndex) {
};
}
function initSearchNav() {
var hoverTimeout;
function nextTab(direction) {
var next = (searchState.currentTab + direction + 3) % searchState.focusedByTab.length;
searchState.focusedByTab[searchState.currentTab] = document.activeElement;
printTab(next);
focusSearchResult();
}
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;
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) {
@ -1047,16 +963,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;
@ -1072,20 +988,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/>" +
@ -1121,7 +1036,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();
@ -1162,7 +1077,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); };
@ -1440,6 +1354,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() {

View file

@ -151,13 +151,16 @@ pre, .rustdoc.source .example-wrap {
color: #c5c5c5;
}
.content .highlighted {
.content a:hover {
background-color: #777;
}
.content a:focus {
color: #000 !important;
background-color: #c6afb3;
}
.content .highlighted a, .content .highlighted span { color: #000 !important; }
.content .highlighted {
background-color: #c6afb3;
.content a:focus {
color: #000 !important;
}
.search-results a {
color: #0096cf;
@ -432,31 +435,21 @@ individually rather than as a group) */
/* FIXME: these rules should be at the bottom of the file but currently must be
above the `@media (max-width: 700px)` rules due to a bug in the css checker */
/* see https://github.com/rust-lang/rust/pull/71237#issuecomment-618170143 */
.content .highlighted.mod, .content .highlighted.externcrate {}
.search-input:focus {}
.content span.attr,.content a.attr,.block a.current.attr,.content span.derive,.content a.derive,
.block a.current.derive,.content span.macro,.content a.macro,.block a.current.macro {}
.content .highlighted.trait {}
.content span.struct,.content a.struct,.block a.current.struct {}
#titles>button:hover,#titles>button.selected {}
.content .highlighted.traitalias {}
.content span.type,.content a.type,.block a.current.type {}
.content span.union,.content a.union,.block a.current.union {}
.content .highlighted.foreigntype {}
pre.rust .lifetime {}
.content .highlighted.primitive {}
.content .highlighted.constant,.content .highlighted.static {}
.stab.unstable {}
.content .highlighted.fn,.content .highlighted.method,.content .highlighted.tymethod {}
h2,h3:not(.impl):not(.method):not(.type):not(.tymethod),h4:not(.method):not(.type):not(.tymethod) {}
.content span.enum,.content a.enum,.block a.current.enum {}
.content span.constant,.content a.constant,.block a.current.constant,.content span.static,
.content a.static,.block a.current.static {}
.content a.static, .block a.current.static {}
.content span.keyword,.content a.keyword,.block a.current.keyword {}
pre.rust .comment {}
.content .highlighted.enum {}
.content .highlighted.struct {}
.content .highlighted.keyword {}
.content span.traitalias,.content a.traitalias,.block a.current.traitalias {}
.content span.fn,.content a.fn,.block a.current.fn,.content span.method,.content a.method,
.block a.current.method,.content span.tymethod,.content a.tymethod,.block a.current.tymethod,
@ -467,15 +460,36 @@ pre.rust .attribute .ident {}
.content span.foreigntype,.content a.foreigntype,.block a.current.foreigntype {}
pre.rust .doccomment {}
.stab.deprecated {}
.content .highlighted.attr,.content .highlighted.derive,.content .highlighted.macro {}
.content a.attr,.content a.derive,.content a.macro {}
.stab.portability {}
.content .highlighted.union {}
.content span.primitive,.content a.primitive,.block a.current.primitive {}
.content span.externcrate,.content span.mod,.content a.mod,.block a.current.mod {}
.content .highlighted.type {}
pre.rust .kw-2,pre.rust .prelude-ty {}
.content span.trait,.content a.trait,.block a.current.trait {}
.search-results a:focus span {}
a.result-trait:focus {}
a.result-traitalias:focus {}
a.result-mod:focus,
a.result-externcrate:focus {}
a.result-mod:focus {}
a.result-externcrate:focus {}
a.result-enum:focus {}
a.result-struct:focus {}
a.result-union:focus {}
a.result-fn:focus,
a.result-method:focus,
a.result-tymethod:focus {}
a.result-type:focus {}
a.result-foreigntype:focus {}
a.result-attr:focus,
a.result-derive:focus,
a.result-macro:focus {}
a.result-constant:focus,
a.result-static:focus {}
a.result-primitive:focus {}
a.result-keyword:focus {}
@media (max-width: 700px) {
.sidebar-menu {
background-color: #14191f;

View file

@ -109,32 +109,36 @@ pre, .rustdoc.source .example-wrap {
color: #ddd;
}
.content .highlighted {
.content a:hover {
background-color: #777;
}
.content a:focus {
color: #eee !important;
background-color: #616161;
}
.content .highlighted a, .content .highlighted span { color: #eee !important; }
.content .highlighted.trait { background-color: #013191; }
.content .highlighted.traitalias { background-color: #013191; }
.content .highlighted.mod,
.content .highlighted.externcrate { background-color: #afc6e4; }
.content .highlighted.mod { background-color: #803a1b; }
.content .highlighted.externcrate { background-color: #396bac; }
.content .highlighted.enum { background-color: #5b4e68; }
.content .highlighted.struct { background-color: #194e9f; }
.content .highlighted.union { background-color: #b7bd49; }
.content .highlighted.fn,
.content .highlighted.method,
.content .highlighted.tymethod { background-color: #4950ed; }
.content .highlighted.type { background-color: #38902c; }
.content .highlighted.foreigntype { background-color: #b200d6; }
.content .highlighted.attr,
.content .highlighted.derive,
.content .highlighted.macro { background-color: #217d1c; }
.content .highlighted.constant,
.content .highlighted.static { background-color: #0063cc; }
.content .highlighted.primitive { background-color: #00708a; }
.content .highlighted.keyword { background-color: #884719; }
.search-results a:focus span { color: #eee !important; }
a.result-trait:focus { background-color: #013191; }
a.result-traitalias:focus { background-color: #013191; }
a.result-mod:focus,
a.result-externcrate:focus { background-color: #afc6e4; }
a.result-mod:focus { background-color: #803a1b; }
a.result-externcrate:focus { background-color: #396bac; }
a.result-enum:focus { background-color: #5b4e68; }
a.result-struct:focus { background-color: #194e9f; }
a.result-union:focus { background-color: #b7bd49; }
a.result-fn:focus,
a.result-method:focus,
a.result-tymethod:focus { background-color: #4950ed; }
a.result-type:focus { background-color: #38902c; }
a.result-foreigntype:focus { background-color: #b200d6; }
a.result-attr:focus,
a.result-derive:focus,
a.result-macro:focus { background-color: #217d1c; }
a.result-constant:focus,
a.result-static:focus { background-color: #0063cc; }
a.result-primitive:focus { background-color: #00708a; }
a.result-keyword:focus { background-color: #884719; }
.content .item-info::before { color: #ccc; }

View file

@ -109,30 +109,34 @@ pre, .rustdoc.source .example-wrap {
color: #4E4C4C;
}
.content .highlighted {
.content a:hover {
background-color: #ddd;
}
.content a:focus {
color: #000 !important;
background-color: #ccc;
}
.content .highlighted a, .content .highlighted span { color: #000 !important; }
.content .highlighted.trait { background-color: #c7b6ff; }
.content .highlighted.traitalias { background-color: #c7b6ff; }
.content .highlighted.mod,
.content .highlighted.externcrate { background-color: #afc6e4; }
.content .highlighted.enum { background-color: #b4d1b9; }
.content .highlighted.struct { background-color: #e7b1a0; }
.content .highlighted.union { background-color: #b7bd49; }
.content .highlighted.fn,
.content .highlighted.method,
.content .highlighted.tymethod { background-color: #c6afb3; }
.content .highlighted.type { background-color: #ffc891; }
.content .highlighted.foreigntype { background-color: #f5c4ff; }
.content .highlighted.attr,
.content .highlighted.derive,
.content .highlighted.macro { background-color: #8ce488; }
.content .highlighted.constant,
.content .highlighted.static { background-color: #c3e0ff; }
.content .highlighted.primitive { background-color: #9aecff; }
.content .highlighted.keyword { background-color: #f99650; }
.search-results a:focus span { color: #000 !important; }
a.result-trait:focus { background-color: #c7b6ff; }
a.result-traitalias:focus { background-color: #c7b6ff; }
a.result-mod:focus,
a.result-externcrate:focus { background-color: #afc6e4; }
a.result-enum:focus { background-color: #b4d1b9; }
a.result-struct:focus { background-color: #e7b1a0; }
a.result-union:focus { background-color: #b7bd49; }
a.result-fn:focus,
a.result-method:focus,
a.result-tymethod:focus { background-color: #c6afb3; }
a.result-type:focus { background-color: #ffc891; }
a.result-foreigntype:focus { background-color: #f5c4ff; }
a.result-attr:focus,
a.result-derive:focus,
a.result-macro:focus { background-color: #8ce488; }
a.result-constant:focus,
a.result-static:focus { background-color: #c3e0ff; }
a.result-primitive:focus { background-color: #9aecff; }
a.result-keyword:focus { background-color: #f99650; }
.content .item-info::before { color: #ccc; }