UNPKG

@base-ui-components/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.

148 lines (146 loc) 5.36 kB
import * as React from 'react'; import { useStableCallback } from '@base-ui-components/utils/useStableCallback'; import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect'; import { useTransitionStatus } from "../useTransitionStatus.js"; import { useOpenChangeComplete } from "../useOpenChangeComplete.js"; /** * 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 registeredElementId = React.useRef(null); return React.useCallback(element => { if (id === undefined) { return undefined; } if (registeredElementId.current !== null) { store.context.triggerElements.delete(registeredElementId.current); registeredElementId.current = null; } if (element !== null) { registeredElementId.current = id; store.context.triggerElements.add(id, element); return () => { if (registeredElementId.current !== null) { store.context.triggerElements.delete(registeredElementId.current); registeredElementId.current = null; } }; } return undefined; }, [store, id]); } /** * Sets up trigger data forwarding to the store. * * @param triggerId Id of the trigger. * @param triggerElement 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, triggerElement, store, stateUpdates) { const isMountedByThisTrigger = store.useState('isMountedByTrigger', triggerId); const baseRegisterTrigger = useTriggerRegistration(triggerId, store); const registerTrigger = useStableCallback(element => { const cleanup = baseRegisterTrigger(element); if (element !== null && store.select('open') && store.select('activeTriggerId') == null) { // This runs when popup is open, but no active trigger is set. // It can happen when using controlled mode and the trigger is mounted after opening or if `triggerId` prop is not set explicitly. // In such cases the first trigger to run this code becomes the active trigger (store.select('activeTriggerId') should not return null after that). // This is mostly for compatibility with contained triggers where no explicit `triggerId` was required in controlled mode. store.update({ activeTriggerId: triggerId, activeTriggerElement: element, ...stateUpdates }); } return cleanup; }); useIsoLayoutEffect(() => { if (isMountedByThisTrigger) { store.update({ activeTriggerElement: triggerElement, ...stateUpdates }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isMountedByThisTrigger, store, triggerElement, ...Object.values(stateUpdates)]); return { registerTrigger, isMountedByThisTrigger }; } /** * Ensures that when there's only one trigger element registered, it is set as the active trigger. * This allows controlled popups to work correctly without an explicit triggerId, maintaining compatibility * with the contained triggers. * * This should be called on the Root part. * * @param open Whether the popup is open. * @param store The Store instance managing the popup state. */ export function useImplicitActiveTrigger(store) { const open = store.useState('open'); useIsoLayoutEffect(() => { if (open && !store.select('activeTriggerId') && store.context.triggerElements.size === 1) { const iteratorResult = store.context.triggerElements.entries().next(); if (!iteratorResult.done) { const [implicitTriggerId, implicitTriggerElement] = iteratorResult.value; store.update({ activeTriggerId: implicitTriggerId, activeTriggerElement: implicitTriggerElement }); } } }, [open, store]); } /** * Mangages the mounted state of the popup. * Sets up the transition status listeners and handles unmounting when needed. * Updates the `mounted` and `transitionStatus` 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); store.useSyncedValues({ mounted, transitionStatus }); const forceUnmount = useStableCallback(() => { setMounted(false); store.update({ activeTriggerId: null, activeTriggerElement: null, mounted: false }); onUnmount?.(); store.context.onOpenChangeComplete?.(false); }); const preventUnmountingOnClose = store.useState('preventUnmountingOnClose'); useOpenChangeComplete({ enabled: !preventUnmountingOnClose, open, ref: store.context.popupRef, onComplete() { if (!open) { forceUnmount(); } } }); return { forceUnmount, transitionStatus }; }