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.

308 lines (301 loc) 12.9 kB
'use client'; import _formatErrorMessage from "@base-ui-components/utils/formatErrorMessage"; import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { useTimeout } from '@base-ui-components/utils/useTimeout'; import { ownerDocument } from '@base-ui-components/utils/owner'; import { useStableCallback } from '@base-ui-components/utils/useStableCallback'; import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect'; import { EMPTY_OBJECT } from '@base-ui-components/utils/empty'; import { safePolygon, useClick, useFloatingTree, useFocus, useHoverReferenceInteraction, useInteractions, useFloatingNodeId, useFloatingParentNodeId, FloatingTreeStore } from "../../floating-ui-react/index.js"; import { contains, getNextTabbable, getTabbableAfterElement, getTabbableBeforeElement, isOutsideEvent } from "../../floating-ui-react/utils.js"; import { useMenuRootContext } from "../root/MenuRootContext.js"; import { pressableTriggerOpenStateMapping } from "../../utils/popupStateMapping.js"; import { useRenderElement } from "../../utils/useRenderElement.js"; import { useButton } from "../../use-button/useButton.js"; import { getPseudoElementBounds } from "../../utils/getPseudoElementBounds.js"; import { CompositeItem } from "../../composite/item/CompositeItem.js"; import { useCompositeRootContext } from "../../composite/root/CompositeRootContext.js"; import { findRootOwnerId } from "../utils/findRootOwnerId.js"; import { useTriggerDataForwarding } from "../../utils/popups/index.js"; import { useBaseUiId } from "../../utils/useBaseUiId.js"; import { REASONS } from "../../utils/reasons.js"; import { useMixedToggleClickHandler } from "../../utils/useMixedToggleClickHander.js"; import { useContextMenuRootContext } from "../../context-menu/root/ContextMenuRootContext.js"; import { useMenubarContext } from "../../menubar/MenubarContext.js"; import { PATIENT_CLICK_THRESHOLD } from "../../utils/constants.js"; import { FocusGuard } from "../../utils/FocusGuard.js"; import { createChangeEventDetails } from "../../utils/createBaseUIEventDetails.js"; import { jsx as _jsx, jsxs as _jsxs } from "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) */ export const 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 = useMenuRootContext(true); const store = handle?.store ?? rootContext?.store; if (!store) { throw 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.' : _formatErrorMessage(85)); } const thisTriggerId = 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 = useCompositeRootContext(true); const floatingTreeRootFromContext = useFloatingTree(); const floatingTreeRoot = React.useMemo(() => { return floatingTreeRootFromContext ?? new FloatingTreeStore(); }, [floatingTreeRootFromContext]); const floatingNodeId = useFloatingNodeId(floatingTreeRoot); const floatingParentNodeId = useFloatingParentNodeId(); const { registerTrigger, isMountedByThisTrigger } = 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 } = 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 = useTimeout(); const handleDocumentMouseUp = useStableCallback(mouseEvent => { if (!triggerRef.current) { return; } allowMouseUpTriggerTimeout.clear(); store.context.allowMouseUpTriggerRef.current = false; const mouseUpTarget = mouseEvent.target; if (contains(triggerRef.current, mouseUpTarget) || contains(store.select('positionerElement'), mouseUpTarget) || mouseUpTarget === triggerRef.current) { return; } if (mouseUpTarget != null && findRootOwnerId(mouseUpTarget) === store.select('rootId')) { return; } const bounds = 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.cancelOpen }); }); React.useEffect(() => { if (isOpenedByThisTrigger && store.select('lastOpenChangeReason') === REASONS.triggerHover) { const doc = 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 = useHoverReferenceInteraction(floatingRootContext, { enabled: openOnHover && !disabled && parent.type !== 'context-menu' && (parent.type !== 'menubar' || parentMenubarHasSubmenuOpen && !isMountedByThisTrigger), handleClose: 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 = 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 = useFocus(floatingRootContext, { enabled: !disabled && (parent.type !== 'menubar' && isOpenedByThisTrigger || parentMenubarHasSubmenuOpen) }); const mixedToggleHandlers = useMixedToggleClickHandler({ open: isOpenedByThisTrigger, enabled: parent.type === 'menubar', mouseDownAction: 'open' }); const localInteractionProps = 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_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 = ownerDocument(event.currentTarget); doc.addEventListener('mouseup', handleDocumentMouseUp, { once: true }); } }, isInMenubar ? { role: 'menuitem' } : {}, mixedToggleHandlers, elementProps, getButtonProps]; const preFocusGuardRef = React.useRef(null); const handlePreFocusGuardFocus = useStableCallback(event => { ReactDOM.flushSync(() => { store.setOpen(false, createChangeEventDetails(REASONS.focusOut, event.nativeEvent, event.currentTarget)); }); const previousTabbable = getTabbableBeforeElement(preFocusGuardRef.current); previousTabbable?.focus(); }); const handleFocusTargetFocus = useStableCallback(event => { const currentPositionerElement = store.select('positionerElement'); if (currentPositionerElement && isOutsideEvent(event, currentPositionerElement)) { store.context.beforeContentFocusGuardRef.current?.focus(); } else { ReactDOM.flushSync(() => { store.setOpen(false, createChangeEventDetails(REASONS.focusOut, event.nativeEvent, event.currentTarget)); }); let nextTabbable = getTabbableAfterElement(triggerElement); while (nextTabbable !== null && contains(currentPositionerElement, nextTabbable) || nextTabbable?.hasAttribute('aria-hidden')) { const prevTabbable = nextTabbable; nextTabbable = getNextTabbable(nextTabbable); if (nextTabbable === prevTabbable) { break; } } nextTabbable?.focus(); } }); const element = useRenderElement('button', componentProps, { enabled: !isInMenubar, stateAttributesMapping: pressableTriggerOpenStateMapping, state, ref, props }); if (isInMenubar) { return /*#__PURE__*/_jsx(CompositeItem, { tag: "button", render: render, className: className, state: state, refs: ref, props: props, stateAttributesMapping: 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__*/_jsxs(React.Fragment, { children: [/*#__PURE__*/_jsx(FocusGuard, { ref: preFocusGuardRef, onFocus: handlePreFocusGuardFocus }, `${thisTriggerId}-pre-focus-guard`), /*#__PURE__*/_jsx(React.Fragment, { children: element }, thisTriggerId), /*#__PURE__*/_jsx(FocusGuard, { ref: store.context.triggerFocusTargetRef, onFocus: handleFocusTargetFocus }, `${thisTriggerId}-post-focus-guard`)] }); } return /*#__PURE__*/_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 = useTimeout(); const [stickIfOpen, setStickIfOpen] = React.useState(false); 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(PATIENT_CLICK_THRESHOLD, () => { setStickIfOpen(false); }); } else if (!open) { stickIfOpenTimeout.clear(); setStickIfOpen(false); } }, [open, openReason, stickIfOpenTimeout]); return stickIfOpen; } function useMenuParent() { const contextMenuContext = useContextMenuRootContext(true); const parentContext = useMenuRootContext(true); const 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; }