Skip to content

Commit b615c0c

Browse files
committed
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 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.
1 parent 952c573 commit b615c0c

File tree

6 files changed

+178
-195
lines changed

6 files changed

+178
-195
lines changed

src/librustdoc/html/static/main.js

+3-6
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,8 @@ function hideThemeButtonState() {
170170
// 1 for "In Parameters"
171171
// 2 for "In Return Types"
172172
currentTab: 0,
173-
mouseMovedAfterSearch: true,
173+
// tab and back preserves the element that was focused.
174+
focusedByTab: [null, null, null],
174175
clearInputTimeout: function() {
175176
if (searchState.timeout !== null) {
176177
clearTimeout(searchState.timeout);
@@ -262,10 +263,6 @@ function hideThemeButtonState() {
262263
search_input.placeholder = searchState.input.origPlaceholder;
263264
});
264265

265-
document.addEventListener("mousemove", function() {
266-
searchState.mouseMovedAfterSearch = true;
267-
});
268-
269266
search_input.removeAttribute('disabled');
270267

271268
// `crates{version}.js` should always be loaded before this script, so we can use it
@@ -1070,7 +1067,7 @@ function hideThemeButtonState() {
10701067
["T", "Focus the theme picker menu"],
10711068
["↑", "Move up in search results"],
10721069
["↓", "Move down in search results"],
1073-
["ctrl + ↑ / ↓", "Switch result tab"],
1070+
["← / →", "Switch result tab (when results focused)"],
10741071
["&#9166;", "Go to active search result"],
10751072
["+", "Expand all sections"],
10761073
["-", "Collapse all sections"],

src/librustdoc/html/static/rustdoc.css

+19-13
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ h4.type.trait-impl, h4.associatedconstant.trait-impl, h4.associatedtype.trait-im
144144
}
145145

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

751+
.search-results {
752+
display: none;
753+
padding-bottom: 2em;
754+
}
755+
756+
.search-results.active {
757+
display: block;
758+
}
759+
751760
.search-results .desc {
752761
white-space: nowrap;
753762
text-overflow: ellipsis;
@@ -756,22 +765,14 @@ a {
756765
}
757766

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

762-
.content .search-results td:first-child {
763-
padding-right: 0;
774+
.result-name {
764775
width: 50%;
765-
}
766-
.content .search-results td:first-child a {
767-
padding-right: 10px;
768-
}
769-
.content .search-results td:first-child a:after {
770-
clear: both;
771-
content: "";
772-
display: block;
773-
}
774-
.content .search-results td:first-child a span {
775776
float: left;
776777
}
777778

@@ -1134,6 +1135,11 @@ pre.rust {
11341135
.search-failed {
11351136
text-align: center;
11361137
margin-top: 20px;
1138+
display: none;
1139+
}
1140+
1141+
.search-failed.active {
1142+
display: block;
11371143
}
11381144

11391145
.search-failed > ul {

src/librustdoc/html/static/search.js

+72-114
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,9 @@ function printTab(nb) {
4848
});
4949
onEachLazy(document.getElementById("results").childNodes, function(elem) {
5050
if (nb === 0) {
51-
elem.style.display = "";
51+
addClass(elem, "active");
5252
} else {
53-
elem.style.display = "none";
53+
removeClass(elem, "active");
5454
}
5555
nb -= 1;
5656
});
@@ -875,106 +875,22 @@ window.initSearch = function(rawSearchIndex) {
875875
};
876876
}
877877

878-
function initSearchNav() {
879-
var hoverTimeout;
880-
881-
var click_func = function(e) {
882-
var el = e.target;
883-
// to retrieve the real "owner" of the event.
884-
while (el.tagName !== "TR") {
885-
el = el.parentNode;
886-
}
887-
var dst = e.target.getElementsByTagName("a");
888-
if (dst.length < 1) {
889-
return;
890-
}
891-
dst = dst[0];
892-
if (window.location.pathname === dst.pathname) {
893-
searchState.hideResults();
894-
document.location.href = dst.href;
895-
}
896-
};
897-
var mouseover_func = function(e) {
898-
if (searchState.mouseMovedAfterSearch) {
899-
var el = e.target;
900-
// to retrieve the real "owner" of the event.
901-
while (el.tagName !== "TR") {
902-
el = el.parentNode;
903-
}
904-
clearTimeout(hoverTimeout);
905-
hoverTimeout = setTimeout(function() {
906-
onEachLazy(document.getElementsByClassName("search-results"), function(e) {
907-
onEachLazy(e.getElementsByClassName("result"), function(i_e) {
908-
removeClass(i_e, "highlighted");
909-
});
910-
});
911-
addClass(el, "highlighted");
912-
}, 20);
913-
}
914-
};
915-
onEachLazy(document.getElementsByClassName("search-results"), function(e) {
916-
onEachLazy(e.getElementsByClassName("result"), function(i_e) {
917-
i_e.onclick = click_func;
918-
i_e.onmouseover = mouseover_func;
919-
});
920-
});
921-
922-
searchState.input.onkeydown = function(e) {
923-
// "actives" references the currently highlighted item in each search tab.
924-
// Each array in "actives" represents a tab.
925-
var actives = [[], [], []];
926-
// "current" is used to know which tab we're looking into.
927-
var current = 0;
928-
onEachLazy(document.getElementById("results").childNodes, function(e) {
929-
onEachLazy(e.getElementsByClassName("highlighted"), function(h_e) {
930-
actives[current].push(h_e);
931-
});
932-
current += 1;
933-
});
934-
var SHIFT = 16;
935-
var CTRL = 17;
936-
var ALT = 18;
878+
function nextTab(direction) {
879+
var next = (searchState.currentTab + direction + 3) % searchState.focusedByTab.length;
880+
searchState.focusedByTab[searchState.currentTab] = document.activeElement;
881+
printTab(next);
882+
focusSearchResult();
883+
}
937884

938-
var currentTab = searchState.currentTab;
939-
if (e.which === 38) { // up
940-
if (e.ctrlKey) { // Going through result tabs.
941-
printTab(currentTab > 0 ? currentTab - 1 : 2);
942-
} else {
943-
if (!actives[currentTab].length ||
944-
!actives[currentTab][0].previousElementSibling) {
945-
return;
946-
}
947-
addClass(actives[currentTab][0].previousElementSibling, "highlighted");
948-
removeClass(actives[currentTab][0], "highlighted");
949-
}
950-
e.preventDefault();
951-
} else if (e.which === 40) { // down
952-
if (e.ctrlKey) { // Going through result tabs.
953-
printTab(currentTab > 1 ? 0 : currentTab + 1);
954-
} else if (!actives[currentTab].length) {
955-
var results = document.getElementById("results").childNodes;
956-
if (results.length > 0) {
957-
var res = results[currentTab].getElementsByClassName("result");
958-
if (res.length > 0) {
959-
addClass(res[0], "highlighted");
960-
}
961-
}
962-
} else if (actives[currentTab][0].nextElementSibling) {
963-
addClass(actives[currentTab][0].nextElementSibling, "highlighted");
964-
removeClass(actives[currentTab][0], "highlighted");
965-
}
966-
e.preventDefault();
967-
} else if (e.which === 13) { // return
968-
if (actives[currentTab].length) {
969-
var elem = actives[currentTab][0].getElementsByTagName("a")[0];
970-
document.location.href = elem.href;
971-
}
972-
} else if ([SHIFT, CTRL, ALT].indexOf(e.which) !== -1) {
973-
// Does nothing, it's just to avoid losing "focus" on the highlighted element.
974-
} else if (actives[currentTab].length > 0) {
975-
removeClass(actives[currentTab][0], "highlighted");
976-
}
977-
};
885+
// focus the first search result on the active tab, or the result that
886+
// was focused last time this tab was active.
887+
function focusSearchResult() {
888+
var target = searchState.focusedByTab[searchState.currentTab] ||
889+
document.querySelectorAll(".search-results.active a").item(0) ||
890+
document.querySelectorAll("#titles > button").item(searchState.currentTab);
891+
if (target) {
892+
target.focus();
893+
}
978894
}
979895

980896
function buildHrefAndPath(item) {
@@ -1044,16 +960,16 @@ window.initSearch = function(rawSearchIndex) {
1044960
}
1045961

1046962
function addTab(array, query, display) {
1047-
var extraStyle = "";
1048-
if (display === false) {
1049-
extraStyle = " style=\"display: none;\"";
963+
var extraClass = "";
964+
if (display === true) {
965+
extraClass = " active";
1050966
}
1051967

1052968
var output = "";
1053969
var duplicates = {};
1054970
var length = 0;
1055971
if (array.length > 0) {
1056-
output = "<table class=\"search-results\"" + extraStyle + ">";
972+
output = "<div class=\"search-results " + extraClass + "\">";
1057973

1058974
array.forEach(function(item) {
1059975
var name, type;
@@ -1069,20 +985,19 @@ window.initSearch = function(rawSearchIndex) {
1069985
}
1070986
length += 1;
1071987

1072-
output += "<tr class=\"" + type + " result\"><td>" +
1073-
"<a href=\"" + item.href + "\">" +
988+
output += "<a class=\"result-" + type + "\" href=\"" + item.href + "\">" +
989+
"<div><div class=\"result-name\">" +
1074990
(item.is_alias === true ?
1075991
("<span class=\"alias\"><b>" + item.alias + " </b></span><span " +
1076992
"class=\"grey\"><i>&nbsp;- see&nbsp;</i></span>") : "") +
1077993
item.displayPath + "<span class=\"" + type + "\">" +
1078-
name + "</span></a></td><td>" +
1079-
"<a href=\"" + item.href + "\">" +
994+
name + "</span></div><div>" +
1080995
"<span class=\"desc\">" + item.desc +
1081-
"&nbsp;</span></a></td></tr>";
996+
"&nbsp;</span></div></div></a>";
1082997
});
1083-
output += "</table>";
998+
output += "</div>";
1084999
} else {
1085-
output = "<div class=\"search-failed\"" + extraStyle + ">No results :(<br/>" +
1000+
output = "<div class=\"search-failed\"" + extraClass + ">No results :(<br/>" +
10861001
"Try on <a href=\"https://duckduckgo.com/?q=" +
10871002
encodeURIComponent("rust " + query.query) +
10881003
"\">DuckDuckGo</a>?<br/><br/>" +
@@ -1118,7 +1033,7 @@ window.initSearch = function(rawSearchIndex) {
11181033
{
11191034
var elem = document.createElement("a");
11201035
elem.href = results.others[0].href;
1121-
elem.style.display = "none";
1036+
removeClass(elem, "active");
11221037
// For firefox, we need the element to be in the DOM so it can be clicked.
11231038
document.body.appendChild(elem);
11241039
elem.click();
@@ -1159,7 +1074,6 @@ window.initSearch = function(rawSearchIndex) {
11591074

11601075
search.innerHTML = output;
11611076
searchState.showResults(search);
1162-
initSearchNav();
11631077
var elems = document.getElementById("titles").childNodes;
11641078
elems[0].onclick = function() { printTab(0); };
11651079
elems[1].onclick = function() { printTab(1); };
@@ -1437,6 +1351,50 @@ window.initSearch = function(rawSearchIndex) {
14371351
};
14381352
searchState.input.onpaste = searchState.input.onchange;
14391353

1354+
searchState.outputElement().addEventListener("keydown", function(e) {
1355+
// We only handle unmodified keystrokes here. We don't want to interfere with,
1356+
// for instance, alt-left and alt-right for history navigation.
1357+
if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey) {
1358+
return;
1359+
}
1360+
// up and down arrow select next/previous search result, or the
1361+
// search box if we're already at the top.
1362+
if (e.which === 38) { // up
1363+
var previous = document.activeElement.previousElementSibling;
1364+
if (previous) {
1365+
console.log("previousElementSibling", previous);
1366+
previous.focus();
1367+
} else {
1368+
searchState.focus();
1369+
}
1370+
e.preventDefault();
1371+
} else if (e.which === 40) { // down
1372+
var next = document.activeElement.nextElementSibling;
1373+
if (next) {
1374+
next.focus();
1375+
}
1376+
var rect = document.activeElement.getBoundingClientRect();
1377+
if (window.innerHeight - rect.bottom < rect.height) {
1378+
window.scrollBy(0, rect.height);
1379+
}
1380+
e.preventDefault();
1381+
} else if (e.which === 37) { // left
1382+
nextTab(-1);
1383+
e.preventDefault();
1384+
} else if (e.which === 39) { // right
1385+
nextTab(1);
1386+
e.preventDefault();
1387+
}
1388+
});
1389+
1390+
searchState.input.addEventListener("keydown", function(e) {
1391+
if (e.which === 40) { // down
1392+
focusSearchResult();
1393+
e.preventDefault();
1394+
}
1395+
});
1396+
1397+
14401398
var selectCrate = document.getElementById("crate-search");
14411399
if (selectCrate) {
14421400
selectCrate.onchange = function() {

0 commit comments

Comments
 (0)