@base-ui-components/react
Version:
Base UI is a library of headless ('unstyled') React components and low-level hooks. You gain complete control over your app's CSS and accessibility features.
217 lines (211 loc) • 9.15 kB
JavaScript
import * as React from 'react';
import { inertValue } from '@base-ui-components/utils/inertValue';
import { useAnimationFrame } from '@base-ui-components/utils/useAnimationFrame';
import { usePreviousValue } from '@base-ui-components/utils/usePreviousValue';
import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect';
import { useStableCallback } from '@base-ui-components/utils/useStableCallback';
import { useTooltipRootContext } from "../root/TooltipRootContext.js";
import { useAnimationsFinished } from "../../utils/useAnimationsFinished.js";
import { useRenderElement } from "../../utils/useRenderElement.js";
import { TooltipViewportCssVars } from "./TooltipViewportCssVars.js";
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
const stateAttributesMapping = {
activationDirection: value => value ? {
'data-activation-direction': value
} : null
};
/**
* A viewport for displaying content transitions.
* This component is only required if one popup can be opened by multiple triggers, its content change based on the trigger
* and switching between them is animated.
* Renders a `<div>` element.
*
* Documentation: [Base UI Tooltip](https://base-ui.com/react/components/tooltip)
*/
export const TooltipViewport = /*#__PURE__*/React.forwardRef(function TooltipViewport(componentProps, forwardedRef) {
const {
render,
className,
children,
...elementProps
} = componentProps;
const store = useTooltipRootContext();
const activeTrigger = store.useState('activeTriggerElement');
const open = store.useState('open');
const floatingContext = store.useState('floatingRootContext');
const instantType = store.useState('instantType');
const previousActiveTrigger = usePreviousValue(open ? activeTrigger : null);
const capturedNodeRef = React.useRef(null);
const [previousContentNode, setPreviousContentNode] = React.useState(null);
const [newTriggerOffset, setNewTriggerOffset] = React.useState(null);
const currentContainerRef = React.useRef(null);
const previousContainerRef = React.useRef(null);
const onAnimationsFinished = useAnimationsFinished(currentContainerRef, true, false);
const cleanupTimeout = useAnimationFrame();
const [previousContentDimensions, setPreviousContentDimensions] = React.useState(null);
const [showStartingStyleAttribute, setShowStartingStyleAttribute] = React.useState(false);
const handleMeasureLayout = useStableCallback(() => {
currentContainerRef.current?.style.setProperty('animation', 'none');
currentContainerRef.current?.style.setProperty('transition', 'none');
previousContainerRef.current?.style.setProperty('display', 'none');
});
const handleMeasureLayoutComplete = useStableCallback(data => {
currentContainerRef.current?.style.removeProperty('animation');
currentContainerRef.current?.style.removeProperty('transition');
previousContainerRef.current?.style.removeProperty('display');
if (!previousContentDimensions) {
setPreviousContentDimensions(data.previousDimensions);
}
});
React.useEffect(() => {
floatingContext.context.events.on('measure-layout', handleMeasureLayout);
floatingContext.context.events.on('measure-layout-complete', handleMeasureLayoutComplete);
return () => {
floatingContext.context.events.off('measure-layout', handleMeasureLayout);
floatingContext.context.events.off('measure-layout-complete', handleMeasureLayoutComplete);
};
}, [floatingContext, handleMeasureLayout, handleMeasureLayoutComplete]);
const lastHandledTriggerRef = React.useRef(null);
useIsoLayoutEffect(() => {
// When a trigger changes, set the captured children HTML to state,
// so we can render both new and old content.
if (activeTrigger && previousActiveTrigger && activeTrigger !== previousActiveTrigger && lastHandledTriggerRef.current !== activeTrigger && capturedNodeRef.current) {
setPreviousContentNode(capturedNodeRef.current);
setShowStartingStyleAttribute(true);
// Calculate the relative position between the previous and new trigger,
// so we can pass it to the style hook for animation purposes.
const offset = calculateRelativePosition(previousActiveTrigger, activeTrigger);
setNewTriggerOffset(offset);
cleanupTimeout.request(() => {
setShowStartingStyleAttribute(false);
onAnimationsFinished(() => {
setPreviousContentNode(null);
setPreviousContentDimensions(null);
capturedNodeRef.current = null;
});
});
lastHandledTriggerRef.current = activeTrigger;
}
}, [activeTrigger, previousActiveTrigger, previousContentNode, onAnimationsFinished, cleanupTimeout]);
// Capture a clone of the current content DOM subtree when not transitioning.
// We can't store previous React nodes as they may be stateful; instead we capture DOM clones for visual continuity.
useIsoLayoutEffect(() => {
// When a transition is in progress, we store the next content in capturedNodeRef.
// This handles the case where the trigger changes multiple times before the transition finishes.
// We want to always capture the latest content for the previous snapshot.
// So clicking quickly on T1, T2, T3 will result in the following sequence:
// 1. T1 -> T2: previousContent = T1, currentContent = T2
// 2. T2 -> T3: previousContent = T2, currentContent = T3
const source = currentContainerRef.current;
if (!source) {
return;
}
const wrapper = document.createElement('div');
for (const child of Array.from(source.childNodes)) {
wrapper.appendChild(child.cloneNode(true));
}
capturedNodeRef.current = wrapper;
});
const isTransitioning = previousContentNode != null;
let childrenToRender;
if (!isTransitioning) {
childrenToRender = /*#__PURE__*/_jsx("div", {
"data-current": true,
ref: currentContainerRef,
children: children
}, 'current');
} else {
childrenToRender = /*#__PURE__*/_jsxs(React.Fragment, {
children: [/*#__PURE__*/_jsx("div", {
"data-previous": true,
inert: inertValue(true),
ref: previousContainerRef,
style: {
[TooltipViewportCssVars.popupWidth]: `${previousContentDimensions?.width}px`,
[TooltipViewportCssVars.popupHeight]: `${previousContentDimensions?.height}px`,
position: 'absolute'
},
"data-ending-style": showStartingStyleAttribute ? undefined : ''
}, 'previous'), /*#__PURE__*/_jsx("div", {
"data-current": true,
ref: currentContainerRef,
"data-starting-style": showStartingStyleAttribute ? '' : undefined,
children: children
}, 'current')]
});
}
// When previousContentNode is present, imperatively populate the previous container with the cloned children.
useIsoLayoutEffect(() => {
const container = previousContainerRef.current;
if (!container || !previousContentNode) {
return;
}
container.replaceChildren(...Array.from(previousContentNode.childNodes));
}, [previousContentNode]);
const state = React.useMemo(() => {
return {
activationDirection: getActivationDirection(newTriggerOffset),
transitioning: isTransitioning,
instant: instantType
};
}, [newTriggerOffset, isTransitioning, instantType]);
return useRenderElement('div', componentProps, {
state,
ref: forwardedRef,
props: [elementProps, {
children: childrenToRender
}],
stateAttributesMapping
});
});
if (process.env.NODE_ENV !== "production") TooltipViewport.displayName = "TooltipViewport";
/**
* Returns a string describing the provided offset.
* It describes both the horizontal and vertical offset, separated by a space.
*
* @param offset
*/
function getActivationDirection(offset) {
if (!offset) {
return undefined;
}
return `${getValueWithTolerance(offset.horizontal, 5, 'right', 'left')} ${getValueWithTolerance(offset.vertical, 5, 'down', 'up')}`;
}
/**
* Returns a label describing the value (positive/negative) trating values
* within tolarance as zero.
*
* @param value Value to check
* @param tolerance Tolerance to treat the value as zero.
* @param positiveLabel
* @param negativeLabel
* @returns If 0 < abs(value) < tolerance, returns an empty string. Otherwise returns positiveLabel or negativeLabel.
*/
function getValueWithTolerance(value, tolerance, positiveLabel, negativeLabel) {
if (value > tolerance) {
return positiveLabel;
}
if (value < -tolerance) {
return negativeLabel;
}
return '';
}
/**
* Calculates the relative position between centers of two elements.
*/
function calculateRelativePosition(from, to) {
const fromRect = from.getBoundingClientRect();
const toRect = to.getBoundingClientRect();
const fromCenter = {
x: fromRect.left + fromRect.width / 2,
y: fromRect.top + fromRect.height / 2
};
const toCenter = {
x: toRect.left + toRect.width / 2,
y: toRect.top + toRect.height / 2
};
return {
horizontal: toCenter.x - fromCenter.x,
vertical: toCenter.y - fromCenter.y
};
}