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;
}