From c1d72de030f3c1287a363ff7cb134664a6bf3032 Mon Sep 17 00:00:00 2001 From: Michael Howell Date: Tue, 23 May 2023 15:29:43 -0700 Subject: rustdoc: add interaction delays for tooltip popovers 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 --- src/librustdoc/html/static/css/rustdoc.css | 8 ++ src/librustdoc/html/static/js/main.js | 113 ++++++++++++++++++++++++++--- 2 files changed, 112 insertions(+), 9 deletions(-) (limited to 'src/librustdoc/html') 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"); } }; }); -- cgit 1.4.1-3-g733a5 From b9302d72346444b74bfbb2274c6da1acda9622a7 Mon Sep 17 00:00:00 2001 From: Michael Howell Date: Tue, 23 May 2023 17:19:35 -0700 Subject: rustdoc: add hover indicator for notable trait tooltip --- src/librustdoc/html/static/css/rustdoc.css | 4 ++++ tests/rustdoc-gui/notable-trait.goml | 17 ++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) (limited to 'src/librustdoc/html') diff --git a/src/librustdoc/html/static/css/rustdoc.css b/src/librustdoc/html/static/css/rustdoc.css index cd5ff48c979..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 */ diff --git a/tests/rustdoc-gui/notable-trait.goml b/tests/rustdoc-gui/notable-trait.goml index 09b3dfe2825..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,9 +133,21 @@ 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']" 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", {"color": |header_color|}, @@ -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)", -- cgit 1.4.1-3-g733a5 From 9968f3ce55d83baf916d55920b721d737581efc7 Mon Sep 17 00:00:00 2001 From: benediktwerner <1benediktwerner@gmail.com> Date: Sat, 25 Feb 2023 17:52:25 +0100 Subject: rustdoc: Fix LinkReplacer link matching --- src/librustdoc/html/markdown.rs | 15 ++++++++---- tests/rustdoc/intra-doc/issue-108459.rs | 37 ++++++++++++++++++++++++++++++ tests/rustdoc/intra-doc/prim-precedence.rs | 2 +- 3 files changed, 48 insertions(+), 6 deletions(-) create mode 100644 tests/rustdoc/intra-doc/issue-108459.rs (limited to 'src/librustdoc/html') diff --git a/src/librustdoc/html/markdown.rs b/src/librustdoc/html/markdown.rs index 9ef0b501c08..2f7ffce017b 100644 --- a/src/librustdoc/html/markdown.rs +++ b/src/librustdoc/html/markdown.rs @@ -382,7 +382,6 @@ impl<'a, I: Iterator>> Iterator for LinkReplacer<'a, I> { Some(Event::Code(text)) => { trace!("saw code {}", text); if let Some(link) = self.shortcut_link { - trace!("original text was {}", link.original_text); // NOTE: this only replaces if the code block is the *entire* text. // If only part of the link has code highlighting, the disambiguator will not be removed. // e.g. [fn@`f`] @@ -391,8 +390,11 @@ impl<'a, I: Iterator>> Iterator for LinkReplacer<'a, I> { // So we could never be sure we weren't replacing too much: // [fn@my_`f`unc] is treated the same as [my_func()] in that pass. // - // NOTE: &[1..len() - 1] is to strip the backticks - if **text == link.original_text[1..link.original_text.len() - 1] { + // NOTE: .get(1..len() - 1) is to strip the backticks + if let Some(link) = self.links.iter().find(|l| { + l.href == link.href + && Some(&**text) == l.original_text.get(1..l.original_text.len() - 1) + }) { debug!("replacing {} with {}", text, link.new_text); *text = CowStr::Borrowed(&link.new_text); } @@ -403,9 +405,12 @@ impl<'a, I: Iterator>> Iterator for LinkReplacer<'a, I> { Some(Event::Text(text)) => { trace!("saw text {}", text); if let Some(link) = self.shortcut_link { - trace!("original text was {}", link.original_text); // NOTE: same limitations as `Event::Code` - if **text == *link.original_text { + if let Some(link) = self + .links + .iter() + .find(|l| l.href == link.href && **text == *l.original_text) + { debug!("replacing {} with {}", text, link.new_text); *text = CowStr::Borrowed(&link.new_text); } diff --git a/tests/rustdoc/intra-doc/issue-108459.rs b/tests/rustdoc/intra-doc/issue-108459.rs new file mode 100644 index 00000000000..eb1c7a05e54 --- /dev/null +++ b/tests/rustdoc/intra-doc/issue-108459.rs @@ -0,0 +1,37 @@ +#![deny(rustdoc::broken_intra_doc_links)] + +pub struct S; +pub mod char {} + +// Ensure this doesn't ICE due to trying to slice off non-existent backticks from "S" + +/// See [S] and [`S`] +pub struct MyStruct1; + +// Ensure that link texts are replaced correctly even if there are multiple links with +// the same target but different text + +/// See also [crate::char] and [mod@char] and [prim@char] +// @has issue_108459/struct.MyStruct2.html '//*[@href="char/index.html"]' 'crate::char' +// @has - '//*[@href="char/index.html"]' 'char' +// @has - '//*[@href="{{channel}}/std/primitive.char.html"]' 'char' +pub struct MyStruct2; + +/// See also [mod@char] and [prim@char] and [crate::char] +// @has issue_108459/struct.MyStruct3.html '//*[@href="char/index.html"]' 'crate::char' +// @has - '//*[@href="char/index.html"]' 'char' +// @has - '//*[@href="{{channel}}/std/primitive.char.html"]' 'char' +pub struct MyStruct3; + +// Ensure that links are correct even if there are multiple links with the same text but +// different targets + +/// See also [char][mod@char] and [char][prim@char] +// @has issue_108459/struct.MyStruct4.html '//*[@href="char/index.html"]' 'char' +// @has - '//*[@href="{{channel}}/std/primitive.char.html"]' 'char' +pub struct MyStruct4; + +/// See also [char][prim@char] and [char][crate::char] +// @has issue_108459/struct.MyStruct5.html '//*[@href="char/index.html"]' 'char' +// @has - '//*[@href="{{channel}}/std/primitive.char.html"]' 'char' +pub struct MyStruct5; diff --git a/tests/rustdoc/intra-doc/prim-precedence.rs b/tests/rustdoc/intra-doc/prim-precedence.rs index 25625b95277..c5a64e42a01 100644 --- a/tests/rustdoc/intra-doc/prim-precedence.rs +++ b/tests/rustdoc/intra-doc/prim-precedence.rs @@ -12,5 +12,5 @@ pub struct MyString; /// See also [crate::char] and [mod@char] // @has prim_precedence/struct.MyString2.html '//*[@href="char/index.html"]' 'crate::char' -// @has - '//*[@href="char/index.html"]' 'mod@char' +// @has - '//*[@href="char/index.html"]' 'char' pub struct MyString2; -- cgit 1.4.1-3-g733a5 From d7d497a3c11ea7dc764d0f8d58a2a15cb395f5e9 Mon Sep 17 00:00:00 2001 From: Michael Howell Date: Wed, 31 May 2023 16:55:59 -0700 Subject: rustdoc: add jsdoc comments for complex functions --- src/librustdoc/html/static/js/main.js | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) (limited to 'src/librustdoc/html') diff --git a/src/librustdoc/html/static/js/main.js b/src/librustdoc/html/static/js/main.js index 11ed2f5f901..6da51ea0a55 100644 --- a/src/librustdoc/html/static/js/main.js +++ b/src/librustdoc/html/static/js/main.js @@ -779,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) { @@ -789,8 +796,9 @@ 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; } @@ -800,6 +808,7 @@ function preLoadCss(cssUrl) { wrapper.innerHTML = "
" + window.NOTABLE_TRAITS[notable_ty] + "
"; } 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"); @@ -859,6 +868,17 @@ function preLoadCss(cssUrl) { }; } + /** + * 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) { @@ -883,6 +903,13 @@ function preLoadCss(cssUrl) { }, 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"); @@ -910,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) { -- cgit 1.4.1-3-g733a5