UNPKG

@atlassian/aui

Version:

Atlassian User Interface library

362 lines (295 loc) 10.4 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, maxWidth: 200, }; let $sharedTip; const getTipNode = () => { return $sharedTip && $sharedTip.get(0); }; const toggleTooltipVisibility = (shouldBeHidden = false) => { const tipNode = getTipNode(); if (tipNode) { tipNode.classList.toggle('hidden', shouldBeHidden); tipNode.setAttribute('aria-hidden', shouldBeHidden); } }; class Tooltip { constructor(triggerElement, options) { this.triggerElement = triggerElement; this.$triggerElement = $(this.triggerElement); this.options = { ...defaultOptions, ...options }; this.enabled = this.options.enabled; this.moveTitleToTooltip(); this.initContainer(); this.observeTriggerRemoval(); } 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; }); } observeTriggerRemoval() { const observedElements = []; if (this.options.$delegationRoot && this.options.$delegationRoot.get(0)) { observedElements.push(this.options.$delegationRoot.get(0)); } if (this.triggerElement) { observedElements.push(this.triggerElement); } this.triggerObservers = observedElements .map((element) => { const parent = element.parentElement; if (parent) { const observer = new MutationObserver(() => { const isToDestroy = !parent.contains(element); if (isToDestroy) { this.destroy(); } }); observer.observe(parent, { childList: true, subtree: false, // We take trigger parent, so we only care about direct children }); return observer; } }) .filter((observer) => !!observer); } 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; } if (this.triggerObservers.length > 0) { this.triggerObservers.forEach((observer) => observer.disconnect()); } // We only need to unbind event handlers from this particular element this.$triggerElement.off(`.${pluginKey}`); } initContainer() { if ($sharedTip === undefined || ($sharedTip.get(0) && !$sharedTip.get(0).isConnected)) { $sharedTip = $( `<div id="${AUI_TOOLTIP_ID}" class="${AUI_TOOLTIP_CLASS_NAME} hidden" role="tooltip" aria-hidden="true"><p class="aui-tooltip-content"></p></div>` ); $(document.body).append($sharedTip); } } buildTip(title) { const options = this.options; const tooltipContentElement = $sharedTip.find('.aui-tooltip-content'); if (options.html) { if (options.sanitize) { title = DOMPurify.sanitize(title); } tooltipContentElement.html(title); } else { tooltipContentElement.text(title); } if (options.maxWidth) { tooltipContentElement.css('max-width', options.maxWidth + 'px'); } 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; } // In order to avoid flickering of the tooltip when we have the same content we need to skip hiding. const isNewTooltip = $sharedTip && tipTitle !== $sharedTip.text(); if (isNewTooltip) { this.hide(); } const triggerElement = this.triggerElement; const placement = GRAVITY_MAP[this.options.gravity]; if (typeof this.options.suppress === 'function') { if (this.options.suppress.call(triggerElement) === true) { return; } } const tipNode = this.buildTip(tipTitle).get(0); this.showTooltip(); this.popperInstance = createPopper(triggerElement, tipNode, { placement, modifiers: [ { name: 'offset', options: { offset: [0, 4], }, }, ], }); $(window).on(`scroll.${pluginKey}`, () => this.hide()); } hide() { this.hideTooltip(); 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']); let showTimeoutId; let hideTimeoutId; $.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 clearAllTimers = function () { clearTimeout(hideTimeoutId); clearTimeout(showTimeoutId); }; const updateTooltipEvents = function () { $sharedTip.off(`mouseenter.${pluginKey}`); $sharedTip.off(`mouseleave.${pluginKey}`); $sharedTip.on(`mouseenter.${pluginKey}`, () => { clearAllTimers(); }); $sharedTip.on(`mouseleave.${pluginKey}`, () => { clearAllTimers(); hide(); }); }; const show = function () { // Stop all events that were triggered by different tooltip clearAllTimers(); const tooltip = getTooltipInstance(this, options); showTimeoutId = setTimeout(() => { clearAllTimers(); tooltip.show(); $sharedTip && updateTooltipEvents(); }, AUI_TOOLTIP_TIMEOUT); }; const hide = function () { // Stop all events that were triggered by different tooltip clearAllTimers(); const tooltip = getTooltipInstance(this, options); hideTimeoutId = setTimeout(() => { tooltip.hide(); }, AUI_TOOLTIP_TIMEOUT); }; const hideOnEsc = function (e) { if (e.code === 'Escape') { 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); $collection.on('keyup', selector, hideOnEsc); } else { $collection.on(activationEvents, show); $collection.on(deactivationEvents, hide); $collection.on('keyup', hideOnEsc); } return $collection; }; export default $;