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