@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.
229 lines (214 loc) • 11.2 kB
JavaScript
"use strict";
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault").default;
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.BasePopupHandle = void 0;
var _formatErrorMessage2 = _interopRequireDefault(require("@base-ui/utils/formatErrorMessage"));
var _useAnimationFrame = require("@base-ui/utils/useAnimationFrame");
var _createBaseUIEventDetails = require("../../internals/createBaseUIEventDetails");
var _reasons = require("../../internals/reasons");
/**
* Minimal store contract exposed by popup handles to detached triggers.
*
* Detached triggers read `store` during render and subscribe to be notified when the handle switches
* between its fallback store and a root's live store.
*
* @template HandleStore Store shape exposed to detached triggers.
*/
/**
* Store shape holding a trigger registry, required by `BasePopupHandle.openByTrigger` to resolve a
* trigger element by id on both the attached root's store and the fallback store.
*/
/**
* Store shape required by `BasePopupHandle.openByTrigger`/`closePopup` to drive open/close state.
* Only the root-owned `Store` needs this — the `HandleStore` view exposed to detached triggers may
* omit `setOpen` entirely (as Dialog and PreviewCard's do) since it is never called while detached.
*/
/**
* Shared implementation for popup handles that coordinate detached triggers with a mounted root.
*
* Subclasses provide the component-specific imperative methods, while this base class owns the
* fallback store, root store attachment stack, subscriber notifications, and development warning for
* overlapping roots.
*
* @template HandleStore Store shape exposed to detached triggers.
* @template Store Root-owned store attached by the component root.
*/
class BasePopupHandle {
/**
* Stores of every root currently using this handle, in attach order. A handle is meant to be used
* by a single mounted root, but roots can transiently overlap (e.g. during an animated route
* transition), so this stack lets `attachStore`'s cleanup restore the previous root instead of
* leaving a still-mounted root uncontrollable when a newer overlapping root detaches first.
*/
attachedStores = [];
/**
* Store of the root that currently controls the handle: the most recently attached one still
* mounted, or `null` when no root is attached. Imperative methods are no-ops while this is `null`.
*/
attachedStoreValue = null;
/**
* Listeners notified when `attachedStore` changes, so detached triggers can follow the store pointer.
*/
storeListeners = new Set();
/**
* Creates a handle backed by the store used while no root is attached.
*
* @param fallbackStore Inert, closed store handed to detached triggers while no root is attached,
* so they can render and register without a mounted root. Triggers register into whichever store
* `store` currently resolves to, so while detached they live in this store's trigger map and
* migrate themselves to the root's store (and back) as it attaches/detaches.
* @param componentName Component name used to prefix dev warnings, e.g. `'Menu'` produces
* `MenuHandle.open()` in warning text.
* @param throwOnMissingTrigger Whether `open(triggerId)` throws when no trigger with that id is
* registered. Anchored popups (Menu, Popover, Tooltip, PreviewCard) need a trigger to anchor to,
* so they throw; Dialog is not anchored and instead opens unassociated with a dev warning.
*/
constructor(fallbackStore, componentName, throwOnMissingTrigger = true) {
this.fallbackStore = fallbackStore;
this.componentName = componentName;
this.throwOnMissingTrigger = throwOnMissingTrigger;
}
get attachedStore() {
return this.attachedStoreValue;
}
/**
* Store that detached triggers read from: the attached root's store, or an inert fallback store
* used while no root is attached.
* @internal
*/
get store() {
return this.attachedStoreValue ?? this.fallbackStore;
}
/**
* Stable fallback store used for server rendering and hydration. Root stores cannot be recorded on
* the handle during render because a handle can be shared by concurrent server-rendered requests.
* @internal
*/
get serverStore() {
return this.fallbackStore;
}
/**
* Subscribes to changes of the attached store pointer so detached triggers re-render and re-bind
* when a root attaches or detaches. Returns a function that removes the listener.
* @internal
*/
subscribeStore(listener) {
this.storeListeners.add(listener);
return () => {
this.storeListeners.delete(listener);
};
}
/**
* Points the handle at a root's store and notifies subscribers so detached triggers re-render and
* re-register into it (their registration ref re-fires on the store-pointer change). Returns a
* cleanup function that detaches the store again.
* @internal
*/
attachStore(newStore) {
this.attachedStores.push(newStore);
this.setActiveStore(newStore);
if (process.env.NODE_ENV !== 'production') {
if (this.attachedStores.length > 1) {
// More than one root is attached at once. This is usually a transient overlap during an
// animated route transition, where the outgoing root unmounts shortly after the incoming
// one mounts. Defer the check by a frame and only warn if the overlap is still present once
// the transition has settled (more than one root stayed mounted), so a clean handoff doesn't
// warn regardless of the exact unmount timing.
// The warning frame only exists in development; it is created lazily so it never appears on
// instances in production (like `controlledValues` in `ReactStore`).
const dev = this;
(dev.overlapWarningFrame ??= _useAnimationFrame.AnimationFrame.create()).request(() => {
if (this.attachedStores.length > 1) {
console.warn('Base UI: A handle is attached to more than one mounted root at the same time. ' + 'The most recently mounted root takes over and the previous one stops being controlled by the handle. ' + 'A handle should be used by a single root that stays mounted for the lifetime of the handle.');
}
});
}
}
return () => {
const index = this.attachedStores.lastIndexOf(newStore);
if (index !== -1) {
this.attachedStores.splice(index, 1);
}
// Restore control to the most recently attached root that is still mounted (or detach fully if
// none remain). Clearing unconditionally would leave a still-mounted older root uncontrollable
// when a newer overlapping root detaches first (e.g. a canceled route transition).
this.setActiveStore(this.attachedStores[this.attachedStores.length - 1] ?? null);
};
}
/**
* Sets the store that currently controls the handle and notifies subscribers when it changes, so
* detached triggers re-render and migrate their registration to the new store.
*/
setActiveStore(store) {
if (this.attachedStoreValue !== store) {
this.attachedStoreValue = store;
this.storeListeners.forEach(listener => {
listener();
});
}
}
/**
* Opens the attached root's store and associates it with the trigger with the given id, or a
* no-op (with a dev warning) while no root is attached. Shared by every concrete handle's public
* `open()` method, which only narrows the parameter type.
*
* When a trigger id is given but no matching trigger is registered, anchored popups throw (see
* `throwOnMissingTrigger`); Dialog opens unassociated with a dev warning instead.
*
* This method should only be called in an event handler or an effect (not during rendering).
*
* @param triggerId ID of the trigger to associate with the popup, or `null`/`undefined` to open
* without associating any trigger.
*/
openByTrigger(triggerId) {
const attachedStore = this.attachedStore;
if (attachedStore === null) {
if (process.env.NODE_ENV !== 'production') {
console.warn(`Base UI: ${this.componentName}Handle.open() was called while no root using this handle is mounted. ` + 'The call was ignored; mount a root with this handle before opening it imperatively.');
}
return;
}
// Registered triggers normally live in the active root's store. During the commit in which a
// root attaches, a still-mounted detached trigger has not re-registered into that store yet (it
// migrates on its next render): it is still registered wherever it lived before — the fallback
// store when this is the first root to attach, or a previously attached root's store during a
// transient overlap (e.g. an animated route transition). Search the whole attachment stack
// (newest first) and the fallback map so an imperative open-by-id called in that commit (e.g.
// from a layout effect) still resolves the trigger instead of treating it as missing.
let triggerElement;
if (triggerId) {
for (let i = this.attachedStores.length - 1; i >= 0 && !triggerElement; i -= 1) {
triggerElement = this.attachedStores[i].context.triggerElements.getById(triggerId);
}
triggerElement ??= this.fallbackStore.context.triggerElements.getById(triggerId);
}
if (triggerId && !triggerElement) {
if (this.throwOnMissingTrigger) {
throw new Error(process.env.NODE_ENV !== "production" ? `Base UI: ${this.componentName}Handle.open() was called with the trigger id "${triggerId}", ` + 'but no matching trigger is registered with this handle. ' + 'An anchored popup cannot open without a trigger to anchor to. ' + `Pass the id of a mounted ${this.componentName}.Trigger that has this handle set on its "handle" prop.` : (0, _formatErrorMessage2.default)(99, this.componentName, triggerId, this.componentName));
}
if (process.env.NODE_ENV !== 'production') {
console.warn(`Base UI: ${this.componentName}Handle.open: No trigger found with id "${triggerId}". ` + 'The popup will open, but the trigger will not be associated with it.');
}
}
attachedStore.setOpen(true, (0, _createBaseUIEventDetails.createChangeEventDetails)(_reasons.REASONS.imperativeAction, undefined, triggerElement));
}
/**
* Closes the popup by setting the attached root's store to closed, or a no-op (with a dev warning)
* while no root is attached. Shared by every concrete handle's public `close()` method.
*
* This method should only be called in an event handler or an effect (not during rendering).
*/
closePopup() {
const attachedStore = this.attachedStore;
if (attachedStore === null) {
if (process.env.NODE_ENV !== 'production') {
console.warn(`Base UI: ${this.componentName}Handle.close() was called while no root using this handle is mounted. ` + 'The call was ignored.');
}
return;
}
attachedStore.setOpen(false, (0, _createBaseUIEventDetails.createChangeEventDetails)(_reasons.REASONS.imperativeAction));
}
}
exports.BasePopupHandle = BasePopupHandle;