I have a tooltip component that uses Javascript to attach mouseover
,mouseout
event listeners to an element and on mouseover
instantiates a tooltip component. I would like to make my tooltips interactive/enterable, so that if the user moves the mouse toward/into the tooltip itself when showing, the tooltip stays visible and allows mouse interaction.
The trouble is that moving the mouse toward the tooltip triggers the mouseout
on the parent element, closing the tooltip. How does one prevent this? Is there a way to expand the element's boundaries invisibly to include the tooltip, so that mouseout
is only fired if the mouse is moved away from both parent element and the tooltip?
Here's a simplified version of my code (I' m using Svelte as my framework, but the question should be valid independent of framework):
<!-- component that wants to use a tooltip -->
<span
use:attachTooltip={{
component: EntityCardTooltip
}}
>My visible text</span>
export const attachTooltip = (node,options) => {
if (options) {
let _component
node.addEventListener('mouseover',addTooltip)
node.addEventListener('mouseout',removeTooltip)
function addTooltip(e) {
const {component, ...otherOpts} = options
_component = new component({
target: node,
props: {
mouseEvent: e,
...otherOpts
}
})
}
function removeTooltip(e) {
_component.$destroy()
}
return {
destroy() {
node.removeEventListener('mouseover',addTooltip)
node.removeEventListener('mouseout',removeTooltip)
}
}
}
}
<!-- EntityCardTooltip --->
<Tooltip {mouseEvent}>
<div class="tipBody" slot="Content">
<!--- tooltip content here --->
</div>
</Tooltip>
<!-- Tooltip --->
<div class="tip" use:adjustTooltipOnMouseOver >
{#if $$slots.Content}
<slot name="Content" />
{:else if innerHTML}
{@html innerHTML}
{/if}
</div>
<style>
@keyframes fadeInFromNone {
0% {
display: block;
opacity: 0;
}
1% {
display: block;
opacity: 0;
}
100% {
display: block;
opacity: 1;
}
}
.tip, .tip:before {
pointer-events: none;
box-sizing: border-box;
display: block;
opacity: 1;
}
.tip:before {
content: "";
position: absolute;
width: 2rem;
height: 2rem;
z-index: 13;
}
.tip {
animation: fadeInFromNone 0.05s linear 0.05s;
animation-fill-mode: both;
position: fixed;
color: black;
min-width: 3rem;
white-space: nowrap;
display: block;
text-overflow: ellipsis;
white-space: pre;
z-index: 12;
}
</style>
There is more CSS not shown here, used to conditionally position by transforms using CSS vars set by adjustTooltipOnMouseOver
(determines direction, prevents overflow, etc.).
I have a tooltip component that uses Javascript to attach mouseover
,mouseout
event listeners to an element and on mouseover
instantiates a tooltip component. I would like to make my tooltips interactive/enterable, so that if the user moves the mouse toward/into the tooltip itself when showing, the tooltip stays visible and allows mouse interaction.
The trouble is that moving the mouse toward the tooltip triggers the mouseout
on the parent element, closing the tooltip. How does one prevent this? Is there a way to expand the element's boundaries invisibly to include the tooltip, so that mouseout
is only fired if the mouse is moved away from both parent element and the tooltip?
Here's a simplified version of my code (I' m using Svelte as my framework, but the question should be valid independent of framework):
<!-- component that wants to use a tooltip -->
<span
use:attachTooltip={{
component: EntityCardTooltip
}}
>My visible text</span>
export const attachTooltip = (node,options) => {
if (options) {
let _component
node.addEventListener('mouseover',addTooltip)
node.addEventListener('mouseout',removeTooltip)
function addTooltip(e) {
const {component, ...otherOpts} = options
_component = new component({
target: node,
props: {
mouseEvent: e,
...otherOpts
}
})
}
function removeTooltip(e) {
_component.$destroy()
}
return {
destroy() {
node.removeEventListener('mouseover',addTooltip)
node.removeEventListener('mouseout',removeTooltip)
}
}
}
}
<!-- EntityCardTooltip --->
<Tooltip {mouseEvent}>
<div class="tipBody" slot="Content">
<!--- tooltip content here --->
</div>
</Tooltip>
<!-- Tooltip --->
<div class="tip" use:adjustTooltipOnMouseOver >
{#if $$slots.Content}
<slot name="Content" />
{:else if innerHTML}
{@html innerHTML}
{/if}
</div>
<style>
@keyframes fadeInFromNone {
0% {
display: block;
opacity: 0;
}
1% {
display: block;
opacity: 0;
}
100% {
display: block;
opacity: 1;
}
}
.tip, .tip:before {
pointer-events: none;
box-sizing: border-box;
display: block;
opacity: 1;
}
.tip:before {
content: "";
position: absolute;
width: 2rem;
height: 2rem;
z-index: 13;
}
.tip {
animation: fadeInFromNone 0.05s linear 0.05s;
animation-fill-mode: both;
position: fixed;
color: black;
min-width: 3rem;
white-space: nowrap;
display: block;
text-overflow: ellipsis;
white-space: pre;
z-index: 12;
}
</style>
There is more CSS not shown here, used to conditionally position by transforms using CSS vars set by adjustTooltipOnMouseOver
(determines direction, prevents overflow, etc.).
2 Answers
Reset to default 1The two common approaches are:
- Moving the mouse away from the trigger schedules the closing of the tooltip via a timeout.
Entering the tooltip then cancels this timeout again. - Adding invisible elements that bridge the gap; this is a lot more involved.
E.g. one can add an invisible wedge that connects the trigger with the edges of the tooltip. Depending on the object hierarchy and given layout this can be a bit complicated so I would lean towards just adding a small timeout.
While it is possible to set a delay to removing the tooltip and set additional listeners on the tooltip node to intercept its removal and retrigger once the mouse is moved back out of the tooltip, I found this to be rather unwieldly and rather bug-prone.
It turns out that the behavior I was looking for could be achieved simply by changing mouseout
to mouseleave
. With mouseout
, the moment I moved toward the tooltip it fired and destroyed my tooltip. But with mouseleave
it doesn't fire until I move out of the parent node-and-tooltip complex, since the tooltip is a child of the parent node so moving into it doesn't leave the parent.
So the solution I found to work is to simply set a property enterable: true
which when true changes the event listened for from mouseout
to mouseleave
. No need to play with timeouts and migrating event handlers!
export const attachTooltip = (node,options) => {
if (options) {
const mouseOutEvent=options.enterable ? "mouseleave" : "mouseout"
node.addEventListener('mouseenter',addTooltip)
node.addEventListener(mouseOutEvent,removeTooltip) -- set proper event
. . .
removeTooltip
so it only actives after a timeout (giving user enough time to "move" to the tooltip), inremoveTooltip
check if user is currently over the tooltip, add a mouseout on the tooltip itself. – fdomn-m Commented Mar 11 at 12:04