@base-ui/react
Version:
Base UI is a library of headless ('unstyled') React components and low-level hooks. You gain complete control over your app's CSS and accessibility features.
374 lines (367 loc) • 14.5 kB
JavaScript
'use client';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { EMPTY_OBJECT } from '@base-ui/utils/empty';
import { useId } from '@base-ui/utils/useId';
import { useStableCallback } from '@base-ui/utils/useStableCallback';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { useOnFirstRender } from '@base-ui/utils/useOnFirstRender';
import { FOCUSABLE_ATTRIBUTE } from "../../floating-ui-react/utils/constants.mjs";
import { useFloatingParentNodeId } from "../../floating-ui-react/components/FloatingTree.mjs";
import { useSyncedFloatingRootContext } from "../../floating-ui-react/hooks/useSyncedFloatingRootContext.mjs";
import { useTransitionStatus } from "../../internals/useTransitionStatus.mjs";
import { useOpenChangeComplete } from "../../internals/useOpenChangeComplete.mjs";
import { createChangeEventDetails } from "../../internals/createBaseUIEventDetails.mjs";
import { REASONS } from "../../internals/reasons.mjs";
export const FOCUSABLE_POPUP_PROPS = {
tabIndex: -1,
[FOCUSABLE_ATTRIBUTE]: ''
};
/**
* Returns the default `initialFocus` resolver for a popup. When opened by touch it focuses the
* popup element itself to prevent the virtual keyboard from opening (required for Android
* specifically; iOS handles this automatically). Otherwise it falls back to the default behavior.
*/
export function createDefaultInitialFocus(popupRef) {
return interactionType => interactionType === 'touch' ? popupRef.current : true;
}
export function usePopupStore(externalStore, createStore, treatPopupAsFloatingElement = false) {
const floatingId = useId();
const nested = useFloatingParentNodeId() != null;
const internalStoreRef = React.useRef(null);
if (externalStore === undefined && internalStoreRef.current === null) {
internalStoreRef.current = createStore(floatingId, nested);
}
const store = externalStore ?? internalStoreRef.current;
useSyncedFloatingRootContext({
popupStore: store,
treatPopupAsFloatingElement,
floatingRootContext: store.state.floatingRootContext,
floatingId,
nested,
onOpenChange: store.setOpen
});
return {
store,
internalStore: internalStoreRef.current
};
}
/**
* Returns a callback ref that registers/unregisters the trigger element in the store.
*
* @param store The Store instance where the trigger should be registered.
*/
export function useTriggerRegistration(id, store) {
// Keep track of the currently registered element to unregister it on unmount or id change.
const registeredElementIdRef = React.useRef(null);
const registeredElementRef = React.useRef(null);
return React.useCallback(element => {
if (id === undefined) {
return;
}
let shouldSyncTriggerCount = false;
if (registeredElementIdRef.current !== null) {
const registeredId = registeredElementIdRef.current;
const registeredElement = registeredElementRef.current;
const currentElement = store.context.triggerElements.getById(registeredId);
if (registeredElement && currentElement === registeredElement) {
store.context.triggerElements.delete(registeredId);
shouldSyncTriggerCount = true;
}
registeredElementIdRef.current = null;
registeredElementRef.current = null;
}
if (element !== null) {
registeredElementIdRef.current = id;
registeredElementRef.current = element;
store.context.triggerElements.add(id, element);
shouldSyncTriggerCount = true;
}
if (shouldSyncTriggerCount) {
const triggerCount = store.context.triggerElements.size;
if (store.select('open') && store.state.triggerCount !== triggerCount) {
store.set('triggerCount', triggerCount);
}
}
}, [store, id]);
}
export function setPopupOpenState(state, open, trigger, preventUnmountOnClose = false) {
if (open) {
// Opening starts a new close cycle, so clear any previous request to keep the popup mounted.
state.preventUnmountingOnClose = false;
} else if (preventUnmountOnClose) {
state.preventUnmountingOnClose = true;
}
const triggerId = trigger?.id ?? null;
// If a popup is closing, the `trigger` may be undefined.
// We want to keep the previous value so that exit animations are played and focus is returned correctly.
if (triggerId || open) {
state.activeTriggerId = triggerId;
state.activeTriggerElement = trigger ?? null;
}
}
export function attachPreventUnmountOnClose(eventDetails) {
let preventUnmountOnClose = false;
eventDetails.preventUnmountOnClose = () => {
preventUnmountOnClose = true;
};
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(store, nextOpen, eventDetails, options = {}) {
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 = {
...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);
};
if (isHover) {
// Flush synchronously for hover so `node.getAnimations()` sees the new state.
ReactDOM.flushSync(changeState);
} else {
changeState();
}
}
export function useInitialOpenSync(store, openProp, defaultOpen, defaultTriggerId) {
useOnFirstRender(() => {
if (openProp === undefined && store.state.open === false && defaultOpen) {
// Avoid notifying detached trigger subscribers while the Root is rendering.
store.state = {
...store.state,
open: true,
activeTriggerId: defaultTriggerId,
preventUnmountingOnClose: false
};
}
});
}
/**
* Sets up trigger data forwarding to the store.
*
* @param triggerId Id of the trigger.
* @param triggerElementRef Ref for the trigger DOM element.
* @param store The Store instance managing the popup state.
* @param stateUpdates An object with state updates to apply when the trigger is active.
*/
export function useTriggerDataForwarding(triggerId, triggerElementRef, store, stateUpdates) {
const isMountedByThisTrigger = store.useState('isMountedByTrigger', triggerId);
const baseRegisterTrigger = useTriggerRegistration(triggerId, store);
const registerTrigger = useStableCallback(element => {
baseRegisterTrigger(element);
if (!element) {
return;
}
const open = store.select('open');
const activeTriggerId = store.select('activeTriggerId');
if (activeTriggerId === triggerId) {
store.update({
activeTriggerElement: element,
...(open ? stateUpdates : null)
});
return;
}
if (activeTriggerId == null && open) {
// If a popup is already open, a detached trigger can mount before any active trigger
// has been established. Claim the first registered trigger so trigger-owned focus
// management and ARIA relationships work.
store.update({
activeTriggerId: triggerId,
activeTriggerElement: element,
...stateUpdates
});
}
});
useIsoLayoutEffect(() => {
if (isMountedByThisTrigger) {
store.update({
activeTriggerElement: triggerElementRef.current,
...stateUpdates
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMountedByThisTrigger, store, triggerElementRef, ...Object.values(stateUpdates)]);
return {
registerTrigger,
isMountedByThisTrigger
};
}
/**
* Keeps trigger registration state synchronized while the popup is open.
*
* When a popup opens without an explicit trigger id and exactly one trigger is registered, that
* trigger is claimed as the active trigger. When the active trigger id is still registered but its
* element changed, the active element is refreshed. When the active trigger unregisters, the
* default path preserves existing ownership so non-closing popup families do not silently claim a
* different trigger while staying open.
*
* If `closeOnActiveTriggerUnmount` is enabled, unregistering the active trigger requests a close
* after a microtask so a same-tick replacement trigger with the same id can register first.
*
* This should be called on the Root part.
*
* @param store The Store instance managing the popup state.
* @param options Options for active trigger unmount behavior.
*/
export function useImplicitActiveTrigger(store, options = {}) {
const {
closeOnActiveTriggerUnmount = false
} = options;
const open = store.useState('open');
const reactiveTriggerCount = store.useState('triggerCount');
useIsoLayoutEffect(() => {
if (!open) {
if (store.state.triggerCount !== 0) {
store.set('triggerCount', 0);
}
return;
}
const triggerCount = store.context.triggerElements.size;
const stateUpdates = {};
if (store.state.triggerCount !== triggerCount) {
stateUpdates.triggerCount = triggerCount;
}
const activeTriggerId = store.select('activeTriggerId');
let lostActiveTriggerId = null;
if (activeTriggerId) {
const activeTriggerElement = store.context.triggerElements.getById(activeTriggerId);
if (!activeTriggerElement) {
lostActiveTriggerId = activeTriggerId;
} else if (activeTriggerElement !== store.state.activeTriggerElement) {
stateUpdates.activeTriggerElement = activeTriggerElement;
}
}
if (!lostActiveTriggerId && !activeTriggerId && triggerCount === 1) {
const iteratorResult = store.context.triggerElements.entries().next();
if (!iteratorResult.done) {
const [implicitTriggerId, implicitTriggerElement] = iteratorResult.value;
stateUpdates.activeTriggerId = implicitTriggerId;
stateUpdates.activeTriggerElement = implicitTriggerElement;
}
}
if (stateUpdates.triggerCount !== undefined || stateUpdates.activeTriggerId !== undefined || stateUpdates.activeTriggerElement !== undefined) {
store.update(stateUpdates);
}
if (lostActiveTriggerId) {
if (closeOnActiveTriggerUnmount) {
// Defer so a same-tick replacement trigger with the same id can register first.
queueMicrotask(() => {
if (store.select('open') && store.select('activeTriggerId') === lostActiveTriggerId && !store.context.triggerElements.getById(lostActiveTriggerId)) {
const eventDetails = createChangeEventDetails(REASONS.none);
store.setOpen(false, eventDetails);
// If closing is canceled, keep the previous active trigger ownership for the
// still-open popup instead of claiming another trigger implicitly.
if (!eventDetails.isCanceled) {
store.update({
activeTriggerId: null,
activeTriggerElement: null
});
}
}
});
}
}
}, [open, store, reactiveTriggerCount, closeOnActiveTriggerUnmount]);
}
/**
* Manages the mounted state of the popup.
* Sets up the transition status listeners and handles unmounting when needed.
* Updates the `mounted`, `transitionStatus`, and `preventUnmountingOnClose` states in the store.
*
* @param open Whether the popup is open.
* @param store The Store instance managing the popup state.
* @param onUnmount Optional callback to be called when the popup is unmounted.
*
* @returns A function to forcibly unmount the popup.
*/
export function useOpenStateTransitions(open, store, onUnmount) {
const {
mounted,
setMounted,
transitionStatus
} = useTransitionStatus(open);
const preventUnmountingOnClose = store.useState('preventUnmountingOnClose');
// Opening starts a new close cycle. Clear during render so the close-completion hook below
// reads the synchronized value on the same pass.
const syncedPreventUnmountingOnClose = open ? false : preventUnmountingOnClose;
store.useSyncedValues({
mounted,
transitionStatus,
preventUnmountingOnClose: syncedPreventUnmountingOnClose
});
const forceUnmount = useStableCallback(() => {
setMounted(false);
store.update({
activeTriggerId: null,
activeTriggerElement: null,
mounted: false,
preventUnmountingOnClose: false
});
onUnmount?.();
store.context.onOpenChangeComplete?.(false);
});
useOpenChangeComplete({
enabled: mounted && !open && !syncedPreventUnmountingOnClose,
open,
ref: store.context.popupRef,
onComplete() {
if (!open) {
forceUnmount();
}
}
});
return {
forceUnmount,
transitionStatus
};
}
export function usePopupInteractionProps(store, statePart) {
store.useSyncedValues(statePart);
useIsoLayoutEffect(() => () => {
store.update({
activeTriggerProps: EMPTY_OBJECT,
inactiveTriggerProps: EMPTY_OBJECT,
popupProps: EMPTY_OBJECT
});
}, [store]);
}
export function usePopupRootSync(store, open) {
useIsoLayoutEffect(() => {
if (!open && store.state.openMethod !== null) {
store.set('openMethod', null);
}
}, [open, store]);
useIsoLayoutEffect(() => () => {
if (store.state.openMethod !== null) {
store.set('openMethod', null);
}
}, [store]);
}