/ Gists / Vue directive + class tooltip (4 learning)
On gists

Vue directive + class tooltip (4 learning)

Vue.js

directive.js Raw #

/* https://github.com/hekigan/vue-directive-tooltip */


/*
 * @author: laurent blanes <laurent.blanes@gmail.com>
 * @tutorial: https://hekigan.github.io/vue-directive-tooltip/
 */
import Tooltip from './tooltip.js';

const BASE_CLASS = 'vue-tooltip';
const POSITIONS = ['auto', 'top', 'bottom', 'left', 'right'];
const SUB_POSITIONS = ['start', 'end'];

/**
 * usage:
 *
 * // basic usage:
 * <div v-tooltip="'my content'">
 * or
 * <div v-tooltip="{content: 'my content'}">
 *
 * // change position of tooltip
 * // options: auto (default) | bottom | top | left | right
 *
 * // change sub-position of tooltip
 * // options: start | end
 *
 * <div v-tooltip.top="{content: 'my content'}">
 *
 * // add custom class
 * <div v-tooltip="{class: 'custom-class', content: 'my content'}">
 *
 * // toggle visibility
 * <div v-tooltip="{visible: false, content: 'my content'}">
 */
export default {
    name: 'tooltip',
    config: {},
    install (Vue, installOptions) {
        Vue.directive('tooltip', {
            bind (el, binding, vnode) {
                if (installOptions) {
                    Tooltip.defaults(installOptions);
                }
            },
            inserted (el, binding, vnode, oldVnode) {
                if (installOptions) {
                    Tooltip.defaults(installOptions);
                }

                let options = filterBindings(binding, vnode);
                el.tooltip = new Tooltip(el, options);

                if (binding.modifiers.notrigger && binding.value.visible === true) {
                    el.tooltip.show();
                }

                if (binding.value && binding.value.visible === false) {
                    el.tooltip.disabled = true;
                }
            },
            componentUpdated (el, binding, vnode, oldVnode) {
                if (hasUpdated(binding.value, binding.oldValue)) {
                    update(el, binding, vnode, oldVnode);
                }
            },
            unbind (el, binding, vnode, oldVnode) {
                el.tooltip.destroy();
            }
        });
    }
};

/**
 *
 * @param {*} vnode component's properties
 * @param {*} oldvnode component's previous properties
 * @return boolean
 */
function hasUpdated (value, oldValue) {
    let updated = false;

    if (typeof value === 'string' && value !== oldValue) {
        updated = true;
    } else if (isObject(value)) {
        Object.keys(value).forEach(prop => {
            if (value[prop] !== oldValue[prop]) {
                updated = true;
            }
        });
    }
    return updated;
}

/**
 * Sanitize data
 * @param {*} binding
 * @param {*} vnode
 * @return {*} filtered data object
 */
function filterBindings (binding, vnode) {
    const delay = !binding.value || isNaN(binding.value.delay) ? Tooltip._defaults.delay : binding.value.delay;

    if (binding.value.ref) {
        if (vnode.context.$refs[binding.value.ref]) {
            binding.value.html = vnode.context.$refs[binding.value.ref];
        } else {
            console.error(`[Tooltip] no REF element [${binding.value.ref}]`); // eslint-disable-line
        }
    }

    return {
        class: getClass(binding),
        id: (binding.value) ? binding.value.id : null,
        html: (binding.value) ? binding.value.html : null,
        placement: getPlacement(binding),
        title: getContent(binding),
        triggers: getTriggers(binding),
        fixIosSafari: binding.modifiers.ios || false,
        offset: (binding.value && binding.value.offset) ? binding.value.offset : Tooltip._defaults.offset,
        delay
    };
}

/**
 * Get placement from modifiers
 * @param {*} binding
 */
function getPlacement ({modifiers, value}) {
    let MODS = Object.keys(modifiers);
    if (MODS.length === 0 && isObject(value) && typeof value.placement === 'string') {
        MODS = value.placement.split('.');
    }
    let head = 'bottom';
    let tail = null;
    for (let i = 0; i < MODS.length; i++) {
        const pos = MODS[i];
        if (POSITIONS.indexOf(pos) > -1) {
            head = pos;
        }
        if (SUB_POSITIONS.indexOf(pos) > -1) {
            tail = pos;
        }
    }
    // console.log((head && tail) ? `${head}-${tail}` : head);
    // return 'auto';
    return (head && tail) ? `${head}-${tail}` : head;
}

