UNPKG

@atlaskit/tooltip

Version:

A tooltip is a floating, non-actionable label used to explain a user interface element or feature.

240 lines 9.88 kB
/** @jsx jsx */ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { jsx } from '@emotion/core'; import { bind } from 'bind-event-listener'; import { usePlatformLeafEventHandler } from '@atlaskit/analytics-next'; import { ExitingPersistence, FadeIn } from '@atlaskit/motion'; import { Popper } from '@atlaskit/popper'; import Portal from '@atlaskit/portal'; import { layers } from '@atlaskit/theme/constants'; import { show } from './internal/tooltip-manager'; import TooltipContainer from './TooltipContainer'; import { getMousePosition } from './utilities'; import { name as packageName, version as packageVersion } from './version.json'; const tooltipZIndex = layers.tooltip(); const analyticsAttributes = { componentName: 'tooltip', packageName, packageVersion, }; function noop() { } function Tooltip({ children, position = 'bottom', mousePosition = 'bottom', content, truncate = false, component: Container = TooltipContainer, tag: TargetContainer = 'div', testId, delay = 300, onShow = noop, onHide = noop, hideTooltipOnClick = false, hideTooltipOnMouseDown = false, analyticsContext, }) { const tooltipPosition = position === 'mouse' ? mousePosition : position; const onShowHandler = usePlatformLeafEventHandler({ fn: onShow, action: 'displayed', analyticsData: analyticsContext, ...analyticsAttributes, }); const onHideHandler = usePlatformLeafEventHandler({ fn: onHide, action: 'hidden', analyticsData: analyticsContext, ...analyticsAttributes, }); const apiRef = useRef(null); const [state, setState] = useState('hide'); const targetRef = useRef(null); const containerRef = useRef(null); const setRef = useCallback((node) => { if (!node || node.firstChild === null) { return; } // @ts-ignore - React Ref typing is too strict for this use case containerRef.current = node; // @ts-ignore - React Ref typing is too strict for this use case targetRef.current = node.firstChild; }, []); // Putting a few things into refs so that we don't have to break memoization const lastState = useRef(state); const lastDelay = useRef(delay); const lastHandlers = useRef({ onShowHandler, onHideHandler }); const hasCalledShowHandler = useRef(false); useEffect(() => { lastState.current = state; lastDelay.current = delay; lastHandlers.current = { onShowHandler, onHideHandler }; }, [delay, onHideHandler, onShowHandler, state]); const start = useCallback((api) => { // @ts-ignore apiRef.current = api; hasCalledShowHandler.current = false; }, []); const done = useCallback(() => { if (!apiRef.current) { return; } // Only call onHideHandler if we have called onShowHandler if (hasCalledShowHandler.current) { lastHandlers.current.onHideHandler(); } // @ts-ignore apiRef.current = null; // @ts-ignore hasCalledShowHandler.current = false; // just in case setState('hide'); }, []); const abort = useCallback(() => { if (!apiRef.current) { return; } apiRef.current.abort(); // Only call onHideHandler if we have called onShowHandler if (hasCalledShowHandler.current) { lastHandlers.current.onHideHandler(); } // @ts-ignore apiRef.current = null; }, []); useEffect(function mount() { return function unmount() { if (apiRef.current) { abort(); } }; }, [abort]); const showTooltip = useCallback((source) => { if (apiRef.current && !apiRef.current.isActive()) { abort(); } // Tell the tooltip to keep showing if (apiRef.current && apiRef.current.isActive()) { apiRef.current.keep(); return; } const entry = { source, delay: lastDelay.current, show: ({ isImmediate }) => { // Call the onShow handler if it hasn't been called yet if (!hasCalledShowHandler.current) { hasCalledShowHandler.current = true; lastHandlers.current.onShowHandler(); } setState(isImmediate ? 'show-immediate' : 'show-fade-in'); }, hide: ({ isImmediate }) => { setState((current) => { if (current !== 'hide') { return isImmediate ? 'hide' : 'fade-out'; } return current; }); }, done: done, }; const api = show(entry); start(api); }, [abort, done, start]); useEffect(() => { if (state === 'hide') { return noop; } const unbind = bind(window, { type: 'scroll', listener: () => { if (apiRef.current) { apiRef.current.requestHide({ isImmediate: true }); } }, options: { capture: true, passive: true, once: true }, }); return unbind; }, [state]); const onMouseDown = useCallback(() => { if (hideTooltipOnMouseDown && apiRef.current) { apiRef.current.requestHide({ isImmediate: true }); } }, [hideTooltipOnMouseDown]); const onClick = useCallback(() => { if (hideTooltipOnClick && apiRef.current) { apiRef.current.requestHide({ isImmediate: true }); } }, [hideTooltipOnClick]); // Ideally we would be using onMouseEnter here, but // because we are binding the event to the target parent // we need to listen for the mouseover of all sub elements // This means when moving along a tooltip we are quickly toggling // between api.requestHide and api.keep. This it not ideal const onMouseOver = useCallback((event) => { // Ignoring events from the container ref if (event.target === containerRef.current) { return; } // Using prevent default as a signal that parent tooltips if (event.defaultPrevented) { return; } event.preventDefault(); const source = position === 'mouse' ? { type: 'mouse', // TODO: ideally not recalculating this object each time mouse: getMousePosition({ left: event.clientX, top: event.clientY, }), } : { type: 'keyboard' }; showTooltip(source); }, [position, showTooltip]); // Ideally we would be using onMouseEnter here, but // because we are binding the event to the target parent // we need to listen for the mouseout of all sub elements // This means when moving along a tooltip we are quickly toggling // between api.requestHide and api.keep. This it not ideal const onMouseOut = useCallback((event) => { // Ignoring events from the container ref if (event.target === containerRef.current) { return; } // Using prevent default as a signal that parent tooltips if (event.defaultPrevented) { return; } event.preventDefault(); if (apiRef.current) { apiRef.current.requestHide({ isImmediate: false }); } }, []); const onFocus = useCallback(() => { showTooltip({ type: 'keyboard' }); }, [showTooltip]); const onBlur = useCallback(() => { if (apiRef.current) { apiRef.current.requestHide({ isImmediate: false }); } }, []); const onAnimationFinished = useCallback((transition) => { // Using lastState here because motion is not picking up the latest value if (transition === 'exiting' && lastState.current === 'fade-out' && apiRef.current) { // @ts-ignore: refs are writeable apiRef.current.finishHideAnimation(); } }, []); // Doing a cast because typescript is struggling to narrow the type const CastTargetContainer = TargetContainer; const shouldRenderTooltipContainer = state !== 'hide' && Boolean(content); const shouldRenderTooltipChildren = state === 'show-immediate' || state === 'show-fade-in'; const getReferentElement = () => { // Use the initial mouse position if appropriate, or the target element const api = apiRef.current; const initialMouse = api ? api.getInitialMouse() : null; if (position === 'mouse' && initialMouse) { return initialMouse; } return targetRef.current || undefined; }; return (jsx(React.Fragment, null, jsx(CastTargetContainer, { onMouseOver: onMouseOver, onMouseOut: onMouseOut, onClick: onClick, onMouseDown: onMouseDown, onFocus: onFocus, onBlur: onBlur, ref: setRef, "data-testid": testId ? `${testId}--container` : undefined }, children), shouldRenderTooltipContainer ? (jsx(Portal, { zIndex: tooltipZIndex }, jsx(Popper, { placement: tooltipPosition, referenceElement: getReferentElement() }, ({ ref, style }) => (jsx(ExitingPersistence, { appear: true }, shouldRenderTooltipChildren && (jsx(FadeIn, { onFinish: onAnimationFinished, duration: state === 'show-immediate' ? 0 : undefined }, ({ className }) => (jsx(Container, { ref: ref, className: `Tooltip ${className}`, style: style, truncate: truncate, placement: tooltipPosition, testId: testId }, content))))))))) : null)); } Tooltip.displayName = 'Tooltip'; export default Tooltip; //# sourceMappingURL=Tooltip.js.map