Skip to content

Commit c747e8f

Browse files
committed
[tooltip][preview-card] Deduplicate popup store open-change logic
Extract the shared `setOpen` sequence (reason classification, instantType mapping, prevent-unmount handling, floating dispatch, synchronous hover flush) into `applyPopupOpenChange`. TooltipStore and PreviewCardStore now supply only their differences via `extraState` (last change reason) and `onBeforeDispatch` (inline-rect capture, with a guard against a missing event).
1 parent 3c5e55f commit c747e8f

3 files changed

Lines changed: 104 additions & 113 deletions

File tree

packages/react/src/preview-card/store/PreviewCardStore.ts

Lines changed: 27 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
import * as React from 'react';
2-
import * as ReactDOM from 'react-dom';
32
import { createSelector, ReactStore } from '@base-ui/utils/store';
43
import {
5-
attachPreventUnmountOnClose,
4+
applyPopupOpenChange,
65
createPopupFloatingRootContext,
76
createInitialPopupStoreState,
87
InlineRectCoords,
98
PopupStoreContext,
109
popupStoreSelectors,
1110
PopupStoreState,
1211
PopupTriggerMap,
13-
setPopupOpenState,
1412
updateInlineRectCoords,
1513
usePopupStore,
1614
} from '../../utils/popups';
@@ -67,70 +65,34 @@ export class PreviewCardStore<Payload> extends ReactStore<
6765
nextOpen: boolean,
6866
eventDetails: Omit<PreviewCardRoot.ChangeEventDetails, 'preventUnmountOnClose'>,
6967
) => {
70-
const reason = eventDetails.reason;
71-
72-
const isHover = reason === REASONS.triggerHover;
73-
const isFocusOpen = nextOpen && reason === REASONS.triggerFocus;
74-
const isDismissClose =
75-
!nextOpen && (reason === REASONS.triggerPress || reason === REASONS.escapeKey);
76-
77-
const shouldPreventUnmountOnClose = attachPreventUnmountOnClose(
68+
applyPopupOpenChange<State<Payload>, PreviewCardRoot.ChangeEventDetails>(
69+
this,
70+
nextOpen,
7871
eventDetails as PreviewCardRoot.ChangeEventDetails,
72+
{
73+
onBeforeDispatch: () => {
74+
// Capture the hovered inline-rect coordinates so the card anchors to the
75+
// exact point on the link that was hovered.
76+
const event = eventDetails.event;
77+
if (
78+
nextOpen &&
79+
eventDetails.reason === REASONS.triggerHover &&
80+
eventDetails.trigger &&
81+
event &&
82+
'clientX' in event &&
83+
'clientY' in event &&
84+
this.context.inlineRectCoordsRef.current?.element !== eventDetails.trigger
85+
) {
86+
updateInlineRectCoords(
87+
this.context.inlineRectCoordsRef,
88+
eventDetails.trigger,
89+
event.clientX,
90+
event.clientY,
91+
);
92+
}
93+
},
94+
},
7995
);
80-
81-
this.context.onOpenChange?.(nextOpen, eventDetails as PreviewCardRoot.ChangeEventDetails);
82-
83-
if (eventDetails.isCanceled) {
84-
return;
85-
}
86-
87-
const event = eventDetails.event;
88-
if (
89-
nextOpen &&
90-
isHover &&
91-
eventDetails.trigger &&
92-
'clientX' in event &&
93-
'clientY' in event &&
94-
this.context.inlineRectCoordsRef.current?.element !== eventDetails.trigger
95-
) {
96-
updateInlineRectCoords(
97-
this.context.inlineRectCoordsRef,
98-
eventDetails.trigger,
99-
event.clientX,
100-
event.clientY,
101-
);
102-
}
103-
104-
this.state.floatingRootContext.dispatchOpenChange(nextOpen, eventDetails);
105-
106-
const changeState = () => {
107-
const updatedState: Partial<State<Payload>> = { open: nextOpen };
108-
109-
if (isFocusOpen) {
110-
updatedState.instantType = 'focus';
111-
} else if (isDismissClose) {
112-
updatedState.instantType = 'dismiss';
113-
} else if (reason === REASONS.triggerHover) {
114-
updatedState.instantType = undefined;
115-
}
116-
117-
setPopupOpenState(
118-
updatedState,
119-
nextOpen,
120-
eventDetails.trigger,
121-
shouldPreventUnmountOnClose(),
122-
);
123-
124-
this.update(updatedState);
125-
};
126-
127-
if (isHover) {
128-
// If a hover reason is provided, we need to flush the state synchronously. This ensures
129-
// `node.getAnimations()` knows about the new state.
130-
ReactDOM.flushSync(changeState);
131-
} else {
132-
changeState();
133-
}
13496
};
13597

13698
public static useStore<Payload>(

packages/react/src/tooltip/store/TooltipStore.ts

Lines changed: 5 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
11
import * as React from 'react';
2-
import * as ReactDOM from 'react-dom';
32
import { createSelector, ReactStore } from '@base-ui/utils/store';
43
import { type TooltipRoot } from '../root/TooltipRoot';
54
import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails';
65
import { REASONS } from '../../internals/reasons';
76
import {
8-
attachPreventUnmountOnClose,
7+
applyPopupOpenChange,
98
createPopupFloatingRootContext,
109
createInitialPopupStoreState,
1110
PopupStoreContext,
1211
popupStoreSelectors,
1312
PopupStoreState,
1413
PopupTriggerMap,
15-
setPopupOpenState,
1614
usePopupStore,
1715
} from '../../utils/popups';
1816

@@ -76,53 +74,12 @@ export class TooltipStore<Payload> extends ReactStore<
7674
nextOpen: boolean,
7775
eventDetails: Omit<TooltipRoot.ChangeEventDetails, 'preventUnmountOnClose'>,
7876
) => {
79-
const reason = eventDetails.reason;
80-
81-
const isHover = reason === REASONS.triggerHover;
82-
const isFocusOpen = nextOpen && reason === REASONS.triggerFocus;
83-
const isDismissClose =
84-
!nextOpen && (reason === REASONS.triggerPress || reason === REASONS.escapeKey);
85-
86-
const shouldPreventUnmountOnClose = attachPreventUnmountOnClose(
77+
applyPopupOpenChange<State<Payload>, TooltipRoot.ChangeEventDetails>(
78+
this,
79+
nextOpen,
8780
eventDetails as TooltipRoot.ChangeEventDetails,
81+
{ extraState: { openChangeReason: eventDetails.reason } },
8882
);
89-
90-
this.context.onOpenChange?.(nextOpen, eventDetails as TooltipRoot.ChangeEventDetails);
91-
92-
if (eventDetails.isCanceled) {
93-
return;
94-
}
95-
96-
this.state.floatingRootContext.dispatchOpenChange(nextOpen, eventDetails);
97-
98-
const changeState = () => {
99-
const updatedState: Partial<State<Payload>> = { open: nextOpen, openChangeReason: reason };
100-
101-
if (isFocusOpen) {
102-
updatedState.instantType = 'focus';
103-
} else if (isDismissClose) {
104-
updatedState.instantType = 'dismiss';
105-
} else if (reason === REASONS.triggerHover) {
106-
updatedState.instantType = undefined;
107-
}
108-
109-
setPopupOpenState(
110-
updatedState,
111-
nextOpen,
112-
eventDetails.trigger,
113-
shouldPreventUnmountOnClose(),
114-
);
115-
116-
this.update(updatedState);
117-
};
118-
119-
if (isHover) {
120-
// If a hover reason is provided, we need to flush the state synchronously. This ensures
121-
// `node.getAnimations()` knows about the new state.
122-
ReactDOM.flushSync(changeState);
123-
} else {
124-
changeState();
125-
}
12683
};
12784

12885
// Used by trigger clicks to clear a delayed hover open without reporting a public open-state change.

packages/react/src/utils/popups/popupStoreUtils.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use client';
22
import * as React from 'react';
3+
import * as ReactDOM from 'react-dom';
34
import { ReactStore } from '@base-ui/utils/store';
45
import { EMPTY_OBJECT } from '@base-ui/utils/empty';
56
import type { InteractionType } from '@base-ui/utils/useEnhancedClickHandler';
@@ -154,6 +155,77 @@ export function attachPreventUnmountOnClose(eventDetails: { preventUnmountOnClos
154155
return () => preventUnmountOnClose;
155156
}
156157

158+
/**
159+
* Runs the shared open-change sequence for a popup store: notifies `onOpenChange`,
160+
* honors cancellation, dispatches the floating root change, maps the reason to an
161+
* `instantType`, and commits the state update (synchronously for hover so
162+
* `getAnimations()` observes it). Stores supply their own differences via
163+
* `extraState` (e.g. the last change reason) and `onBeforeDispatch` (e.g. updating
164+
* inline-rect coordinates).
165+
*/
166+
export function applyPopupOpenChange<
167+
State extends PopupStoreState<unknown> & {
168+
instantType?: 'delay' | 'dismiss' | 'focus' | undefined;
169+
},
170+
EventDetails extends BaseUIChangeEventDetails<string>,
171+
>(
172+
store: {
173+
readonly context: Pick<PopupStoreContext<EventDetails>, 'onOpenChange'>;
174+
readonly state: Pick<PopupStoreState<unknown>, 'floatingRootContext'>;
175+
update(state: Partial<State>): void;
176+
},
177+
nextOpen: boolean,
178+
eventDetails: EventDetails & { preventUnmountOnClose(): void },
179+
options: {
180+
onBeforeDispatch?: (() => void) | undefined;
181+
extraState?: Partial<State> | undefined;
182+
} = {},
183+
): void {
184+
const reason = eventDetails.reason;
185+
const isHover = reason === REASONS.triggerHover;
186+
const isFocusOpen = nextOpen && reason === REASONS.triggerFocus;
187+
const isDismissClose =
188+
!nextOpen && (reason === REASONS.triggerPress || reason === REASONS.escapeKey);
189+
190+
const shouldPreventUnmountOnClose = attachPreventUnmountOnClose(eventDetails);
191+
192+
store.context.onOpenChange?.(nextOpen, eventDetails);
193+
194+
if (eventDetails.isCanceled) {
195+
return;
196+
}
197+
198+
options.onBeforeDispatch?.();
199+
200+
store.state.floatingRootContext.dispatchOpenChange(nextOpen, eventDetails);
201+
202+
const changeState = () => {
203+
// Spread `extraState` first so `open` always reflects `nextOpen`, keeping it in
204+
// sync with the value already passed to `dispatchOpenChange`/`setPopupOpenState`.
205+
const updatedState: Partial<PopupStoreState<unknown>> & {
206+
instantType?: 'delay' | 'dismiss' | 'focus' | undefined;
207+
} = { ...options.extraState, open: nextOpen };
208+
209+
if (isFocusOpen) {
210+
updatedState.instantType = 'focus';
211+
} else if (isDismissClose) {
212+
updatedState.instantType = 'dismiss';
213+
} else if (isHover) {
214+
updatedState.instantType = undefined;
215+
}
216+
217+
setPopupOpenState(updatedState, nextOpen, eventDetails.trigger, shouldPreventUnmountOnClose());
218+
store.update(updatedState as Partial<State>);
219+
};
220+
221+
if (isHover) {
222+
// Flush synchronously for hover so `node.getAnimations()` sees the new state.
223+
ReactDOM.flushSync(changeState);
224+
} else {
225+
changeState();
226+
}
227+
}
228+
157229
export function useInitialOpenSync<State extends PopupStoreState<unknown>>(
158230
store: ReactStore<State, PopupStoreContext<never>, PopupStoreSelectors>,
159231
openProp: boolean | undefined,

0 commit comments

Comments
 (0)