@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
JavaScript
/** @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