Skip to content

Commit c1d72de

Browse files
committed
rustdoc: add interaction delays for tooltip popovers
Designing a good hover microinteraction is a matter of guessing user intent from what are, literally, vague gestures. In this case, guessing if hovering in our out of the tooltip base is intentional or not. To figure this out, a few different techniques are used: * When the mouse pointer enters a tooltip anchor point, its hitbox is grown on the bottom, where the popover is/will appear. This was already there before this commit: search "hover tunnel" in rustdoc.css for the implementation. * This commit adds a delay when the mouse pointer enters the base anchor, in case the mouse pointer was just passing through and the user didn't want to open it. * This commit also adds a delay when the mouse pointer exits the tooltip's base anchor or its popover, before hiding it. * A fade-out animation is layered onto the pointer exit delay to immediately inform the user that they successfully dismissed the popover, while still providing a way for them to cancel it if it was a mistake and they still wanted to interact with it. * No animation is used for revealing it, because we don't want people to try to interact with an element while it's in the middle of fading in: either they're allowed to interact with it while it's fading in, meaning it can't serve as mistake- proofing for opening the popover, or they can't, but they might try and be frustrated. See also: * https://www.nngroup.com/articles/timing-exposing-content/ * https://www.nngroup.com/articles/tooltip-guidelines/ * https://bjk5.com/post/44698559168/breaking-down-amazons-mega-dropdown
1 parent 52dd1cd commit c1d72de

File tree

4 files changed

+115
-10
lines changed

4 files changed

+115
-10
lines changed

src/librustdoc/html/static/css/rustdoc.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1191,6 +1191,14 @@ a.tooltip:hover::after {
11911191
content: "\00a0";
11921192
}
11931193

1194+
/* This animation is layered onto the mistake-proofing delay for dismissing
1195+
a hovered tooltip, to ensure it feels responsive even with the delay.
1196+
*/
1197+
.fade-out {
1198+
opacity: 0;
1199+
transition: opacity 0.45s cubic-bezier(0, 0, 0.1, 1.0);
1200+
}
1201+
11941202
.popover.tooltip .content {
11951203
margin: 0.25em 0.5em;
11961204
}

src/librustdoc/html/static/js/main.js

Lines changed: 104 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@
44

55
"use strict";
66

