@base-ui/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.
253 lines (245 loc) • 10.8 kB
JavaScript
"use strict";
'use client';
var _interopRequireWildcard = require("@babel/runtime/helpers/interopRequireWildcard").default;
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.usePopupViewport = usePopupViewport;
var React = _interopRequireWildcard(require("react"));
var _inertValue = require("@base-ui/utils/inertValue");
var _useAnimationFrame = require("@base-ui/utils/useAnimationFrame");
var _usePreviousValue = require("@base-ui/utils/usePreviousValue");
var _useIsoLayoutEffect = require("@base-ui/utils/useIsoLayoutEffect");
var _useStableCallback = require("@base-ui/utils/useStableCallback");
var _useAnimationsFinished = require("./useAnimationsFinished");
var _usePopupAutoResize = require("./usePopupAutoResize");
var _directionProvider = require("../direction-provider");
var _jsxRuntime = require("react/jsx-runtime");
/**
* Builds morphing viewport containers for popups that animate between trigger-based content.
* Handles previous-content snapshots, auto-resize, and state attributes for transitions.
*/
function usePopupViewport(parameters) {
const {
store,
side,
cssVars,
children
} = parameters;
const direction = (0, _directionProvider.useDirection)();
const activeTrigger = store.useState('activeTriggerElement');
const activeTriggerId = store.useState('activeTriggerId');
const open = store.useState('open');
const payload = store.useState('payload');
const mounted = store.useState('mounted');
const popupElement = store.useState('popupElement');
const positionerElement = store.useState('positionerElement');
const previousActiveTrigger = (0, _usePreviousValue.usePreviousValue)(open ? activeTrigger : null);
// Remount current content on trigger changes (and once more when payload lags) to avoid DOM reuse flashes.
// The key bumps immediately on trigger switches, then again if the payload arrives on a later render.
const currentContentKey = usePopupContentKey(activeTriggerId, payload);
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 = (0, _useAnimationsFinished.useAnimationsFinished)(currentContainerRef, true, false);
const cleanupFrame = (0, _useAnimationFrame.useAnimationFrame)();
const [previousContentDimensions, setPreviousContentDimensions] = React.useState(null);
const [showStartingStyleAttribute, setShowStartingStyleAttribute] = React.useState(false);
(0, _useIsoLayoutEffect.useIsoLayoutEffect)(() => {
store.set('hasViewport', true);
return () => {
store.set('hasViewport', false);
};
}, [store]);
const handleMeasureLayout = (0, _useStableCallback.useStableCallback)(() => {
currentContainerRef.current?.style.setProperty('animation', 'none');
currentContainerRef.current?.style.setProperty('transition', 'none');
previousContainerRef.current?.style.setProperty('display', 'none');
});
const handleMeasureLayoutComplete = (0, _useStableCallback.useStableCallback)(previousDimensions => {
currentContainerRef.current?.style.removeProperty('animation');
currentContainerRef.current?.style.removeProperty('transition');
previousContainerRef.current?.style.removeProperty('display');
if (previousDimensions) {
setPreviousContentDimensions(previousDimensions);
}
});
const lastHandledTriggerRef = React.useRef(null);
(0, _useIsoLayoutEffect.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);
cleanupFrame.request(() => {
cleanupFrame.request(() => {
setShowStartingStyleAttribute(false);
onAnimationsFinished(() => {
setPreviousContentNode(null);
setPreviousContentDimensions(null);
capturedNodeRef.current = null;
});
});
});
lastHandledTriggerRef.current = activeTrigger;
}
}, [activeTrigger, previousActiveTrigger, previousContentNode, onAnimationsFinished, cleanupFrame]);
// 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.
(0, _useIsoLayoutEffect.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__*/(0, _jsxRuntime.jsx)("div", {
"data-current": true,
ref: currentContainerRef,
children: children
}, currentContentKey);
} else {
childrenToRender = /*#__PURE__*/(0, _jsxRuntime.jsxs)(React.Fragment, {
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
"data-previous": true,
inert: (0, _inertValue.inertValue)(true),
ref: previousContainerRef,
style: {
[cssVars.popupWidth]: `${previousContentDimensions?.width}px`,
[cssVars.popupHeight]: `${previousContentDimensions?.height}px`,
position: 'absolute'
},
"data-ending-style": showStartingStyleAttribute ? undefined : ''
}, "previous"), /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
"data-current": true,
ref: currentContainerRef,
"data-starting-style": showStartingStyleAttribute ? '' : undefined,
children: children
}, currentContentKey)]
});
}
// When previousContentNode is present, imperatively populate the previous container with the cloned children.
(0, _useIsoLayoutEffect.useIsoLayoutEffect)(() => {
const container = previousContainerRef.current;
if (!container || !previousContentNode) {
return;
}
container.replaceChildren(...Array.from(previousContentNode.childNodes));
}, [previousContentNode]);
(0, _usePopupAutoResize.usePopupAutoResize)({
popupElement,
positionerElement,
mounted,
content: payload,
onMeasureLayout: handleMeasureLayout,
onMeasureLayoutComplete: handleMeasureLayoutComplete,
side,
direction
});
const state = {
activationDirection: getActivationDirection(newTriggerOffset),
transitioning: isTransitioning
};
return {
children: childrenToRender,
state
};
}
/**
* 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) treating values
* within tolerance 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
};
}
/**
* Returns a key that forces remounting content when triggers change or a payload is updated.
*/
function usePopupContentKey(activeTriggerId, payload) {
const [contentKey, setContentKey] = React.useState(0);
const previousActiveTriggerIdRef = React.useRef(activeTriggerId);
const previousPayloadRef = React.useRef(payload);
const pendingPayloadUpdateRef = React.useRef(false);
(0, _useIsoLayoutEffect.useIsoLayoutEffect)(() => {
// Compare against the last committed values to decide whether we need a new DOM subtree.
const previousActiveTriggerId = previousActiveTriggerIdRef.current;
const previousPayload = previousPayloadRef.current;
const triggerIdChanged = activeTriggerId !== previousActiveTriggerId;
const payloadChanged = payload !== previousPayload;
if (triggerIdChanged) {
// Remount immediately on trigger change; remember if payload hasn't caught up yet.
setContentKey(value => value + 1);
pendingPayloadUpdateRef.current = !payloadChanged;
} else if (pendingPayloadUpdateRef.current && payloadChanged) {
// Payload arrived a render later, so remount once more to avoid reusing the old <img>.
setContentKey(value => value + 1);
pendingPayloadUpdateRef.current = false;
}
// Persist current values for the next render's comparison.
previousActiveTriggerIdRef.current = activeTriggerId;
previousPayloadRef.current = payload;
}, [activeTriggerId, payload]);
return `${activeTriggerId ?? 'current'}-${contentKey}`;
}