/**
* --------------------------------------------------------------------------
* NJ: Tooltip.ts
* --------------------------------------------------------------------------
*/
import { Core, EventName } from '../../globals/ts/enum';
import Popper, { Placement } from 'popper.js';
import AbstractComponent from '../../globals/ts/abstract-component';
import Data from '../../globals/ts/data';
import EventHandler from '../../globals/ts/event-handler';
import Manipulator from '../../globals/ts/manipulator';
import Util from '../../globals/ts/util';
export default class Tooltip extends AbstractComponent {
static readonly NAME = `${Core.KEY_PREFIX}-tooltip`;
protected static readonly DATA_KEY = `${Core.KEY_PREFIX}.tooltip`;
protected static readonly EVENT_KEY = `.${Tooltip.DATA_KEY}`;
private static readonly CLASS_NAME = {
default: `${Core.KEY_PREFIX}-tooltip`,
inner: `${Core.KEY_PREFIX}-tooltip__inner`,
arrow: `${Core.KEY_PREFIX}-tooltip__arrow`,
withoutArrow: `${Core.KEY_PREFIX}-tooltip--without-arrow`,
inverse: `${Core.KEY_PREFIX}-tooltip--inverse`,
fade: 'fade',
show: 'show'
};
private static readonly NJCLS_PREFIX_REGEX = new RegExp(`(^|\\s)${Tooltip.CLASS_NAME.default}\\S+`, 'g');
private static readonly DEFAULT_TYPE = {
animation: 'boolean',
template: 'string',
title: '(string|element|function)',
trigger: 'string',
delay: '(number|object)',
html: 'boolean',
selector: '(string|boolean)',
placement: '(string|function)',
offset: '(number|string)',
container: '(string|element|boolean)',
fallbackPlacement: '(string|array)',
boundary: '(string|element)',
arrow: 'boolean'
};
private static readonly ATTACHMENT_MAP: { [key: string]: Placement } = {
AUTO: 'auto',
TOP: 'top',
RIGHT: 'right',
BOTTOM: 'bottom',
LEFT: 'left'
};
public static readonly DEFAULT_OPTIONS = {
animation: true,
template:
`
`,
trigger: 'hover focus',
title: '',
delay: 0,
html: false,
selector: false,
placement: 'top',
offset: 0,
container: false,
fallbackPlacement: 'flip',
boundary: 'scrollParent',
arrow: true
};
private static readonly HOVER_STATE = {
show: 'show',
out: 'out'
};
private static readonly EVENT = {
hide: `${EventName.hide}${Tooltip.EVENT_KEY}`,
hidden: `${EventName.hidden}${Tooltip.EVENT_KEY}`,
show: `${EventName.show}${Tooltip.EVENT_KEY}`,
shown: `${EventName.shown}${Tooltip.EVENT_KEY}`,
inserted: `${EventName.inserted}${Tooltip.EVENT_KEY}`,
click: `${EventName.click}${Tooltip.EVENT_KEY}`,
focusin: `${EventName.focusin}${Tooltip.EVENT_KEY}`,
focusout: `${EventName.focusout}${Tooltip.EVENT_KEY}`,
mouseenter: `${EventName.mouseenter}${Tooltip.EVENT_KEY}`,
mouseleave: `${EventName.mouseleave}${Tooltip.EVENT_KEY}`
};
public static readonly SELECTOR = {
default: `[data-toggle="tooltip"]`,
inner: `.${Tooltip.CLASS_NAME.inner}`,
arrow: `.${Tooltip.CLASS_NAME.arrow}`,
tooltip: `.${Core.KEY_PREFIX}-tooltip`
};
private static readonly TRIGGER = {
hover: 'hover',
focus: 'focus',
click: 'click',
manual: 'manual'
};
private isEnabled = true;
public timeout = 0;
public hoverState = '';
public activeTrigger: any = {};
private popper = null;
private tip: HTMLElement | null = null;
constructor(element: HTMLElement, options = {}) {
super(Tooltip, element, Tooltip.getOptions(element, options));
this.setListeners();
Data.setData(element, Tooltip.DATA_KEY, this);
}
enable(): void {
this.isEnabled = true;
}
disable(): void {
this.isEnabled = false;
}
toggleEnabled(): void {
this.isEnabled = !this.isEnabled;
}
toggle(event): void {
if (!this.isEnabled) {
return;
}
if (event) {
const dataKey = Tooltip.DATA_KEY;
let context = Tooltip.getInstance(event.delegateTarget);
if (!context) {
context = new Tooltip(event.delegateTarget, this.getDelegateConfig());
Data.setData(event.delegateTarget, dataKey, context);
}
context.activeTrigger.click = !context.activeTrigger.click;
if (context.isWithActiveTrigger()) {
context.enter(null, context);
} else {
context.leave(null, context);
}
} else {
if (this.getTipElement().classList.contains(Tooltip.CLASS_NAME.show)) {
this.leave(null, this);
return;
}
this.enter(null, this);
}
}
dispose(): void {
clearTimeout(this.timeout);
Data.removeData(this.element, Tooltip.DATA_KEY);
EventHandler.off(this.element, Tooltip.EVENT_KEY);
EventHandler.off(this.element.closest('.modal'), `hide.${Core.KEY_PREFIX}.modal`);
if (this.tip && this.tip.parentNode) {
this.tip.parentNode.removeChild(this.tip);
}
this.isEnabled = null;
this.timeout = null;
this.hoverState = null;
this.activeTrigger = null;
if (this.popper !== null) {
this.popper.destroy();
}
this.popper = null;
this.element = null;
this.options = null;
this.tip = null;
}
show(): void {
if (this.element.style.display === 'none') {
throw new Error('Please use show on visible elements');
}
if (this.isWithContent() && this.isEnabled) {
const showEvent = EventHandler.trigger(this.element, Tooltip.EVENT.show);
const shadowRoot = Util.findShadowRoot(this.element);
const isInTheDom =
shadowRoot !== null
? shadowRoot.contains(this.element)
: this.element.ownerDocument.documentElement.contains(this.element);
if (showEvent.defaultPrevented || !isInTheDom) {
return;
}
const tip = this.getTipElement();
const tipId = Util.getUID(Tooltip.NAME);
tip.setAttribute('id', tipId);
this.toggleAriaDescribedby(true, tipId);
this.setContent();
if (this.options.animation) {
tip.classList.add(Tooltip.CLASS_NAME.fade);
}
const placement =
typeof this.options.placement === 'function'
? this.options.placement.call(this, tip, this.element)
: this.options.placement;
const attachment = Tooltip.getAttachment(placement);
// Attachment Class
this.addAttachmentClass(attachment);
// Arrow class
if (!this.options.arrow) {
this.getTipElement().classList.add(Tooltip.CLASS_NAME.withoutArrow);
}
if (this.options.variant === 'inverse') {
this.getTipElement().classList.add(Tooltip.CLASS_NAME.inverse);
}
const container = this.getContainer();
Data.setData(tip, Tooltip.DATA_KEY, this);
if (!this.element.ownerDocument.documentElement.contains(this.tip)) {
container.appendChild(tip);
}
EventHandler.trigger(this.element, Tooltip.EVENT.inserted);
// eslint-disable-next-line no-undef
this.popper = new Popper(this.element, tip, {
placement: attachment,
modifiers: {
offset: {
offset: this.options.offset
},
flip: {
behavior: this.options.fallbackPlacement
},
arrow: {
element: Tooltip.SELECTOR.arrow
},
preventOverflow: {
boundariesElement: this.options.boundary
}
},
onCreate: (data): void => {
if (data.originalPlacement !== data.placement) {
this.handlePopperPlacementChange(data);
}
},
onUpdate: (data): void => this.handlePopperPlacementChange(data)
});
tip.classList.add(Tooltip.CLASS_NAME.show);
// If this is a touch-enabled device we add extra
// empty mouseover listeners to the body's immediate children;
// only needed because of broken event delegation on iOS
// https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html
if ('ontouchstart' in document.documentElement) {
Util.makeArray(document.body.children).forEach((element) => {
EventHandler.on(element, 'mouseover');
});
}
const complete = (): void => {
if (this.options.animation) {
this.fixTransition();
}
const prevHoverState = this.hoverState;
this.hoverState = null;
EventHandler.trigger(this.element, Tooltip.EVENT.shown);
if (prevHoverState === Tooltip.HOVER_STATE.out) {
this.leave(null, this);
}
};
if (this.tip.classList.contains(Tooltip.CLASS_NAME.fade)) {
const transitionDuration = Util.getTransitionDurationFromElement(this.tip);
EventHandler.one(this.tip, Util.TRANSITION_END, complete);
Util.emulateTransitionEnd(this.tip, transitionDuration);
} else {
complete();
}
}
}
hide(callback?: () => never): void {
const tip = this.getTipElement();
const complete = (): void => {
// Checks that the element still exists after setTimeout() of Util.emulateTransitionEnd() function
if (!this.element) {
return;
}
if (this.hoverState !== Tooltip.HOVER_STATE.show && tip.parentNode) {
tip.parentNode.removeChild(tip);
}
this.cleanTipClass();
this.toggleAriaDescribedby(false);
EventHandler.trigger(this.element, Tooltip.EVENT.hidden);
if (this.popper !== null) {
this.popper.destroy();
}
if (callback) {
callback();
}
};
const hideEvent = EventHandler.trigger(this.element, Tooltip.EVENT.hide);
if (hideEvent.defaultPrevented) {
return;
}
tip.classList.remove(Tooltip.CLASS_NAME.show);
// If this is a touch-enabled device we remove the extra
// empty mouseover listeners we added for iOS support
if ('ontouchstart' in document.documentElement) {
Util.makeArray(document.body.children).forEach((element) => EventHandler.off(element, 'mouseover'));
}
this.activeTrigger[Tooltip.TRIGGER.click] = false;
this.activeTrigger[Tooltip.TRIGGER.focus] = false;
this.activeTrigger[Tooltip.TRIGGER.hover] = false;
if (this.tip.classList.contains(Tooltip.CLASS_NAME.fade)) {
const transitionDuration = Util.getTransitionDurationFromElement(tip);
EventHandler.one(tip, Util.TRANSITION_END, complete);
Util.emulateTransitionEnd(tip, transitionDuration);
} else {
complete();
}
this.hoverState = '';
}
update(): void {
if (this.popper !== null) {
this.popper.scheduleUpdate();
}
}
isWithContent(): boolean {
return Boolean(this.getTitle());
}
addAttachmentClass(attachment): void {
this.getTipElement().classList.add(`${Tooltip.CLASS_NAME.default}--${attachment}`);
}
/**
* Set attribute on element or its first children if it has
* a `data-tooltip-wrapper` which is the case in the React library.
*/
toggleAriaDescribedby(value: boolean, id?: string): void {
const el = this.element.hasAttribute('data-tooltip-wrapper') ? this.element.firstElementChild : this.element;
if (value) {
el.setAttribute('aria-describedby', id);
} else {
el.removeAttribute('aria-describedby');
}
}
getTipElement(): HTMLElement | null {
if (this.tip) {
return this.tip;
}
const element = document.createElement('div');
element.innerHTML = this.options.template;
this.tip = element.children[0] as HTMLElement;
return this.tip;
}
setContent(): void {
const tip = this.getTipElement();
this.setElementContent(tip.querySelector(Tooltip.SELECTOR.inner), this.getTitle());
tip.classList.remove(Tooltip.CLASS_NAME.fade);
tip.classList.remove(Tooltip.CLASS_NAME.show);
}
setElementContent(element, content): void {
if (element === null) {
return;
}
const html = this.options.html;
if (typeof content === 'object' && content.nodeType) {
// content is a DOM node
if (html) {
if (content.parentNode !== element) {
element.innerHTML = '';
element.appendChild(content);
}
} else {
element.innerText = content.textContent;
}
} else {
element[html ? 'innerHTML' : 'innerText'] = content;
}
}
getTitle(): string {
let title = this.element.getAttribute('data-original-title');
if (!title) {
title = typeof this.options.title === 'function' ? this.options.title.call(this.element) : this.options.title;
}
return title;
}
getContainer(): Element {
if (this.options.container === false) {
return document.body;
}
if (Util.isElement(this.options.container)) {
return this.options.container;
}
return document.querySelector(this.options.container);
}
private setListeners(): void {
const triggers = this.options.trigger.split(' ');
triggers.forEach((trigger) => {
if (trigger === 'click') {
EventHandler.on(this.element, Tooltip.EVENT.click, this.options.selector, (event) => this.toggle(event));
} else if (trigger !== Tooltip.TRIGGER.manual) {
const eventIn = trigger === Tooltip.TRIGGER.hover ? Tooltip.EVENT.mouseenter : Tooltip.EVENT.focusin;
const eventOut = trigger === Tooltip.TRIGGER.hover ? Tooltip.EVENT.mouseleave : Tooltip.EVENT.focusout;
EventHandler.on(this.element, eventIn, this.options.selector, (event) => this.enter(event));
EventHandler.on(this.element, eventOut, this.options.selector, (event) => this.leave(event));
}
});
// TODO : rework when modal component will be created
EventHandler.on(this.element.closest('.modal'), `hide.${Core.KEY_PREFIX}.modal`, () => {
if (this.element) {
this.hide();
}
});
if (this.options.selector) {
this.options = {
...this.options,
trigger: 'manual',
selector: ''
};
} else {
this.fixTitle();
}
}
private fixTitle(): void {
const titleType = typeof this.element.getAttribute('data-original-title');
if (this.element.getAttribute('title') || titleType !== 'string') {
this.element.setAttribute('data-original-title', this.element.getAttribute('title') || '');
this.element.setAttribute('title', '');
}
}
enter(event, context?): void {
const dataKey = Tooltip.DATA_KEY;
context = context || Data.getData(event.delegateTarget, dataKey);
if (!context) {
context = new Tooltip(event.delegateTarget, this.getDelegateConfig());
Data.setData(event.delegateTarget, dataKey, context);
}
if (event) {
const type = event.type === 'focusin' ? Tooltip.TRIGGER.focus : Tooltip.TRIGGER.hover;
context.activeTrigger[type] = true;
}
if (
context.getTipElement().classList.contains(Tooltip.CLASS_NAME.show) ||
context.hoverState === Tooltip.HOVER_STATE.show
) {
context.hoverState = Tooltip.HOVER_STATE.show;
return;
}
clearTimeout(context.timeout);
context.hoverState = Tooltip.HOVER_STATE.show;
if (!context.options.delay || !context.options.delay.show) {
context.show();
return;
}
context.timeout = setTimeout(() => {
if (context._hoverState === Tooltip.HOVER_STATE.show) {
context.show();
}
}, context.options.delay.show);
}
leave(event, context?): void {
const dataKey = Tooltip.DATA_KEY;
context = context || Data.getData(event.delegateTarget, dataKey);
if (!context) {
context = new Tooltip(event.delegateTarget, this.getDelegateConfig());
Data.setData(event.delegateTarget, dataKey, context);
}
if (event) {
const type = event.type === 'focusout' ? Tooltip.TRIGGER.focus : Tooltip.TRIGGER.hover;
context.activeTrigger[type] = false;
}
if (context.isWithActiveTrigger()) {
return;
}
clearTimeout(context.timeout);
context.hoverState = Tooltip.HOVER_STATE.out;
if (!context.options.delay || !context.options.delay.hide) {
context.hide();
return;
}
context.timeout = setTimeout(() => {
if (context.hoverState === Tooltip.HOVER_STATE.out) {
context.hide();
}
}, context.options.delay.hide);
}
isWithActiveTrigger(): boolean {
for (const trigger in this.activeTrigger) {
if (this.activeTrigger[trigger]) {
return true;
}
}
return false;
}
private static getOptions(element: HTMLElement, options): any {
options = {
...Tooltip.DEFAULT_OPTIONS,
...Manipulator.getDataAttributes(element),
...(typeof options === 'object' && options ? options : {})
};
if (typeof options.delay === 'number') {
options.delay = {
show: options.delay,
hide: options.delay
};
}
if (typeof options.title === 'number') {
options.title = options.title.toString();
}
if (typeof options.content === 'number') {
options.content = options.content.toString();
}
Util.typeCheckConfig(Tooltip.NAME, options, Tooltip.DEFAULT_TYPE);
return options;
}
private getDelegateConfig(): any {
const config = {};
if (this.options) {
for (const key in this.options) {
if (Tooltip.DEFAULT_OPTIONS[key] !== this.options[key]) {
config[key] = this.options[key];
}
}
}
return config;
}
private cleanTipClass(): void {
const tip = this.getTipElement();
const tabClass = tip.getAttribute('class').match(Tooltip.NJCLS_PREFIX_REGEX);
if (tabClass !== null && tabClass.length) {
tabClass.map((token) => token.trim()).forEach((tClass: string) => tip.classList.remove(tClass));
}
}
private handlePopperPlacementChange(popperData): void {
const popperInstance = popperData.instance;
this.tip = popperInstance.popper;
this.cleanTipClass();
this.addAttachmentClass(Tooltip.getAttachment(popperData.placement));
}
private fixTransition(): void {
const tip = this.getTipElement();
const initConfigAnimation = this.options.animation;
if (tip.getAttribute('x-placement') !== null) {
return;
}
tip.classList.remove(Tooltip.CLASS_NAME.fade);
this.options.animation = false;
this.hide();
this.show();
this.options.animation = initConfigAnimation;
}
static getAttachment(placement): Placement {
return Tooltip.ATTACHMENT_MAP[placement.toUpperCase()];
}
static getInstance(element: HTMLElement): Tooltip {
return Data.getData(element, Tooltip.DATA_KEY) as Tooltip;
}
static init(): [] {
return [];
}
}