Kategorijos
Technical stuff

Tooltip WEB Component with Vue and Vuex

The idea

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 todays world of Web Components we would probably love to make use of them here. Lets 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. But 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 have 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 better?

What if we could have only one piece of such popup/tooltip like block which would become visible after we hover 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 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.
I know what could be a perfect fit here – Vue and Vuex state management. So when we hover some content which should act as a tooltip trigger we need to set data to the state and after that set some state flag to show up. 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.

First things first

So we have a plan. Let`s get to the job. 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
}

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 bounding rectangle.
             */
            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: {
                /**
                 * This will 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>
        .magic-tooltip-container {
            display: inline-block;
            position: fixed;
            z-index: 10;
        }

        .magic-tooltip-text-container {
            white-space: nowrap;
            z-index: 1;  
            position: absolute;
            padding: 5px;
        }

        .magic-tooltip-text-container.top {
            padding-bottom: 5px;
            bottom: 100%;
            left: 50%;
            transform: translateX(-50%);
        }

        .magic-tooltip-text-container.right {
            top: -10px;
            padding-left: 5px;
            left: 110%;
        }

        .magic-tooltip-text-container.left {
            top: -10px;
            padding-right: 5px;
            right: 110%;
        }
    </style>

So here You go – a tooltip with some magic! You can see this stuff in action here or You can try it in Your project by installing magic-tooltip via npm or yarn (magic-toolbar on npm). I am a Web developer with a bit of experience (since 2007…) but this is my first package on npm. So I am sure You can/will find a mistakes there – I will try to fix them all, but maybe after my vacation.