@atlassian/aui
Version:
Atlassian User Interface library
362 lines (295 loc) • 10.4 kB
JavaScript
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 $;