/**
 * Get trigger value from modifiers
 * @param {*} binding
 * @return String
 */
function getTriggers ({modifiers}) {
    let trigger = [];
    if (modifiers.notrigger) {
        return trigger;
    } else if (modifiers.manual) {
        trigger.push('manual');
    } else {
        if (modifiers.click) {
            trigger.push('click');
        }

        if (modifiers.hover) {
            trigger.push('hover');
        }

        if (modifiers.focus) {
            trigger.push('focus');
        }

        if (trigger.length === 0) {
            trigger.push('hover', 'focus');
        }
    }

    return trigger;
}

/**
 * Check if the variable is an object
 * @param {*} value
 * @return Boolean
 */
function isObject (value) {
    return typeof value === 'object';
}

/**
 * Check if the variable is an html element
 * @param {*} value
 * @return Boolean
 */
function isElement (value) {
    return value instanceof window.Element;
}

/**
 * Get the css class
 * @param {*} binding
 * @return HTMLElement | String
 */
function getClass ({value}) {
    if (value === null) {
        return BASE_CLASS;
    } else if (isObject(value) && typeof value.class === 'string') {
        return `${BASE_CLASS} ${value.class}`;
    } else if (Tooltip._defaults.class) {
        return `${BASE_CLASS} ${Tooltip._defaults.class}`;
    } else {
        return BASE_CLASS;
    }
}

/**
 * Get the content
 * @param {*} binding
 * @return HTMLElement | String
 */
function getContent ({value}, vnode) {
    if (value !== null && isObject(value)) {
        if (value.content !== undefined) {
            return `${value.content}`;
        } else if (value.id && document.getElementById(value.id)) {
            return document.getElementById(value.id);
        } else if (value.html && document.getElementById(value.html)) {
            return document.getElementById(value.html);
        } else if (isElement(value.html)) {
            return value.html;
        } else if (value.ref && vnode) {
            return vnode.context.$refs[value.ref] || '';
        } else {
            return '';
        }
    } else {
        return `${value}`;
    }
}

/**
 * Action on element update
 * @param {*} el Vue element
 * @param {*} binding
 */
function update (el, binding, vnode, oldVnode) {
    if (typeof binding.value === 'string') {
        el.tooltip.content(binding.value);
    } else {
        if (binding.value && binding.value.class && binding.value.class.trim() !== el.tooltip.options.class.replace(BASE_CLASS, '').trim()) {
            el.tooltip.class = `${BASE_CLASS} ${binding.value.class.trim()}`;
        }

        el.tooltip.content(getContent(binding, vnode));

        if (!binding.modifiers.notrigger && binding.value && typeof binding.value.visible === 'boolean') {
            el.tooltip.disabled = !binding.value.visible;
            return;
        } else if (binding.modifiers.notrigger) {
            el.tooltip.disabled = false;
        }

        const dir = vnode.data.directives[0];

        if (dir.oldValue.visible !== dir.value.visible) {
            if (!el.tooltip.disabled) {
                el.tooltip.toggle(dir.value.visible);
            }
        }
    }
}

tooltip.js Raw #

import Popper from 'popper.js';

const CSS = {
    HIDDEN: 'vue-tooltip-hidden',
    VISIBLE: 'vue-tooltip-visible'
};
const BASE_CLASS = `h-tooltip  ${CSS.HIDDEN}`;
const PLACEMENT = ['top', 'left', 'right', 'bottom', 'auto'];
const SUB_PLACEMENT = ['start', 'end'];

const EVENTS = {
    ADD: 1,
    REMOVE: 2
};

const DEFAULT_OPTIONS = {
    container: false,
    delay: 200,
    instance: null, // the popper.js instance
    fixIosSafari: false,
    eventsEnabled: false,
    html: false,
    modifiers: {
        arrow: {
            element: '.tooltip-arrow'
        }
    },
    placement: '',
    placementPostfix: null, // start | end
    removeOnDestroy: true,
    title: '',
    class: '', // ex: 'tooltip-custom tooltip-other-custom'
    triggers: ['hover', 'focus'],
    offset: 5
};

const includes = (stack, needle) => {
    return stack.indexOf(needle) > -1;
};

