UNPKG

@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.

392 lines (385 loc) 15.7 kB
"use strict"; 'use client'; var _interopRequireWildcard = require("@babel/runtime/helpers/interopRequireWildcard").default; Object.defineProperty(exports, "__esModule", { value: true }); exports.FOCUSABLE_POPUP_PROPS = void 0; exports.applyPopupOpenChange = applyPopupOpenChange; exports.attachPreventUnmountOnClose = attachPreventUnmountOnClose; exports.createDefaultInitialFocus = createDefaultInitialFocus; exports.setPopupOpenState = setPopupOpenState; exports.useImplicitActiveTrigger = useImplicitActiveTrigger; exports.useInitialOpenSync = useInitialOpenSync; exports.useOpenStateTransitions = useOpenStateTransitions; exports.usePopupInteractionProps = usePopupInteractionProps; exports.usePopupRootSync = usePopupRootSync; exports.usePopupStore = usePopupStore; exports.useTriggerDataForwarding = useTriggerDataForwarding; exports.useTriggerRegistration = useTriggerRegistration; var React = _interopRequireWildcard(require("react")); var ReactDOM = _interopRequireWildcard(require("react-dom")); var _empty = require("@base-ui/utils/empty"); var _useId = require("@base-ui/utils/useId"); var _useStableCallback = require("@base-ui/utils/useStableCallback"); var _useIsoLayoutEffect = require("@base-ui/utils/useIsoLayoutEffect"); var _useOnFirstRender = require("@base-ui/utils/useOnFirstRender"); var _constants = require("../../floating-ui-react/utils/constants"); var _FloatingTree = require("../../floating-ui-react/components/FloatingTree"); var _useSyncedFloatingRootContext = require("../../floating-ui-react/hooks/useSyncedFloatingRootContext"); var _useTransitionStatus = require("../../internals/useTransitionStatus"); var _useOpenChangeComplete = require("../../internals/useOpenChangeComplete"); var _createBaseUIEventDetails = require("../../internals/createBaseUIEventDetails"); var _reasons = require("../../internals/reasons"); const FOCUSABLE_POPUP_PROPS = exports.FOCUSABLE_POPUP_PROPS = { tabIndex: -1, [_constants.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. */ function createDefaultInitialFocus(popupRef) { return interactionType => interactionType === 'touch' ? popupRef.current : true; } function usePopupStore(externalStore, createStore, treatPopupAsFloatingElement = false) { const floatingId = (0, _useId.useId)(); const nested = (0, _FloatingTree.useFloatingParentNodeId)() != null; const internalStoreRef = React.useRef(null); if (externalStore === undefined && internalStoreRef.current === null) { internalStoreRef.current = createStore(floatingId, nested); } const store = externalStore ?? internalStoreRef.current; (0, _useSyncedFloatingRootContext.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. */ 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]); } 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; } } 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). */ function applyPopupOpenChange(store, nextOpen, eventDetails, options = {}) { const reason = eventDetails.reason; const isHover = reason === _reasons.REASONS.triggerHover; const isFocusOpen = nextOpen && reason === _reasons.REASONS.triggerFocus; const isDismissClose = !nextOpen && (reason === _reasons.REASONS.triggerPress || reason === _reasons.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(); } } function useInitialOpenSync(store, openProp, defaultOpen, defaultTriggerId) { (0, _useOnFirstRender.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. */ function useTriggerDataForwarding(triggerId, triggerElementRef, store, stateUpdates) { const isMountedByThisTrigger = store.useState('isMountedByTrigger', triggerId); const baseRegisterTrigger = useTriggerRegistration(triggerId, store); const registerTrigger = (0, _useStableCallback.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 }); } }); (0, _useIsoLayoutEffect.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. */ function useImplicitActiveTrigger(store, options = {}) { const { closeOnActiveTriggerUnmount = false } = options; const open = store.useState('open'); const reactiveTriggerCount = store.useState('triggerCount'); (0, _useIsoLayoutEffect.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 = (0, _createBaseUIEventDetails.createChangeEventDetails)(_reasons.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. */ function useOpenStateTransitions(open, store, onUnmount) { const { mounted, setMounted, transitionStatus } = (0, _useTransitionStatus.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 = (0, _useStableCallback.useStableCallback)(() => { setMounted(false); store.update({ activeTriggerId: null, activeTriggerElement: null, mounted: false, preventUnmountingOnClose: false }); onUnmount?.(); store.context.onOpenChangeComplete?.(false); }); (0, _useOpenChangeComplete.useOpenChangeComplete)({ enabled: mounted && !open && !syncedPreventUnmountingOnClose, open, ref: store.context.popupRef, onComplete() { if (!open) { forceUnmount(); } } }); return { forceUnmount, transitionStatus }; } function usePopupInteractionProps(store, statePart) { store.useSyncedValues(statePart); (0, _useIsoLayoutEffect.useIsoLayoutEffect)(() => () => { store.update({ activeTriggerProps: _empty.EMPTY_OBJECT, inactiveTriggerProps: _empty.EMPTY_OBJECT, popupProps: _empty.EMPTY_OBJECT }); }, [store]); } function usePopupRootSync(store, open) { (0, _useIsoLayoutEffect.useIsoLayoutEffect)(() => { if (!open && store.state.openMethod !== null) { store.set('openMethod', null); } }, [open, store]); (0, _useIsoLayoutEffect.useIsoLayoutEffect)(() => () => { if (store.state.openMethod !== null) { store.set('openMethod', null); } }, [store]); }