Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 27 additions & 65 deletions packages/react/src/preview-card/store/PreviewCardStore.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
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,
PopupStoreContext,
popupStoreSelectors,
PopupStoreState,
PopupTriggerMap,
setPopupOpenState,
updateInlineRectCoords,
usePopupStore,
} from '../../utils/popups';
Expand Down Expand Up @@ -67,70 +65,34 @@ export class PreviewCardStore<Payload> extends ReactStore<
nextOpen: boolean,
eventDetails: Omit<PreviewCardRoot.ChangeEventDetails, 'preventUnmountOnClose'>,
) => {
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<State<Payload>, 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<State<Payload>> = { 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<Payload>(
Expand Down
53 changes: 5 additions & 48 deletions packages/react/src/tooltip/store/TooltipStore.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -76,53 +74,12 @@ export class TooltipStore<Payload> extends ReactStore<
nextOpen: boolean,
eventDetails: Omit<TooltipRoot.ChangeEventDetails, 'preventUnmountOnClose'>,
) => {
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<State<Payload>, 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<State<Payload>> = { 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.
Expand Down
72 changes: 72 additions & 0 deletions packages/react/src/utils/popups/popupStoreUtils.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<unknown> & {
instantType?: 'delay' | 'dismiss' | 'focus' | undefined;
},
EventDetails extends BaseUIChangeEventDetails<string>,
>(
store: {
readonly context: Pick<PopupStoreContext<EventDetails>, 'onOpenChange'>;
readonly state: Pick<PopupStoreState<unknown>, 'floatingRootContext'>;
update(state: Partial<State>): void;
},
nextOpen: boolean,
eventDetails: EventDetails & { preventUnmountOnClose(): void },
options: {
onBeforeDispatch?: (() => void) | undefined;
extraState?: Partial<State> | 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<PopupStoreState<unknown>> & {
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<State>);
};

if (isHover) {
// Flush synchronously for hover so `node.getAnimations()` sees the new state.
ReactDOM.flushSync(changeState);
} else {
changeState();
}
}

export function useInitialOpenSync<State extends PopupStoreState<unknown>>(
store: ReactStore<State, PopupStoreContext<never>, PopupStoreSelectors>,
openProp: boolean | undefined,
Expand Down
Loading