UNPKG

@i-novus/ui-core

Version:

Базовые UI компоненты

556 lines (554 loc) 21.5 kB
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; });