diff --git a/packages/react/src/preview-card/store/PreviewCardStore.ts b/packages/react/src/preview-card/store/PreviewCardStore.ts index 95001d0a058..00c3c465ec5 100644 --- a/packages/react/src/preview-card/store/PreviewCardStore.ts +++ b/packages/react/src/preview-card/store/PreviewCardStore.ts @@ -1,8 +1,7 @@ import * as React from 'react'; -import * as ReactDOM from 'react-dom'; import { createSelector, ReactStore } from '@base-ui/utils/store'; import { - attachPreventUnmountOnClose, + applyPopupOpenChange, createPopupFloatingRootContext, createInitialPopupStoreState, InlineRectCoords, @@ -10,7 +9,6 @@ import { popupStoreSelectors, PopupStoreState, PopupTriggerMap, - setPopupOpenState, updateInlineRectCoords, usePopupStore, } from '../../utils/popups'; @@ -67,70 +65,34 @@ export class PreviewCardStore extends ReactStore< nextOpen: boolean, eventDetails: Omit, ) => { - const reason = eventDetails.reason; - - const isHover = reason === REASONS.triggerHover; - const isFocusOpen = nextOpen && reason === REASONS.triggerFocus; - const isDismissClose = - !nextOpen && (reason === REASONS.triggerPress || reason === REASONS.escapeKey); - - const shouldPreventUnmountOnClose = attachPreventUnmountOnClose( + applyPopupOpenChange, PreviewCardRoot.ChangeEventDetails>( + this, + nextOpen, eventDetails as PreviewCardRoot.ChangeEventDetails, + { + onBeforeDispatch: () => { + // Capture the hovered inline-rect coordinates so the card anchors to the + // exact point on the link that was hovered. + const event = eventDetails.event; + if ( + nextOpen && + eventDetails.reason === REASONS.triggerHover && + eventDetails.trigger && + event && + 'clientX' in event && + 'clientY' in event && + this.context.inlineRectCoordsRef.current?.element !== eventDetails.trigger + ) { + updateInlineRectCoords( + this.context.inlineRectCoordsRef, + eventDetails.trigger, + event.clientX, + event.clientY, + ); + } + }, + }, ); - - this.context.onOpenChange?.(nextOpen, eventDetails as PreviewCardRoot.ChangeEventDetails); - - if (eventDetails.isCanceled) { - return; - } - - const event = eventDetails.event; - if ( - nextOpen && - isHover && - eventDetails.trigger && - 'clientX' in event && - 'clientY' in event && - this.context.inlineRectCoordsRef.current?.element !== eventDetails.trigger - ) { - updateInlineRectCoords( - this.context.inlineRectCoordsRef, - eventDetails.trigger, - event.clientX, - event.clientY, - ); - } - - this.state.floatingRootContext.dispatchOpenChange(nextOpen, eventDetails); - - const changeState = () => { - const updatedState: Partial> = { open: nextOpen }; - - if (isFocusOpen) { - updatedState.instantType = 'focus'; - } else if (isDismissClose) { - updatedState.instantType = 'dismiss'; - } else if (reason === REASONS.triggerHover) { - updatedState.instantType = undefined; - } - - setPopupOpenState( - updatedState, - nextOpen, - eventDetails.trigger, - shouldPreventUnmountOnClose(), - ); - - this.update(updatedState); - }; - - if (isHover) { - // If a hover reason is provided, we need to flush the state synchronously. This ensures - // `node.getAnimations()` knows about the new state. - ReactDOM.flushSync(changeState); - } else { - changeState(); - } }; public static useStore( diff --git a/packages/react/src/tooltip/store/TooltipStore.ts b/packages/react/src/tooltip/store/TooltipStore.ts index 2447d3cff28..e3b006fa0bd 100644 --- a/packages/react/src/tooltip/store/TooltipStore.ts +++ b/packages/react/src/tooltip/store/TooltipStore.ts @@ -1,18 +1,16 @@ import * as React from 'react'; -import * as ReactDOM from 'react-dom'; import { createSelector, ReactStore } from '@base-ui/utils/store'; import { type TooltipRoot } from '../root/TooltipRoot'; import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails'; import { REASONS } from '../../internals/reasons'; import { - attachPreventUnmountOnClose, + applyPopupOpenChange, createPopupFloatingRootContext, createInitialPopupStoreState, PopupStoreContext, popupStoreSelectors, PopupStoreState, PopupTriggerMap, - setPopupOpenState, usePopupStore, } from '../../utils/popups'; @@ -76,53 +74,12 @@ export class TooltipStore extends ReactStore< nextOpen: boolean, eventDetails: Omit, ) => { - const reason = eventDetails.reason; - - const isHover = reason === REASONS.triggerHover; - const isFocusOpen = nextOpen && reason === REASONS.triggerFocus; - const isDismissClose = - !nextOpen && (reason === REASONS.triggerPress || reason === REASONS.escapeKey); - - const shouldPreventUnmountOnClose = attachPreventUnmountOnClose( + applyPopupOpenChange, TooltipRoot.ChangeEventDetails>( + this, + nextOpen, eventDetails as TooltipRoot.ChangeEventDetails, + { extraState: { openChangeReason: eventDetails.reason } }, ); - - this.context.onOpenChange?.(nextOpen, eventDetails as TooltipRoot.ChangeEventDetails); - - if (eventDetails.isCanceled) { - return; - } - - this.state.floatingRootContext.dispatchOpenChange(nextOpen, eventDetails); - - const changeState = () => { - const updatedState: Partial> = { open: nextOpen, openChangeReason: reason }; - - if (isFocusOpen) { - updatedState.instantType = 'focus'; - } else if (isDismissClose) { - updatedState.instantType = 'dismiss'; - } else if (reason === REASONS.triggerHover) { - updatedState.instantType = undefined; - } - - setPopupOpenState( - updatedState, - nextOpen, - eventDetails.trigger, - shouldPreventUnmountOnClose(), - ); - - this.update(updatedState); - }; - - if (isHover) { - // If a hover reason is provided, we need to flush the state synchronously. This ensures - // `node.getAnimations()` knows about the new state. - ReactDOM.flushSync(changeState); - } else { - changeState(); - } }; // Used by trigger clicks to clear a delayed hover open without reporting a public open-state change. diff --git a/packages/react/src/utils/popups/popupStoreUtils.ts b/packages/react/src/utils/popups/popupStoreUtils.ts index d484fc5daa3..70b951496f0 100644 --- a/packages/react/src/utils/popups/popupStoreUtils.ts +++ b/packages/react/src/utils/popups/popupStoreUtils.ts @@ -1,5 +1,6 @@ 'use client'; import * as React from 'react'; +import * as ReactDOM from 'react-dom'; import { ReactStore } from '@base-ui/utils/store'; import { EMPTY_OBJECT } from '@base-ui/utils/empty'; import type { InteractionType } from '@base-ui/utils/useEnhancedClickHandler'; @@ -154,6 +155,77 @@ export function attachPreventUnmountOnClose(eventDetails: { preventUnmountOnClos return () => preventUnmountOnClose; } +/** + * Runs the shared open-change sequence for a popup store: notifies `onOpenChange`, + * honors cancellation, dispatches the floating root change, maps the reason to an + * `instantType`, and commits the state update (synchronously for hover so + * `getAnimations()` observes it). Stores supply their own differences via + * `extraState` (e.g. the last change reason) and `onBeforeDispatch` (e.g. updating + * inline-rect coordinates). + */ +export function applyPopupOpenChange< + State extends PopupStoreState & { + instantType?: 'delay' | 'dismiss' | 'focus' | undefined; + }, + EventDetails extends BaseUIChangeEventDetails, +>( + store: { + readonly context: Pick, 'onOpenChange'>; + readonly state: Pick, 'floatingRootContext'>; + update(state: Partial): void; + }, + nextOpen: boolean, + eventDetails: EventDetails & { preventUnmountOnClose(): void }, + options: { + onBeforeDispatch?: (() => void) | undefined; + extraState?: Partial | undefined; + } = {}, +): void { + const reason = eventDetails.reason; + const isHover = reason === REASONS.triggerHover; + const isFocusOpen = nextOpen && reason === REASONS.triggerFocus; + const isDismissClose = + !nextOpen && (reason === REASONS.triggerPress || reason === REASONS.escapeKey); + + const shouldPreventUnmountOnClose = attachPreventUnmountOnClose(eventDetails); + + store.context.onOpenChange?.(nextOpen, eventDetails); + + if (eventDetails.isCanceled) { + return; + } + + options.onBeforeDispatch?.(); + + store.state.floatingRootContext.dispatchOpenChange(nextOpen, eventDetails); + + const changeState = () => { + // Spread `extraState` first so `open` always reflects `nextOpen`, keeping it in + // sync with the value already passed to `dispatchOpenChange`/`setPopupOpenState`. + const updatedState: Partial> & { + instantType?: 'delay' | 'dismiss' | 'focus' | undefined; + } = { ...options.extraState, open: nextOpen }; + + if (isFocusOpen) { + updatedState.instantType = 'focus'; + } else if (isDismissClose) { + updatedState.instantType = 'dismiss'; + } else if (isHover) { + updatedState.instantType = undefined; + } + + setPopupOpenState(updatedState, nextOpen, eventDetails.trigger, shouldPreventUnmountOnClose()); + store.update(updatedState as Partial); + }; + + if (isHover) { + // Flush synchronously for hover so `node.getAnimations()` sees the new state. + ReactDOM.flushSync(changeState); + } else { + changeState(); + } +} + export function useInitialOpenSync>( store: ReactStore, PopupStoreSelectors>, openProp: boolean | undefined,