11'use client' ;
22import * as React from 'react' ;
3- import { inertValue } from '@base-ui/utils/inertValue' ;
4- import { useAnimationFrame } from '@base-ui/utils/useAnimationFrame' ;
5- import { usePreviousValue } from '@base-ui/utils/usePreviousValue' ;
6- import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect' ;
7- import { useStableCallback } from '@base-ui/utils/useStableCallback' ;
83import { usePopoverRootContext } from '../root/PopoverRootContext' ;
94import { usePopoverPositionerContext } from '../positioner/PopoverPositionerContext' ;
105import { BaseUIComponentProps } from '../../utils/types' ;
11- import { useAnimationsFinished } from '../../utils/useAnimationsFinished' ;
12- import { usePopupAutoResize } from '../../utils/usePopupAutoResize' ;
136import { useRenderElement } from '../../utils/useRenderElement' ;
147import { StateAttributesMapping } from '../../utils/getStateAttributesProps' ;
15- import { Dimensions } from '../../floating-ui-react/types' ;
168import { PopoverViewportCssVars } from './PopoverViewportCssVars' ;
17- import { useDirection } from '../../direction-provider/DirectionContext ' ;
9+ import { usePopupViewport } from '../../utils/usePopupViewport ' ;
1810
1911const stateAttributesMapping : StateAttributesMapping < PopoverViewport . State > = {
2012 activationDirection : ( value ) =>
@@ -39,185 +31,21 @@ export const PopoverViewport = React.forwardRef(function PopoverViewport(
3931) {
4032 const { render, className, children, ...elementProps } = componentProps ;
4133 const { store } = usePopoverRootContext ( ) ;
42- const positioner = usePopoverPositionerContext ( ) ;
43- const direction = useDirection ( ) ;
34+ const { side } = usePopoverPositionerContext ( ) ;
4435
45- const activeTrigger = store . useState ( 'activeTriggerElement' ) ;
46- const open = store . useState ( 'open' ) ;
47- const mounted = store . useState ( 'mounted' ) ;
48- const payload = store . useState ( 'payload' ) ;
49- const popupElement = store . useState ( 'popupElement' ) ;
50- const positionerElement = store . useState ( 'positionerElement' ) ;
36+ const instantType = store . useState ( 'instantType' ) ;
5137
52- const previousActiveTrigger = usePreviousValue ( open ? activeTrigger : null ) ;
53-
54- const capturedNodeRef = React . useRef < HTMLElement | null > ( null ) ;
55- const [ previousContentNode , setPreviousContentNode ] = React . useState < HTMLElement | null > ( null ) ;
56-
57- const [ newTriggerOffset , setNewTriggerOffset ] = React . useState < Offset | null > ( null ) ;
58-
59- const currentContainerRef = React . useRef < HTMLDivElement > ( null ) ;
60- const previousContainerRef = React . useRef < HTMLDivElement > ( null ) ;
61-
62- const onAnimationsFinished = useAnimationsFinished ( currentContainerRef , true , false ) ;
63- const cleanupFrame = useAnimationFrame ( ) ;
64-
65- const [ previousContentDimensions , setPreviousContentDimensions ] = React . useState < {
66- width : number ;
67- height : number ;
68- } | null > ( null ) ;
69-
70- const [ showStartingStyleAttribute , setShowStartingStyleAttribute ] = React . useState ( false ) ;
71-
72- useIsoLayoutEffect ( ( ) => {
73- store . set ( 'hasViewport' , true ) ;
74- return ( ) => {
75- store . set ( 'hasViewport' , false ) ;
76- } ;
77- } , [ store ] ) ;
78-
79- // Capture a clone of the current content DOM subtree when not transitioning.
80- // We can't store previous React nodes as they may be stateful; instead we capture DOM clones for visual continuity.
81- useIsoLayoutEffect ( ( ) => {
82- // When a transition is in progress, we store the next content in capturedNodeRef.
83- // This handles the case where the trigger changes multiple times before the transition finishes.
84- // We want to always capture the latest content for the previous snapshot.
85- // So clicking quickly on T1, T2, T3 will result in the following sequence:
86- // 1. T1 -> T2: previousContent = T1, currentContent = T2
87- // 2. T2 -> T3: previousContent = T2, currentContent = T3
88- const source = currentContainerRef . current ;
89- if ( ! source ) {
90- return ;
91- }
92-
93- const wrapper = document . createElement ( 'div' ) ;
94- for ( const child of Array . from ( source . childNodes ) ) {
95- wrapper . appendChild ( child . cloneNode ( true ) ) ;
96- }
97-
98- capturedNodeRef . current = wrapper ;
99- } ) ;
100-
101- const handleMeasureLayout = useStableCallback ( ( ) => {
102- currentContainerRef . current ?. style . setProperty ( 'animation' , 'none' ) ;
103- currentContainerRef . current ?. style . setProperty ( 'transition' , 'none' ) ;
104-
105- previousContainerRef . current ?. style . setProperty ( 'display' , 'none' ) ;
106- } ) ;
107-
108- const handleMeasureLayoutComplete = useStableCallback ( ( previousDimensions : Dimensions | null ) => {
109- currentContainerRef . current ?. style . removeProperty ( 'animation' ) ;
110- currentContainerRef . current ?. style . removeProperty ( 'transition' ) ;
111-
112- previousContainerRef . current ?. style . removeProperty ( 'display' ) ;
113-
114- if ( previousDimensions ) {
115- setPreviousContentDimensions ( previousDimensions ) ;
116- }
117- } ) ;
118-
119- const lastHandledTriggerRef = React . useRef < Element | null > ( null ) ;
120-
121- useIsoLayoutEffect ( ( ) => {
122- // When a trigger changes, set the captured children HTML to state,
123- // so we can render both new and old content.
124- if (
125- activeTrigger &&
126- previousActiveTrigger &&
127- activeTrigger !== previousActiveTrigger &&
128- lastHandledTriggerRef . current !== activeTrigger &&
129- capturedNodeRef . current
130- ) {
131- setPreviousContentNode ( capturedNodeRef . current ) ;
132- setShowStartingStyleAttribute ( true ) ;
133-
134- // Calculate the relative position between the previous and new trigger,
135- // so we can pass it to the style hook for animation purposes.
136- const offset = calculateRelativePosition ( previousActiveTrigger , activeTrigger ) ;
137- setNewTriggerOffset ( offset ) ;
138-
139- cleanupFrame . request ( ( ) => {
140- cleanupFrame . request ( ( ) => {
141- setShowStartingStyleAttribute ( false ) ;
142- onAnimationsFinished ( ( ) => {
143- setPreviousContentNode ( null ) ;
144- setPreviousContentDimensions ( null ) ;
145- capturedNodeRef . current = null ;
146- } ) ;
147- } ) ;
148- } ) ;
149-
150- lastHandledTriggerRef . current = activeTrigger ;
151- }
152- } , [
153- activeTrigger ,
154- previousActiveTrigger ,
155- previousContentNode ,
156- onAnimationsFinished ,
157- cleanupFrame ,
158- ] ) ;
159-
160- const isTransitioning = previousContentNode != null ;
161- let childrenToRender : React . ReactNode ;
162- if ( ! isTransitioning ) {
163- childrenToRender = (
164- < div data-current ref = { currentContainerRef } key = { 'current' } >
165- { children }
166- </ div >
167- ) ;
168- } else {
169- childrenToRender = (
170- < React . Fragment >
171- < div
172- data-previous
173- inert = { inertValue ( true ) }
174- ref = { previousContainerRef }
175- style = {
176- {
177- [ PopoverViewportCssVars . popupWidth ] : `${ previousContentDimensions ?. width } px` ,
178- [ PopoverViewportCssVars . popupHeight ] : `${ previousContentDimensions ?. height } px` ,
179- position : 'absolute' ,
180- } as React . CSSProperties
181- }
182- key = { 'previous' }
183- data-ending-style = { showStartingStyleAttribute ? undefined : '' }
184- />
185- < div
186- data-current
187- ref = { currentContainerRef }
188- key = { 'current' }
189- data-starting-style = { showStartingStyleAttribute ? '' : undefined }
190- >
191- { children }
192- </ div >
193- </ React . Fragment >
194- ) ;
195- }
196-
197- // When previousContentNode is present, imperatively populate the previous container with the cloned children.
198- useIsoLayoutEffect ( ( ) => {
199- const container = previousContainerRef . current ;
200- if ( ! container || ! previousContentNode ) {
201- return ;
202- }
203-
204- container . replaceChildren ( ...Array . from ( previousContentNode . childNodes ) ) ;
205- } , [ previousContentNode ] ) ;
206-
207- usePopupAutoResize ( {
208- popupElement,
209- positionerElement,
210- mounted,
211- content : payload ,
212- onMeasureLayout : handleMeasureLayout ,
213- onMeasureLayoutComplete : handleMeasureLayoutComplete ,
214- side : positioner . side ,
215- direction,
38+ const { children : childrenToRender , state : viewportState } = usePopupViewport ( {
39+ store,
40+ side,
41+ cssVars : PopoverViewportCssVars ,
42+ children,
21643 } ) ;
21744
21845 const state : PopoverViewport . State = {
219- activationDirection : getActivationDirection ( newTriggerOffset ) ,
220- transitioning : isTransitioning ,
46+ activationDirection : viewportState . activationDirection ,
47+ transitioning : viewportState . transitioning ,
48+ instant : instantType ,
22149 } ;
22250
22351 return useRenderElement ( 'div' , componentProps , {
@@ -242,73 +70,9 @@ export namespace PopoverViewport {
24270 * Whether the viewport is currently transitioning between contents.
24371 */
24472 transitioning : boolean ;
73+ /**
74+ * Present if animations should be instant.
75+ */
76+ instant : 'dismiss' | 'click' | undefined ;
24577 }
24678}
247-
248- type Offset = {
249- horizontal : number ;
250- vertical : number ;
251- } ;
252-
253- /**
254- * Returns a string describing the provided offset.
255- * It describes both the horizontal and vertical offset, separated by a space.
256- *
257- * @param offset
258- */
259- function getActivationDirection ( offset : Offset | null ) : string | undefined {
260- if ( ! offset ) {
261- return undefined ;
262- }
263-
264- return `${ getValueWithTolerance ( offset . horizontal , 5 , 'right' , 'left' ) } ${ getValueWithTolerance ( offset . vertical , 5 , 'down' , 'up' ) } ` ;
265- }
266-
267- /**
268- * Returns a label describing the value (positive/negative) trating values
269- * within tolarance as zero.
270- *
271- * @param value Value to check
272- * @param tolerance Tolerance to treat the value as zero.
273- * @param positiveLabel
274- * @param negativeLabel
275- * @returns If 0 < abs(value) < tolerance, returns an empty string. Otherwise returns positiveLabel or negativeLabel.
276- */
277- function getValueWithTolerance (
278- value : number ,
279- tolerance : number ,
280- positiveLabel : string ,
281- negativeLabel : string ,
282- ) {
283- if ( value > tolerance ) {
284- return positiveLabel ;
285- }
286-
287- if ( value < - tolerance ) {
288- return negativeLabel ;
289- }
290-
291- return '' ;
292- }
293-
294- /**
295- * Calculates the relative position between centers of two elements.
296- */
297- function calculateRelativePosition ( from : Element , to : Element ) : Offset {
298- const fromRect = from . getBoundingClientRect ( ) ;
299- const toRect = to . getBoundingClientRect ( ) ;
300-
301- const fromCenter = {
302- x : fromRect . left + fromRect . width / 2 ,
303- y : fromRect . top + fromRect . height / 2 ,
304- } ;
305- const toCenter = {
306- x : toRect . left + toRect . width / 2 ,
307- y : toRect . top + toRect . height / 2 ,
308- } ;
309-
310- return {
311- horizontal : toCenter . x - fromCenter . x ,
312- vertical : toCenter . y - fromCenter . y ,
313- } ;
314- }
0 commit comments