react-tooltip
Version:
react tooltip component
1,216 lines (1,199 loc) • 76.6 kB
JavaScript
/*
* React Tooltip
* {@link https://github.com/ReactTooltip/react-tooltip}
* @copyright ReactTooltip Team
* @license MIT
*/
import React, { useLayoutEffect, useEffect, useState, useRef, useMemo, memo, useCallback, useImperativeHandle } from 'react';
import clsx from 'clsx';
import { createPortal } from 'react-dom';
import { flip, shift, arrow, computePosition, offset, autoUpdate } from '@floating-ui/dom';
// This is the ID for the core styles of ReactTooltip
const REACT_TOOLTIP_CORE_STYLES_ID = 'react-tooltip-core-styles';
// This is the ID for the visual styles of ReactTooltip
const REACT_TOOLTIP_BASE_STYLES_ID = 'react-tooltip-base-styles';
const injected = {
core: false,
base: false,
};
/**
* Note about `state` parameter:
* This parameter is used to keep track of the state of the styles
* into the tests since the const `injected` is not acessible or resettable in the tests
*/
function injectStyle({ css, id = REACT_TOOLTIP_BASE_STYLES_ID, type = 'base', ref, state = {}, }) {
if (!css ||
typeof document === 'undefined' ||
(typeof state[type] !== 'undefined' ? state[type] : injected[type])) {
return;
}
if (type === 'core' &&
typeof process !== 'undefined' && // this validation prevents docs from breaking even with `process?`
process.env &&
process.env.REACT_TOOLTIP_DISABLE_CORE_STYLES) {
return;
}
if (type === 'base' &&
typeof process !== 'undefined' && // this validation prevents docs from breaking even with `process?`
process.env &&
process.env.REACT_TOOLTIP_DISABLE_BASE_STYLES) {
return;
}
if (type === 'core') {
id = REACT_TOOLTIP_CORE_STYLES_ID;
}
if (!ref) {
ref = {};
}
const { insertAt } = ref;
if (document.getElementById(id)) {
// this could happen in cases the tooltip is imported by multiple js modules
return;
}
const head = document.head || document.getElementsByTagName('head')[0];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const style = document.createElement('style');
style.id = id;
style.type = 'text/css';
if (insertAt === 'top') {
if (head.firstChild) {
head.insertBefore(style, head.firstChild);
}
else {
head.appendChild(style);
}
}
else {
head.appendChild(style);
}
if (style.styleSheet) {
style.styleSheet.cssText = css;
}
else {
style.appendChild(document.createTextNode(css));
}
if (typeof state[type] !== 'undefined') {
state[type] = true;
}
else {
injected[type] = true; // internal global state that jest doesn't have access
}
}
// Hoisted constant middlewares — these configs never change
const defaultFlip = flip({ fallbackAxisSideDirection: 'start' });
const defaultShift = shift({ padding: 5 });
const computeTooltipPosition = async ({ elementReference = null, tooltipReference = null, tooltipArrowReference = null, place = 'top', offset: offsetValue = 10, strategy = 'absolute', middlewares = [offset(Number(offsetValue)), defaultFlip, defaultShift], border, arrowSize = 8, }) => {
if (!elementReference) {
// elementReference can be null or undefined and we will not compute the position
// console.error('The reference element for tooltip was not defined: ', elementReference)
return { tooltipStyles: {}, tooltipArrowStyles: {}, place };
}
if (tooltipReference === null) {
return { tooltipStyles: {}, tooltipArrowStyles: {}, place };
}
const middleware = [...middlewares];
if (tooltipArrowReference) {
middleware.push(arrow({ element: tooltipArrowReference, padding: 5 }));
return computePosition(elementReference, tooltipReference, {
placement: place,
strategy,
middleware,
}).then(({ x, y, placement, middlewareData }) => {
var _a, _b;
const styles = { left: `${x}px`, top: `${y}px`, border };
/* c8 ignore start */
const { x: arrowX, y: arrowY } = (_a = middlewareData.arrow) !== null && _a !== void 0 ? _a : { x: 0, y: 0 };
const staticSide = (_b = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right',
}[placement.split('-')[0]]) !== null && _b !== void 0 ? _b : 'bottom';
/* c8 ignore end */
const borderSide = border && {
borderBottom: border,
borderRight: border,
};
let borderWidth = 0;
if (border) {
const match = `${border}`.match(/(\d+)px/);
if (match === null || match === void 0 ? void 0 : match[1]) {
borderWidth = Number(match[1]);
}
else {
/**
* this means `border` was set without `width`,
* or non-px value (such as `medium`, `thick`, ...)
*/
borderWidth = 1;
}
}
/* c8 ignore start */
const arrowStyle = {
left: arrowX != null ? `${arrowX}px` : '',
top: arrowY != null ? `${arrowY}px` : '',
right: '',
bottom: '',
...borderSide,
[staticSide]: `-${arrowSize / 2 + borderWidth - 1}px`,
};
/* c8 ignore end */
return { tooltipStyles: styles, tooltipArrowStyles: arrowStyle, place: placement };
});
}
return computePosition(elementReference, tooltipReference, {
placement: 'bottom',
strategy,
middleware,
}).then(({ x, y, placement }) => {
const styles = { left: `${x}px`, top: `${y}px` };
return { tooltipStyles: styles, tooltipArrowStyles: {}, place: placement };
});
};
const cssTimeToMs = (time) => {
const match = time.match(/^([\d.]+)(m?s)$/);
if (!match) {
return 0;
}
const [, amount, unit] = match;
return Number(amount) * (unit === 'ms' ? 1 : 1000);
};
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* This function debounce the received function
* @param { function } func Function to be debounced
* @param { number } wait Time to wait before execut the function
* @param { boolean } immediate Param to define if the function will be executed immediately
*/
const debounce = (func, wait, immediate) => {
let timeout = null;
let currentFunc = func;
const debounced = function debounced(...args) {
const later = () => {
timeout = null;
};
if (!timeout) {
/**
* there's no need to clear the timeout
* since we expect it to resolve and set `timeout = null`
*/
currentFunc.apply(this, args);
timeout = setTimeout(later, wait);
}
};
debounced.cancel = () => {
/* c8 ignore start */
if (!timeout) {
return;
}
/* c8 ignore end */
clearTimeout(timeout);
timeout = null;
};
debounced.setCallback = (newFunc) => {
currentFunc = newFunc;
};
return debounced;
};
const isScrollable = (node) => {
if (!(node instanceof HTMLElement || node instanceof SVGElement)) {
return false;
}
const style = getComputedStyle(node);
return ['overflow', 'overflow-x', 'overflow-y'].some((propertyName) => {
const value = style.getPropertyValue(propertyName);
return value === 'auto' || value === 'scroll';
});
};
const getScrollParent = (node) => {
if (!node) {
return null;
}
let currentParent = node.parentElement;
while (currentParent) {
if (isScrollable(currentParent)) {
return currentParent;
}
currentParent = currentParent.parentElement;
}
return document.scrollingElement || document.documentElement;
};
// React currently throws a warning when using useLayoutEffect on the server.
// To get around it, we can conditionally useEffect on the server (no-op) and
// useLayoutEffect in the browser. We need useLayoutEffect to ensure the store
// subscription callback always has the selector from the latest render commit
// available, otherwise a store update may happen between render and the effect,
// which may cause missed updates; we also must ensure the store subscription
// is created synchronously, otherwise a store update may occur before the
// subscription is created and an inconsistent state may be observed
const isHopefullyDomEnvironment = typeof window !== 'undefined' &&
typeof window.document !== 'undefined' &&
typeof window.document.createElement !== 'undefined';
const useIsomorphicLayoutEffect = isHopefullyDomEnvironment ? useLayoutEffect : useEffect;
const clearTimeoutRef = (ref) => {
if (ref.current) {
clearTimeout(ref.current);
ref.current = null;
}
};
function parseDataTooltipIdSelector(selector) {
const match = selector.match(/^\[data-tooltip-id=(['"])((?:\\.|(?!\1).)*)\1\]$/);
if (!match) {
return null;
}
return match[2].replace(/\\(['"])/g, '$1');
}
function resolveDataTooltipAnchor(targetElement, tooltipId) {
let currentElement = targetElement;
while (currentElement) {
const dataset = currentElement.dataset;
if ((dataset === null || dataset === void 0 ? void 0 : dataset.tooltipId) === tooltipId) {
return currentElement;
}
currentElement = currentElement.parentElement;
}
return null;
}
var coreStyles = {"tooltip":"core-styles-module_tooltip__3vRRp","fixed":"core-styles-module_fixed__pcSol","arrow":"core-styles-module_arrow__cvMwQ","content":"core-styles-module_content__BRKdB","noArrow":"core-styles-module_noArrow__xock6","clickable":"core-styles-module_clickable__ZuTTB","show":"core-styles-module_show__Nt9eE","closing":"core-styles-module_closing__sGnxF"};
var styles = {"tooltip":"styles-module_tooltip__mnnfp","content":"styles-module_content__ydYdI","arrow":"styles-module_arrow__K0L3T","dark":"styles-module_dark__xNqje","light":"styles-module_light__Z6W-X","success":"styles-module_success__A2AKt","warning":"styles-module_warning__SCK0X","error":"styles-module_error__JvumD","info":"styles-module_info__BWdHW"};
const registry = new Map();
let documentObserver = null;
/**
* Extract a tooltip ID from a simple `[data-tooltip-id='value']` selector.
* Returns null for complex or custom selectors.
*/
function extractTooltipId(selector) {
const match = selector.match(/^\[data-tooltip-id=(['"])((?:\\.|(?!\1).)*)\1\]$/);
return match ? match[2].replace(/\\(['"])/g, '$1') : null;
}
function areAnchorListsEqual(left, right) {
if (left.length !== right.length) {
return false;
}
return left.every((anchor, index) => anchor === right[index]);
}
function readAnchorsForSelector(selector) {
try {
return {
anchors: Array.from(document.querySelectorAll(selector)),
error: null,
};
}
catch (error) {
return {
anchors: [],
error: error instanceof Error ? error : new Error(String(error)),
};
}
}
function notifySubscribers(entry) {
entry.subscribers.forEach((subscriber) => subscriber(entry.anchors, entry.error));
}
function refreshEntry(selector, entry) {
var _a, _b, _c, _d;
const nextState = readAnchorsForSelector(selector);
const nextErrorMessage = (_b = (_a = nextState.error) === null || _a === void 0 ? void 0 : _a.message) !== null && _b !== void 0 ? _b : null;
const previousErrorMessage = (_d = (_c = entry.error) === null || _c === void 0 ? void 0 : _c.message) !== null && _d !== void 0 ? _d : null;
if (areAnchorListsEqual(entry.anchors, nextState.anchors) &&
nextErrorMessage === previousErrorMessage) {
return;
}
const nextEntry = {
...entry,
anchors: nextState.anchors,
error: nextState.error,
};
registry.set(selector, nextEntry);
notifySubscribers(nextEntry);
}
function refreshAllEntries() {
registry.forEach((entry, selector) => {
refreshEntry(selector, entry);
});
}
let refreshScheduled = false;
let pendingTooltipIds = null;
let pendingFullRefresh = false;
function scheduleRefresh(affectedTooltipIds) {
if (affectedTooltipIds) {
if (!pendingTooltipIds) {
pendingTooltipIds = new Set();
}
affectedTooltipIds.forEach((id) => pendingTooltipIds.add(id));
}
else {
pendingFullRefresh = true;
}
if (refreshScheduled) {
return;
}
refreshScheduled = true;
const flush = () => {
refreshScheduled = false;
const fullRefresh = pendingFullRefresh;
const ids = pendingTooltipIds;
pendingFullRefresh = false;
pendingTooltipIds = null;
if (fullRefresh) {
refreshAllEntries();
}
else if (ids && ids.size > 0) {
refreshEntriesForTooltipIds(ids);
}
};
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(flush);
}
else {
Promise.resolve().then(flush);
}
}
/**
* Only refresh entries whose tooltipId is in the affected set,
* plus any entries with custom (non-tooltipId) selectors.
*/
function refreshEntriesForTooltipIds(affectedIds) {
registry.forEach((entry, selector) => {
if (entry.tooltipId === null || affectedIds.has(entry.tooltipId)) {
refreshEntry(selector, entry);
}
});
}
/**
* Collect tooltip IDs from mutation records. Returns null when targeted
* analysis is not worthwhile (few registry entries, or too many nodes to scan).
*/
function collectAffectedTooltipIds(records) {
var _a;
// Targeted refresh only pays off when there are many distinct selectors.
// With few entries, full refresh is already cheap — skip the analysis overhead.
if (registry.size <= 4) {
return null;
}
const ids = new Set();
for (const record of records) {
if (record.type === 'attributes') {
const target = record.target;
const currentId = (_a = target.getAttribute) === null || _a === void 0 ? void 0 : _a.call(target, 'data-tooltip-id');
if (currentId)
ids.add(currentId);
if (record.oldValue)
ids.add(record.oldValue);
continue;
}
if (record.type === 'childList') {
const gatherIds = (nodes) => {
var _a, _b;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (node.nodeType !== Node.ELEMENT_NODE)
continue;
const el = node;
const id = (_a = el.getAttribute) === null || _a === void 0 ? void 0 : _a.call(el, 'data-tooltip-id');
if (id)
ids.add(id);
// For large subtrees, bail out to full refresh to avoid double-scanning
const descendants = (_b = el.querySelectorAll) === null || _b === void 0 ? void 0 : _b.call(el, '[data-tooltip-id]');
if (descendants) {
if (descendants.length > 50) {
return true; // signal bail-out
}
for (let j = 0; j < descendants.length; j++) {
const descId = descendants[j].getAttribute('data-tooltip-id');
if (descId)
ids.add(descId);
}
}
}
return false;
};
if (gatherIds(record.addedNodes) || gatherIds(record.removedNodes)) {
return null; // large mutation — full refresh is cheaper
}
continue;
}
}
return ids;
}
function ensureDocumentObserver() {
if (documentObserver || typeof MutationObserver === 'undefined') {
return;
}
documentObserver = new MutationObserver((records) => {
const affectedIds = collectAffectedTooltipIds(records);
scheduleRefresh(affectedIds);
});
documentObserver.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['data-tooltip-id'],
attributeOldValue: true,
});
}
function cleanupDocumentObserverIfUnused() {
if (registry.size !== 0 || !documentObserver) {
return;
}
documentObserver.disconnect();
documentObserver = null;
}
function subscribeAnchorSelector(selector, subscriber) {
let entry = registry.get(selector);
if (!entry) {
const initialState = readAnchorsForSelector(selector);
entry = {
anchors: initialState.anchors,
error: initialState.error,
subscribers: new Set(),
tooltipId: extractTooltipId(selector),
};
registry.set(selector, entry);
}
entry.subscribers.add(subscriber);
ensureDocumentObserver();
subscriber([...entry.anchors], entry.error);
return () => {
const currentEntry = registry.get(selector);
if (!currentEntry) {
return;
}
currentEntry.subscribers.delete(subscriber);
if (currentEntry.subscribers.size === 0) {
registry.delete(selector);
}
cleanupDocumentObserverIfUnused();
};
}
const getAnchorSelector = ({ id, anchorSelect, imperativeAnchorSelect, }) => {
var _a;
let selector = (_a = imperativeAnchorSelect !== null && imperativeAnchorSelect !== void 0 ? imperativeAnchorSelect : anchorSelect) !== null && _a !== void 0 ? _a : '';
if (!selector && id) {
selector = `[data-tooltip-id='${id.replace(/'/g, "\\'")}']`;
}
return selector;
};
const useTooltipAnchors = ({ id, anchorSelect, imperativeAnchorSelect, activeAnchor, disableTooltip, onActiveAnchorRemoved, trackAnchors, }) => {
const [rawAnchorElements, setRawAnchorElements] = useState([]);
const [selectorError, setSelectorError] = useState(null);
const warnedSelectorRef = useRef(null);
const selector = useMemo(() => getAnchorSelector({ id, anchorSelect, imperativeAnchorSelect }), [id, anchorSelect, imperativeAnchorSelect]);
const anchorElements = useMemo(() => rawAnchorElements.filter((anchor) => !(disableTooltip === null || disableTooltip === void 0 ? void 0 : disableTooltip(anchor))), [rawAnchorElements, disableTooltip]);
const activeAnchorMatchesSelector = useMemo(() => {
if (!activeAnchor || !selector) {
return false;
}
try {
return activeAnchor.matches(selector);
}
catch (_a) {
return false;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeAnchor, selector, anchorElements]);
useEffect(() => {
if (!selector || !trackAnchors) {
setRawAnchorElements([]);
setSelectorError(null);
return undefined;
}
return subscribeAnchorSelector(selector, (anchors, error) => {
setRawAnchorElements(anchors);
setSelectorError(error);
});
}, [selector, trackAnchors]);
useEffect(() => {
if (!selectorError || warnedSelectorRef.current === selector) {
return;
}
warnedSelectorRef.current = selector;
/* c8 ignore end */
}, [selector, selectorError]);
useEffect(() => {
if (!activeAnchor) {
return;
}
if (!activeAnchor.isConnected) {
onActiveAnchorRemoved();
return;
}
if (!anchorElements.includes(activeAnchor) && !activeAnchorMatchesSelector) {
onActiveAnchorRemoved();
}
}, [activeAnchor, anchorElements, activeAnchorMatchesSelector, onActiveAnchorRemoved]);
return {
anchorElements,
selector,
};
};
/**
* Shared document event delegation.
*
* Instead of N tooltips each calling document.addEventListener(type, handler),
* we maintain ONE document listener per event type. When the event fires,
* we iterate through all registered handlers for that type.
*
* This reduces document-level listeners from O(N × eventTypes) to O(eventTypes).
*/
const handlersByType = new Map();
function getListenerKey(eventType, capture) {
return `${eventType}:${capture ? 'capture' : 'bubble'}`;
}
function getOrCreateListener(eventType, capture) {
const key = getListenerKey(eventType, capture);
let listener = handlersByType.get(key);
if (!listener) {
const handlers = new Set();
const dispatch = (event) => {
handlers.forEach((handler) => {
handler(event);
});
};
listener = { handlers, dispatch, eventType, capture };
handlersByType.set(key, listener);
document.addEventListener(eventType, dispatch, { capture });
}
return listener;
}
/**
* Register a handler for a document-level event type.
* Returns an unsubscribe function.
*/
function addDelegatedEventListener(eventType, handler, options = {}) {
const capture = Boolean(options.capture);
const key = getListenerKey(eventType, capture);
const listener = getOrCreateListener(eventType, capture);
listener.handlers.add(handler);
return () => {
listener.handlers.delete(handler);
if (listener.handlers.size === 0) {
handlersByType.delete(key);
document.removeEventListener(eventType, listener.dispatch, { capture });
}
};
}
const useTooltipEvents = ({ activeAnchor, anchorElements, anchorSelector, clickable, closeEvents, delayHide, delayShow, disableTooltip, float, globalCloseEvents, handleHideTooltipDelayed, handleShow, handleShowTooltipDelayed, handleTooltipPosition, hoveringTooltip, imperativeModeOnly, lastFloatPosition, openEvents, openOnClick, rendered, setActiveAnchor, show, tooltipHideDelayTimerRef, tooltipRef, tooltipShowDelayTimerRef, updateTooltipPosition, }) => {
// Ref-stable debounced handlers — avoids recreating debounce instances on every effect run
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const debouncedShowRef = useRef(debounce((_anchor) => { }, 50));
const debouncedHideRef = useRef(debounce(() => { }, 50));
// Cache scroll parents — only recompute when the element actually changes
const anchorScrollParentRef = useRef(null);
const tooltipScrollParentRef = useRef(null);
const prevAnchorRef = useRef(null);
const prevTooltipRef = useRef(null);
if (activeAnchor !== prevAnchorRef.current) {
prevAnchorRef.current = activeAnchor;
anchorScrollParentRef.current = getScrollParent(activeAnchor);
}
const currentTooltipEl = tooltipRef.current;
if (currentTooltipEl !== prevTooltipRef.current) {
prevTooltipRef.current = currentTooltipEl;
tooltipScrollParentRef.current = getScrollParent(currentTooltipEl);
}
// Memoize event config objects — only rebuild when the relevant props change
const hasClickEvent = openOnClick || (openEvents === null || openEvents === void 0 ? void 0 : openEvents.click) || (openEvents === null || openEvents === void 0 ? void 0 : openEvents.dblclick) || (openEvents === null || openEvents === void 0 ? void 0 : openEvents.mousedown);
const actualOpenEvents = useMemo(() => {
const events = openEvents
? { ...openEvents }
: {
mouseenter: true,
focus: true,
click: false,
dblclick: false,
mousedown: false,
};
if (!openEvents && openOnClick) {
Object.assign(events, {
mouseenter: false,
focus: false,
click: true,
});
}
if (imperativeModeOnly) {
Object.assign(events, {
mouseenter: false,
focus: false,
click: false,
dblclick: false,
mousedown: false,
});
}
return events;
}, [openEvents, openOnClick, imperativeModeOnly]);
const actualCloseEvents = useMemo(() => {
const events = closeEvents
? { ...closeEvents }
: {
mouseleave: true,
blur: true,
click: false,
dblclick: false,
mouseup: false,
};
if (!closeEvents && openOnClick) {
Object.assign(events, {
mouseleave: false,
blur: false,
});
}
if (imperativeModeOnly) {
Object.assign(events, {
mouseleave: false,
blur: false,
click: false,
dblclick: false,
mouseup: false,
});
}
return events;
}, [closeEvents, openOnClick, imperativeModeOnly]);
const actualGlobalCloseEvents = useMemo(() => {
const events = globalCloseEvents
? { ...globalCloseEvents }
: {
escape: false,
scroll: false,
resize: false,
clickOutsideAnchor: hasClickEvent || false,
};
if (imperativeModeOnly) {
Object.assign(events, {
escape: false,
scroll: false,
resize: false,
clickOutsideAnchor: false,
});
}
return events;
}, [globalCloseEvents, hasClickEvent, imperativeModeOnly]);
// --- Refs for values read inside event handlers (avoids effect deps) ---
const activeAnchorRef = useRef(activeAnchor);
activeAnchorRef.current = activeAnchor;
const showRef = useRef(show);
showRef.current = show;
const anchorElementsRef = useRef(anchorElements);
anchorElementsRef.current = anchorElements;
const handleShowRef = useRef(handleShow);
handleShowRef.current = handleShow;
const handleTooltipPositionRef = useRef(handleTooltipPosition);
handleTooltipPositionRef.current = handleTooltipPosition;
const updateTooltipPositionRef = useRef(updateTooltipPosition);
updateTooltipPositionRef.current = updateTooltipPosition;
// --- Handler refs (updated every render, read via ref indirection in effects) ---
const resolveAnchorElementRef = useRef(() => null);
const handleShowTooltipRef = useRef(() => { });
const handleHideTooltipRef = useRef(() => { });
const dataTooltipId = anchorSelector ? parseDataTooltipIdSelector(anchorSelector) : null;
resolveAnchorElementRef.current = (target) => {
var _a, _b;
if (!(target instanceof Element) || !target.isConnected) {
return null;
}
const targetElement = target;
if (dataTooltipId) {
const matchedAnchor = resolveDataTooltipAnchor(targetElement, dataTooltipId);
if (matchedAnchor && !(disableTooltip === null || disableTooltip === void 0 ? void 0 : disableTooltip(matchedAnchor))) {
return matchedAnchor;
}
}
else if (anchorSelector) {
try {
const matchedAnchor = (_a = (targetElement.matches(anchorSelector)
? targetElement
: targetElement.closest(anchorSelector))) !== null && _a !== void 0 ? _a : null;
if (matchedAnchor && !(disableTooltip === null || disableTooltip === void 0 ? void 0 : disableTooltip(matchedAnchor))) {
return matchedAnchor;
}
}
catch (_c) {
return null;
}
}
return ((_b = anchorElementsRef.current.find((anchor) => anchor === targetElement || anchor.contains(targetElement))) !== null && _b !== void 0 ? _b : null);
};
handleShowTooltipRef.current = (anchor) => {
if (!anchor) {
return;
}
if (!anchor.isConnected) {
setActiveAnchor(null);
return;
}
if (disableTooltip === null || disableTooltip === void 0 ? void 0 : disableTooltip(anchor)) {
return;
}
if (delayShow && activeAnchorRef.current && anchor !== activeAnchorRef.current) {
// Moving to a different anchor while one is already active — defer the anchor
// switch until the show delay fires to prevent content/position from updating
// before visibility transitions complete.
if (tooltipShowDelayTimerRef.current) {
clearTimeout(tooltipShowDelayTimerRef.current);
}
tooltipShowDelayTimerRef.current = setTimeout(() => {
setActiveAnchor(anchor);
handleShow(true);
}, delayShow);
}
else {
setActiveAnchor(anchor);
if (delayShow) {
handleShowTooltipDelayed();
}
else {
handleShow(true);
}
}
if (tooltipHideDelayTimerRef.current) {
clearTimeout(tooltipHideDelayTimerRef.current);
}
};
handleHideTooltipRef.current = () => {
if (clickable) {
handleHideTooltipDelayed(delayHide || 100);
}
else if (delayHide) {
handleHideTooltipDelayed();
}
else {
handleShow(false);
}
if (tooltipShowDelayTimerRef.current) {
clearTimeout(tooltipShowDelayTimerRef.current);
}
};
// Update debounced callbacks to always delegate to latest handler refs
const debouncedShow = debouncedShowRef.current;
const debouncedHide = debouncedHideRef.current;
debouncedShow.setCallback((anchor) => handleShowTooltipRef.current(anchor));
debouncedHide.setCallback(() => handleHideTooltipRef.current());
// --- Effect 1: Delegated anchor events + tooltip hover ---
// Only re-runs when the set of active event types or interaction mode changes.
// Handlers read reactive values (activeAnchor, show, etc.) from refs at invocation
// time, so this effect is decoupled from show/hide state changes.
useEffect(() => {
const cleanupFns = [];
const addDelegatedListener = (eventType, listener, options) => {
cleanupFns.push(addDelegatedEventListener(eventType, listener, options));
};
const activeAnchorContainsTarget = (event) => { var _a; return Boolean((event === null || event === void 0 ? void 0 : event.target) instanceof Node && ((_a = activeAnchorRef.current) === null || _a === void 0 ? void 0 : _a.contains(event.target))); };
const debouncedHandleShowTooltip = (anchor) => {
debouncedHide.cancel();
debouncedShow(anchor);
};
const debouncedHandleHideTooltip = () => {
debouncedShow.cancel();
debouncedHide();
};
const addDelegatedHoverOpenListener = () => {
addDelegatedListener('mouseover', (event) => {
const anchor = resolveAnchorElementRef.current(event.target);
if (!anchor) {
return;
}
const relatedAnchor = resolveAnchorElementRef.current(event.relatedTarget);
if (relatedAnchor === anchor) {
return;
}
debouncedHandleShowTooltip(anchor);
});
};
const addDelegatedHoverCloseListener = () => {
addDelegatedListener('mouseout', (event) => {
const targetAnchor = resolveAnchorElementRef.current(event.target);
if (!targetAnchor && !activeAnchorContainsTarget(event)) {
return;
}
const relatedTarget = event.relatedTarget;
const containerAnchor = targetAnchor || activeAnchorRef.current;
if (relatedTarget instanceof Node && (containerAnchor === null || containerAnchor === void 0 ? void 0 : containerAnchor.contains(relatedTarget))) {
return;
}
debouncedHandleHideTooltip();
});
};
if (actualOpenEvents.mouseenter) {
addDelegatedHoverOpenListener();
}
if (actualCloseEvents.mouseleave) {
addDelegatedHoverCloseListener();
}
if (actualOpenEvents.mouseover) {
addDelegatedHoverOpenListener();
}
if (actualCloseEvents.mouseout) {
addDelegatedHoverCloseListener();
}
if (actualOpenEvents.focus) {
addDelegatedListener('focusin', (event) => {
debouncedHandleShowTooltip(resolveAnchorElementRef.current(event.target));
});
}
if (actualOpenEvents.mouseenter || actualOpenEvents.mouseover || actualOpenEvents.focus) {
addDelegatedListener('touchstart', (event) => {
debouncedHandleShowTooltip(resolveAnchorElementRef.current(event.target));
});
}
if (actualCloseEvents.blur) {
addDelegatedListener('focusout', (event) => {
const targetAnchor = resolveAnchorElementRef.current(event.target);
if (!targetAnchor && !activeAnchorContainsTarget(event)) {
return;
}
const relatedTarget = event.relatedTarget;
const containerAnchor = targetAnchor || activeAnchorRef.current;
if (relatedTarget instanceof Node && (containerAnchor === null || containerAnchor === void 0 ? void 0 : containerAnchor.contains(relatedTarget))) {
return;
}
debouncedHandleHideTooltip();
});
}
const regularEvents = ['mouseover', 'mouseout', 'mouseenter', 'mouseleave', 'focus', 'blur'];
const clickEvents = ['click', 'dblclick', 'mousedown', 'mouseup'];
const handleClickOpenTooltipAnchor = (event) => {
var _a;
const anchor = resolveAnchorElementRef.current((_a = event === null || event === void 0 ? void 0 : event.target) !== null && _a !== void 0 ? _a : null);
if (!anchor) {
return;
}
if (showRef.current && activeAnchorRef.current === anchor) {
return;
}
handleShowTooltipRef.current(anchor);
};
const handleClickCloseTooltipAnchor = (event) => {
if (!showRef.current || !activeAnchorContainsTarget(event)) {
return;
}
handleHideTooltipRef.current();
};
Object.entries(actualOpenEvents).forEach(([event, enabled]) => {
if (!enabled || regularEvents.includes(event)) {
return;
}
if (clickEvents.includes(event)) {
addDelegatedListener(event, handleClickOpenTooltipAnchor, {
capture: true,
});
}
});
Object.entries(actualCloseEvents).forEach(([event, enabled]) => {
if (!enabled || regularEvents.includes(event)) {
return;
}
if (clickEvents.includes(event)) {
addDelegatedListener(event, handleClickCloseTooltipAnchor, {
capture: true,
});
}
});
if (float) {
addDelegatedListener('pointermove', (event) => {
const currentActiveAnchor = activeAnchorRef.current;
if (!currentActiveAnchor) {
return;
}
const targetAnchor = resolveAnchorElementRef.current(event.target);
if (targetAnchor !== currentActiveAnchor) {
return;
}
const mouseEvent = event;
const mousePosition = {
x: mouseEvent.clientX,
y: mouseEvent.clientY,
};
handleTooltipPositionRef.current(mousePosition);
lastFloatPosition.current = mousePosition;
});
}
const tooltipElement = tooltipRef.current;
const handleMouseOverTooltip = () => {
hoveringTooltip.current = true;
};
const handleMouseOutTooltip = () => {
hoveringTooltip.current = false;
handleHideTooltipRef.current();
};
const addHoveringTooltipListeners = clickable && (actualCloseEvents.mouseout || actualCloseEvents.mouseleave);
if (addHoveringTooltipListeners) {
tooltipElement === null || tooltipElement === void 0 ? void 0 : tooltipElement.addEventListener('mouseover', handleMouseOverTooltip);
tooltipElement === null || tooltipElement === void 0 ? void 0 : tooltipElement.addEventListener('mouseout', handleMouseOutTooltip);
}
return () => {
cleanupFns.forEach((fn) => fn());
if (addHoveringTooltipListeners) {
tooltipElement === null || tooltipElement === void 0 ? void 0 : tooltipElement.removeEventListener('mouseover', handleMouseOverTooltip);
tooltipElement === null || tooltipElement === void 0 ? void 0 : tooltipElement.removeEventListener('mouseout', handleMouseOutTooltip);
}
debouncedShow.cancel();
debouncedHide.cancel();
};
// `rendered` needs to be a dependency because `tooltipRef` becomes stale when the
// tooltip is removed from / added to the DOM.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actualOpenEvents, actualCloseEvents, float, clickable, rendered]);
// --- Effect 2: Global close events + auto-update ---
// Re-runs when the global close config changes, or when the active anchor changes
// (for scroll parent listeners and floating-ui autoUpdate).
useEffect(() => {
const handleScrollResize = () => {
handleShowRef.current(false);
clearTimeoutRef(tooltipShowDelayTimerRef);
};
const tooltipScrollParent = tooltipScrollParentRef.current;
const anchorScrollParent = anchorScrollParentRef.current;
if (actualGlobalCloseEvents.scroll) {
window.addEventListener('scroll', handleScrollResize);
anchorScrollParent === null || anchorScrollParent === void 0 ? void 0 : anchorScrollParent.addEventListener('scroll', handleScrollResize);
tooltipScrollParent === null || tooltipScrollParent === void 0 ? void 0 : tooltipScrollParent.addEventListener('scroll', handleScrollResize);
}
let updateTooltipCleanup = null;
if (actualGlobalCloseEvents.resize) {
window.addEventListener('resize', handleScrollResize);
}
else if (activeAnchor && tooltipRef.current) {
updateTooltipCleanup = autoUpdate(activeAnchor, tooltipRef.current, () => updateTooltipPositionRef.current(), {
ancestorResize: true,
elementResize: true,
layoutShift: true,
});
}
const handleEsc = (event) => {
if (event.key !== 'Escape') {
return;
}
handleShowRef.current(false);
};
if (actualGlobalCloseEvents.escape) {
window.addEventListener('keydown', handleEsc);
}
const handleClickOutsideAnchors = (event) => {
var _a, _b;
if (!showRef.current) {
return;
}
const target = event.target;
if (!(target instanceof Node) || !target.isConnected) {
return;
}
if ((_a = tooltipRef.current) === null || _a === void 0 ? void 0 : _a.contains(target)) {
return;
}
if ((_b = activeAnchorRef.current) === null || _b === void 0 ? void 0 : _b.contains(target)) {
return;
}
if (anchorElementsRef.current.some((anchor) => anchor === null || anchor === void 0 ? void 0 : anchor.contains(target))) {
return;
}
handleShowRef.current(false);
clearTimeoutRef(tooltipShowDelayTimerRef);
};
if (actualGlobalCloseEvents.clickOutsideAnchor) {
window.addEventListener('click', handleClickOutsideAnchors);
}
return () => {
if (actualGlobalCloseEvents.scroll) {
window.removeEventListener('scroll', handleScrollResize);
anchorScrollParent === null || anchorScrollParent === void 0 ? void 0 : anchorScrollParent.removeEventListener('scroll', handleScrollResize);
tooltipScrollParent === null || tooltipScrollParent === void 0 ? void 0 : tooltipScrollParent.removeEventListener('scroll', handleScrollResize);
}
if (actualGlobalCloseEvents.resize) {
window.removeEventListener('resize', handleScrollResize);
}
if (updateTooltipCleanup) {
updateTooltipCleanup();
}
if (actualGlobalCloseEvents.escape) {
window.removeEventListener('keydown', handleEsc);
}
if (actualGlobalCloseEvents.clickOutsideAnchor) {
window.removeEventListener('click', handleClickOutsideAnchors);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actualGlobalCloseEvents, activeAnchor]);
};
// Shared across all tooltip instances — the CSS variable is on :root and never changes per-instance
let globalTransitionShowDelay = null;
const Tooltip = ({
// props
forwardRef, id, className, classNameArrow, variant = 'dark', portalRoot, anchorSelect, place = 'top', offset = 10, openOnClick = false, positionStrategy = 'absolute', middlewares, wrapper: WrapperElement, delayShow = 0, delayHide = 0, autoClose, float = false, hidden = false, noArrow = false, clickable = false, openEvents, closeEvents, globalCloseEvents, imperativeModeOnly, style: externalStyles, position, afterShow, afterHide, disableTooltip,
// props handled by controller
content, contentWrapperRef, isOpen, defaultIsOpen = false, setIsOpen, previousActiveAnchor, activeAnchor, setActiveAnchor, border, opacity, arrowColor, arrowSize = 8, role = 'tooltip', }) => {
var _a;
const tooltipRef = useRef(null);
const tooltipArrowRef = useRef(null);
const tooltipShowDelayTimerRef = useRef(null);
const tooltipHideDelayTimerRef = useRef(null);
const tooltipAutoCloseTimerRef = useRef(null);
const missedTransitionTimerRef = useRef(null);
const [computedPosition, setComputedPosition] = useState({
tooltipStyles: {},
tooltipArrowStyles: {},
place,
});
const [show, setShow] = useState(false);
const [rendered, setRendered] = useState(false);
const [imperativeOptions, setImperativeOptions] = useState(null);
const wasShowing = useRef(false);
const lastFloatPosition = useRef(null);
const hoveringTooltip = useRef(false);
const mounted = useRef(false);
const virtualElementRef = useRef({
getBoundingClientRect: () => ({
x: 0,
y: 0,
width: 0,
height: 0,
top: 0,
left: 0,
right: 0,
bottom: 0,
}),
});
/**
* useLayoutEffect runs before useEffect,
* but should be used carefully because of caveats
* https://beta.reactjs.org/reference/react/useLayoutEffect#caveats
*/
useIsomorphicLayoutEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const handleShow = useCallback((value) => {
if (!mounted.current) {
return;
}
if (value) {
setRendered(true);
}
/**
* wait for the component to render and calculate position
* before actually showing
*/
setTimeout(() => {
if (!mounted.current) {
return;
}
setIsOpen === null || setIsOpen === void 0 ? void 0 : setIsOpen(value);
if (isOpen === undefined) {
setShow(value);
}
}, 10);
}, [isOpen, setIsOpen]);
/**
* Add aria-describedby to activeAnchor when tooltip is active
*/
useEffect(() => {
if (!id)
return;
function getAriaDescribedBy(element) {
var _a;
return ((_a = element === null || element === void 0 ? void 0 : element.getAttribute('aria-describedby')) === null || _a === void 0 ? void 0 : _a.split(' ')) || [];
}
function removeAriaDescribedBy(element) {
const newDescribedBy = getAriaDescribedBy(element).filter((s) => s !== id);
if (newDescribedBy.length) {
element === null || element === void 0 ? void 0 : element.setAttribute('aria-describedby', newDescribedBy.join(' '));
}
else {
element === null || element === void 0 ? void 0 : element.removeAttribute('aria-describedby');
}
}
if (show) {
removeAriaDescribedBy(previousActiveAnchor);
const currentDescribedBy = getAriaDescribedBy(activeAnchor);
const describedBy = [...new Set([...currentDescribedBy, id])].filter(Boolean).join(' ');
activeAnchor === null || activeAnchor === void 0 ? void 0 : activeAnchor.setAttribute('aria-describedby', describedBy);
}
else {
removeAriaDescribedBy(activeAnchor);
}
return () => {
// cleanup aria-describedby when the tooltip is closed
removeAriaDescribedBy(activeAnchor);
removeAriaDescribedBy(previousActiveAnchor);
};
}, [activeAnchor, show, id, previousActiveAnchor]);
/**
* this replicates the effect from `handleShow()`
* when `isOpen` is changed from outside
*/
useEffect(() => {
if (isOpen === undefined) {
return () => null;
}
if (isOpen) {
setRendered(true);
}
const timeout = setTimeout(() => {
setShow(isOpen);
}, 10);
return () => {
clearTimeout(timeout);
};
}, [isOpen]);
useEffect(() => {
if (show === wasShowing.current) {
return;
}
clearTimeoutRef(missedTransitionTimerRef);
wasShowing.current = show;
if (show) {
afterShow === null || afterShow === void 0 ? void 0 : afterShow();
}
else {
/**
* see `onTransitionEnd` on tooltip wrapper
*/
if (globalTransitionShowDelay === null) {
const style = getComputedStyle(document.body);
globalTransitionShowDelay = cssTimeToMs(style.getPropertyValue('--rt-transition-show-delay'));
}
const transitionShowDelay = globalTransitionShowDelay;
missedTransitionTimerRef.current = setTimeout(() => {
/**
* if the tooltip switches from `show === true` to `show === false` too fast
* the transition never runs, so `onTransitionEnd` callback never gets fired
*/
setRendered(false);
setImperativeOptions(null);
afterHide === null || afterHide === void 0 ? void 0 : afterHide();
// +25ms just to make sure `onTransitionEnd` (if it gets fired) has time to run
}, transitionShowDelay + 25);
}
}, [afterHide, afterShow, show]);
useEffect(() => {
clearTimeoutRef(tooltipAutoCloseTimerRef);
if (!show || !autoClose || autoClose <= 0) {
return () => {
clearTimeoutRef(tooltipAutoCloseTimerRef);
};
}
tooltipAutoCloseTimerRef.current = setTimeout(() => {
handleShow(false);
}, autoClose);
return () => {
clearTimeoutRef(tooltipAutoCloseTimerRef);
};
}, [activeAnchor, autoClose, handleShow, show]);
const handleComputedPosition = useCallback((newComputedPosition) => {
if (!mounted.current) {
return;
}
setComputedPosition((oldComputedPosition) => {
if (oldComputedPosition.place === newComputedPosition.place &&
oldComputedPosition.tooltipStyles.left === newComputedPosition.tooltipStyles.left &&
oldComputedPosition.tooltipStyles.top === newComputedPosition.tooltipStyles.top &&
oldComputedPosition.tooltipStyles.border === newComputedPosition.tooltipStyles.border &&
oldComputedPosition.tooltipArrowStyles.left ===
newComputedPosition.tooltipArrowStyles.left &&
oldComputedPosition.tooltipArrowStyles.top === newComputedPosition.tooltipArrowStyles.top &&
oldComputedPosition.tooltipArrowStyles.right ===
newComputedPosition.tooltipArrowStyles.right &&
oldComputedPosition.tooltipArrowStyles.bottom ===
newComputedPosition.tooltipArrowStyles.bottom &&
oldComputedPosition.tooltipArrowStyles.borderBottom ===
newComputedPo