/** * -------------------------------------------------------------------------- * 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 []; } }