Skip to content

Commit c9b6bb9

Browse files
authored
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
2 parents a5560a6 + b615c0c commit c9b6bb9

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
@@ -1064,7 +1061,7 @@ function hideThemeButtonState() {
10641061
["T", "Focus the theme picker menu"],
10651062
["↑", "Move up in search results"],
10661063
["↓", "Move down in search results"],
1067-
["ctrl + ↑ / ↓", "Switch result tab"],
1064+
["← / →", "Switch result tab (when results focused)"],
10681065
["&#9166;", "Go to active search result"],
10691066
["+", "Expand all sections"],
10701067
["-", "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
@@ -51,9 +51,9 @@ function printTab(nb) {
5151
});
5252
onEachLazy(document.getElementById("results").childNodes, function(elem) {
5353
if (nb === 0) {
54-
elem.style.display = "";
54+
addClass(elem, "active");
5555
} else {
56-
elem.style.display = "none";
56+
removeClass(elem, "active");
5757
}
5858
nb -= 1;
5959
});
@@ -878,106 +878,22 @@ window.initSearch = function(rawSearchIndex) {
878878
};
879879
}
880880

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

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

983899
function buildHrefAndPath(item) {
@@ -1047,16 +963,16 @@ window.initSearch = function(rawSearchIndex) {
1047963
}
1048964

1049965
function addTab(array, query, display) {
1050-
var extraStyle = "";
1051-
if (display === false) {
1052-
extraStyle = " style=\"display: none;\"";
966+
var extraClass = "";
967+
if (display === true) {
968+
extraClass = " active";
1053969
}
1054970

1055971
var output = "";
1056972
var duplicates = {};
1057973
var length = 0;
1058974
if (array.length > 0) {
1059-
output = "<table class=\"search-results\"" + extraStyle + ">";
975+
output = "<div class=\"search-results " + extraClass + "\">";
1060976

1061977
array.forEach(function(item) {
1062978
var name, type;
@@ -1072,20 +988,19 @@ window.initSearch = function(rawSearchIndex) {
1072988
}
1073989
length += 1;
1074990

1075-
output += "<tr class=\"" + type + " result\"><td>" +
1076-
"<a href=\"" + item.href + "\">" +
991+
output += "<a class=\"result-" + type + "\" href=\"" + item.href + "\">" +
992+
"<div><div class=\"result-name\">" +
1077993
(item.is_alias === true ?
1078994
("<span class=\"alias\"><b>" + item.alias + " </b></span><span " +
1079995
"class=\"grey\"><i>&nbsp;- see&nbsp;</i></span>") : "") +
1080996
item.displayPath + "<span class=\"" + type + "\">" +
1081-
name + "</span></a></td><td>" +
1082-
"<a href=\"" + item.href + "\">" +
997+
name + "</span></div><div>" +
1083998
"<span class=\"desc\">" + item.desc +
1084-
"&nbsp;</span></a></td></tr>";
999+
"&nbsp;</span></div></div></a>";
10851000
});
1086-
output += "</table>";
1001+
output += "</div>";
10871002
} else {
1088-
output = "<div class=\"search-failed\"" + extraStyle + ">No results :(<br/>" +
1003+
output = "<div class=\"search-failed\"" + extraClass + ">No results :(<br/>" +
10891004
"Try on <a href=\"https://duckduckgo.com/?q=" +
10901005
encodeURIComponent("rust " + query.query) +
10911006
"\">DuckDuckGo</a>?<br/><br/>" +
@@ -1121,7 +1036,7 @@ window.initSearch = function(rawSearchIndex) {
11211036
{
11221037
var elem = document.createElement("a");
11231038
elem.href = results.others[0].href;
1124-
elem.style.display = "none";
1039+
removeClass(elem, "active");
11251040
// For firefox, we need the element to be in the DOM so it can be clicked.
11261041
document.body.appendChild(elem);
11271042
elem.click();
@@ -1162,7 +1077,6 @@ window.initSearch = function(rawSearchIndex) {
11621077

11631078
search.innerHTML = output;
11641079
searchState.showResults(search);
1165-
initSearchNav();
11661080
var elems = document.getElementById("titles").childNodes;
11671081
elems[0].onclick = function() { printTab(0); };
11681082
elems[1].onclick = function() { printTab(1); };
@@ -1440,6 +1354,50 @@ window.initSearch = function(rawSearchIndex) {
14401354
};
14411355
searchState.input.onpaste = searchState.input.onchange;
14421356

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

0 commit comments

Comments
 (0)