export default class Tooltip {
    constructor (el, options = {}) {
        // Tooltip._defaults = DEFAULT_OPTIONS;
        this._options = {
            ...Tooltip._defaults,
            ...{
                onCreate: (data) => {
                    this.content(this.tooltip.options.title);
                    // this._$tt.update();
                },
                onUpdate: (data) => {
                    this.content(this.tooltip.options.title);
                    // this._$tt.update();
                }
            },
            ...Tooltip.filterOptions(options)
        };

        this._$el = el;

        this._$tpl = this._createTooltipElement(this.options);
        this._$tt = new Popper(el, this._$tpl, this._options);
        this.setupPopper();
    }

    setupPopper () {
        // this._$el.insertAdjacentElement('afterend', this._$tpl);
        this.disabled = false;
        this._visible = false;
        this._clearDelay = null;
        this._$tt.disableEventListeners();
        this._setEvents();
    }

    destroy () {
        this._cleanEvents();
        if (this._$tpl && this._$tpl.parentNode) {
            this._$tpl.parentNode.removeChild(this._$tpl);
        }
    }

    get options () {
        return {...this._options};
    }

    get tooltip () {
        return this._$tt;
    }

    get visible () {
        return this._visible;
    }

    set visible (val) {
        if (typeof val === 'boolean') {
            this._visible = val;
        }
    }

    get disabled () {
        return this._disabled;
    }

    set disabled (val) {
        if (typeof val === 'boolean') {
            this._disabled = val;
        }
    }

    show () {
        this.toggle(true);
    }

    hide () {
        this.toggle(false);
    }

    toggle (visible, autoHide = true) {
        let delay = this._options.delay;

        if (this.disabled === true) {
            visible = false;
            delay = 0;
        }

        if (typeof visible !== 'boolean') {
            visible = !this._visible;
        }

        if (visible === true) {
            delay = 0;
        }

        clearTimeout(this._clearDelay);

        if (autoHide === true) {
            this._clearDelay = setTimeout(() => {
                this.visible = visible;
                if (this.visible === true && this.disabled !== true) {
                    // add tooltip node
                    // this._$el.insertAdjacentElement('afterend', this._$tpl);
                    document.querySelector('body').appendChild(this._$tpl);

                    // Need the timeout to be sure that the element is inserted in the DOM
                    setTimeout(() => {
                        // enable eventListeners
                        this._$tt.enableEventListeners();
                        // only update if the tooltip is visible
                        this._$tt.scheduleUpdate();
                        // switch CSS
                        this._$tpl.classList.replace(CSS.HIDDEN, CSS.VISIBLE);
                    }, 60);
                } else {
                    this._$tpl.classList.replace(CSS.VISIBLE, CSS.HIDDEN);
                    // remove tooltip node
                    if (this._$tpl && this._$tpl.parentNode) {
                        this._$tpl.parentNode.removeChild(this._$tpl);
                    }

                    this._$tt.disableEventListeners();
                }
            }, delay);
        }
    }

    _createTooltipElement (options) {
        // wrapper
        let $popper = document.createElement('div');
        $popper.setAttribute('id', `tooltip-${randomId()}`);
        $popper.setAttribute('class', `${BASE_CLASS} ${this._options.class}`);

        // make arrow
        let $arrow = document.createElement('div');
        $arrow.setAttribute('class', 'tooltip-arrow');
        $arrow.setAttribute('x-arrow', '');
        $popper.appendChild($arrow);

        // make content container
        let $content = document.createElement('div');
        $content.setAttribute('class', 'tooltip-content');
        $popper.appendChild($content);

        return $popper;
    }

