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
This commit is contained in:
Michael Howell 2023-05-23 15:29:43 -07:00
parent 52dd1cde59
commit c1d72de030
4 changed files with 115 additions and 10 deletions

View File

@ -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;
}

View File

@ -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 = "<div class=\"content\">" +
window.NOTABLE_TRAITS[notable_ty] + "</div>";
} 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");
}
};
});

View File

@ -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: (

View File

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