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.

125 lines (124 loc) 4.86 kB
/* eslint-disable react-hooks/rules-of-hooks */ import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { ReactStore, createSelector } from '@base-ui-components/utils/store'; import { Timeout } from '@base-ui-components/utils/useTimeout'; import { useRefWithInit } from '@base-ui-components/utils/useRefWithInit'; import { useOnMount } from '@base-ui-components/utils/useOnMount'; import { REASONS } from "../../utils/reasons.js"; import { createInitialPopupStoreState, popupStoreSelectors, PopupTriggerMap } from "../../utils/popups/index.js"; import { PATIENT_CLICK_THRESHOLD } from "../../utils/constants.js"; function createInitialState() { return { ...createInitialPopupStoreState(), disabled: false, modal: false, instantType: undefined, openMethod: null, openChangeReason: null, titleElementId: undefined, descriptionElementId: undefined, stickIfOpen: true, nested: false, openOnHover: false, closeDelay: 0 }; } const selectors = { ...popupStoreSelectors, disabled: createSelector(state => state.disabled), instantType: createSelector(state => state.instantType), openMethod: createSelector(state => state.openMethod), openChangeReason: createSelector(state => state.openChangeReason), modal: createSelector(state => state.modal), stickIfOpen: createSelector(state => state.stickIfOpen), titleElementId: createSelector(state => state.titleElementId), descriptionElementId: createSelector(state => state.descriptionElementId), openOnHover: createSelector(state => state.openOnHover), closeDelay: createSelector(state => state.closeDelay) }; export class PopoverStore extends ReactStore { constructor(initialState) { const initial = { ...createInitialState(), ...initialState }; if (initial.open && initialState?.mounted === undefined) { initial.mounted = true; } super(initial, { popupRef: /*#__PURE__*/React.createRef(), backdropRef: /*#__PURE__*/React.createRef(), internalBackdropRef: /*#__PURE__*/React.createRef(), onOpenChange: undefined, onOpenChangeComplete: undefined, triggerFocusTargetRef: /*#__PURE__*/React.createRef(), beforeContentFocusGuardRef: /*#__PURE__*/React.createRef(), stickIfOpenTimeout: new Timeout(), triggerElements: new PopupTriggerMap() }, 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); eventDetails.preventUnmountOnClose = () => { this.set('preventUnmountingOnClose', true); }; this.context.onOpenChange?.(nextOpen, eventDetails); if (eventDetails.isCanceled) { return; } const details = { open: nextOpen, nativeEvent: eventDetails.event, reason: eventDetails.reason, nested: this.state.nested, triggerElement: eventDetails.trigger }; const floatingEvents = this.state.floatingRootContext.context.events; floatingEvents?.emit('openchange', details); const changeState = () => { const updatedState = { open: nextOpen, openChangeReason: eventDetails.reason }; // If a popup is closing, the `trigger` may be null. // We want to keep the previous value so that exit animations are played and focus is returned correctly. const newTriggerId = eventDetails.trigger?.id ?? null; if (newTriggerId || nextOpen) { updatedState.activeTriggerId = newTriggerId; updatedState.activeTriggerElement = eventDetails.trigger ?? null; } 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(); } if (isKeyboardClick || isDismissClose) { this.set('instantType', isKeyboardClick ? 'click' : 'dismiss'); } else if (eventDetails.reason === REASONS.focusOut) { this.set('instantType', 'focus'); } else { this.set('instantType', undefined); } }; static useStore(externalStore, initialState) { const store = useRefWithInit(() => { return externalStore ?? new PopoverStore(initialState); }).current; useOnMount(store.disposeEffect); return store; } disposeEffect = () => { return this.context.stickIfOpenTimeout.disposeEffect(); }; }