7+
// The amount of time that the cursor must remain still over a hover target before
8+
// revealing a tooltip.
9+
//
10+
// https://www.nngroup.com/articles/timing-exposing-content/
11+
window.RUSTDOC_TOOLTIP_HOVER_MS = 300;
12+
window.RUSTDOC_TOOLTIP_HOVER_EXIT_MS = 450;
13+
714
// Given a basename (e.g. "storage") and an extension (e.g. ".js"), return a URL
815
// for a resource under the root-path, with the resource-suffix.
916
function resourcePath(basename, extension) {
@@ -784,18 +791,25 @@ function preLoadCss(cssUrl) {
784791
}
785792
if (window.CURRENT_TOOLTIP_ELEMENT && window.CURRENT_TOOLTIP_ELEMENT.TOOLTIP_BASE === e) {
786793
// Make this function idempotent.
794+
clearTooltipHoverTimeout(window.CURRENT_TOOLTIP_ELEMENT);
787795
return;
788796
}
789797
window.hideAllModals(false);
790798
const wrapper = document.createElement("div");
791799
if (notable_ty) {
792800
wrapper.innerHTML = "<div class=\"content\">" +
793801
window.NOTABLE_TRAITS[notable_ty] + "</div>";
794-
} else if (e.getAttribute("title") !== undefined) {
795-
const titleContent = document.createElement("div");
796-
titleContent.className = "content";
797-
titleContent.appendChild(document.createTextNode(e.getAttribute("title")));
798-
wrapper.appendChild(titleContent);
802+
} else {
803+
if (e.getAttribute("title") !== null) {
804+
e.setAttribute("data-title", e.getAttribute("title"));
805+
e.removeAttribute("title");
806+
}
807+
if (e.getAttribute("data-title") !== null) {
808+
const titleContent = document.createElement("div");
809+
titleContent.className = "content";
810+
titleContent.appendChild(document.createTextNode(e.getAttribute("data-title")));
811+
wrapper.appendChild(titleContent);
812+
}
799813
}
800814
wrapper.className = "tooltip popover";
801815
const focusCatcher = document.createElement("div");
@@ -824,17 +838,59 @@ function preLoadCss(cssUrl) {
824838
wrapper.style.visibility = "";
825839
window.CURRENT_TOOLTIP_ELEMENT = wrapper;
826840
window.CURRENT_TOOLTIP_ELEMENT.TOOLTIP_BASE = e;
841+
clearTooltipHoverTimeout(window.CURRENT_TOOLTIP_ELEMENT);
842+
wrapper.onpointerenter = function(ev) {
843+
// If this is a synthetic touch event, ignore it. A click event will be along shortly.
844+
if (ev.pointerType !== "mouse") {
845+
return;
846+
}
847+
clearTooltipHoverTimeout(e);
848+
};
827849
wrapper.onpointerleave = function(ev) {
828850
// If this is a synthetic touch event, ignore it. A click event will be along shortly.
829851
if (ev.pointerType !== "mouse") {
830852
return;
831853
}
832-
if (!e.TOOLTIP_FORCE_VISIBLE && !elemIsInParent(event.relatedTarget, e)) {
833-
hideTooltip(true);
854+
if (!e.TOOLTIP_FORCE_VISIBLE && !elemIsInParent(ev.relatedTarget, e)) {
855+
// See "Tooltip pointer leave gesture" below.
856+
setTooltipHoverTimeout(e, false);
857+
addClass(wrapper, "fade-out");
834858
}
835859
};
836860
}
837861

862+
function setTooltipHoverTimeout(element, show) {
863+
clearTooltipHoverTimeout(element);
864+
if (!show && !window.CURRENT_TOOLTIP_ELEMENT) {
865+
// To "hide" an already hidden element, just cancel its timeout.
866+
return;
867+
}
868+
if (show && window.CURRENT_TOOLTIP_ELEMENT) {
869+
// To "show" an already visible element, just cancel its timeout.
870+
return;
871+
}
872+
if (window.CURRENT_TOOLTIP_ELEMENT &&
873+
window.CURRENT_TOOLTIP_ELEMENT.TOOLTIP_BASE !== element) {
874+
// Don't do anything if another tooltip is already visible.
875+
return;
876+
}
877+
element.TOOLTIP_HOVER_TIMEOUT = setTimeout(() => {
878+
if (show) {
879+
showTooltip(element);
880+
} else if (!element.TOOLTIP_FORCE_VISIBLE) {
881+
hideTooltip(false);
882+
}
883+
}, show ? window.RUSTDOC_TOOLTIP_HOVER_MS : window.RUSTDOC_TOOLTIP_HOVER_EXIT_MS);
884+
}
885+
886+
function clearTooltipHoverTimeout(element) {
887+
if (element.TOOLTIP_HOVER_TIMEOUT !== undefined) {
888+
removeClass(window.CURRENT_TOOLTIP_ELEMENT, "fade-out");
889+
clearTimeout(element.TOOLTIP_HOVER_TIMEOUT);
890+
delete element.TOOLTIP_HOVER_TIMEOUT;
891+
}
892+
}
893+
838894
function tooltipBlurHandler(event) {
839895
if (window.CURRENT_TOOLTIP_ELEMENT &&
840896
!elemIsInParent(document.activeElement, window.CURRENT_TOOLTIP_ELEMENT) &&
@@ -864,6 +920,7 @@ function preLoadCss(cssUrl) {
864920
}
865921
const body = document.getElementsByTagName("body")[0];
866922
body.removeChild(window.CURRENT_TOOLTIP_ELEMENT);
923+
clearTooltipHoverTimeout(window.CURRENT_TOOLTIP_ELEMENT);
867924
window.CURRENT_TOOLTIP_ELEMENT = null;
868925
}
869926
}
@@ -886,7 +943,14 @@ function preLoadCss(cssUrl) {
886943
if (ev.pointerType !== "mouse") {
887944
return;
888945
}
889-
showTooltip(this);
946+
setTooltipHoverTimeout(this, true);
947+
};
948+
e.onpointermove = function(ev) {
949+
// If this is a synthetic touch event, ignore it. A click event will be along shortly.
950+
if (ev.pointerType !== "mouse") {
951+
return;
952+
}
953+
setTooltipHoverTimeout(this, true);
890954
};
891955
e.onpointerleave = function(ev) {
892956
// If this is a synthetic touch event, ignore it. A click event will be along shortly.
@@ -895,7 +959,38 @@ function preLoadCss(cssUrl) {
895959
}
896960
if (!this.TOOLTIP_FORCE_VISIBLE &&
897961
!elemIsInParent(ev.relatedTarget, window.CURRENT_TOOLTIP_ELEMENT)) {
898-
hideTooltip(true);
962+
// Tooltip pointer leave gesture:
963+
//
964+
// Designing a good hover microinteraction is a matter of guessing user
965+
// intent from what are, literally, vague gestures. In this case, guessing if
966+
// hovering in or out of the tooltip base is intentional or not.
967+
//
968+
// To figure this out, a few different techniques are used:
969+
//
970+
// * When the mouse pointer enters a tooltip anchor point, its hitbox is grown
971+
// on the bottom, where the popover is/will appear. Search "hover tunnel" in
972+
// rustdoc.css for the implementation.
973+
// * There's a delay when the mouse pointer enters the popover base anchor, in
974+
// case the mouse pointer was just passing through and the user didn't want
975+
// to open it.
976+
// * Similarly, a delay is added when exiting the anchor, or the popover
977+
// itself, before hiding it.
978+
// * A fade-out animation is layered onto the pointer exit delay to immediately
979+
// inform the user that they successfully dismissed the popover, while still
980+
// providing a way for them to cancel it if it was a mistake and they still
981+
// wanted to interact with it.
982+
// * No animation is used for revealing it, because we don't want people to try
983+
// to interact with an element while it's in the middle of fading in: either
984+
// they're allowed to interact with it while it's fading in, meaning it can't
985+
// serve as mistake-proofing for the popover, or they can't, but
986+
// they might try and be frustrated.
987+
//
988+
// See also:
989+
// * https://www.nngroup.com/articles/timing-exposing-content/
990+
// * https://www.nngroup.com/articles/tooltip-guidelines/
991+
// * https://bjk5.com/post/44698559168/breaking-down-amazons-mega-dropdown
992+
setTooltipHoverTimeout(e, false);
993+
addClass(window.CURRENT_TOOLTIP_ELEMENT, "fade-out");
899994
}
900995
};
901996
});

tests/rustdoc-gui/codeblock-tooltip.goml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ define-function: (
4040
"background-color": |background|,
4141
"border-color": |border|,
4242
})
43+
click: ".docblock .example-wrap.compile_fail .tooltip"
4344

4445
// should_panic block
4546
assert-css: (
@@ -71,6 +72,7 @@ define-function: (
7172
"background-color": |background|,
7273
"border-color": |border|,
7374
})
75+
click: ".docblock .example-wrap.should_panic .tooltip"
7476

7577
// ignore block
7678
assert-css: (

tests/rustdoc-gui/notable-trait.goml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ define-function: (
134134
reload:
135135

136136
move-cursor-to: "//*[@id='method.create_an_iterator_from_read']//*[@class='tooltip']"
137-
assert-count: (".tooltip.popover", 1)
137+
wait-for-count: (".tooltip.popover", 1)
138138

139139
assert-css: (
140140
".tooltip.popover h3",

0 commit comments

Comments
 (0)