Skip to content

Commit 65a7eef

Browse files
authored
Rollup merge of rust-lang#111892 - notriddle:notriddle/timeout-tooltip, r=me,GuillaumeGomez,Manishearth
rustdoc: add interaction delays for tooltip popovers Preview: * [notable traits](http://notriddle.com/rustdoc-demo-html-3/delay-tooltip/testing/struct.Vec.html#method.iter) * [panicking code block](http://notriddle.com/rustdoc-demo-html-3/delay-tooltip/testing/struct.Vec.html#indexing) 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
2 parents 70e2564 + d7d497a commit 65a7eef

File tree

4 files changed

+169
-12
lines changed

4 files changed

+169
-12
lines changed

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1179,6 +1179,10 @@ a.test-arrow:hover {
11791179
position: relative;
11801180
}
11811181

1182+
.code-header a.tooltip:hover {
1183+
color: var(--link-color);
1184+
}
1185+
11821186
/* placeholder thunk so that the mouse can easily travel from "(i)" to popover
11831187
the resulting "hover tunnel" is a stepped triangle, approximating
11841188
https://bjk5.com/post/44698559168/breaking-down-amazons-mega-dropdown */
@@ -1191,6 +1195,14 @@ a.tooltip:hover::after {
11911195
content: "\00a0";
11921196
}
11931197

1198+
/* This animation is layered onto the mistake-proofing delay for dismissing
1199+
a hovered tooltip, to ensure it feels responsive even with the delay.
1200+
*/
1201+
.fade-out {
1202+
opacity: 0;
1203+
transition: opacity 0.45s cubic-bezier(0, 0, 0.1, 1.0);
1204+
}
1205+
11941206
.popover.tooltip .content {
11951207
margin: 0.25em 0.5em;
11961208
}

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

Lines changed: 138 additions & 10 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) {
@@ -772,6 +779,13 @@ function preLoadCss(cssUrl) {
772779
});
773780
});
774781

