4
4
5
5
"use strict" ;
6
6
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
+
7
14
// Given a basename (e.g. "storage") and an extension (e.g. ".js"), return a URL
8
15
// for a resource under the root-path, with the resource-suffix.
9
16
function resourcePath ( basename , extension ) {
@@ -772,6 +779,13 @@ function preLoadCss(cssUrl) {
772
779
} ) ;
773
780
} ) ;
774
781
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
+ */
775
789
function showTooltip ( e ) {
776
790
const notable_ty = e . getAttribute ( "data-notable-ty" ) ;
777
791
if ( ! window . NOTABLE_TRAITS && notable_ty ) {
@@ -782,20 +796,29 @@ function preLoadCss(cssUrl) {
782
796
throw new Error ( "showTooltip() called with notable without any notable traits!" ) ;
783
797
}
784
798
}
799
+ // Make this function idempotent. If the tooltip is already shown, avoid doing extra work
800
+ // and leave it alone.
785
801
if ( window . CURRENT_TOOLTIP_ELEMENT && window . CURRENT_TOOLTIP_ELEMENT . TOOLTIP_BASE === e ) {
786
- // Make this function idempotent.
802
+ clearTooltipHoverTimeout ( window . CURRENT_TOOLTIP_ELEMENT ) ;
787
803
return ;
788
804
}
789
805
window . hideAllModals ( false ) ;
790
806
const wrapper = document . createElement ( "div" ) ;
791
807
if ( notable_ty ) {
792
808
wrapper . innerHTML = "<div class=\"content\">" +
793
809
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
+ }
799
822
}
800
823
wrapper . className = "tooltip popover" ;
801
824
const focusCatcher = document . createElement ( "div" ) ;
@@ -824,17 +847,77 @@ function preLoadCss(cssUrl) {
824
847
wrapper . style . visibility = "" ;
825
848
window . CURRENT_TOOLTIP_ELEMENT = wrapper ;
826
849
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
+ } ;
827
858
wrapper . onpointerleave = function ( ev ) {
828
859
// If this is a synthetic touch event, ignore it. A click event will be along shortly.
829
860
if ( ev . pointerType !== "mouse" ) {
830
861
return ;
831
862
}
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" ) ;
834
867
}
835
868
} ;
836
869
}
837
870
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
+
838
921
function tooltipBlurHandler ( event ) {
839
922
if ( window . CURRENT_TOOLTIP_ELEMENT &&
840
923
! elemIsInParent ( document . activeElement , window . CURRENT_TOOLTIP_ELEMENT ) &&
@@ -854,6 +937,12 @@ function preLoadCss(cssUrl) {
854
937
}
855
938
}
856
939
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
+ */
857
946
function hideTooltip ( focus ) {
858
947
if ( window . CURRENT_TOOLTIP_ELEMENT ) {
859
948
if ( window . CURRENT_TOOLTIP_ELEMENT . TOOLTIP_BASE . TOOLTIP_FORCE_VISIBLE ) {
@@ -864,6 +953,7 @@ function preLoadCss(cssUrl) {
864
953
}
865
954
const body = document . getElementsByTagName ( "body" ) [ 0 ] ;
866
955
body . removeChild ( window . CURRENT_TOOLTIP_ELEMENT ) ;
956
+ clearTooltipHoverTimeout ( window . CURRENT_TOOLTIP_ELEMENT ) ;
867
957
window . CURRENT_TOOLTIP_ELEMENT = null ;
868
958
}
869
959
}
@@ -886,7 +976,14 @@ function preLoadCss(cssUrl) {
886
976
if ( ev . pointerType !== "mouse" ) {
887
977
return ;
888
978
}
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 ) ;
890
987
} ;
891
988
e . onpointerleave = function ( ev ) {
892
989
// If this is a synthetic touch event, ignore it. A click event will be along shortly.
@@ -895,7 +992,38 @@ function preLoadCss(cssUrl) {
895
992
}
896
993
if ( ! this . TOOLTIP_FORCE_VISIBLE &&
897
994
! 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" ) ;
899
1027
}
900
1028
} ;
901
1029
} ) ;
0 commit comments