@atlassian/aui
Version:
Atlassian User Interface library
272 lines (219 loc) • 7.63 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
}
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 $;