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)",