44
55"use strict" ;
66
7+ // The amount of time that the cursor must remain still over a hover target before
8+ // revealing a tooltip.
9+ //
10+ // https://www.nngroup.com/articles/timing-exposing-content/
11+ window . RUSTDOC_TOOLTIP_HOVER_MS = 300 ;
12+ window . RUSTDOC_TOOLTIP_HOVER_EXIT_MS = 450 ;
13+
714// Given a basename (e.g. "storage") and an extension (e.g. ".js"), return a URL
815// for a resource under the root-path, with the resource-suffix.
916function resourcePath ( basename , extension ) {
@@ -772,6 +779,13 @@ function preLoadCss(cssUrl) {
772779 } ) ;
773780 } ) ;
774781
782+ /**
783+ * Show a tooltip immediately.
784+ *
785+ * @param {DOMElement } e - The tooltip's anchor point. The DOM is consulted to figure
786+ * out what the tooltip should contain, and where it should be
787+ * positioned.
788+ */
775789 function showTooltip ( e ) {
776790 const notable_ty = e . getAttribute ( "data-notable-ty" ) ;
777791 if ( ! window . NOTABLE_TRAITS && notable_ty ) {
@@ -782,20 +796,29 @@ function preLoadCss(cssUrl) {
782796 throw new Error ( "showTooltip() called with notable without any notable traits!" ) ;
783797 }
784798 }
799+ // Make this function idempotent. If the tooltip is already shown, avoid doing extra work
800+ // and leave it alone.
785801 if ( window . CURRENT_TOOLTIP_ELEMENT && window . CURRENT_TOOLTIP_ELEMENT . TOOLTIP_BASE === e ) {
786- // Make this function idempotent.
802+ clearTooltipHoverTimeout ( window . CURRENT_TOOLTIP_ELEMENT ) ;
787803 return ;
788804 }
789805 window . hideAllModals ( false ) ;
790806 const wrapper = document . createElement ( "div" ) ;
791807 if ( notable_ty ) {
792808 wrapper . innerHTML = "<div class=\"content\">" +
793809 window . NOTABLE_TRAITS [ notable_ty ] + "</div>" ;
794- } else if ( e . getAttribute ( "title" ) !== undefined ) {
795- const titleContent = document . createElement ( "div" ) ;
796- titleContent . className = "content" ;
797- titleContent . appendChild ( document . createTextNode ( e . getAttribute ( "title" ) ) ) ;
798- wrapper . appendChild ( titleContent ) ;
810+ } else {
811+ // Replace any `title` attribute with `data-title` to avoid double tooltips.
812+ if ( e . getAttribute ( "title" ) !== null ) {
813+ e . setAttribute ( "data-title" , e . getAttribute ( "title" ) ) ;
814+ e . removeAttribute ( "title" ) ;
815+ }
816+ if ( e . getAttribute ( "data-title" ) !== null ) {
817+ const titleContent = document . createElement ( "div" ) ;
818+ titleContent . className = "content" ;
819+ titleContent . appendChild ( document . createTextNode ( e . getAttribute ( "data-title" ) ) ) ;
820+ wrapper . appendChild ( titleContent ) ;
821+ }
799822 }
800823 wrapper . className = "tooltip popover" ;
801824 const focusCatcher = document . createElement ( "div" ) ;
@@ -824,17 +847,77 @@ function preLoadCss(cssUrl) {
824847 wrapper . style . visibility = "" ;
825848 window . CURRENT_TOOLTIP_ELEMENT = wrapper ;
826849 window . CURRENT_TOOLTIP_ELEMENT . TOOLTIP_BASE = e ;
850+ clearTooltipHoverTimeout ( window . CURRENT_TOOLTIP_ELEMENT ) ;
851+ wrapper . onpointerenter = function ( ev ) {
852+ // If this is a synthetic touch event, ignore it. A click event will be along shortly.
853+ if ( ev . pointerType !== "mouse" ) {
854+ return ;
855+ }
856+ clearTooltipHoverTimeout ( e ) ;
857+ } ;
827858 wrapper . onpointerleave = function ( ev ) {
828859 // If this is a synthetic touch event, ignore it. A click event will be along shortly.
829860 if ( ev . pointerType !== "mouse" ) {
830861 return ;
831862 }
832- if ( ! e . TOOLTIP_FORCE_VISIBLE && ! elemIsInParent ( event . relatedTarget , e ) ) {
833- hideTooltip ( true ) ;
863+ if ( ! e . TOOLTIP_FORCE_VISIBLE && ! elemIsInParent ( ev . relatedTarget , e ) ) {
864+ // See "Tooltip pointer leave gesture" below.
865+ setTooltipHoverTimeout ( e , false ) ;
866+ addClass ( wrapper , "fade-out" ) ;
834867 }
835868 } ;
836869 }
837870
871+ /**
872+ * Show or hide the tooltip after a timeout. If a timeout was already set before this function
873+ * was called, that timeout gets cleared. If the tooltip is already in the requested state,
874+ * this function will still clear any pending timeout, but otherwise do nothing.
875+ *
876+ * @param {DOMElement } element - The tooltip's anchor point. The DOM is consulted to figure
877+ * out what the tooltip should contain, and where it should be
878+ * positioned.
879+ * @param {boolean } show - If true, the tooltip will be made visible. If false, it will
880+ * be hidden.
881+ */
882+ function setTooltipHoverTimeout ( element , show ) {
883+ clearTooltipHoverTimeout ( element ) ;
884+ if ( ! show && ! window . CURRENT_TOOLTIP_ELEMENT ) {
885+ // To "hide" an already hidden element, just cancel its timeout.
886+ return ;
887+ }
888+ if ( show && window . CURRENT_TOOLTIP_ELEMENT ) {
889+ // To "show" an already visible element, just cancel its timeout.
890+ return ;
891+ }
892+ if ( window . CURRENT_TOOLTIP_ELEMENT &&
893+ window . CURRENT_TOOLTIP_ELEMENT . TOOLTIP_BASE !== element ) {
894+ // Don't do anything if another tooltip is already visible.
895+ return ;
896+ }
897+ element . TOOLTIP_HOVER_TIMEOUT = setTimeout ( ( ) => {
898+ if ( show ) {
899+ showTooltip ( element ) ;
900+ } else if ( ! element . TOOLTIP_FORCE_VISIBLE ) {
901+ hideTooltip ( false ) ;
902+ }
903+ } , show ? window . RUSTDOC_TOOLTIP_HOVER_MS : window . RUSTDOC_TOOLTIP_HOVER_EXIT_MS ) ;
904+ }
905+
906+ /**
907+ * If a show/hide timeout was set by `setTooltipHoverTimeout`, cancel it. If none exists,
908+ * do nothing.
909+ *
910+ * @param {DOMElement } element - The tooltip's anchor point,
911+ * as passed to `setTooltipHoverTimeout`.
912+ */
913+ function clearTooltipHoverTimeout ( element ) {
914+ if ( element . TOOLTIP_HOVER_TIMEOUT !== undefined ) {
915+ removeClass ( window . CURRENT_TOOLTIP_ELEMENT , "fade-out" ) ;
916+ clearTimeout ( element . TOOLTIP_HOVER_TIMEOUT ) ;
917+ delete element . TOOLTIP_HOVER_TIMEOUT ;
918+ }
919+ }
920+
838921 function tooltipBlurHandler ( event ) {
839922 if ( window . CURRENT_TOOLTIP_ELEMENT &&
840923 ! elemIsInParent ( document . activeElement , window . CURRENT_TOOLTIP_ELEMENT ) &&
@@ -854,6 +937,12 @@ function preLoadCss(cssUrl) {
854937 }
855938 }
856939
940+ /**
941+ * Hide the current tooltip immediately.
942+ *
943+ * @param {boolean } focus - If set to `true`, move keyboard focus to the tooltip anchor point.
944+ * If set to `false`, leave keyboard focus alone.
945+ */
857946 function hideTooltip ( focus ) {
858947 if ( window . CURRENT_TOOLTIP_ELEMENT ) {
859948 if ( window . CURRENT_TOOLTIP_ELEMENT . TOOLTIP_BASE . TOOLTIP_FORCE_VISIBLE ) {
@@ -864,6 +953,7 @@ function preLoadCss(cssUrl) {
864953 }
865954 const body = document . getElementsByTagName ( "body" ) [ 0 ] ;
866955 body . removeChild ( window . CURRENT_TOOLTIP_ELEMENT ) ;
956+ clearTooltipHoverTimeout ( window . CURRENT_TOOLTIP_ELEMENT ) ;
867957 window . CURRENT_TOOLTIP_ELEMENT = null ;
868958 }
869959 }
@@ -886,7 +976,14 @@ function preLoadCss(cssUrl) {
886976 if ( ev . pointerType !== "mouse" ) {
887977 return ;
888978 }
889- showTooltip ( this ) ;
979+ setTooltipHoverTimeout ( this , true ) ;
980+ } ;
981+ e . onpointermove = function ( ev ) {
982+ // If this is a synthetic touch event, ignore it. A click event will be along shortly.
983+ if ( ev . pointerType !== "mouse" ) {
984+ return ;
985+ }
986+ setTooltipHoverTimeout ( this , true ) ;
890987 } ;
891988 e . onpointerleave = function ( ev ) {
892989 // If this is a synthetic touch event, ignore it. A click event will be along shortly.
@@ -895,7 +992,38 @@ function preLoadCss(cssUrl) {
895992 }
896993 if ( ! this . TOOLTIP_FORCE_VISIBLE &&
897994 ! elemIsInParent ( ev . relatedTarget , window . CURRENT_TOOLTIP_ELEMENT ) ) {
898- hideTooltip ( true ) ;
995+ // Tooltip pointer leave gesture:
996+ //
997+ // Designing a good hover microinteraction is a matter of guessing user
998+ // intent from what are, literally, vague gestures. In this case, guessing if
999+ // hovering in or out of the tooltip base is intentional or not.
1000+ //
1001+ // To figure this out, a few different techniques are used:
1002+ //
1003+ // * When the mouse pointer enters a tooltip anchor point, its hitbox is grown
1004+ // on the bottom, where the popover is/will appear. Search "hover tunnel" in
1005+ // rustdoc.css for the implementation.
1006+ // * There's a delay when the mouse pointer enters the popover base anchor, in
1007+ // case the mouse pointer was just passing through and the user didn't want
1008+ // to open it.
1009+ // * Similarly, a delay is added when exiting the anchor, or the popover
1010+ // itself, before hiding it.
1011+ // * A fade-out animation is layered onto the pointer exit delay to immediately
1012+ // inform the user that they successfully dismissed the popover, while still
1013+ // providing a way for them to cancel it if it was a mistake and they still
1014+ // wanted to interact with it.
1015+ // * No animation is used for revealing it, because we don't want people to try
1016+ // to interact with an element while it's in the middle of fading in: either
1017+ // they're allowed to interact with it while it's fading in, meaning it can't
1018+ // serve as mistake-proofing for the popover, or they can't, but
1019+ // they might try and be frustrated.
1020+ //
1021+ // See also:
1022+ // * https://www.nngroup.com/articles/timing-exposing-content/
1023+ // * https://www.nngroup.com/articles/tooltip-guidelines/
1024+ // * https://bjk5.com/post/44698559168/breaking-down-amazons-mega-dropdown
1025+ setTooltipHoverTimeout ( e , false ) ;
1026+ addClass ( window . CURRENT_TOOLTIP_ELEMENT , "fade-out" ) ;
8991027 }
9001028 } ;
9011029 } ) ;
0 commit comments