    _events (type = EVENTS.ADD) {
        const evtType = (type === EVENTS.ADD) ? 'addEventListener' : 'removeEventListener';
        if (!Array.isArray(this.options.triggers)) {
            console.error('trigger should be an array', this.options.triggers); // eslint-disable-line
            return;
        }

        let lis = (...params) => this._$el[evtType](...params);

        if (includes(this.options.triggers, 'manual')) {
            lis('click', this._onToggle.bind(this), false);
        } else {
            // For the strange iOS/safari behaviour, we remove any 'hover' and replace it by a 'click' event
            if (this.options.fixIosSafari && Tooltip.isIosSafari() && includes(this.options.triggers, 'hover')) {
                const pos = this.options.triggers.indexOf('hover');
                const click = includes(this.options.triggers, 'click');
                this._options.triggers[pos] = (click !== -1) ? 'click' : null;
            }

            this.options.triggers.map(evt => {
                switch (evt) {
                case 'click':
                    lis('click', (e) => { this._onToggle(e); }, false);
                    // document[evtType]('click', this._onDeactivate.bind(this), false);
                    break;
                case 'hover':
                    lis('mouseenter', this._onActivate.bind(this), false);
                    lis('mouseleave', this._onDeactivate.bind(this), false);
                    break;
                case 'focus':
                    lis('focus', this._onActivate.bind(this), false);
                    lis('blur', this._onDeactivate.bind(this), true);
                    break;
                }
            });

            if (includes(this.options.triggers, 'hover') || includes(this.options.triggers, 'focus')) {
                this._$tpl[evtType]('mouseenter', this._onMouseOverTooltip.bind(this), false);
                this._$tpl[evtType]('mouseleave', this._onMouseOutTooltip.bind(this), false);
            }
        }
    }

    _setEvents () {
        this._events();
    }

    _cleanEvents () {
        this._events(EVENTS.REMOVE);
    }

    _onActivate (e) {
        this.show();
    }

    _onDeactivate (e) {
        this.hide();
    }

    _onToggle (e) {
        e.stopPropagation();
        e.preventDefault();
        this.toggle();
    }

    _onMouseOverTooltip (e) {
        this.toggle(true, false);
    }

    _onMouseOutTooltip (e) {
        this.toggle(false);
    }

    content (content) {
        const wrapper = this.tooltip.popper.querySelector('.tooltip-content');
        if (typeof content === 'string') {
            this.tooltip.options.title = content;
            wrapper.textContent = content;
        } else if (isElement(content)) {
            if (content !== wrapper.children[0]) {
                wrapper.innerHTML = '';
                // this.tooltip.htmlContent = content.cloneNode(true);
                this.tooltip.htmlContent = content;
                wrapper.appendChild(this.tooltip.htmlContent);
            }
        } else {
            console.error('unsupported content type', content); // eslint-disable-line
        }
    }

    set class (val) {
        if (typeof val === 'string') {
            const classList = this._$tpl.classList.value.replace(this.options.class, val);
            this._options.class = classList;
            this._$tpl.setAttribute('class', classList);
        }
    }

    static filterOptions (options) {
        let opt = {...options};

        opt.modifiers = {};
        let head = null;
        let tail = null;
        if (opt.placement.indexOf('-') > -1) {
            [head, tail] = opt.placement.split('-');
            opt.placement = (includes(PLACEMENT, head) && includes(SUB_PLACEMENT, tail)) ? opt.placement : Tooltip._defaults.placement;
        } else {
            opt.placement = (includes(PLACEMENT, opt.placement)) ? opt.placement : Tooltip._defaults.placement;
        }

        opt.modifiers.offset = {
            fn: Tooltip._setOffset
        };

        return opt;
    }

    static _setOffset (data, opts) {
        let offset = data.instance.options.offset;

        if (window.isNaN(offset) || offset < 0) {
            offset = Tooltip._defaults.offset;
        }

        if (data.placement.indexOf('top') !== -1) {
            data.offsets.popper.top -= offset;
        } else if (data.placement.indexOf('right') !== -1) {
            data.offsets.popper.left += offset;
        } else if (data.placement.indexOf('bottom') !== -1) {
            data.offsets.popper.top += offset;
        } else if (data.placement.indexOf('left') !== -1) {
            data.offsets.popper.left -= offset;
        }

        return data;
    }

    static isIosSafari () {
        return includes(navigator.userAgent.toLowerCase(), 'mobile') && includes(navigator.userAgent.toLowerCase(), 'safari') &&
                (navigator.platform.toLowerCase() === 'iphone' || navigator.platform.toLowerCase() === 'ipad');
    }

    static defaults (data) {
        // if (data.placement) {
        //     data.originalPlacement = data.placement;
        // }
        Tooltip._defaults = {...Tooltip._defaults, ...data};
    }
}

Tooltip._defaults = {...DEFAULT_OPTIONS};

function randomId () {
    return `${Date.now()}-${Math.round(Math.random() * 100000000)}`;
}

/**
 * Check if the variable is an html element
 * @param {*} value
 * @return Boolean
 */
function isElement (value) {
    return value instanceof window.Element;
}