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.

133 lines (129 loc) 5.71 kB
'use client'; import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { ReactStore } from '@base-ui/utils/store'; import { Timeout } from '@base-ui/utils/useTimeout'; import { NOOP } from '@base-ui/utils/empty'; import { REASONS } from "../../internals/reasons.mjs"; import { NullStore } from "../../utils/NullStore.mjs"; import { attachPreventUnmountOnClose, createPopupFloatingRootContext, createInitialPopupStoreState, popupStoreSelectors, PopupTriggerMap, setPopupOpenState } from "../../utils/popups/index.mjs"; import { PATIENT_CLICK_THRESHOLD } from "../../internals/constants.mjs"; const selectors = { ...popupStoreSelectors, disabled: state => state.disabled, instantType: state => state.instantType, openMethod: state => state.openMethod, openChangeReason: state => state.openChangeReason, modal: state => state.modal, focusManagerModal: state => state.focusManagerModal, stickIfOpen: state => state.stickIfOpen, titleElementId: state => state.titleElementId, descriptionElementId: state => state.descriptionElementId, openOnHover: state => state.openOnHover, closeDelay: state => state.closeDelay, adaptiveOrigin: state => state.adaptiveOrigin }; /** * The store view that detached handle-backed triggers read from. Both the real `PopoverStore` and * the inert fallback store satisfy it, so a trigger can read from whichever store the handle * currently exposes. Narrowed to the members a trigger actually uses — the trigger-data members plus * `setOpen` (called by the focus guards) — so the exposed surface can't bypass the open-change * pipeline; on the detached fallback store every one of these mutations is a no-op. */ export class PopoverStore extends ReactStore { constructor(initialState, floatingId, nested) { const triggerElements = new PopupTriggerMap(); super(createInitialState(initialState, triggerElements, floatingId, nested), createInitialContext(triggerElements), selectors); } setOpen = (nextOpen, eventDetails) => { const isHover = eventDetails.reason === REASONS.triggerHover; const isKeyboardClick = eventDetails.reason === REASONS.triggerPress && eventDetails.event.detail === 0; const isDismissClose = !nextOpen && (eventDetails.reason === REASONS.escapeKey || eventDetails.reason == null); const shouldPreventUnmountOnClose = attachPreventUnmountOnClose(eventDetails); const activeTriggerId = this.select('activeTriggerId'); if (!nextOpen && eventDetails.reason === REASONS.closePress && eventDetails.trigger == null && activeTriggerId != null) { eventDetails.trigger = this.context.triggerElements.getById(activeTriggerId) ?? this.select('activeTriggerElement') ?? undefined; } this.context.onOpenChange?.(nextOpen, eventDetails); if (eventDetails.isCanceled) { return; } this.state.floatingRootContext.dispatchOpenChange(nextOpen, eventDetails); const changeState = () => { const updatedState = { open: nextOpen, openChangeReason: eventDetails.reason }; setPopupOpenState(updatedState, nextOpen, eventDetails.trigger, shouldPreventUnmountOnClose()); this.update(updatedState); }; if (isHover) { // Only allow "patient" clicks to close the popover if it's open. // If they clicked within 500ms of the popover opening, keep it open. this.set('stickIfOpen', true); this.context.stickIfOpenTimeout.start(PATIENT_CLICK_THRESHOLD, () => { this.set('stickIfOpen', false); }); ReactDOM.flushSync(changeState); } else { changeState(); } let instantType; if (isKeyboardClick) { instantType = 'click'; } else if (isDismissClose) { instantType = 'dismiss'; } else if (eventDetails.reason === REASONS.focusOut) { instantType = 'focus'; } this.set('instantType', instantType); }; } /** * Creates the inert fallback store used by detached handle-backed triggers while no * `Popover.Root` is attached. It preserves a popover-specific trigger registry in context so * detached triggers can register before migrating to the live root store. `setOpen` is a no-op * (matching the inert reads/writes of `NullStore`), so a trigger can hand the store to focus-guard * helpers that expect `setOpen` without it ever taking effect while detached. */ export function createNullPopoverStore() { const triggerElements = new PopupTriggerMap(); const store = new NullStore(Object.freeze(createInitialState(undefined, triggerElements)), Object.freeze(createInitialContext(triggerElements)), selectors); return Object.assign(store, { setOpen: NOOP }); } function createInitialState(initialState, triggerElements, floatingId, nested = false) { const state = { ...createInitialPopupStoreState(), disabled: false, modal: false, focusManagerModal: false, instantType: undefined, openMethod: null, openChangeReason: null, titleElementId: undefined, descriptionElementId: undefined, stickIfOpen: true, openOnHover: false, closeDelay: 0, adaptiveOrigin: undefined, ...initialState }; if (state.open && initialState?.mounted === undefined) { state.mounted = true; } state.floatingRootContext = createPopupFloatingRootContext(triggerElements, floatingId, nested); return state; } function createInitialContext(triggerElements) { return { popupRef: /*#__PURE__*/React.createRef(), onOpenChange: undefined, onOpenChangeComplete: undefined, triggerFocusTargetRef: /*#__PURE__*/React.createRef(), beforeContentFocusGuardRef: /*#__PURE__*/React.createRef(), stickIfOpenTimeout: new Timeout(), triggerElements }; }