Skip to content

Commit c3f6013

Browse files
authored
[popups] Reuse Viewport logic across components (#3886)
1 parent 1a6ce1b commit c3f6013

File tree

9 files changed

+515
-791
lines changed

9 files changed

+515
-791
lines changed

docs/reference/generated/popover-viewport.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
"data-current": {
3131
"description": "Applied to the direct child of the viewport when no transitions are present or the new content when it's entering."
3232
},
33+
"data-instant": {
34+
"description": "Present if animations should be instant.",
35+
"type": "'dismiss' | 'click'"
36+
},
3337
"data-previous": {
3438
"description": "Applied to the direct child of the viewport that contains the exiting content when transitions are present."
3539
},

docs/src/app/(docs)/react/components/page.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -839,7 +839,7 @@ An accessible popup anchored to a button.
839839
- Props: className, nativeButton, render, style
840840
- Popover - Viewport
841841
- Props: children, className, render, style
842-
- Data Attributes: data-activation-direction, data-current, data-previous, data-transitioning
842+
- Data Attributes: data-activation-direction, data-current, data-instant, data-previous, data-transitioning
843843
- CSS Variables: --popup-height, --popup-width
844844

845845
</details>

packages/react/src/popover/viewport/PopoverViewport.test.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,55 @@ describe('<Popover.Viewport />', () => {
4444
expect(currentContainer!.textContent).to.equal('Content');
4545
});
4646

47+
it('should remount the `current` container when the active trigger changes', async () => {
48+
const { user } = await render(
49+
<Popover.Root>
50+
{({ payload }) => (
51+
<React.Fragment>
52+
<Popover.Trigger payload="first" data-testid="trigger1">
53+
Trigger 1
54+
</Popover.Trigger>
55+
<Popover.Trigger payload="second" data-testid="trigger2">
56+
Trigger 2
57+
</Popover.Trigger>
58+
<Popover.Portal>
59+
<Popover.Positioner>
60+
<Popover.Popup>
61+
<Popover.Viewport>
62+
{payload === 'first' ? (
63+
<img data-testid="payload-image-1" src="about:blank" alt="Preview 1" />
64+
) : null}
65+
{payload === 'second' ? (
66+
<img data-testid="payload-image-2" src="about:blank" alt="Preview 2" />
67+
) : null}
68+
</Popover.Viewport>
69+
</Popover.Popup>
70+
</Popover.Positioner>
71+
</Popover.Portal>
72+
</React.Fragment>
73+
)}
74+
</Popover.Root>,
75+
);
76+
77+
const trigger1 = screen.getByTestId('trigger1');
78+
const trigger2 = screen.getByTestId('trigger2');
79+
80+
await user.click(trigger1);
81+
82+
const firstImage = await screen.findByTestId('payload-image-1');
83+
const firstContainer = firstImage.closest('[data-current]');
84+
expect(firstContainer).not.to.equal(null);
85+
86+
await user.click(trigger2);
87+
88+
await waitFor(() => {
89+
const secondImage = screen.getByTestId('payload-image-2');
90+
const secondContainer = secondImage.closest('[data-current]');
91+
expect(secondContainer).not.to.equal(null);
92+
expect(secondContainer).not.to.equal(firstContainer);
93+
});
94+
});
95+
4796
describe.skipIf(isJSDOM)('morphing containers with multiple triggers and payloads', () => {
4897
beforeEach(() => {
4998
globalThis.BASE_UI_ANIMATIONS_DISABLED = false;
Lines changed: 15 additions & 251 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,12 @@
11
'use client';
22
import * 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';
83
import { usePopoverRootContext } from '../root/PopoverRootContext';
94
import { usePopoverPositionerContext } from '../positioner/PopoverPositionerContext';
105
import { BaseUIComponentProps } from '../../utils/types';
11-
import { useAnimationsFinished } from '../../utils/useAnimationsFinished';
12-
import { usePopupAutoResize } from '../../utils/usePopupAutoResize';
136
import { useRenderElement } from '../../utils/useRenderElement';
147
import { StateAttributesMapping } from '../../utils/getStateAttributesProps';
15-
import { Dimensions } from '../../floating-ui-react/types';
168
import { PopoverViewportCssVars } from './PopoverViewportCssVars';
17-
import { useDirection } from '../../direction-provider/DirectionContext';
9+
import { usePopupViewport } from '../../utils/usePopupViewport';
1810

1911
const 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-
}

packages/react/src/popover/viewport/PopoverViewportDataAttributes.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,9 @@ export enum PopoverViewportDataAttributes {
1818
* Indicates that the viewport is currently transitioning between old and new content.
1919
*/
2020
transitioning = 'data-transitioning',
21+
/**
22+
* Present if animations should be instant.
23+
* @type {'dismiss' | 'click'}
24+
*/
25+
instant = 'data-instant',
2126
}

0 commit comments

Comments
 (0)