import { transitionHiddenElement } from "@cloudfour/transition-hidden-element"; import type { Instance as PopperInstance, Options as PopperOptions, } from "@popperjs/core"; import { createPopper } from "@popperjs/core"; import { keys } from "../../utils"; export interface TooltipConfig { rootContainerSelector: string; removeDialogOnDestroy: boolean; popperOptions: PopperOptions; } export const defaultConfig: TooltipConfig = { rootContainerSelector: "#root-tooltips", removeDialogOnDestroy: false, popperOptions: { placement: "bottom", strategy: "absolute", modifiers: [ { name: "arrow", options: { element: ".tooltip__arrow", padding: 10, }, }, { name: "eventListeners", options: { scroll: false, resize: false, }, }, { name: "flip", options: { fallbackPlacements: ["right", "top", "auto"], }, }, { name: "offset", options: { offset: ({ placement }: { placement: string }) => { if (placement.includes("start")) { return [-10, 0]; } else if (placement.includes("end")) { return [10, 0]; } return [0, 0]; }, }, }, ], }, }; export default class Tooltip { public element: HTMLElement; public config: TooltipConfig; public instance!: PopperInstance; public trigger!: HTMLElement; public rootContainer?: HTMLElement | null; public transitioner: any; private isOpen = false; private ignoreNextBlur = false; constructor(element: HTMLElement, config?: Partial) { this.element = element; this.config = { ...defaultConfig, ...config }; this.config.popperOptions = { ...defaultConfig.popperOptions, ...(config?.popperOptions || {}), }; this.config.popperOptions.modifiers = [ ...defaultConfig.popperOptions.modifiers!, ...(config?.popperOptions?.modifiers || []), ]; this.handleVisibility = this.handleVisibility.bind(this); this.onKeydown = this.onKeydown.bind(this); this.onDialogMouseDown = this.onDialogMouseDown.bind(this); this.onDocumentClick = this.onDocumentClick.bind(this); (this.element as any).ODS_Tooltip = this; this.init(); return this; } static getInstance(el: HTMLElement): Tooltip | null { return el && (el as any).ODS_Tooltip ? (el as any).ODS_Tooltip : null; } wrapLastWordAndButton(): void { if (!this.element.hasAttribute("data-tooltip-keep-with-text")) return; const previousSibling = this.trigger.previousSibling; if (!previousSibling || previousSibling.nodeType !== Node.TEXT_NODE) { console.warn("Previous sibling is not a text node."); return; } const textContent = previousSibling.textContent?.trim() || ""; const words = textContent.split(" "); const lastWord = words.pop(); const updatedTextContent = words.join(" "); previousSibling.textContent = updatedTextContent + " "; const span = document.createElement("span"); span.className = "text-nowrap"; span.innerHTML = `${lastWord} ${this.trigger.outerHTML}`; this.trigger.parentNode?.replaceChild(span, this.trigger); this.trigger = span.querySelector("[data-tooltip-trigger]") as HTMLElement; } private init(): void { this.trigger = document.querySelector( `[data-tooltip-trigger][aria-describedby=${this.element.getAttribute("id")}]`, ) as HTMLElement; if (!this.trigger) { console.warn( `tooltip trigger element is not defined and the tooltip cannot be attached. Please provide an element with [aria-describedby=${this.element.getAttribute("id")}]`, ); } if (this.element.hasAttribute("data-tooltip-keep-with-text")) { this.wrapLastWordAndButton(); } this.rootContainer = document.querySelector( this.config.rootContainerSelector, ); if (this.rootContainer) { this.rootContainer.appendChild(this.element); } else { console.warn( "rootContainer element is not defined. Tooltips will be placed inside content which can affect positioning. Please provide rootContainer element (should be placed outside of main content, usually in end of tag)", ); } const customPlacement = this.element.getAttribute("data-tooltip-placement"); if (customPlacement) { this.config.popperOptions.placement = customPlacement as any; } this.instance = createPopper( this.trigger, this.element, this.config.popperOptions, ); this.transitioner = transitionHiddenElement({ element: this.element, visibleClass: "is-visible", }); this.element.addEventListener("mousedown", this.onDialogMouseDown); this.trigger.addEventListener("click", this.handleVisibility); this.trigger.addEventListener("keydown", this.handleVisibility); this.trigger.addEventListener("blur", this.handleVisibility); } private onDialogMouseDown(): void { this.ignoreNextBlur = true; } private onDocumentClick(e: MouseEvent): void { const target = e.target as Node | null; if (!target) { return; } if (this.trigger.contains(target) || this.element.contains(target)) { return; } this.hide(); } private handleVisibility(e: Event): void { if (e.type === "click") { this.toggle(); return; } if (e.type === "keydown") { this.onKeydown(e as KeyboardEvent); return; } if (e.type === "blur") { if (this.ignoreNextBlur) { this.ignoreNextBlur = false; return; } this.hide(); } } private onKeydown(e: KeyboardEvent): void { if ( (e.key === "Enter" || e.key === " ") && this.trigger.tagName !== "BUTTON" ) { e.preventDefault(); this.toggle(); return; } if (keys.ESC.includes(e.key)) { this.hide(); } } private toggle(): void { if (this.isOpen) { this.hide(); } else { this.show(); } } public show(): void { this.transitioner.show(); this.trigger.classList.add("is-active"); this.isOpen = true; this.instance.forceUpdate(); document.addEventListener("click", this.onDocumentClick); } public hide(): void { this.transitioner.hide(); this.trigger.classList.remove("is-active"); this.isOpen = false; document.removeEventListener("click", this.onDocumentClick); } public update(): void { this.instance.forceUpdate(); } public destroy(): void { this.element.removeEventListener("mousedown", this.onDialogMouseDown); this.trigger.removeEventListener("click", this.handleVisibility); this.trigger.removeEventListener("keydown", this.handleVisibility); this.trigger.removeEventListener("blur", this.handleVisibility); document.removeEventListener("click", this.onDocumentClick); this.instance.destroy(); (this.element as any).ODS_Tooltip = null; if (this.config.removeDialogOnDestroy) { this.element.remove(); } } }