UNPKG

tippy.js

Version:

Pure JS Tooltip Library

961 lines (807 loc) 30.7 kB
import Popper from 'popper.js' /**! * @file tippy.js | Pure JS Tooltip Library * @version 0.11.3 * @license MIT */ // Touch user is assumed false until a `touchstart` event is fired // id counter for our aria-describedby labelling (tooltip IDs) const GLOBALS = { touchUser: false, idCounter: 0 } // Storage object to hold all references from instance instantiation // Allows us to hide tooltips from other instances when clicking on the body const STORE = { refs: [], els: [], poppers: [] } const DEFAULTS = { html: false, position: 'top', animation: 'shift', animateFill: true, arrow: false, arrowSize: 'regular', delay: 0, hideDelay: 0, trigger: 'mouseenter focus', duration: 375, hideDuration: 375, interactive: false, theme: 'dark', size: 'regular', distance: 10, offset: 0, hideOnClick: true, multiple: false, followCursor: false, inertia: false, transitionFlip: true, popperOptions: {} } const DEFAULTS_KEYS = Object.keys(DEFAULTS) const SELECTORS = { popper: '.tippy-popper', tooltip: '.tippy-tooltip', content: '.tippy-tooltip-content', circle: '[x-circle]', arrow: '[x-arrow]', el: '[data-tooltipped]', controller: '[data-tippy-controller]' } // Determine touch users function handleDocumentTouchstart() { GLOBALS.touchUser = true // iOS needs a `cursor: pointer` on the body to register clicks if (/(iPad|iPhone|iPod)/g.test(navigator.userAgent) && !window.MSStream) { document.body.classList.add('tippy-touch') } document.removeEventListener('touchstart', handleDocumentTouchstart) } // Handle clicks anywhere on the document function handleDocumentClick(event) { const el = closest(event.target, SELECTORS.el) const popper = closest(event.target, SELECTORS.popper) if (popper) { const ref = STORE.refs[STORE.poppers.indexOf(popper)] if (ref.settings.interactive) return } if (el) { const ref = STORE.refs[STORE.els.indexOf(el)] // If they clicked before the show() was to fire, clear it if (ref.settings.hideOnClick === true && !GLOBALS.touchUser) { clearTimeout(ref.popper.getAttribute('data-delay')) } // Hide all poppers except the one belonging to the element that was clicked IF // `multiple` is false AND they are a touch user, OR // `multiple` is false AND it's triggered by a click if ( (!ref.settings.multiple && GLOBALS.touchUser) || (!ref.settings.multiple && ref.settings.trigger.indexOf('click') !== -1) ) { return hideAllPoppers(ref) } // If hideOnClick is not strictly true or triggered by a click don't hide poppers if (ref.settings.hideOnClick !== true || ref.settings.trigger.indexOf('click') !== -1 ) return } // Don't trigger a hide for tippy controllers, and don't needlessly run loop if (closest(event.target, SELECTORS.controller) || !document.body.querySelector(SELECTORS.popper) ) return hideAllPoppers() } if (document.addEventListener) { document.addEventListener('click', handleDocumentClick) document.addEventListener('touchstart', handleDocumentTouchstart) } /** * Returns the supported prefixed property - only `webkit` is needed, `moz`, `ms` and `o` are obsolete * @param {String} property * @return {String} - browser supported prefixed property */ function prefix(property) { const prefixes = [false, 'webkit'] const upperProp = property.charAt(0).toUpperCase() + property.slice(1) for (var i = 0; i < prefixes.length; i++) { const prefix = prefixes[i] const prefixedProp = prefix ? '' + prefix + upperProp : property if (typeof window.document.body.style[prefixedProp] !== 'undefined') { return prefixedProp } } return null } /** * Returns the non-shifted placement (e.g., 'bottom-start' => 'bottom') * @param {String} placement * @return {String} */ function getCorePlacement(placement) { return placement.replace(/-.+/, '') } /** * Polyfill to get the closest parent element * @param {Element} element - child of parent to be returned * @param {String} parentSelector - selector to match the parent if found * @return {Element} */ function closest(element, parentSelector) { if (!Element.prototype.matches) { Element.prototype.matches = Element.prototype.matchesSelector || Element.prototype.webkitMatchesSelector || Element.prototype.mozMatchesSelector || Element.prototype.msMatchesSelector || function(s) { var matches = (this.document || this.ownerDocument).querySelectorAll(s), i = matches.length; while (--i >= 0 && matches.item(i) !== this) {} return i > -1; } } if (!Element.prototype.closest) Element.prototype.closest = function(selector) { var el = this while (el) { if (el.matches(selector)) { return el } el = el.parentElement } } return element.closest(parentSelector) } /** * Creates a new popper instance * @param {Object} ref * @return {Object} - the popper instance */ function createPopperInstance(ref) { const settings = ref.settings const tooltip = ref.popper.querySelector(SELECTORS.tooltip) const config = { placement: settings.position, ...(settings.popperOptions || {}), modifiers: { ...(settings.popperOptions ? settings.popperOptions.modifiers : {}), flip: { padding: parseInt(settings.distance) + 5 /* 5px from viewport boundary */, ...(settings.popperOptions && settings.popperOptions.modifiers ? settings.popperOptions.modifiers.flip : {}) }, offset: { offset: parseInt(settings.offset), ...(settings.popperOptions && settings.popperOptions.modifiers ? settings.popperOptions.modifiers.offset : {}) } }, onUpdate() { tooltip.style.top = '' tooltip.style.bottom = '' tooltip.style.left = '' tooltip.style.right = '' tooltip.style[getCorePlacement(ref.popper.getAttribute('x-placement'))] = -(settings.distance - 10) + 'px' } } return new Popper(ref.el, ref.popper, config) } /** * Creates a popper element then returns it * @param {Number} id - the popper id * @param {String} title - the tooltip's `title` attribute * @param {Object} settings - individual settings * @return {Element} - the popper element */ function createPopperElement(id, title, settings) { const popper = document.createElement('div') popper.setAttribute('class', 'tippy-popper') popper.setAttribute('role', 'tooltip') popper.setAttribute('aria-hidden', 'true') popper.setAttribute('id', `tippy-tooltip-${id}`) const tooltip = document.createElement('div') tooltip.setAttribute('class', `tippy-tooltip tippy-tooltip--${settings.size} ${settings.theme}-theme leave`) tooltip.setAttribute('data-animation', settings.animation) if (settings.arrow) { // Add an arrow const arrow = document.createElement('div') arrow.setAttribute('class', `arrow-${settings.arrowSize}`) arrow.setAttribute('x-arrow', '') tooltip.appendChild(arrow) } if (settings.animateFill) { // Create animateFill circle element for animation tooltip.setAttribute('data-animatefill', '') const circle = document.createElement('div') circle.setAttribute('class', 'leave') circle.setAttribute('x-circle', '') tooltip.appendChild(circle) } if (settings.inertia) { // Change transition timing function cubic bezier tooltip.setAttribute('data-inertia', '') } // Tooltip content (text or HTML) const content = document.createElement('div') content.setAttribute('class', 'tippy-tooltip-content') if (settings.html) { let templateId if (settings.html instanceof Element) { content.innerHTML = settings.html.innerHTML templateId = settings.html.id || 'tippy-html-template' } else { content.innerHTML = document.getElementById(settings.html.replace('#', '')).innerHTML templateId = settings.html } popper.classList.add('html-template') popper.setAttribute('tabindex', '0') tooltip.setAttribute('data-template-id', templateId) } else { content.innerHTML = title } // Init distance. Further updates are made in the popper instance's `onUpdate()` method tooltip.style[getCorePlacement(settings.position)] = -(settings.distance - 10) + 'px' tooltip.appendChild(content) popper.appendChild(tooltip) return popper } /** * Creates a trigger * @param {Object} event - the custom event specified in the `trigger` setting * @param {Element} el - tooltipped element * @param {Object} handlers - the handlers for each listener * @return {Array} - array of listener objects */ function createTrigger(event, el, handlers) { const listeners = [] if (event === 'manual') return listeners // Enter el.addEventListener(event, handlers.handleTrigger) listeners.push({ event, handler: handlers.handleTrigger }) // Leave if (event === 'mouseenter') { el.addEventListener('mouseleave', handlers.handleMouseleave) listeners.push({ event: 'mouseleave', handler: handlers.handleMouseleave }) } if (event === 'focus') { el.addEventListener('blur', handlers.handleBlur) listeners.push({ event: 'blur', handler: handlers.handleBlur }) } return listeners } /** * Adds each reference (tooltipped element, popper and its settings/listeners etc) * into global storage * @param {Object} ref - current ref in the forEach loop to be pushed */ function pushIntoStorage(ref) { STORE.refs.push(ref) STORE.els.push(ref.el) STORE.poppers.push(ref.popper) } /** * Removes the title from the tooltipped element * @param {Element} el */ function removeTitle(el) { const title = el.getAttribute('title') el.setAttribute('data-original-title', title || 'html') el.removeAttribute('title') } /** * Determines if an element is visible in the viewport * @param {Element} el * @return {Boolean} */ function elementIsInViewport(el) { const rect = el.getBoundingClientRect() return ( rect.top >= 0 && rect.left >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && rect.right <= (window.innerWidth || document.documentElement.clientWidth) ) } /** * Mousemove event listener callback method for follow cursor setting * @param {Object} e (event) */ function followCursor(e) { const ref = STORE.refs[STORE.els.indexOf(this)] const position = getCorePlacement(ref.popper.getAttribute('x-placement')) const halfPopperWidth = Math.round( ref.popper.offsetWidth / 2 ) const halfPopperHeight = Math.round( ref.popper.offsetHeight / 2 ) // Default = top let x = e.pageX - halfPopperWidth let y = e.pageY - 2.5 * halfPopperHeight if (position === 'left') { x = e.pageX - ( 2 * halfPopperWidth ) - 15 y = e.pageY - halfPopperHeight } else if (position === 'right') { x = e.pageX + halfPopperHeight y = e.pageY - halfPopperHeight } else if (position === 'bottom') { y = e.pageY + halfPopperHeight/1.5 } ref.popper.style[prefix('transform')] = `translate3d(${x}px, ${y}px, 0)` } /** * Triggers a document repaint or reflow for CSS transition * @param {Element} tooltip * @param {Element} circle */ function triggerReflow(tooltip, circle) { // Safari needs the specific 'transform' property to be accessed circle ? window.getComputedStyle(circle)[prefix('transform')] : window.getComputedStyle(tooltip).opacity } /** * Modifies elements' class lists * @param {Array} els - HTML elements * @param {Function} callback */ function modifyClassList(els, callback) { els.forEach(el => { if (!el) return callback(el.classList) }) } /** * Applies the transition duration to each element * @param {Array} els - HTML elements * @param {Number} duration */ function applyTransitionDuration(els, duration) { els.forEach(el => { if (!el) return if (el.hasAttribute('x-circle')) duration /= 1.25 el.style[prefix('transitionDuration')] = duration + 'ms' }) } /** * Fixes CSS transition * @param {Object} ref - element/popper reference * @param {Function} callback - the quick hide/show correction */ function correctTransition(ref, callback) { // Queue once popper has setTimeout(() => { const isFlipped = ref.settings.position !== ref.popper.getAttribute('x-placement') if (!ref.flipped && isFlipped) { ref.flipped = true callback() } else if (ref.flipped && !isFlipped) { ref.flipped = false callback() } }, 0) } /** * Prepares the callback functions for `show` and `hide` methods * @param {Object} ref - the element/popper reference * @param {Number} duration * @param {Function} callback - callback function to fire once transitions complete */ function onTransitionEnd(ref, duration, callback) { const tooltip = ref.popper.querySelector(SELECTORS.tooltip) let transitionendFired = false const listenerCallback = (e) => { if (e.target !== tooltip) return transitionendFired = true tooltip.removeEventListener('webkitTransitionEnd', listenerCallback) tooltip.removeEventListener('transitionend', listenerCallback) callback() } // Wait for transitions to complete tooltip.addEventListener('webkitTransitionEnd', listenerCallback) tooltip.addEventListener('transitionend', listenerCallback) // transitionend listener sometimes may not fire clearTimeout(ref.transitionendTimeout) ref.transitionendTimeout = setTimeout(() => { if (!transitionendFired) { callback() } }, duration) } /** * @param {Element} popper * @return {Boolean} */ function isExpectedState(popper, type) { const visibility = popper.style.visibility return type === 'show' ? visibility === 'visible' : visibility === 'hidden' } /** * Appends the popper and creates a popper instance if one does not exist * Also updates its position if need be and enables event listeners * @param {Object} ref - the element/popper reference */ function awakenPopper(ref) { document.body.appendChild(ref.popper) if (!ref.popperInstance) { // Create instance if it hasn't been created yet ref.popperInstance = createPopperInstance(ref) // Follow cursor setting if (ref.settings.followCursor && !GLOBALS.touchUser) { ref.el.addEventListener('mousemove', followCursor) ref.popperInstance.disableEventListeners() } } else { ref.popperInstance.update() // Waiting on Popper.js fix. Works in Chrome and FF, but not Safari // =============================================================== // In cases where the window is resized, the update() method won't always move it // back into the viewport properly, it slowly moves back in with each update // Here we make updates until it's back in the viewport let updateCounter = 0 // 50 max updates ;(function shiftIntoViewport() { setTimeout(() => { ref.popperInstance.scheduleUpdate() if (updateCounter < 50 && window.innerWidth <= ref.popper.getBoundingClientRect().right) { shiftIntoViewport() } }, 0) updateCounter++ })() if (!ref.settings.followCursor) { ref.popperInstance.enableEventListeners() } } } /** * Hides all poppers * @param {Object} - currentRef */ function hideAllPoppers(currentRef) { STORE.refs.forEach(ref => { // Don't hide already hidden ones if (!document.body.contains(ref.popper)) return // hideOnClick can have the truthy value of 'persistent', so strict check is needed const isHideOnClick = ref.settings.hideOnClick === true const isNotCurrentRef = !currentRef || ref.popper !== currentRef.popper if (isHideOnClick && isNotCurrentRef) { ref.tippyInstance.hide(ref.popper, ref.settings.hideDuration) } }) } /** * The class to be exported to be used on the `window` * Private methods are prefixed with an underscore _ * @param {String|Element} selector * @param {Object} settings (optional) - the object of settings to be applied to the instance */ export default class Tippy { constructor(selector, settings = {}) { // Use default browser tooltip on old browsers (IE < 10) and Opera Mini if ( !('addEventListener' in window) || /MSIE 9/i.test(navigator.userAgent) || window.operamini ) return this.settings = Object.assign(JSON.parse(JSON.stringify(DEFAULTS)), settings) this.callbacks = { wait: settings.wait, beforeShown: settings.beforeShown || new Function, shown: settings.shown || new Function, beforeHidden: settings.beforeHidden || new Function, hidden: settings.hidden || new Function } // Check if selector is a DOM element const els = (selector instanceof Element) ? [selector] : [].slice.call(document.querySelectorAll(selector)) this._createTooltips(els) } /** * Returns an object of settings to override global settings * @param {Element} el - the tooltipped element * @return {Object} - individual settings */ _applyIndividualSettings(el) { const settings = {} DEFAULTS_KEYS.forEach(key => { let val = el.getAttribute(`data-${ key.toLowerCase() }`) || this.settings[key] if (val === 'false') val = false settings[key] = val }) // animateFill is disabled if an arrow is true if (settings.arrow) settings['animateFill'] = false return Object.assign({}, this.settings, settings) } /** * Returns relevant listener callbacks for each ref * @param {Element} el * @param {Element} popper * @param {Object} settings * @return {Object} - relevant listener callback methods */ _getEventListenerHandlers(el, popper, settings) { const clearTimeouts = () => { clearTimeout(popper.getAttribute('data-delay')) clearTimeout(popper.getAttribute('data-hidedelay')) } const _show = () => { clearTimeouts() // Already visible. For clicking when it also has a `focus` event listener if (popper.style.visibility === 'visible') return if (settings.delay) { const delay = setTimeout( () => this.show(popper, settings.duration), settings.delay ) popper.setAttribute('data-delay', delay) } else { this.show(popper, settings.duration) } } const show = event => this.callbacks.wait ? this.callbacks.wait(_show, event) : _show() const hide = () => { clearTimeouts() if (settings.hideDelay) { const delay = setTimeout( () => this.hide(popper, settings.hideDuration), settings.hideDelay ) popper.setAttribute('data-hidedelay', delay) } else { this.hide(popper, settings.hideDuration) } } const handleTrigger = event => { // Toggle show/hide when clicking click-triggered tooltips const isClick = event.type === 'click' const isVisible = popper.style.visibility === 'visible' const isNotPersistent = settings.hideOnClick !== 'persistent' isClick && isVisible && isNotPersistent ? hide() : show(event) } const handleMouseleave = event => { if (settings.interactive) { // Temporarily handle mousemove to check if the mouse left somewhere // other than its popper const handleMousemove = event => { const isOverPopper = closest(event.target, SELECTORS.popper) === popper const isOverEl = closest(event.target, SELECTORS.el) === el const isClickTriggered = settings.trigger.indexOf('click') !== -1 if (isOverPopper || isOverEl || isClickTriggered) return // Firefox (and maybe other browsers) do not reliably place the popper // directly next to the element, use 2px padding to ensure the cursor is far // enough away const popperRect = popper.getBoundingClientRect() const padding = 2 if ( Math.abs(event.clientY - popperRect.bottom) >= padding && Math.abs(event.clientY - popperRect.top) >= padding && Math.abs(event.clientX - popperRect.left) >= padding && Math.abs(event.clientX - popperRect.right) >= padding ) { document.removeEventListener('mousemove', handleMousemove) hide() } } return document.addEventListener('mousemove', handleMousemove) } // If it's not interactive, just hide it hide() } const handleBlur = event => { // Only hide if not a touch user and has a focus 'relatedtarget', of which is not // a popper element if (GLOBALS.touchUser || !event.relatedTarget) return if (closest(event.relatedTarget, SELECTORS.popper)) return hide() } return { handleTrigger, handleMouseleave, handleBlur } } /** * Creates tooltips for all elements that match the instance's selector */ _createTooltips(els) { els.forEach(el => { const settings = this._applyIndividualSettings(el) const title = el.title if (!title && !settings.html) return const id = GLOBALS.idCounter el.setAttribute('data-tooltipped', '') el.setAttribute('aria-describedby', `tippy-tooltip-${id}`) removeTitle(el) const popper = createPopperElement(id, title, settings) const handlers = this._getEventListenerHandlers(el, popper, settings) let listeners = [] settings.trigger.trim().split(' ').forEach( event => listeners = listeners.concat(createTrigger(event, el, handlers)) ) pushIntoStorage({ id, el, popper, settings, listeners, tippyInstance: this }) GLOBALS.idCounter++ }) Tippy.store = STORE // Allow others to access `STORE` if need be } /** * Returns a tooltipped element's popper reference * @param {Element} el * @return {Element} */ getPopperElement(el) { try { return STORE.refs[STORE.els.indexOf(el)].popper } catch (e) { throw new Error('[Tippy error]: Element does not exist in any Tippy instances') } } /** * Returns a popper's tooltipped element reference * @param {Element} popper * @return {Element} */ getTooltippedElement(popper) { try { return STORE.refs[STORE.poppers.indexOf(popper)].el } catch (e) { throw new Error('[Tippy error]: Popper does not exist in any Tippy instances') } } /** * Shows a popper * @param {Element} popper * @param {Number} duration (optional) * @param {Boolean} enableCallback (optional) */ show(popper, duration = this.settings.duration, enableCallback = true) { const ref = STORE.refs[STORE.poppers.indexOf(popper)] const tooltip = popper.querySelector(SELECTORS.tooltip) const circle = popper.querySelector(SELECTORS.circle) if (enableCallback) { this.callbacks.beforeShown() // Flipping causes CSS transition to go haywire correctTransition(ref, () => { this.hide(popper, 0, false) setTimeout(() => { // Under fast-moving cursor cases, the tooltip can stay stuck because // the mouseleave triggered before this show // hidden only becomes `true` in the `hide` method if callback is enabled // (i.e. legitimate hide, not triggered by this correcttransition function) if (ref.hidden) return this.show(popper, duration, false) }, 0) }) } if (!document.body.contains(popper)) { awakenPopper(ref) } // Interactive tooltips receive a class of 'active' if (ref.settings.interactive) { ref.el.classList.add('active') } ref.hidden = false ref.popper.style.visibility = 'visible' ref.popper.setAttribute('aria-hidden', 'false') // Repaint/reflow is required for CSS transition when appending triggerReflow(tooltip, circle) modifyClassList([tooltip, circle], list => { list.remove('leave') list.add('enter') }) applyTransitionDuration([tooltip, circle], duration) // Wait for transitions to complete onTransitionEnd(ref, duration, () => { if (!isExpectedState(popper, 'show') || ref.onShownFired) return if (!ref.settings.transitionFlip) { tooltip.classList.add('tippy-notransition') } // Focus interactive tooltips only if (ref.settings.interactive) { popper.focus() } // Prevents shown() from firing more than once from early transition cancellations ref.onShownFired = true if (enableCallback) this.callbacks.shown() }) } /** * Hides a popper * @param {Element} popper * @param {Number} duration (optional) * @param {Boolean} enableCallback (optional) */ hide(popper, duration = this.settings.duration, enableCallback = true) { const ref = STORE.refs[STORE.poppers.indexOf(popper)] const tooltip = popper.querySelector(SELECTORS.tooltip) const circle = popper.querySelector(SELECTORS.circle) const content = popper.querySelector(SELECTORS.content) if (enableCallback) { this.callbacks.beforeHidden() // flag needed for correctTransition, popper.style.visibility must be used by // correctTransition ref.hidden = true ref.el.classList.remove('active') ref.onShownFired = false if (!ref.settings.transitionFlip) { tooltip.classList.remove('tippy-notransition') } ref.flipped = (ref.settings.position !== popper.getAttribute('x-placement')) } popper.style.visibility = 'hidden' popper.setAttribute('aria-hidden', 'true') // Use same duration as show if it's the default if (duration === DEFAULTS.hideDuration) { duration = parseInt(tooltip.style[prefix('transitionDuration')]) } else { applyTransitionDuration([tooltip, circle], duration) } modifyClassList([tooltip, circle], list => { list.remove('enter') list.add('leave') }) // Re-focus click-triggered html elements // and the tooltipped element IS in the viewport (otherwise it causes unsightly scrolling // if the tooltip is closed and the element isn't in the viewport anymore) if (ref.settings.html && ref.settings.trigger.indexOf('click') !== -1 && elementIsInViewport(ref.el)) { ref.el.focus() } // Wait for transitions to complete onTransitionEnd(ref, duration, () => { if (!isExpectedState(popper, 'hide') || !document.body.contains(popper)) return ref.popperInstance.disableEventListeners() document.body.removeChild(popper) if (enableCallback) this.callbacks.hidden() }) } /** * Destroys a popper * @param {Element} popper */ destroy(popper) { const index = STORE.poppers.indexOf(popper) const ref = STORE.refs[index] // Remove Tippy-only event listeners from tooltipped element ref.listeners.forEach( listener => ref.el.removeEventListener(listener.event, listener.handler) ) ref.el.removeAttribute('data-tooltipped') ref.el.removeAttribute('aria-describedby') if (ref.popperInstance) ref.popperInstance.destroy() // Remove from storage STORE.refs.splice(index, 1) STORE.els.splice(index, 1) STORE.poppers.splice(index, 1) } /** * Updates a popper with new content * @param {Element} popper */ update(popper) { const ref = STORE.refs[STORE.poppers.indexOf(popper)] const content = popper.querySelector(SELECTORS.content) const template = ref.settings.html if (template) { content.innerHTML = (template instanceof Element) ? template.innerHTML : document.getElementById(template.replace('#', '')).innerHTML } else { content.innerHTML = ref.el.getAttribute('title') || ref.el.getAttribute('data-original-title') removeTitle(ref.el) } } }