diff --git a/src/librustdoc/html/static/css/rustdoc.css b/src/librustdoc/html/static/css/rustdoc.css
index a7d5f497756..054cfe7597e 100644
--- a/src/librustdoc/html/static/css/rustdoc.css
+++ b/src/librustdoc/html/static/css/rustdoc.css
@@ -1179,6 +1179,10 @@ a.test-arrow:hover {
position: relative;
}
+.code-header a.tooltip:hover {
+ color: var(--link-color);
+}
+
/* placeholder thunk so that the mouse can easily travel from "(i)" to popover
the resulting "hover tunnel" is a stepped triangle, approximating
https://bjk5.com/post/44698559168/breaking-down-amazons-mega-dropdown */
@@ -1191,6 +1195,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..6da51ea0a55 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) {
@@ -772,6 +779,13 @@ function preLoadCss(cssUrl) {
});
});
+ /**
+ * Show a tooltip immediately.
+ *
+ * @param {DOMElement} e - The tooltip's anchor point. The DOM is consulted to figure
+ * out what the tooltip should contain, and where it should be
+ * positioned.
+ */
function showTooltip(e) {
const notable_ty = e.getAttribute("data-notable-ty");
if (!window.NOTABLE_TRAITS && notable_ty) {
@@ -782,8 +796,10 @@ function preLoadCss(cssUrl) {
throw new Error("showTooltip() called with notable without any notable traits!");
}
}
+ // Make this function idempotent. If the tooltip is already shown, avoid doing extra work
+ // and leave it alone.
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 +807,18 @@ 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 {
+ // Replace any `title` attribute with `data-title` to avoid double tooltips.
+ 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 +847,77 @@ 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");
}
};
}
+ /**
+ * Show or hide the tooltip after a timeout. If a timeout was already set before this function
+ * was called, that timeout gets cleared. If the tooltip is already in the requested state,
+ * this function will still clear any pending timeout, but otherwise do nothing.
+ *
+ * @param {DOMElement} element - The tooltip's anchor point. The DOM is consulted to figure
+ * out what the tooltip should contain, and where it should be
+ * positioned.
+ * @param {boolean} show - If true, the tooltip will be made visible. If false, it will
+ * be hidden.
+ */
+ 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);
+ }
+
+ /**
+ * If a show/hide timeout was set by `setTooltipHoverTimeout`, cancel it. If none exists,
+ * do nothing.
+ *
+ * @param {DOMElement} element - The tooltip's anchor point,
+ * as passed to `setTooltipHoverTimeout`.
+ */
+ 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) &&
@@ -854,6 +937,12 @@ function preLoadCss(cssUrl) {
}
}
+ /**
+ * Hide the current tooltip immediately.
+ *
+ * @param {boolean} focus - If set to `true`, move keyboard focus to the tooltip anchor point.
+ * If set to `false`, leave keyboard focus alone.
+ */
function hideTooltip(focus) {
if (window.CURRENT_TOOLTIP_ELEMENT) {
if (window.CURRENT_TOOLTIP_ELEMENT.TOOLTIP_BASE.TOOLTIP_FORCE_VISIBLE) {
@@ -864,6 +953,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 +976,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 +992,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..371931d51fc 100644
--- a/tests/rustdoc-gui/notable-trait.goml
+++ b/tests/rustdoc-gui/notable-trait.goml
@@ -122,7 +122,7 @@ assert-count: ("//*[@class='tooltip popover']", 0)
// Now check the colors.
define-function: (
"check-colors",
- (theme, header_color, content_color, type_color, trait_color),
+ (theme, header_color, content_color, type_color, trait_color, link_color),
block {
go-to: "file://" + |DOC_PATH| + "/test_docs/struct.NotableStructWithLongName.html"
// This is needed to ensure that the text color is computed.
@@ -133,8 +133,20 @@ define-function: (
// We reload the page so the local storage settings are being used.
reload:
+ assert-css: (
+ "//*[@id='method.create_an_iterator_from_read']//*[@class='tooltip']",
+ {"color": |content_color|},
+ ALL,
+ )
+
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: (
+ "//*[@id='method.create_an_iterator_from_read']//*[@class='tooltip']",
+ {"color": |link_color|},
+ ALL,
+ )
assert-css: (
".tooltip.popover h3",
@@ -163,6 +175,7 @@ call-function: (
"check-colors",
{
"theme": "ayu",
+ "link_color": "rgb(57, 175, 215)",
"content_color": "rgb(230, 225, 207)",
"header_color": "rgb(255, 255, 255)",
"type_color": "rgb(255, 160, 165)",
@@ -174,6 +187,7 @@ call-function: (
"check-colors",
{
"theme": "dark",
+ "link_color": "rgb(210, 153, 29)",
"content_color": "rgb(221, 221, 221)",
"header_color": "rgb(221, 221, 221)",
"type_color": "rgb(45, 191, 184)",
@@ -185,6 +199,7 @@ call-function: (
"check-colors",
{
"theme": "light",
+ "link_color": "rgb(56, 115, 173)",
"content_color": "rgb(0, 0, 0)",
"header_color": "rgb(0, 0, 0)",
"type_color": "rgb(173, 55, 138)",