782+
/**
783+
* Show a tooltip immediately.
784+
*
785+
* @param {DOMElement} e - The tooltip's anchor point. The DOM is consulted to figure
786+
* out what the tooltip should contain, and where it should be
787+
* positioned.
788+
*/
775789
function showTooltip(e) {
776790
const notable_ty = e.getAttribute("data-notable-ty");
777791
if (!window.NOTABLE_TRAITS && notable_ty) {
@@ -782,20 +796,29 @@ function preLoadCss(cssUrl) {
782796
throw new Error("showTooltip() called with notable without any notable traits!");
783797
}
784798
}
799+
// Make this function idempotent. If the tooltip is already shown, avoid doing extra work
800+
// and leave it alone.
785801
if (window.CURRENT_TOOLTIP_ELEMENT && window.CURRENT_TOOLTIP_ELEMENT.TOOLTIP_BASE === e) {
786-
// Make this function idempotent.
802+
clearTooltipHoverTimeout(window.CURRENT_TOOLTIP_ELEMENT);
787803
return;
788804
}
789805
window.hideAllModals(false);
790806
const wrapper = document.createElement("div");
791807
if (notable_ty) {
792808
wrapper.innerHTML = "<div class=\"content\">" +
793809
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);
810+
} else {
811+
// Replace any `title` attribute with `data-title` to avoid double tooltips.
812+
if (e.getAttribute("title") !== null) {
813+
e.setAttribute("data-title", e.getAttribute("title"));
814+
e.removeAttribute("title");
815+
}
816+
if (e.getAttribute("data-title") !== null) {
817+
const titleContent = document.createElement("div");
818+
titleContent.className = "content";
819+
titleContent.appendChild(document.createTextNode(e.getAttribute("data-title")));
820+
wrapper.appendChild(titleContent);
821+
}
799822
}
800823
wrapper.className = "tooltip popover";
801824
const focusCatcher = document.createElement("div");
@@ -824,17 +847,77 @@ function preLoadCss(cssUrl) {
824847
wrapper.style.visibility = "";
825848
window.CURRENT_TOOLTIP_ELEMENT = wrapper;
826849
window.CURRENT_TOOLTIP_ELEMENT.TOOLTIP_BASE = e;
850+
clearTooltipHoverTimeout(window.CURRENT_TOOLTIP_ELEMENT);
851+
wrapper.onpointerenter = function(ev) {
852+
// If this is a synthetic touch event, ignore it. A click event will be along shortly.
853+
if (ev.pointerType !== "mouse") {
854+
return;
855+
}
856+
clearTooltipHoverTimeout(e);
857+
};
827858
wrapper.onpointerleave = function(ev) {
828859
// If this is a synthetic touch event, ignore it. A click event will be along shortly.
829860
if (ev.pointerType !== "mouse") {
830861
return;
831862
}
832-
if (!e.TOOLTIP_FORCE_VISIBLE && !elemIsInParent(event.relatedTarget, e)) {
833-
hideTooltip(true);
863+
if (!e.TOOLTIP_FORCE_VISIBLE && !elemIsInParent(ev.relatedTarget, e)) {
864+
// See "Tooltip pointer leave gesture" below.
865+
setTooltipHoverTimeout(e, false);
866+
addClass(wrapper, "fade-out");
834867
}
835868
};
836869
}
837870

871+
/**
872+
* Show or hide the tooltip after a timeout. If a timeout was already set before this function
873+
* was called, that timeout gets cleared. If the tooltip is already in the requested state,
874+
* this function will still clear any pending timeout, but otherwise do nothing.
875+
*
876+
* @param {DOMElement} element - The tooltip's anchor point. The DOM is consulted to figure
877+
* out what the tooltip should contain, and where it should be
878+
* positioned.
879+
* @param {boolean} show - If true, the tooltip will be made visible. If false, it will
880+
* be hidden.
881+
*/
882+
function setTooltipHoverTimeout(element, show) {
883+
clearTooltipHoverTimeout(element);
884+
if (!show && !window.CURRENT_TOOLTIP_ELEMENT) {
885+
// To "hide" an already hidden element, just cancel its timeout.
886+
return;
887+
}
888+
if (show && window.CURRENT_TOOLTIP_ELEMENT) {
889+
// To "show" an already visible element, just cancel its timeout.
890+
return;
891+
}
892+
if (window.CURRENT_TOOLTIP_ELEMENT &&
893+
window.CURRENT_TOOLTIP_ELEMENT.TOOLTIP_BASE !== element) {
894+
// Don't do anything if another tooltip is already visible.
895+
return;
896+
}
897+
element.TOOLTIP_HOVER_TIMEOUT = setTimeout(() => {
898+
if (show) {
899+
showTooltip(element);
900+
} else if (!element.TOOLTIP_FORCE_VISIBLE) {
901+
hideTooltip(false);
902+
}
903+
}, show ? window.RUSTDOC_TOOLTIP_HOVER_MS : window.RUSTDOC_TOOLTIP_HOVER_EXIT_MS);
904+
}
905+
906+
/**
907+
* If a show/hide timeout was set by `setTooltipHoverTimeout`, cancel it. If none exists,
908+
* do nothing.
909+
*
910+
* @param {DOMElement} element - The tooltip's anchor point,
911+
* as passed to `setTooltipHoverTimeout`.
912+
*/
913+
function clearTooltipHoverTimeout(element) {
914+
if (element.TOOLTIP_HOVER_TIMEOUT !== undefined) {
915+
removeClass(window.CURRENT_TOOLTIP_ELEMENT, "fade-out");
916+
clearTimeout(element.TOOLTIP_HOVER_TIMEOUT);
917+
delete element.TOOLTIP_HOVER_TIMEOUT;
918+
}
919+
}
920+
838921
function tooltipBlurHandler(event) {
839922
if (window.CURRENT_TOOLTIP_ELEMENT &&
840923
!elemIsInParent(document.activeElement, window.CURRENT_TOOLTIP_ELEMENT) &&
@@ -854,6 +937,12 @@ function preLoadCss(cssUrl) {
854937
}
855938
}
856939

940+
/**
941+
* Hide the current tooltip immediately.
942+
*
943+
* @param {boolean} focus - If set to `true`, move keyboard focus to the tooltip anchor point.
944+
* If set to `false`, leave keyboard focus alone.
945+
*/
857946
function hideTooltip(focus) {
858947
if (window.CURRENT_TOOLTIP_ELEMENT) {
859948
if (window.CURRENT_TOOLTIP_ELEMENT.TOOLTIP_BASE.TOOLTIP_FORCE_VISIBLE) {
@@ -864,6 +953,7 @@ function preLoadCss(cssUrl) {
864953
}
865954
const body = document.getElementsByTagName("body")[0];
866955
body.removeChild(window.CURRENT_TOOLTIP_ELEMENT);
956+
clearTooltipHoverTimeout(window.CURRENT_TOOLTIP_ELEMENT);
867957
window.CURRENT_TOOLTIP_ELEMENT = null;
868958
}
869959
}
@@ -886,7 +976,14 @@ function preLoadCss(cssUrl) {
886976
if (ev.pointerType !== "mouse") {
887977
return;
888978
}
889-
showTooltip(this);
979+
setTooltipHoverTimeout(this, true);
980+
};
981+
e.onpointermove = function(ev) {
982+
// If this is a synthetic touch event, ignore it. A click event will be along shortly.
983+
if (ev.pointerType !== "mouse") {
984+
return;
985+
}
986+
setTooltipHoverTimeout(this, true);
890987
};
891988
e.onpointerleave = function(ev) {
892989
// If this is a synthetic touch event, ignore it. A click event will be along shortly.
@@ -895,7 +992,38 @@ function preLoadCss(cssUrl) {
895992
}
896993
if (!this.TOOLTIP_FORCE_VISIBLE &&
897994
!elemIsInParent(ev.relatedTarget, window.CURRENT_TOOLTIP_ELEMENT)) {
898-
hideTooltip(true);
995+
// Tooltip pointer leave gesture:
996+
//
997+
// Designing a good hover microinteraction is a matter of guessing user
998+
// intent from what are, literally, vague gestures. In this case, guessing if
999+
// hovering in or out of the tooltip base is intentional or not.
1000+
//
1001+
// To figure this out, a few different techniques are used:
1002+
//
1003+
// * When the mouse pointer enters a tooltip anchor point, its hitbox is grown
1004+
// on the bottom, where the popover is/will appear. Search "hover tunnel" in
1005+
// rustdoc.css for the implementation.
1006+
// * There's a delay when the mouse pointer enters the popover base anchor, in
1007+
// case the mouse pointer was just passing through and the user didn't want
1008+
// to open it.
1009+
// * Similarly, a delay is added when exiting the anchor, or the popover
1010+
// itself, before hiding it.
1011+
// * A fade-out animation is layered onto the pointer exit delay to immediately
1012+
// inform the user that they successfully dismissed the popover, while still
1013+
// providing a way for them to cancel it if it was a mistake and they still
1014+
// wanted to interact with it.
1015+
// * No animation is used for revealing it, because we don't want people to try
1016+
// to interact with an element while it's in the middle of fading in: either
1017+
// they're allowed to interact with it while it's fading in, meaning it can't
1018+
// serve as mistake-proofing for the popover, or they can't, but
1019+
// they might try and be frustrated.
1020+
//
1021+
// See also:
1022+
// * https://www.nngroup.com/articles/timing-exposing-content/
1023+
// * https://www.nngroup.com/articles/tooltip-guidelines/
1024+
// * https://bjk5.com/post/44698559168/breaking-down-amazons-mega-dropdown
1025+
setTooltipHoverTimeout(e, false);
1026+
addClass(window.CURRENT_TOOLTIP_ELEMENT, "fade-out");
8991027
}
9001028
};
9011029
});

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: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ assert-count: ("//*[@class='tooltip popover']", 0)
122122
// Now check the colors.
123123
define-function: (
124124
"check-colors",
125-
(theme, header_color, content_color, type_color, trait_color),
125+
(theme, header_color, content_color, type_color, trait_color, link_color),
126126
block {
127127
go-to: "file://" + |DOC_PATH| + "/test_docs/struct.NotableStructWithLongName.html"
128128
// This is needed to ensure that the text color is computed.
@@ -133,8 +133,20 @@ define-function: (
133133
// We reload the page so the local storage settings are being used.
134134
reload:
135135

136+
assert-css: (
137+
"//*[@id='method.create_an_iterator_from_read']//*[@class='tooltip']",
138+
{"color": |content_color|},
139+
ALL,
140+
)
141+
136142
move-cursor-to: "//*[@id='method.create_an_iterator_from_read']//*[@class='tooltip']"
137-
assert-count: (".tooltip.popover", 1)
143+
wait-for-count: (".tooltip.popover", 1)
144+
145+
assert-css: (
146+
"//*[@id='method.create_an_iterator_from_read']//*[@class='tooltip']",
147+
{"color": |link_color|},
148+
ALL,
149+
)
138150

139151
assert-css: (
140152
".tooltip.popover h3",
@@ -163,6 +175,7 @@ call-function: (
163175
"check-colors",
164176
{
165177
"theme": "ayu",
178+
"link_color": "rgb(57, 175, 215)",
166179
"content_color": "rgb(230, 225, 207)",
167180
"header_color": "rgb(255, 255, 255)",
168181
"type_color": "rgb(255, 160, 165)",
@@ -174,6 +187,7 @@ call-function: (
174187
"check-colors",
175188
{
176189
"theme": "dark",
190+
"link_color": "rgb(210, 153, 29)",
177191
"content_color": "rgb(221, 221, 221)",
178192
"header_color": "rgb(221, 221, 221)",
179193
"type_color": "rgb(45, 191, 184)",
@@ -185,6 +199,7 @@ call-function: (
185199
"check-colors",
186200
{
187201
"theme": "light",
202+
"link_color": "rgb(56, 115, 173)",
188203
"content_color": "rgb(0, 0, 0)",
189204
"header_color": "rgb(0, 0, 0)",
190205
"type_color": "rgb(173, 55, 138)",

0 commit comments

Comments
 (0)