The tooltip WEB Component with Vue and Vuex

Laimonas Narbutas
8 min readAug 20, 2018

--

Imagine we have a list of data to show for the users. That list has a lot of usual fields like some status, phone, address, name, last name, notes, when it was created and updated and who did those actions.

So we need to make some listing with a bunch of data. There are many ways to make it work — make a table, make a list, use some data table, etc. But in today’s world of Web Component we would probably love to make use of them here. Let’s think about one row of this list.

Row can be build of smaller parts — small component for status flag, small component for user chip, small component for modification date etc. And sure we can put as many components in that row as we want. Well if we have to show only 10 rows at once it should be fine. But what if we want to show 100 rows? Then performance issues comes into action.

When row component hierarchy is flat we should be good to go. But what if we would add some „smart“ components? For example it might be handy to show user avatar picture with name and date of action (create/update) close together, like in one piece. Also it would be great to show some additional info when user hover the mouse on this block of information or part of it. For example we might show some other component with links to user’s profile, some actions (like in Gmail when hovering on some contact entry in a Hangouts chat block). And when we want to have all that additional stuff in the every row the only thing which could help is Web Components.

We can do that!

It is not hard to put all of this together — we can find or make a component for everyone piece of that puzzle. So let’s use them! And in the end we will get our row made of bunch of stuff. And our browser will not be very happy with that even if our eyes will. So in order to optimize our list`s row we need to get rid of as much „smart“ components as we can. Or make „smart“ really smart and light.

How we could make things smarter?

What if we could have only one piece of such popup/tooltip like block which would become visible after we hover on one or another block? How we could do that? Well we need somehow tell what should be shown in that block and where should we position that block. Also we should have a control for when to show up and hide. And that is about it.

Let’s try to build that.

Show me the code

So first thing which comes into my mind is data exchange between row and that magic single page wide block. When it comes to Web Components — Vue is what I am most in love with at this moment. I have tried React, Angular, Google Polymer, HTML5 native web components, but none of them is so enjoyable to use as Vue. If You still considering which route to take I would suggest to try Vue. Of course this stuff I am writing here can be done in any of these type of technologies and it’s up to You to pick one.

So I will use here Vue and Vuex state management. When we hover some content which should act as a tooltip trigger we need to set content data to the state and after that set some state flag to show up. And it would be nice to show it nicely — maybe after some time, also close it after some timeout etc. In that single global block we can watch for changes on that flag and do other parts — set the content, position and show that block.

The result usage of all this idea should look like this:

<!-- inside of some page -->
<ul> <!-- the list -->
<!-- the row -->
<li v-for="(row, index) in dataSrc" :key="index">
#row-id | more components or content of the row goes here |
<tooltip-target direction="right">
<div slot="content">
{{row.name}} <br />
<i>{{row.company.catchPhrase}}</i><br>
{{row.company.name}}<br><br>
<a :href="row.website" target="_blank">{{row.website}}</a>
</div>
<span slot="target">{{row.username}}</span>
</tooltip-target>
</li>
</ul><tooltip-display></tooltip-display>

First things first

So we have a plan. Let`s get to the job. We need a place to store state information. Store could look something like this:

const state = { 
content: [],
position: {},
open: false,
shouldClose: false
};
const getters = {
tooltipSingleGetContent: (state) => {
return state.content ? state.content : [];
},
tooltipSingleGetPosition: (state) => {
return state.position;
},
tooltipSingleGetOpenState: (state) => {
return state.open;
},
tooltipShouldClose: (state) => {
return state.shouldClose;
}
};
const actions = {
setContent({commit}, {content}) {
commit('SET_CONTENT', {content});
},
setPosition({commit}, {position}) {
commit('SET_POSITION', {position});
},
openTooltip({commit}, {}) {
commit('SET_OPEN_STATE', {});
},
closeTooltip({commit}, {}) {
commit('SET_CLOSED_STATE', {});
},
setShouldClose({commit}, {value}) {
commit('SET_SHOULD_CLOSE_STATE', {value});
}
};
const mutations = {
SET_CONTENT(state, payload) {
state.content = payload.content;
},

SET_POSITION(state, payload) {
state.position = payload.position;
},
SET_OPEN_STATE(state) {
state.open = true;
},
SET_CLOSED_STATE(state) {
state.open = false;
},
SET_SHOULD_CLOSE_STATE(state, payload) {
state.shouldClose = payload.value;
}
};
export default {
state,
getters,
actions,
mutations
}

It’s just a regular store declaration with few properties. Content — is what we will be transporting from tooltip target to tooltip display. Position is self explanatory. Open — is like visibility flag. And ShouldOpen will help show it more smoothely.

Then the visible part of tooltip — the tooltip trigger. The template part is very simple here — just a slot for target and another slot for the content which will be forwarded to another global component. And a little bit more stuff to control all moving parts.

