UNPKG

@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
"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}`; }