UNPKG

@atlassian/aui

Version:

Atlassian User Interface library

272 lines (219 loc) 7.63 kB
import $ from './jquery'; import { createPopper } from '@popperjs/core'; import DOMPurify from 'dompurify'; const AUI_TOOLTIP_CLASS_NAME = 'aui-tooltip'; const AUI_TOOLTIP_ID = 'aui-tooltip'; const AUI_TOOLTIP_TIMEOUT = 300; /** * The purpose of this map is to make it possible to use old Tipsy tooltip positions * with Popper. * * @enum * @name GravityOptions * @type {{n: string, ne: string, e: string, se: string, s: string, sw: string, w: string, nw: string}} */ const GRAVITY_MAP = { 'n': 'bottom', 'ne': 'bottom-end', 'e': 'left', 'se': 'top-end', 's': 'top', 'sw': 'top-start', 'w': 'right', 'nw': 'bottom-start', }; // This key is used to differentiate events related to this particular plugin. const pluginKey = 'aui-tooltip'; const defaultOptions = { gravity: 'n', html: false, live: false, enabled: true, suppress: () => false, aria: true, sanitize: true } let $sharedTip; const getTipNode = () => { return $sharedTip && $sharedTip.get(0); } const toggleTooltipVisibility = (shouldBeHidden = false) => { const tipNode = getTipNode(); if (tipNode) { tipNode.classList.toggle('assistive', shouldBeHidden) } } class Tooltip { constructor(triggerElement, options) { this.triggerElement = triggerElement; this.$triggerElement = $(this.triggerElement); this.options = { ...defaultOptions, ...options }; this.enabled = this.options.enabled this.moveTitleToTooltip() } destroy() { this.unbindHandlers(); this.hide(); tooltipsByDomNode.delete(this.triggerElement); } moveTitleToTooltip() { const tooltip = this; const $triggerElement = this.$triggerElement; $triggerElement.attr('title', function (_, originalTitle) { tooltip.originalTitle = originalTitle; if (tooltip.options.aria) { $triggerElement.attr('aria-describedby', AUI_TOOLTIP_ID); } return null; }); } unbindHandlers() { const selector = this.options.live; // Keep in mind that unbinding handlers from one tooltip // managed by delegation will unbind handlers for the whole // collection. if (this.options.$delegationRoot && selector) { this.options.$delegationRoot.off(`.${pluginKey}`, selector); return; } // We only need to unbind event handlers from this particular element this.$triggerElement.off(`.${pluginKey}`); } buildTip(title) { const options = this.options; if ($sharedTip === undefined || $sharedTip.get(0) && !$sharedTip.get(0).isConnected) { $sharedTip = $(`<div id="${AUI_TOOLTIP_ID}" class="${AUI_TOOLTIP_CLASS_NAME} assistive" role="tooltip"><p class="aui-tooltip-content"></p></div>`); $(document.body).append($sharedTip); } const tooltipContentElement = $sharedTip.find('.aui-tooltip-content'); if (options.html) { if (options.sanitize) { title = DOMPurify.sanitize(title); } tooltipContentElement.html(title); } else { tooltipContentElement.text(title); } return $sharedTip; } getTipTitle() { const options = this.options; let title = typeof options.title === 'function' ? options.title : typeof options.title === 'string' ? () => options.title : () => this.originalTitle || ''; let actualTitle = title.call(this.triggerElement); return (!actualTitle || !actualTitle.trim().length) ? undefined : actualTitle; } show() { const tipTitle = this.getTipTitle(); if (this.enabled === false || !tipTitle) { return; } this.hide(); const triggerElement = this.triggerElement; const placement = GRAVITY_MAP[this.options.gravity]; clearTimeout(this.popperTimeout); if (typeof this.options.suppress === 'function') { if (this.options.suppress.call(triggerElement) === true) { return; } } const tipNode = this.buildTip(tipTitle).get(0); this.popperTimeout = setTimeout(() => { this.showTooltip(); this.popperInstance = createPopper(triggerElement, tipNode, { placement, modifiers: [ { name: 'offset', options: { offset: [0, 4], }, }, ], }); $(window).on(`scroll.${pluginKey}`, () => this.hide()); }, AUI_TOOLTIP_TIMEOUT); } hide() { this.hideTooltip(); clearTimeout(this.popperTimeout); if (this.popperInstance) { this.popperInstance.destroy(); delete this.popperInstance; } $(window).off(`scroll.${pluginKey}`); } showTooltip() { toggleTooltipVisibility(false); } hideTooltip() { toggleTooltipVisibility(true); } enable() { this.enabled = true; } disable() { this.hide(); this.enabled = false; } } const tooltipsByDomNode = new WeakMap(); const getTooltipInstance = (domNode, options) => { // Options will be ignored if there is an existing tooltip instance // assigned to given DOM node. To override it you need to first destroy // the old tooltip. let tooltip = tooltipsByDomNode.get(domNode); if (tooltip === undefined) { tooltip = new Tooltip(domNode, options); if (typeof domNode === 'object') { tooltipsByDomNode.set(domNode, tooltip); } } return tooltip; } const namespacify = events => events.map(event => `${event}.${pluginKey}`).join(' '); const activationEvents = namespacify(['mouseenter', 'focus']); const deactivationEvents = namespacify(['click', 'mouseleave', 'blur']); $.fn.tooltip = function (arg) { // We have an actual jQuery collection available under `this` const $collection = this; // Get the tooltip instance assigned to the first element in the collection if (arg === true) { const firstDomNode = $collection.get(0); return getTooltipInstance(firstDomNode); } // Call the particular method from the first tooltip instance if (typeof arg === 'string') { const tooltip = $collection.tooltip(true); const commandName = arg; if (typeof tooltip[commandName] !== 'function') { throw new Error(`Method ${commandName} does not exist on tooltip.`) } tooltip[commandName](); return $collection; } const options = arg || {} const show = function () { const tooltip = getTooltipInstance(this, options); tooltip.show(); } const hide = function () { const tooltip = getTooltipInstance(this, options); tooltip.hide(); } const selector = options.live; if (selector !== undefined) { // We store it so that it's possible to kill the whole delegation later options.$delegationRoot = $collection; $collection.on(activationEvents, selector, show); $collection.on(deactivationEvents, selector, hide); return $collection; } $collection.on(activationEvents, show); $collection.on(deactivationEvents, hide); return $collection; }; export default $;