@i-novus/ui-core
Version:
Базовые UI компоненты
556 lines (554 loc) • 21.5 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useState, useRef, useCallback, forwardRef } from 'react';
import { autoUpdate } from '@floating-ui/dom';
import classNames from 'classnames';
import { useComposeRef } from 'rc-util';
import { getScrollParent, useIsomorphicLayoutEffect, debounce, computeTooltipPosition } from './utils';
import { useTooltip } from './TooltipProvider';
// eslint-disable-next-line sonarjs/cognitive-complexity
export const Tooltip = forwardRef((props, ref) => {
const { id, className, classNameArrow, variant = 'light', anchorId, anchorSelect, place = 'top', offset = 10, events = ['hover'], openOnClick = false, positionStrategy = 'absolute', middlewares, wrapper: WrapperElement, delayShow = 0, delayHide = 0, float = false, hidden = false, noArrow = false, clickable = false, closeOnEsc = false, closeOnScroll = false, closeOnResize = false, style: externalStyles, position, afterShow, afterHide,
// props handled by controller
content, contentWrapperRef, isOpen, setIsOpen, activeAnchor, setActiveAnchor, border, opacity, arrowColor, } = props;
const tooltipRef = useRef(null);
const tooltipArrowRef = useRef(null);
const tooltipShowDelayTimerRef = useRef(null);
const tooltipHideDelayTimerRef = useRef(null);
const [actualPlacement, setActualPlacement] = useState(place);
const [inlineStyles, setInlineStyles] = useState({});
const [inlineArrowStyles, setInlineArrowStyles] = useState({});
const [show, setShow] = useState(false);
const [rendered, setRendered] = useState(false);
const wasShowing = useRef(false);
const lastFloatPosition = useRef(null);
const { anchorRefs, setActiveAnchor: setProviderActiveAnchor } = useTooltip(id);
const hoveringTooltip = useRef(false);
const [anchorsBySelect, setAnchorsBySelect] = useState([]);
const mounted = useRef(false);
const shouldOpenOnClick = openOnClick || events.includes('click');
/**
* 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;
};
}, []);
useEffect(() => {
if (!show) {
/**
* this fixes weird behavior when switching between two anchor elements very quickly
* remove the timeout and switch quickly between two adjancent anchor elements to see it
*
* in practice, this means the tooltip is not immediately removed from the DOM on hide
*/
const timeout = setTimeout(() => {
setRendered(false);
}, 150);
return () => {
clearTimeout(timeout);
};
}
return () => null;
}, [show]);
const handleShow = (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?.(value);
if (isOpen === undefined) {
setShow(value);
}
}, 10);
};
/**
* 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;
}
wasShowing.current = show;
if (show) {
afterShow?.();
}
else {
afterHide?.();
}
}, [show]);
const handleShowTooltipDelayed = () => {
if (tooltipShowDelayTimerRef.current) {
clearTimeout(tooltipShowDelayTimerRef.current);
}
tooltipShowDelayTimerRef.current = setTimeout(() => {
handleShow(true);
}, delayShow);
};
const handleHideTooltipDelayed = (delay = delayHide) => {
if (tooltipHideDelayTimerRef.current) {
clearTimeout(tooltipHideDelayTimerRef.current);
}
tooltipHideDelayTimerRef.current = setTimeout(() => {
if (hoveringTooltip.current) {
return;
}
handleShow(false);
}, delay);
};
const handleShowTooltip = (event) => {
if (!event) {
return;
}
const target = (event.currentTarget ?? event.target);
if (!target?.isConnected) {
/**
* this happens when the target is removed from the DOM
* at the same time the tooltip gets triggered
*/
setActiveAnchor(null);
setProviderActiveAnchor({ current: null });
return;
}
if (delayShow) {
handleShowTooltipDelayed();
}
else {
handleShow(true);
}
setActiveAnchor(target);
setProviderActiveAnchor({ current: target });
if (tooltipHideDelayTimerRef.current) {
clearTimeout(tooltipHideDelayTimerRef.current);
}
};
const handleHideTooltip = () => {
if (clickable) {
// allow time for the mouse to reach the tooltip, in case there's a gap
handleHideTooltipDelayed(delayHide || 100);
}
else if (delayHide) {
handleHideTooltipDelayed();
}
else {
handleShow(false);
}
if (tooltipShowDelayTimerRef.current) {
clearTimeout(tooltipShowDelayTimerRef.current);
}
};
const handleTooltipPosition = ({ x, y }) => {
const virtualElement = {
getBoundingClientRect() {
return {
x,
y,
width: 0,
height: 0,
top: y,
left: x,
right: x,
bottom: y,
};
},
};
computeTooltipPosition({
place,
offset,
elementReference: virtualElement,
tooltipReference: tooltipRef.current,
tooltipArrowReference: tooltipArrowRef.current,
strategy: positionStrategy,
middlewares,
border,
}).then((computedStylesData) => {
if (Object.keys(computedStylesData.tooltipStyles).length) {
setInlineStyles(computedStylesData.tooltipStyles);
}
if (Object.keys(computedStylesData.tooltipArrowStyles).length) {
setInlineArrowStyles(computedStylesData.tooltipArrowStyles);
}
setActualPlacement(computedStylesData.place);
}).catch((e) => {
console.error(e);
});
};
const handleMouseMove = (event) => {
if (!event) {
return;
}
const mouseEvent = event;
const mousePosition = {
x: mouseEvent.clientX,
y: mouseEvent.clientY,
};
handleTooltipPosition(mousePosition);
lastFloatPosition.current = mousePosition;
};
const handleClickTooltipAnchor = (event) => {
handleShowTooltip(event);
if (delayHide) {
handleHideTooltipDelayed();
}
};
// debounce handler to prevent call twice when
// mouse enter and focus events being triggered toggether
const debouncedHandleShowTooltip = debounce(handleShowTooltip, 50, true);
const debouncedHandleHideTooltip = debounce(handleHideTooltip, 50, true);
const updateTooltipPosition = useCallback(() => {
if (position) {
// if `position` is set, override regular and `float` positioning
handleTooltipPosition(position);
return;
}
if (float) {
if (lastFloatPosition.current) {
/*
Without this, changes to `content`, `place`, `offset`, ..., will only
trigger a position calculation after a `mousemove` event.
To see why this matters, comment this line, run `yarn dev` and click the
"Hover me!" anchor.
*/
handleTooltipPosition(lastFloatPosition.current);
}
// if `float` is set, override regular positioning
return;
}
computeTooltipPosition({
place,
offset,
elementReference: activeAnchor,
tooltipReference: tooltipRef.current,
tooltipArrowReference: tooltipArrowRef.current,
strategy: positionStrategy,
middlewares,
border,
}).then((computedStylesData) => {
if (!mounted.current) {
// invalidate computed positions after remount
return;
}
if (Object.keys(computedStylesData.tooltipStyles).length) {
setInlineStyles(computedStylesData.tooltipStyles);
}
if (Object.keys(computedStylesData.tooltipArrowStyles).length) {
setInlineArrowStyles(computedStylesData.tooltipArrowStyles);
}
setActualPlacement(computedStylesData.place);
}).catch((e) => {
console.error(e);
});
}, [
show,
activeAnchor,
content,
externalStyles,
place,
offset,
positionStrategy,
position,
float,
]);
useEffect(() => {
const elementRefs = new Set(anchorRefs);
anchorsBySelect.forEach((anchor) => {
elementRefs.add({ current: anchor });
});
const anchorById = document.querySelector(`[id='${anchorId}']`);
if (anchorById) {
elementRefs.add({ current: anchorById });
}
const handleScrollResize = () => {
handleShow(false);
};
const anchorScrollParent = getScrollParent(activeAnchor);
const tooltipScrollParent = getScrollParent(tooltipRef.current);
if (closeOnScroll) {
window.addEventListener('scroll', handleScrollResize);
anchorScrollParent?.addEventListener('scroll', handleScrollResize);
tooltipScrollParent?.addEventListener('scroll', handleScrollResize);
}
let updateTooltipCleanup = null;
if (closeOnResize) {
window.addEventListener('resize', handleScrollResize);
}
else if (activeAnchor && tooltipRef.current) {
updateTooltipCleanup = autoUpdate(activeAnchor, tooltipRef.current, updateTooltipPosition, {
ancestorResize: true,
elementResize: true,
layoutShift: true,
});
}
const handleEsc = (event) => {
if (event.key !== 'Escape') {
return;
}
handleShow(false);
};
if (closeOnEsc) {
window.addEventListener('keydown', handleEsc);
}
const enabledEvents = [];
if (shouldOpenOnClick) {
enabledEvents.push({ event: 'click', listener: handleClickTooltipAnchor });
}
else {
enabledEvents.push({ event: 'mouseenter', listener: debouncedHandleShowTooltip }, { event: 'mouseleave', listener: debouncedHandleHideTooltip }, { event: 'focus', listener: debouncedHandleShowTooltip }, { event: 'blur', listener: debouncedHandleHideTooltip });
if (float) {
enabledEvents.push({
event: 'mousemove',
listener: handleMouseMove,
});
}
}
const handleMouseEnterTooltip = () => {
hoveringTooltip.current = true;
};
const handleMouseLeaveTooltip = () => {
hoveringTooltip.current = false;
handleHideTooltip();
};
if (clickable && !shouldOpenOnClick) {
tooltipRef.current?.addEventListener('mouseenter', handleMouseEnterTooltip);
tooltipRef.current?.addEventListener('mouseleave', handleMouseLeaveTooltip);
}
enabledEvents.forEach(({ event, listener }) => {
elementRefs.forEach((ref) => {
ref.current?.addEventListener(event, listener);
});
});
return () => {
if (closeOnScroll) {
window.removeEventListener('scroll', handleScrollResize);
anchorScrollParent?.removeEventListener('scroll', handleScrollResize);
tooltipScrollParent?.removeEventListener('scroll', handleScrollResize);
}
if (closeOnResize) {
window.removeEventListener('resize', handleScrollResize);
}
else {
updateTooltipCleanup?.();
}
if (closeOnEsc) {
window.removeEventListener('keydown', handleEsc);
}
if (clickable && !shouldOpenOnClick) {
tooltipRef.current?.removeEventListener('mouseenter', handleMouseEnterTooltip);
tooltipRef.current?.removeEventListener('mouseleave', handleMouseLeaveTooltip);
}
enabledEvents.forEach(({ event, listener }) => {
elementRefs.forEach((ref) => {
ref.current?.removeEventListener(event, listener);
});
});
};
/**
* rendered is also a dependency to ensure anchor observers are re-registered
* since `tooltipRef` becomes stale after removing/adding the tooltip to the DOM
*/
}, [
activeAnchor,
updateTooltipPosition,
rendered,
anchorRefs,
anchorsBySelect,
closeOnEsc,
events,
]);
useEffect(() => {
let selector = anchorSelect ?? '';
if (!selector && id) {
selector = `[data-tooltip-id='${id}']`;
}
const documentObserverCallback = (mutationList) => {
const newAnchors = [];
mutationList.forEach((mutation) => {
// eslint-disable-next-line sonarjs/no-duplicate-string
if (mutation.type === 'attributes' && mutation.attributeName === 'data-tooltip-id') {
const newId = mutation.target.getAttribute('data-tooltip-id');
if (newId === id) {
newAnchors.push(mutation.target);
}
}
if (mutation.type !== 'childList') {
return;
}
if (activeAnchor) {
[...mutation.removedNodes].some((node) => {
if (node?.contains?.(activeAnchor)) {
setRendered(false);
handleShow(false);
setActiveAnchor(null);
if (tooltipShowDelayTimerRef.current) {
clearTimeout(tooltipShowDelayTimerRef.current);
}
if (tooltipHideDelayTimerRef.current) {
clearTimeout(tooltipHideDelayTimerRef.current);
}
return true;
}
return false;
});
}
if (!selector) {
return;
}
try {
const elements = [...mutation.addedNodes].filter(node => node.nodeType === 1);
newAnchors.push(
// the element itself is an anchor
...elements.filter(element => element.matches(selector)));
newAnchors.push(
// the element has children which are anchors
...elements.flatMap(element => [...element.querySelectorAll(selector)]));
}
catch {
/**
* invalid CSS selector.
* already warned on tooltip controller
*/
}
});
if (newAnchors.length) {
setAnchorsBySelect(anchors => [...anchors, ...newAnchors]);
}
};
const documentObserver = new MutationObserver(documentObserverCallback);
// watch for anchor being removed from the DOM
documentObserver.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['data-tooltip-id'],
});
return () => {
documentObserver.disconnect();
};
}, [id, anchorSelect, activeAnchor]);
useEffect(() => {
updateTooltipPosition();
}, [updateTooltipPosition]);
useEffect(() => {
if (!contentWrapperRef?.current) {
return () => null;
}
const contentObserver = new ResizeObserver(() => {
updateTooltipPosition();
});
contentObserver.observe(contentWrapperRef.current);
return () => {
contentObserver.disconnect();
};
}, [content, contentWrapperRef?.current]);
useEffect(() => {
const anchorById = document.querySelector(`[id='${anchorId}']`);
const anchors = [...anchorsBySelect, anchorById];
if (!activeAnchor || !anchors.includes(activeAnchor)) {
/**
* if there is no active anchor,
* or if the current active anchor is not amongst the allowed ones,
* reset it
*/
setActiveAnchor(anchorsBySelect[0] ?? anchorById);
}
}, [anchorId, anchorsBySelect, activeAnchor]);
useEffect(() => {
return () => {
if (tooltipShowDelayTimerRef.current) {
clearTimeout(tooltipShowDelayTimerRef.current);
}
if (tooltipHideDelayTimerRef.current) {
clearTimeout(tooltipHideDelayTimerRef.current);
}
};
}, []);
useEffect(() => {
let selector = anchorSelect;
if (!selector && id) {
selector = `[data-tooltip-id='${id}']`;
}
if (!selector) {
return;
}
try {
const anchors = Array.from(document.querySelectorAll(selector));
setAnchorsBySelect(anchors);
}
catch {
// warning was already issued in the controller
setAnchorsBySelect([]);
}
}, [id, anchorSelect]);
useEffect(() => {
const handleClickOutsideAnchors = (event) => {
const anchorById = document.querySelector(`[id='${anchorId}']`);
const anchors = [anchorById, ...anchorsBySelect];
if (anchors.some(anchor => anchor?.contains(event.target))) {
return;
}
if (tooltipRef.current?.contains(event.target)) {
return;
}
handleShow(false);
if (tooltipShowDelayTimerRef.current) {
clearTimeout(tooltipShowDelayTimerRef.current);
}
};
if (show && shouldOpenOnClick) {
window.addEventListener('click', handleClickOutsideAnchors);
}
return () => {
window.removeEventListener('click', handleClickOutsideAnchors);
};
}, [show, shouldOpenOnClick, anchorId, anchorsBySelect]);
const canShow = !hidden && content && show && Object.keys(inlineStyles).length > 0;
const composeRef = useComposeRef(tooltipRef, ref);
return rendered ? (_jsxs(WrapperElement, { id: id, role: "tooltip", className: classNames('react-tooltip', `react-tooltip__variant-${variant}`, className, `react-tooltip__place-${actualPlacement}`, {
'react-tooltip__show': canShow,
'react-tooltip__fixed': positionStrategy === 'fixed',
'react-tooltip__clickable': clickable,
}), style: {
...externalStyles,
...inlineStyles,
opacity: opacity !== undefined && canShow ? opacity : undefined,
}, ref: composeRef, children: [content, _jsx(WrapperElement, { className: classNames('react-tooltip-arrow', classNameArrow, {
/**
* changed from dash `no-arrow` to camelcase because of:
* https://github.com/indooorsman/esbuild-css-modules-plugin/issues/42
*/
'react-tooltip-no-arrow': noArrow,
}), style: {
...inlineArrowStyles,
background: arrowColor
? `linear-gradient(to right bottom, transparent 50%, ${arrowColor} 50%)`
: undefined,
}, ref: tooltipArrowRef })] })) : null;
});