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.

315 lines (308 loc) 14 kB
"use strict"; 'use client'; var _interopRequireWildcard = require("@babel/runtime/helpers/interopRequireWildcard").default; var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault").default; Object.defineProperty(exports, "__esModule", { value: true }); exports.MenuTrigger = void 0; var _formatErrorMessage2 = _interopRequireDefault(require("@base-ui-components/utils/formatErrorMessage")); var React = _interopRequireWildcard(require("react")); var ReactDOM = _interopRequireWildcard(require("react-dom")); var _useTimeout = require("@base-ui-components/utils/useTimeout"); var _owner = require("@base-ui-components/utils/owner"); var _useStableCallback = require("@base-ui-components/utils/useStableCallback"); var _useIsoLayoutEffect = require("@base-ui-components/utils/useIsoLayoutEffect"); var _empty = require("@base-ui-components/utils/empty"); var _floatingUiReact = require("../../floating-ui-react"); var _utils = require("../../floating-ui-react/utils"); var _MenuRootContext = require("../root/MenuRootContext"); var _popupStateMapping = require("../../utils/popupStateMapping"); var _useRenderElement = require("../../utils/useRenderElement"); var _useButton = require("../../use-button/useButton"); var _getPseudoElementBounds = require("../../utils/getPseudoElementBounds"); var _CompositeItem = require("../../composite/item/CompositeItem"); var _CompositeRootContext = require("../../composite/root/CompositeRootContext"); var _findRootOwnerId = require("../utils/findRootOwnerId"); var _popups = require("../../utils/popups"); var _useBaseUiId = require("../../utils/useBaseUiId"); var _reasons = require("../../utils/reasons"); var _useMixedToggleClickHander = require("../../utils/useMixedToggleClickHander"); var _ContextMenuRootContext = require("../../context-menu/root/ContextMenuRootContext"); var _MenubarContext = require("../../menubar/MenubarContext"); var _constants = require("../../utils/constants"); var _FocusGuard = require("../../utils/FocusGuard"); var _createBaseUIEventDetails = require("../../utils/createBaseUIEventDetails"); var _jsxRuntime = require("react/jsx-runtime"); const BOUNDARY_OFFSET = 2; /** * A button that opens the menu. * Renders a `<button>` element. * * Documentation: [Base UI Menu](https://base-ui.com/react/components/menu) */ const MenuTrigger = exports.MenuTrigger = /*#__PURE__*/React.forwardRef(function MenuTrigger(componentProps, forwardedRef) { const { render, className, disabled: disabledProp = false, nativeButton = true, id: idProp, openOnHover: openOnHoverProp, delay = 100, closeDelay = 0, handle, payload, ...elementProps } = componentProps; const rootContext = (0, _MenuRootContext.useMenuRootContext)(true); const store = handle?.store ?? rootContext?.store; if (!store) { throw /* FIXME (minify-errors-in-prod): Unminifyable error in production! */new Error(process.env.NODE_ENV !== "production" ? 'Base UI: <Menu.Trigger> must be either used within a <Menu.Root> component or provided with a handle.' : (0, _formatErrorMessage2.default)(85)); } const thisTriggerId = (0, _useBaseUiId.useBaseUiId)(idProp); const isTriggerActive = store.useState('isTriggerActive', thisTriggerId); const floatingRootContext = store.useState('floatingRootContext'); const isOpenedByThisTrigger = store.useState('isOpenedByTrigger', thisTriggerId); const [triggerElement, setTriggerElement] = React.useState(null); const parent = useMenuParent(); const compositeRootContext = (0, _CompositeRootContext.useCompositeRootContext)(true); const floatingTreeRootFromContext = (0, _floatingUiReact.useFloatingTree)(); const floatingTreeRoot = React.useMemo(() => { return floatingTreeRootFromContext ?? new _floatingUiReact.FloatingTreeStore(); }, [floatingTreeRootFromContext]); const floatingNodeId = (0, _floatingUiReact.useFloatingNodeId)(floatingTreeRoot); const floatingParentNodeId = (0, _floatingUiReact.useFloatingParentNodeId)(); const { registerTrigger, isMountedByThisTrigger } = (0, _popups.useTriggerDataForwarding)(thisTriggerId, triggerElement, store, { payload, closeDelay, parent, floatingTreeRoot, floatingNodeId, floatingParentNodeId, keyboardEventRelay: compositeRootContext?.relayKeyboardEvent }); const rootDisabled = store.useState('disabled'); const disabled = disabledProp || rootDisabled || parent.type === 'menubar' && parent.context.disabled; const { getButtonProps, buttonRef } = (0, _useButton.useButton)({ disabled, native: nativeButton }); React.useEffect(() => { if (!isOpenedByThisTrigger && parent.type === undefined) { store.context.allowMouseUpTriggerRef.current = false; } }, [store, isOpenedByThisTrigger, parent.type]); const triggerRef = React.useRef(null); const allowMouseUpTriggerTimeout = (0, _useTimeout.useTimeout)(); const handleDocumentMouseUp = (0, _useStableCallback.useStableCallback)(mouseEvent => { if (!triggerRef.current) { return; } allowMouseUpTriggerTimeout.clear(); store.context.allowMouseUpTriggerRef.current = false; const mouseUpTarget = mouseEvent.target; if ((0, _utils.contains)(triggerRef.current, mouseUpTarget) || (0, _utils.contains)(store.select('positionerElement'), mouseUpTarget) || mouseUpTarget === triggerRef.current) { return; } if (mouseUpTarget != null && (0, _findRootOwnerId.findRootOwnerId)(mouseUpTarget) === store.select('rootId')) { return; } const bounds = (0, _getPseudoElementBounds.getPseudoElementBounds)(triggerRef.current); if (mouseEvent.clientX >= bounds.left - BOUNDARY_OFFSET && mouseEvent.clientX <= bounds.right + BOUNDARY_OFFSET && mouseEvent.clientY >= bounds.top - BOUNDARY_OFFSET && mouseEvent.clientY <= bounds.bottom + BOUNDARY_OFFSET) { return; } floatingTreeRoot.events.emit('close', { domEvent: mouseEvent, reason: _reasons.REASONS.cancelOpen }); }); React.useEffect(() => { if (isOpenedByThisTrigger && store.select('lastOpenChangeReason') === _reasons.REASONS.triggerHover) { const doc = (0, _owner.ownerDocument)(triggerRef.current); doc.addEventListener('mouseup', handleDocumentMouseUp, { once: true }); } }, [isOpenedByThisTrigger, handleDocumentMouseUp, store]); const parentMenubarHasSubmenuOpen = parent.type === 'menubar' && parent.context.hasSubmenuOpen; const openOnHover = openOnHoverProp ?? parentMenubarHasSubmenuOpen ?? false; const hoverProps = (0, _floatingUiReact.useHoverReferenceInteraction)(floatingRootContext, { enabled: openOnHover && !disabled && parent.type !== 'context-menu' && (parent.type !== 'menubar' || parentMenubarHasSubmenuOpen && !isMountedByThisTrigger), handleClose: (0, _floatingUiReact.safePolygon)({ blockPointerEvents: parent.type !== 'menubar' }), mouseOnly: true, move: false, restMs: parent.type === undefined ? delay : undefined, delay: { close: closeDelay }, triggerElement, externalTree: floatingTreeRoot, isActiveTrigger: isTriggerActive }); // Whether to ignore clicks to open the menu. // `lastOpenChangeReason` doesnt't need to be reactive here, as we need to run this // only when `isOpenedByThisTrigger` changes. const stickIfOpen = useStickIfOpen(isOpenedByThisTrigger, store.select('lastOpenChangeReason')); const click = (0, _floatingUiReact.useClick)(floatingRootContext, { enabled: !disabled && parent.type !== 'context-menu', event: isOpenedByThisTrigger && parent.type === 'menubar' ? 'click' : 'mousedown', toggle: true, ignoreMouse: false, stickIfOpen: parent.type === undefined ? stickIfOpen : false }); const focus = (0, _floatingUiReact.useFocus)(floatingRootContext, { enabled: !disabled && (parent.type !== 'menubar' && isOpenedByThisTrigger || parentMenubarHasSubmenuOpen) }); const mixedToggleHandlers = (0, _useMixedToggleClickHander.useMixedToggleClickHandler)({ open: isOpenedByThisTrigger, enabled: parent.type === 'menubar', mouseDownAction: 'open' }); const localInteractionProps = (0, _floatingUiReact.useInteractions)([click, focus]); const isInMenubar = parent.type === 'menubar'; const state = React.useMemo(() => ({ disabled, open: isOpenedByThisTrigger }), [disabled, isOpenedByThisTrigger]); const rootTriggerProps = store.useState('triggerProps', isMountedByThisTrigger); const ref = [triggerRef, forwardedRef, buttonRef, registerTrigger, setTriggerElement]; const props = [localInteractionProps.getReferenceProps(), hoverProps ?? _empty.EMPTY_OBJECT, rootTriggerProps, { 'aria-haspopup': 'menu', id: thisTriggerId, onMouseDown: event => { if (store.select('open')) { return; } // mousedown -> mouseup on menu item should not trigger it within 200ms. allowMouseUpTriggerTimeout.start(200, () => { store.context.allowMouseUpTriggerRef.current = true; }); const doc = (0, _owner.ownerDocument)(event.currentTarget); doc.addEventListener('mouseup', handleDocumentMouseUp, { once: true }); } }, isInMenubar ? { role: 'menuitem' } : {}, mixedToggleHandlers, elementProps, getButtonProps]; const preFocusGuardRef = React.useRef(null); const handlePreFocusGuardFocus = (0, _useStableCallback.useStableCallback)(event => { ReactDOM.flushSync(() => { store.setOpen(false, (0, _createBaseUIEventDetails.createChangeEventDetails)(_reasons.REASONS.focusOut, event.nativeEvent, event.currentTarget)); }); const previousTabbable = (0, _utils.getTabbableBeforeElement)(preFocusGuardRef.current); previousTabbable?.focus(); }); const handleFocusTargetFocus = (0, _useStableCallback.useStableCallback)(event => { const currentPositionerElement = store.select('positionerElement'); if (currentPositionerElement && (0, _utils.isOutsideEvent)(event, currentPositionerElement)) { store.context.beforeContentFocusGuardRef.current?.focus(); } else { ReactDOM.flushSync(() => { store.setOpen(false, (0, _createBaseUIEventDetails.createChangeEventDetails)(_reasons.REASONS.focusOut, event.nativeEvent, event.currentTarget)); }); let nextTabbable = (0, _utils.getTabbableAfterElement)(triggerElement); while (nextTabbable !== null && (0, _utils.contains)(currentPositionerElement, nextTabbable) || nextTabbable?.hasAttribute('aria-hidden')) { const prevTabbable = nextTabbable; nextTabbable = (0, _utils.getNextTabbable)(nextTabbable); if (nextTabbable === prevTabbable) { break; } } nextTabbable?.focus(); } }); const element = (0, _useRenderElement.useRenderElement)('button', componentProps, { enabled: !isInMenubar, stateAttributesMapping: _popupStateMapping.pressableTriggerOpenStateMapping, state, ref, props }); if (isInMenubar) { return /*#__PURE__*/(0, _jsxRuntime.jsx)(_CompositeItem.CompositeItem, { tag: "button", render: render, className: className, state: state, refs: ref, props: props, stateAttributesMapping: _popupStateMapping.pressableTriggerOpenStateMapping }); } // A fragment with key is required to ensure that the `element` is mounted to the same DOM node // regardless of whether the focus guards are rendered or not. if (isOpenedByThisTrigger) { return /*#__PURE__*/(0, _jsxRuntime.jsxs)(React.Fragment, { children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_FocusGuard.FocusGuard, { ref: preFocusGuardRef, onFocus: handlePreFocusGuardFocus }, `${thisTriggerId}-pre-focus-guard`), /*#__PURE__*/(0, _jsxRuntime.jsx)(React.Fragment, { children: element }, thisTriggerId), /*#__PURE__*/(0, _jsxRuntime.jsx)(_FocusGuard.FocusGuard, { ref: store.context.triggerFocusTargetRef, onFocus: handleFocusTargetFocus }, `${thisTriggerId}-post-focus-guard`)] }); } return /*#__PURE__*/(0, _jsxRuntime.jsx)(React.Fragment, { children: element }, thisTriggerId); }); if (process.env.NODE_ENV !== "production") MenuTrigger.displayName = "MenuTrigger"; /** * Determines whether to ignore clicks after a hover-open. */ function useStickIfOpen(open, openReason) { const stickIfOpenTimeout = (0, _useTimeout.useTimeout)(); const [stickIfOpen, setStickIfOpen] = React.useState(false); (0, _useIsoLayoutEffect.useIsoLayoutEffect)(() => { if (open && openReason === 'trigger-hover') { // Only allow "patient" clicks to close the menu if it's open. // If they clicked within 500ms of the menu opening, keep it open. setStickIfOpen(true); stickIfOpenTimeout.start(_constants.PATIENT_CLICK_THRESHOLD, () => { setStickIfOpen(false); }); } else if (!open) { stickIfOpenTimeout.clear(); setStickIfOpen(false); } }, [open, openReason, stickIfOpenTimeout]); return stickIfOpen; } function useMenuParent() { const contextMenuContext = (0, _ContextMenuRootContext.useContextMenuRootContext)(true); const parentContext = (0, _MenuRootContext.useMenuRootContext)(true); const menubarContext = (0, _MenubarContext.useMenubarContext)(true); const parent = React.useMemo(() => { if (menubarContext) { return { type: 'menubar', context: menubarContext }; } // Ensure this is not a Menu nested inside ContextMenu.Trigger. // ContextMenu parentContext is always undefined as ContextMenu.Root is instantiated with // <MenuRootContext.Provider value={undefined}> if (contextMenuContext && !parentContext) { return { type: 'context-menu', context: contextMenuContext }; } return { type: undefined }; }, [contextMenuContext, parentContext, menubarContext]); return parent; }