<template> 
<div class="tooltip"
@mouseenter="enterHandler"
@mouseleave="leaveHandler"
ref="tooltipText">
<slot name="target"></slot>
<!-- this slot is used for content transportation only -->
<!-- <slot name="content"></slot> -->
</div>
</template>
<script>
import {mapGetters} from 'vuex'
export default {
name: "TooltipTrigger",
props: {
direction: {
type: String,
default: "top"
}
},

data() {
return {
showFn: null,
cancelFn: null
}
},
computed: {
...mapGetters({
ttPosition: 'tooltipSingleGetPosition',
ttContent: 'tooltipSingleGetContent',
ttOpen: 'tooltipSingleGetOpenState',
ttShouldClose: 'tooltipShouldClose'
}),
},
methods: {
/**
* Clear cancel function and set timeout with show actions.
*/
enterHandler(e) {
clearTimeout(this.cancelFn);
var position = this.getPosition();
if (!position) {
return;
}
// if position is different and ttOpen is true
// - we need to close previous
if ((this.ttOpen || this.ttShouldClose)
&& !this.inSamePosition(position, this.ttPosition)) {
this.$store.dispatch('setShouldClose', {value: true});
this.showFn = setTimeout(() => {
this.$store.dispatch('setContent', {
content: this.$slots.content
});
this.$store.dispatch('setPosition', {position: position});
this.$store.dispatch('openTooltip', {});
}, 500);
return;
}

// if position of tooltip is the same and it is opened
// - prevent from closing
if (this.ttOpen
&& this.inSamePosition(position, this.ttPosition)) {
this.$store.dispatch('setShouldClose', {value: false});
return;
}

// if we still here
// - just set content, position and open the tooltip
if (!this.ttOpen) {
this.showFn = setTimeout(() => {
this.$store.dispatch('setContent', {
content: this.$slots.content
});
this.$store.dispatch('setPosition', {position: position});
this.$store.dispatch('openTooltip', {});
}, 500);
}
},

/**
* Clear show function and set timeout with cancel actions.
*/
leaveHandler() {
this.cancelFn = setTimeout(() => {
clearTimeout(this.showFn);
this.$store.dispatch('setShouldClose', {value: true});
}, 200);
},

/**
* This will try to detect position of tooltipText.
*/
getPosition() {
// grab text block coordinates
var targetEl = this.$refs.tooltipText.getBoundingClientRect();
if (!targetEl || targetEl == undefined) {
return false;
}

targetEl.direction = this.direction;
return targetEl;
},

/**
* Compare detected position and position which
* is set on tooltip element.
*/
inSamePosition(pos1, pos2) {
return (pos1 && pos2
&& pos1.left === pos2.left
&& pos1.top === pos2.top);
}
}
}
</script>

Then the magic tooltip display. Vue render function can give a lot of power. For example in this case we take all the slot content and set it to the Vuex store as a variable and in this component we can grab all that at once and set it back as a simple variable and the render function will do the job perfectly.

<script> 
import {mapGetters} from 'vuex'
export default {
name: 'TooltipDisplay',
data() {
return {
hovered: false,
show: false,
showTimeOut: null,
hideTimeOut: null,
};
},

computed: {
...mapGetters({
position: 'tooltipSingleGetPosition',
content: 'tooltipSingleGetContent',
open: 'tooltipSingleGetOpenState',
shouldClose: 'tooltipShouldClose',
}),
},
methods: {
/**
* Mouse enter handler for tooltip content.
*/
enterHandler(e) {
clearTimeout(this.hideTimeOut);
this.hovered = true;
this.showContent(e);
this.$store.dispatch('setShouldClose', {value: false});
},
/**
* Mouse leave handler for tooltip content.
*/
leaveHandler(e) {
this.hovered = false;
this.$store.dispatch('setShouldClose', {value: true});
// this gives user a chanse to move
// the mouse back on trigger target
// and prevent tooltip from closing
this.hideTimeOut = setTimeout(() => {
this.hideContent();
}, 200);
},
/**
* Clear cancel function and set timeout with show actions.
*/
showContent(e) {
this.show = true;
},
/**
* Hide tooltip content function.
*/
hideContent() {
// check if this should not be closed
if (!this.shouldClose) {
return;
}

this.hovered = false;
this.$store.dispatch('closeTooltip', {});
this.$store.dispatch('setShouldClose', {value: false});
this.show = false;
},
},
watch: {
/**
* Call hide function when shouldClose is set to true
*/
shouldClose(val) {
if (val && !this.hovered) {
this.hideContent();
}
},
/**
* This will call show/hide handlers by open state.
*/
open(val) {
if (val) {
this.showContent();
} else if (!this.hovered) {
this.hideContent();
}
},
},
/**
* Main output rendering function.
*/
render: function (createElement) {
let showableBlock = [];
showableBlock.push(this.$slots.target);
if (this.show) {
showableBlock.push( createElement(
'div',
{
class: {
'tooltip-text-container': true,
[this.position.direction]: true,
},
attrs: {
id: 'tooltipTextContainer'
},
on: {
mouseenter: this.enterHandler,
mouseleave: this.leaveHandler,
}
},
[ createElement(
'div',
{ class: {'tooltip-text': true} },
[this.content] // <- content from the store goes here
)
]
));
}
return createElement(
'div',
{
class: {'tooltip-container': true},
style: {
top: this.position.y + 'px',
left: this.position.x + 'px'
},
},
showableBlock
);
}
};
</script>
<style scoped>
.tooltip-container {
display: inline-block;
position: fixed;
z-index: 10;
}
.tooltip-text-container {
white-space: nowrap;
z-index: 1;
position: absolute;
padding: 5px;
}
.tooltip-text-container.top {
padding-bottom: 5px;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
}
.tooltip-text-container.right {
top: -10px;
padding-left: 5px;
left: 110%;
}
.tooltip-text-container.left {
top: -10px;
padding-right: 5px;
right: 110%;
}
</style>

Style here is only for initial positioning of that tooltip display container. You have to style content block by Yourself — this component does not now what the content will be passed.

So here You go — a tooltip with some magic! You can see this stuff in action here or You can try it in Your Vue project by installing magic-tooltip via npm or yarn (magic-toolbar on npm).

I loved the concept also I was little bit surprised how store can be used in such case and I think it can help in many situations — that is why I decided to share this with You.

Originally published at www.monas.lt.

--

--