diff --git a/src/librustdoc/html/static/css/rustdoc.css b/src/librustdoc/html/static/css/rustdoc.css
index a7d5f497756..cd5ff48c979 100644
--- a/src/librustdoc/html/static/css/rustdoc.css
+++ b/src/librustdoc/html/static/css/rustdoc.css
@@ -1191,6 +1191,14 @@ a.tooltip:hover::after {
content: "\00a0";
}
+/* This animation is layered onto the mistake-proofing delay for dismissing
+ a hovered tooltip, to ensure it feels responsive even with the delay.
+ */
+.fade-out {
+ opacity: 0;
+ transition: opacity 0.45s cubic-bezier(0, 0, 0.1, 1.0);
+}
+
.popover.tooltip .content {
margin: 0.25em 0.5em;
}
diff --git a/src/librustdoc/html/static/js/main.js b/src/librustdoc/html/static/js/main.js
index bccf675c14b..11ed2f5f901 100644
--- a/src/librustdoc/html/static/js/main.js
+++ b/src/librustdoc/html/static/js/main.js
@@ -4,6 +4,13 @@
"use strict";
+// The amount of time that the cursor must remain still over a hover target before
+// revealing a tooltip.
+//
+// https://www.nngroup.com/articles/timing-exposing-content/
+window.RUSTDOC_TOOLTIP_HOVER_MS = 300;
+window.RUSTDOC_TOOLTIP_HOVER_EXIT_MS = 450;
+
// Given a basename (e.g. "storage") and an extension (e.g. ".js"), return a URL
// for a resource under the root-path, with the resource-suffix.
function resourcePath(basename, extension) {
@@ -784,6 +791,7 @@ function preLoadCss(cssUrl) {
}
if (window.CURRENT_TOOLTIP_ELEMENT && window.CURRENT_TOOLTIP_ELEMENT.TOOLTIP_BASE === e) {
// Make this function idempotent.
+ clearTooltipHoverTimeout(window.CURRENT_TOOLTIP_ELEMENT);
return;
}
window.hideAllModals(false);
@@ -791,11 +799,17 @@ function preLoadCss(cssUrl) {
if (notable_ty) {
wrapper.innerHTML = "
" +
window.NOTABLE_TRAITS[notable_ty] + "
";
- } else if (e.getAttribute("title") !== undefined) {
- const titleContent = document.createElement("div");
- titleContent.className = "content";
- titleContent.appendChild(document.createTextNode(e.getAttribute("title")));
- wrapper.appendChild(titleContent);
+ } else {
+ if (e.getAttribute("title") !== null) {
+ e.setAttribute("data-title", e.getAttribute("title"));
+ e.removeAttribute("title");
+ }
+ if (e.getAttribute("data-title") !== null) {
+ const titleContent = document.createElement("div");
+ titleContent.className = "content";
+ titleContent.appendChild(document.createTextNode(e.getAttribute("data-title")));
+ wrapper.appendChild(titleContent);
+ }
}
wrapper.className = "tooltip popover";
const focusCatcher = document.createElement("div");
@@ -824,17 +838,59 @@ function preLoadCss(cssUrl) {
wrapper.style.visibility = "";
window.CURRENT_TOOLTIP_ELEMENT = wrapper;
window.CURRENT_TOOLTIP_ELEMENT.TOOLTIP_BASE = e;
+ clearTooltipHoverTimeout(window.CURRENT_TOOLTIP_ELEMENT);
+ wrapper.onpointerenter = function(ev) {
+ // If this is a synthetic touch event, ignore it. A click event will be along shortly.
+ if (ev.pointerType !== "mouse") {
+ return;
+ }
+ clearTooltipHoverTimeout(e);
+ };
wrapper.onpointerleave = function(ev) {
// If this is a synthetic touch event, ignore it. A click event will be along shortly.
if (ev.pointerType !== "mouse") {
return;
}
- if (!e.TOOLTIP_FORCE_VISIBLE && !elemIsInParent(event.relatedTarget, e)) {
- hideTooltip(true);
+ if (!e.TOOLTIP_FORCE_VISIBLE && !elemIsInParent(ev.relatedTarget, e)) {
+ // See "Tooltip pointer leave gesture" below.
+ setTooltipHoverTimeout(e, false);
+ addClass(wrapper, "fade-out");
}
};
}
+ function setTooltipHoverTimeout(element, show) {
+ clearTooltipHoverTimeout(element);
+ if (!show && !window.CURRENT_TOOLTIP_ELEMENT) {
+ // To "hide" an already hidden element, just cancel its timeout.
+ return;
+ }
+ if (show && window.CURRENT_TOOLTIP_ELEMENT) {
+ // To "show" an already visible element, just cancel its timeout.
+ return;
+ }
+ if (window.CURRENT_TOOLTIP_ELEMENT &&
+ window.CURRENT_TOOLTIP_ELEMENT.TOOLTIP_BASE !== element) {
+ // Don't do anything if another tooltip is already visible.
+ return;
+ }
+ element.TOOLTIP_HOVER_TIMEOUT = setTimeout(() => {
+ if (show) {
+ showTooltip(element);
+ } else if (!element.TOOLTIP_FORCE_VISIBLE) {
+ hideTooltip(false);
+ }
+ }, show ? window.RUSTDOC_TOOLTIP_HOVER_MS : window.RUSTDOC_TOOLTIP_HOVER_EXIT_MS);
+ }
+
+ function clearTooltipHoverTimeout(element) {
+ if (element.TOOLTIP_HOVER_TIMEOUT !== undefined) {
+ removeClass(window.CURRENT_TOOLTIP_ELEMENT, "fade-out");
+ clearTimeout(element.TOOLTIP_HOVER_TIMEOUT);
+ delete element.TOOLTIP_HOVER_TIMEOUT;
+ }
+ }
+
function tooltipBlurHandler(event) {
if (window.CURRENT_TOOLTIP_ELEMENT &&
!elemIsInParent(document.activeElement, window.CURRENT_TOOLTIP_ELEMENT) &&
@@ -864,6 +920,7 @@ function preLoadCss(cssUrl) {
}
const body = document.getElementsByTagName("body")[0];
body.removeChild(window.CURRENT_TOOLTIP_ELEMENT);
+ clearTooltipHoverTimeout(window.CURRENT_TOOLTIP_ELEMENT);
window.CURRENT_TOOLTIP_ELEMENT = null;
}
}
@@ -886,7 +943,14 @@ function preLoadCss(cssUrl) {
if (ev.pointerType !== "mouse") {
return;
}
- showTooltip(this);
+ setTooltipHoverTimeout(this, true);
+ };
+ e.onpointermove = function(ev) {
+ // If this is a synthetic touch event, ignore it. A click event will be along shortly.
+ if (ev.pointerType !== "mouse") {
+ return;
+ }
+ setTooltipHoverTimeout(this, true);
};
e.onpointerleave = function(ev) {
// If this is a synthetic touch event, ignore it. A click event will be along shortly.
@@ -895,7 +959,38 @@ function preLoadCss(cssUrl) {
}
if (!this.TOOLTIP_FORCE_VISIBLE &&
!elemIsInParent(ev.relatedTarget, window.CURRENT_TOOLTIP_ELEMENT)) {
- hideTooltip(true);
+ // Tooltip pointer leave gesture:
+ //
+ // 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 or 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. Search "hover tunnel" in
+ // rustdoc.css for the implementation.
+ // * There's a delay when the mouse pointer enters the popover base anchor, in
+ // case the mouse pointer was just passing through and the user didn't want
+ // to open it.
+ // * Similarly, a delay is added when exiting the anchor, or the popover
+ // itself, 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 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
+ setTooltipHoverTimeout(e, false);
+ addClass(window.CURRENT_TOOLTIP_ELEMENT, "fade-out");
}
};
});
diff --git a/tests/rustdoc-gui/codeblock-tooltip.goml b/tests/rustdoc-gui/codeblock-tooltip.goml
index e1c81ed79e4..7be5e39ba47 100644
--- a/tests/rustdoc-gui/codeblock-tooltip.goml
+++ b/tests/rustdoc-gui/codeblock-tooltip.goml
@@ -40,6 +40,7 @@ define-function: (
"background-color": |background|,
"border-color": |border|,
})
+ click: ".docblock .example-wrap.compile_fail .tooltip"
// should_panic block
assert-css: (
@@ -71,6 +72,7 @@ define-function: (
"background-color": |background|,
"border-color": |border|,
})
+ click: ".docblock .example-wrap.should_panic .tooltip"
// ignore block
assert-css: (
diff --git a/tests/rustdoc-gui/notable-trait.goml b/tests/rustdoc-gui/notable-trait.goml
index ecb57c274a5..09b3dfe2825 100644
--- a/tests/rustdoc-gui/notable-trait.goml
+++ b/tests/rustdoc-gui/notable-trait.goml
@@ -134,7 +134,7 @@ define-function: (
reload:
move-cursor-to: "//*[@id='method.create_an_iterator_from_read']//*[@class='tooltip']"
- assert-count: (".tooltip.popover", 1)
+ wait-for-count: (".tooltip.popover", 1)
assert-css: (
".tooltip.popover h3",