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.

684 lines (680 loc) 26.2 kB
'use client'; import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { isHTMLElement } from '@floating-ui/utils/dom'; import { addEventListener } from '@base-ui/utils/addEventListener'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import { ownerWindow } from '@base-ui/utils/owner'; import { useStableCallback } from '@base-ui/utils/useStableCallback'; import { useTimeout } from '@base-ui/utils/useTimeout'; import { useAnimationFrame } from '@base-ui/utils/useAnimationFrame'; import { useValueAsRef } from '@base-ui/utils/useValueAsRef'; import { EMPTY_ARRAY } from '@base-ui/utils/empty'; import { safePolygon, useClick, useFloatingRootContext, useFloatingTree, useHoverReferenceInteraction, useInteractions } from "../../floating-ui-react/index.js"; import { applySafePolygonPointerEventsMutation, clearSafePolygonPointerEventsMutation, useHoverInteractionSharedState } from "../../floating-ui-react/hooks/useHoverInteractionSharedState.js"; import { contains, getTabbableAfterElement, getNextTabbable, getPreviousTabbable, isOutsideEvent, stopEvent } from "../../floating-ui-react/utils.js"; import { useNavigationMenuItemContext } from "../item/NavigationMenuItemContext.js"; import { useNavigationMenuRootContext, useNavigationMenuTreeContext } from "../root/NavigationMenuRootContext.js"; import { createChangeEventDetails } from "../../internals/createBaseUIEventDetails.js"; import { REASONS } from "../../internals/reasons.js"; import { ownerVisuallyHidden, PATIENT_CLICK_THRESHOLD } from "../../internals/constants.js"; import { FocusGuard } from "../../utils/FocusGuard.js"; import { pressableTriggerOpenStateMapping } from "../../utils/popupStateMapping.js"; import { TransitionStatusDataAttributes } from "../../internals/stateAttributesMapping.js"; import { isOutsideMenuEvent } from "../utils/isOutsideMenuEvent.js"; import { CompositeItem } from "../../internals/composite/item/CompositeItem.js"; import { useButton } from "../../internals/use-button/index.js"; import { useAnimationsFinished } from "../../internals/useAnimationsFinished.js"; import { getCssDimensions } from "../../utils/getCssDimensions.js"; import { NAVIGATION_MENU_TRIGGER_IDENTIFIER } from "../utils/constants.js"; import { useNavigationMenuDismissContext } from "../list/NavigationMenuDismissContext.js"; import { NavigationMenuPopupCssVars } from "../popup/NavigationMenuPopupCssVars.js"; import { NavigationMenuPositionerCssVars } from "../positioner/NavigationMenuPositionerCssVars.js"; import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; const DEFAULT_SIZE = { width: 0, height: 0 }; /** * Opens the navigation menu popup when hovered or clicked, revealing the * associated content. * Renders a `<button>` element. * * Documentation: [Base UI Navigation Menu](https://base-ui.com/react/components/navigation-menu) */ export const NavigationMenuTrigger = /*#__PURE__*/React.forwardRef(function NavigationMenuTrigger(componentProps, forwardedRef) { const { className, render, nativeButton = true, disabled, style, ...elementProps } = componentProps; const { value, setValue, mounted, open, positionerElement, setActivationDirection, setFloatingRootContext, popupElement, viewportElement, transitionStatus, rootRef, beforeOutsideRef, afterOutsideRef, afterInsideRef, beforeInsideRef, prevTriggerElementRef, popupAutoSizeResetRef, currentContentRef, delay, closeDelay, orientation, setViewportInert, nested } = useNavigationMenuRootContext(); const { value: itemValue } = useNavigationMenuItemContext(); const nodeId = useNavigationMenuTreeContext(); const tree = useFloatingTree(); const dismissProps = useNavigationMenuDismissContext(); const stickIfOpenTimeout = useTimeout(); const focusFrame = useAnimationFrame(); const mutationFrame = useAnimationFrame(); const resizeFrame = useAnimationFrame(); const sizeFrame = useAnimationFrame(); const [triggerElement, setTriggerElement] = React.useState(null); const [stickIfOpen, setStickIfOpen] = React.useState(true); const [pointerType, setPointerType] = React.useState(''); const triggerElementRef = React.useRef(null); const allowFocusRef = React.useRef(false); const prevSizeRef = React.useRef(DEFAULT_SIZE); const skipAutoSizeSyncRef = React.useRef(false); const isActiveItem = open && value === itemValue; const isActiveItemRef = useValueAsRef(isActiveItem); const interactionsEnabled = positionerElement ? true : !value; const hoverFloatingElement = positionerElement || viewportElement; const hoverInteractionsEnabled = hoverFloatingElement ? true : !value; const runOnceAnimationsFinish = useAnimationsFinished(popupElement, false, false); const handleTriggerElement = React.useCallback(element => { triggerElementRef.current = element; setTriggerElement(element); }, []); const cancelAutoSizeReset = useStableCallback((force = false) => { if (!force && popupAutoSizeResetRef.current.owner !== itemValue) { return; } popupAutoSizeResetRef.current.abortController?.abort(); popupAutoSizeResetRef.current.abortController = null; popupAutoSizeResetRef.current.owner = null; }); useIsoLayoutEffect(() => { if (isActiveItem) { return; } mutationFrame.cancel(); sizeFrame.cancel(); cancelAutoSizeReset(); }, [isActiveItem, mutationFrame, sizeFrame, cancelAutoSizeReset]); function setAutoSizes() { if (!popupElement) { return; } popupElement.style.setProperty(NavigationMenuPopupCssVars.popupWidth, 'auto'); popupElement.style.setProperty(NavigationMenuPopupCssVars.popupHeight, 'auto'); } function clearFixedSizes() { if (!popupElement || !positionerElement) { return; } popupElement.style.removeProperty(NavigationMenuPopupCssVars.popupWidth); popupElement.style.removeProperty(NavigationMenuPopupCssVars.popupHeight); positionerElement.style.removeProperty(NavigationMenuPositionerCssVars.positionerWidth); positionerElement.style.removeProperty(NavigationMenuPositionerCssVars.positionerHeight); } function setSharedFixedSizes(width, height) { if (!popupElement || !positionerElement) { return; } popupElement.style.setProperty(NavigationMenuPopupCssVars.popupWidth, `${width}px`); popupElement.style.setProperty(NavigationMenuPopupCssVars.popupHeight, `${height}px`); positionerElement.style.setProperty(NavigationMenuPositionerCssVars.positionerWidth, `${width}px`); positionerElement.style.setProperty(NavigationMenuPositionerCssVars.positionerHeight, `${height}px`); } function scheduleAutoSizeReset() { cancelAutoSizeReset(true); const abortController = new AbortController(); popupAutoSizeResetRef.current.abortController = abortController; popupAutoSizeResetRef.current.owner = itemValue; runOnceAnimationsFinish(() => { if (popupAutoSizeResetRef.current.abortController !== abortController || popupAutoSizeResetRef.current.owner !== itemValue) { return; } popupAutoSizeResetRef.current.abortController = null; popupAutoSizeResetRef.current.owner = null; setAutoSizes(); }, abortController.signal); } const handleValueChange = useStableCallback((currentWidth, currentHeight, options = {}) => { if (!popupElement || !positionerElement) { return; } cancelAutoSizeReset(true); const { syncPositioner = false } = options; clearFixedSizes(); const { width, height } = getCssDimensions(popupElement); const measuredWidth = width || prevSizeRef.current.width; const measuredHeight = height || prevSizeRef.current.height; if (currentHeight === 0 || currentWidth === 0) { currentWidth = measuredWidth; currentHeight = measuredHeight; } popupElement.style.setProperty(NavigationMenuPopupCssVars.popupWidth, `${currentWidth}px`); popupElement.style.setProperty(NavigationMenuPopupCssVars.popupHeight, `${currentHeight}px`); positionerElement.style.setProperty(NavigationMenuPositionerCssVars.positionerWidth, `${syncPositioner ? currentWidth : measuredWidth}px`); positionerElement.style.setProperty(NavigationMenuPositionerCssVars.positionerHeight, `${syncPositioner ? currentHeight : measuredHeight}px`); sizeFrame.request(() => { if (!isActiveItemRef.current) { return; } popupElement.style.setProperty(NavigationMenuPopupCssVars.popupWidth, `${measuredWidth}px`); popupElement.style.setProperty(NavigationMenuPopupCssVars.popupHeight, `${measuredHeight}px`); if (syncPositioner) { positionerElement.style.setProperty(NavigationMenuPositionerCssVars.positionerWidth, `${measuredWidth}px`); positionerElement.style.setProperty(NavigationMenuPositionerCssVars.positionerHeight, `${measuredHeight}px`); } scheduleAutoSizeReset(); }); }); const handleInterruptedMutationResize = useStableCallback((currentWidth, currentHeight) => { if (!popupElement || !positionerElement) { return; } sizeFrame.cancel(); mutationFrame.cancel(); cancelAutoSizeReset(true); if (currentWidth === 0 || currentHeight === 0) { return; } setSharedFixedSizes(currentWidth, currentHeight); mutationFrame.request(() => { mutationFrame.request(() => { clearFixedSizes(); const { width, height } = getCssDimensions(popupElement); const measuredWidth = width || currentWidth || prevSizeRef.current.width; const measuredHeight = height || currentHeight || prevSizeRef.current.height; setSharedFixedSizes(currentWidth, currentHeight); sizeFrame.request(() => { if (!isActiveItemRef.current) { return; } setSharedFixedSizes(measuredWidth, measuredHeight); scheduleAutoSizeReset(); }); }); }); }); const syncCurrentSize = useStableCallback(() => { if (!popupElement || !positionerElement) { return; } sizeFrame.cancel(); cancelAutoSizeReset(true); clearFixedSizes(); const { width, height } = getCssDimensions(popupElement); if (width === 0 || height === 0) { return; } prevSizeRef.current = { width, height }; setAutoSizes(); positionerElement.style.setProperty(NavigationMenuPositionerCssVars.positionerWidth, `${width}px`); positionerElement.style.setProperty(NavigationMenuPositionerCssVars.positionerHeight, `${height}px`); }); const getMutationBaseline = useStableCallback(() => { if (!popupElement) { return { size: prevSizeRef.current, syncPositioner: false }; } const popupWidth = popupElement.style.getPropertyValue(NavigationMenuPopupCssVars.popupWidth); const popupHeight = popupElement.style.getPropertyValue(NavigationMenuPopupCssVars.popupHeight); const isResizing = popupWidth !== '' && popupWidth !== 'auto' && popupHeight !== '' && popupHeight !== 'auto'; if (!isResizing) { return { size: prevSizeRef.current, syncPositioner: false }; } return { size: { width: popupElement.offsetWidth || prevSizeRef.current.width, height: popupElement.offsetHeight || prevSizeRef.current.height }, syncPositioner: true }; }); React.useEffect(() => { if (!open) { stickIfOpenTimeout.clear(); mutationFrame.cancel(); resizeFrame.cancel(); sizeFrame.cancel(); cancelAutoSizeReset(true); skipAutoSizeSyncRef.current = false; setPointerType(''); } }, [stickIfOpenTimeout, open, mutationFrame, resizeFrame, sizeFrame, cancelAutoSizeReset]); React.useEffect(() => { if (!mounted) { prevSizeRef.current = DEFAULT_SIZE; } }, [mounted]); React.useEffect(() => { if (!popupElement || typeof ResizeObserver !== 'function') { return undefined; } const resizeObserver = new ResizeObserver(() => { prevSizeRef.current = { width: popupElement.offsetWidth, height: popupElement.offsetHeight }; }); resizeObserver.observe(popupElement); return () => { resizeObserver.disconnect(); }; }, [popupElement]); React.useEffect(() => { if (!open || !isActiveItem || !popupElement || !positionerElement) { return undefined; } const win = ownerWindow(positionerElement); function handleResize() { resizeFrame.cancel(); resizeFrame.request(syncCurrentSize); } const unsubscribe = addEventListener(win, 'resize', handleResize); return () => { resizeFrame.cancel(); unsubscribe(); }; }, [open, isActiveItem, popupElement, positionerElement, resizeFrame, syncCurrentSize]); React.useEffect(() => { const observedElement = currentContentRef.current; if (!observedElement || !popupElement || !isActiveItem || typeof MutationObserver !== 'function') { return undefined; } const mutationObserver = new MutationObserver(() => { if (transitionStatus === 'starting' || popupElement.hasAttribute(TransitionStatusDataAttributes.startingStyle)) { syncCurrentSize(); return; } const { size, syncPositioner } = getMutationBaseline(); if (syncPositioner) { handleInterruptedMutationResize(size.width, size.height); return; } handleValueChange(size.width, size.height); }); mutationObserver.observe(observedElement, { childList: true, subtree: true, characterData: true }); return () => { mutationObserver.disconnect(); }; }, [currentContentRef, popupElement, isActiveItem, transitionStatus, getMutationBaseline, handleInterruptedMutationResize, handleValueChange, syncCurrentSize]); React.useEffect(() => { if (isActiveItem && open && popupElement && allowFocusRef.current) { allowFocusRef.current = false; focusFrame.request(() => { beforeOutsideRef.current?.focus(); }); } return () => { focusFrame.cancel(); }; }, [beforeOutsideRef, focusFrame, isActiveItem, open, popupElement]); useIsoLayoutEffect(() => { if (isActiveItemRef.current && open && popupElement) { if (transitionStatus === 'starting') { const hasNestedMenu = currentContentRef.current?.querySelector('[data-nested]') != null; if (hasNestedMenu) { sizeFrame.request(syncCurrentSize); return () => { sizeFrame.cancel(); }; } } if (skipAutoSizeSyncRef.current) { skipAutoSizeSyncRef.current = false; return undefined; } const { width, height } = getCssDimensions(popupElement); handleValueChange(width, height); } return undefined; }, [currentContentRef, handleValueChange, isActiveItemRef, open, popupElement, sizeFrame, syncCurrentSize, transitionStatus]); function handleOpenChange(nextOpen, eventDetails) { const isHover = eventDetails.reason === REASONS.triggerHover; if (!interactionsEnabled) { return; } if (pointerType === 'touch' && isHover) { return; } if (!nextOpen && value !== itemValue) { return; } function changeState() { if (isHover) { // Only allow "patient" clicks to close the popup if it's open. // If they clicked within 500ms of the popup opening, keep it open. setStickIfOpen(true); stickIfOpenTimeout.clear(); stickIfOpenTimeout.start(PATIENT_CLICK_THRESHOLD, () => { setStickIfOpen(false); }); } if (nextOpen) { setValue(itemValue, eventDetails); } else { setValue(null, eventDetails); setPointerType(''); } } if (isHover) { ReactDOM.flushSync(changeState); } else { changeState(); } } const context = useFloatingRootContext({ open, onOpenChange: handleOpenChange, elements: { reference: triggerElement, floating: hoverFloatingElement } }); const hoverInteractionState = useHoverInteractionSharedState(context); const shouldBlockSafePolygonPointerEvents = pointerType !== 'touch'; React.useEffect(() => { if (!open) { context.context.dataRef.current.openEvent = undefined; hoverInteractionState.pointerType = undefined; hoverInteractionState.interactedInside = false; hoverInteractionState.restTimeoutPending = false; hoverInteractionState.openChangeTimeout.clear(); hoverInteractionState.restTimeout.clear(); clearSafePolygonPointerEventsMutation(hoverInteractionState); } }, [context, hoverInteractionState, open]); const getInlineHandleCloseContext = useStableCallback(() => { if (!nested || positionerElement || !triggerElementRef.current || !hoverFloatingElement) { return null; } return getHandleCloseContext(triggerElementRef.current, hoverFloatingElement, nodeId); }); function getScope() { if (!nested || !positionerElement) { return triggerElementRef.current?.closest('ul') ?? null; } return null; } const hoverProps = useHoverReferenceInteraction(context, { enabled: hoverInteractionsEnabled, move: false, handleClose: safePolygon({ blockPointerEvents: shouldBlockSafePolygonPointerEvents, getScope }), restMs: mounted && positionerElement ? 0 : delay, delay: { close: closeDelay }, triggerElementRef, getHandleCloseContext: getInlineHandleCloseContext }); const hover = React.useMemo(() => hoverProps ? { reference: hoverProps } : undefined, [hoverProps]); const click = useClick(context, { enabled: interactionsEnabled, stickIfOpen, toggle: isActiveItem }); const { getReferenceProps } = useInteractions([hover, click]); useIsoLayoutEffect(() => { if (isActiveItem) { setFloatingRootContext(context); prevTriggerElementRef.current = triggerElement; } }, [isActiveItem, context, setFloatingRootContext, prevTriggerElementRef, triggerElement]); function handleActivation(event) { ReactDOM.flushSync(() => { const currentTarget = isHTMLElement(event.currentTarget) ? event.currentTarget : null; const prevTriggerRect = prevTriggerElementRef.current?.getBoundingClientRect(); if (mounted && prevTriggerRect && triggerElement) { const nextTriggerRect = triggerElement.getBoundingClientRect(); const isMovingRight = nextTriggerRect.left > prevTriggerRect.left; const isMovingDown = nextTriggerRect.top > prevTriggerRect.top; if (orientation === 'horizontal' && nextTriggerRect.left !== prevTriggerRect.left) { setActivationDirection(isMovingRight ? 'right' : 'left'); } else if (orientation === 'vertical' && nextTriggerRect.top !== prevTriggerRect.top) { setActivationDirection(isMovingDown ? 'down' : 'up'); } } // Reset the `openEvent` to `undefined` when the active item changes so that a // `click` -> `hover` on new trigger -> `hover` back to old trigger doesn't unexpectedly // cause the popup to remain stuck open when leaving the old trigger. if (event.type !== 'click' && value != null) { context.context.dataRef.current.openEvent = undefined; } if (pointerType === 'touch' && event.type !== 'click') { return; } if (value != null) { setValue(itemValue, createChangeEventDetails(event.type === 'mouseenter' ? REASONS.triggerHover : REASONS.triggerPress, event.nativeEvent)); } if (event.type === 'mouseenter' && shouldBlockSafePolygonPointerEvents && (!nested || !positionerElement) && hoverFloatingElement && currentTarget) { const applyPointerEventsMutation = () => { const scopeElement = getScope() ?? currentTarget.ownerDocument.body; applySafePolygonPointerEventsMutation(hoverInteractionState, { scopeElement, referenceElement: currentTarget, floatingElement: hoverFloatingElement }); }; if (value != null && value !== itemValue) { queueMicrotask(applyPointerEventsMutation); } else { applyPointerEventsMutation(); } } }); } const handleOpenEvent = useStableCallback(event => { if (!popupElement || !positionerElement) { handleActivation(event); return; } const { width, height } = getCssDimensions(popupElement); const shouldSkipAutoSizeSync = value != null && value !== itemValue && (event.type === 'click' || pointerType !== 'touch'); handleActivation(event); if (shouldSkipAutoSizeSync) { skipAutoSizeSyncRef.current = true; } handleValueChange(width, height); }); const state = { open: isActiveItem }; function handleSetPointerType(event) { setPointerType(event.pointerType); } function handleTriggerPointerDown(event) { handleSetPointerType(event); clearSafePolygonPointerEventsMutation(hoverInteractionState); } const defaultProps = { tabIndex: 0, onMouseEnter: handleOpenEvent, onClick: handleOpenEvent, onPointerEnter: handleSetPointerType, onPointerDown: handleTriggerPointerDown, 'aria-expanded': isActiveItem, 'aria-controls': isActiveItem ? popupElement?.id : undefined, [NAVIGATION_MENU_TRIGGER_IDENTIFIER]: '', onFocus() { if (!isActiveItem) { return; } setViewportInert(false); }, onMouseMove() { allowFocusRef.current = false; }, onKeyDown(event) { allowFocusRef.current = true; // For nested (submenu) triggers, don't intercept arrow keys that are used for // navigation in the parent content. The arrow keys should be handled by the // parent's CompositeRoot for navigating between items. if (nested) { return; } const openHorizontal = orientation === 'horizontal' && event.key === 'ArrowDown'; const openVertical = orientation === 'vertical' && event.key === 'ArrowRight'; if (openHorizontal || openVertical) { setValue(itemValue, createChangeEventDetails(REASONS.listNavigation, event.nativeEvent)); handleOpenEvent(event); stopEvent(event); } }, onBlur(event) { if (positionerElement && popupElement && isOutsideMenuEvent({ currentTarget: event.currentTarget, relatedTarget: event.relatedTarget }, { popupElement, rootRef, tree, nodeId })) { setValue(null, createChangeEventDetails(REASONS.focusOut, event.nativeEvent)); } } }; const { getButtonProps, buttonRef } = useButton({ disabled, focusableWhenDisabled: true, native: nativeButton }); const referenceElement = hoverFloatingElement; return /*#__PURE__*/_jsxs(React.Fragment, { children: [/*#__PURE__*/_jsx(CompositeItem, { tag: "button", render: render, className: className, style: style, state: state, stateAttributesMapping: pressableTriggerOpenStateMapping, refs: [forwardedRef, handleTriggerElement, buttonRef], props: [getReferenceProps, dismissProps?.reference || EMPTY_ARRAY, defaultProps, elementProps, getButtonProps] }), isActiveItem && /*#__PURE__*/_jsxs(React.Fragment, { children: [/*#__PURE__*/_jsx(FocusGuard, { ref: beforeOutsideRef, onFocus: event => { if (referenceElement && isOutsideEvent(event, referenceElement)) { beforeInsideRef.current?.focus(); } else { const prevTabbable = getPreviousTabbable(triggerElement); prevTabbable?.focus(); } } }), /*#__PURE__*/_jsx("span", { "aria-owns": viewportElement?.id, style: ownerVisuallyHidden }), /*#__PURE__*/_jsx(FocusGuard, { ref: afterOutsideRef, onFocus: event => { if (referenceElement && isOutsideEvent(event, referenceElement)) { ReactDOM.flushSync(() => { setViewportInert(false); }); const elementToFocus = afterInsideRef.current || triggerElement; elementToFocus?.focus(); } else { let nextTabbable = getNextTabbable(triggerElement); if (nested && !positionerElement && referenceElement && nextTabbable && contains(referenceElement, nextTabbable)) { nextTabbable = getTabbableAfterElement(afterInsideRef.current); } nextTabbable?.focus(); if ((!nested || positionerElement) && !contains(rootRef.current, nextTabbable)) { setValue(null, createChangeEventDetails(REASONS.focusOut, event.nativeEvent)); } } } })] })] }); }); if (process.env.NODE_ENV !== "production") NavigationMenuTrigger.displayName = "NavigationMenuTrigger"; function getPlacementFromElements(domReferenceElement, floatingElement) { const referenceRect = domReferenceElement.getBoundingClientRect(); const floatingRect = floatingElement.getBoundingClientRect(); const referenceCenterX = referenceRect.left + referenceRect.width / 2; const referenceCenterY = referenceRect.top + referenceRect.height / 2; const floatingCenterX = floatingRect.left + floatingRect.width / 2; const floatingCenterY = floatingRect.top + floatingRect.height / 2; const deltaX = floatingCenterX - referenceCenterX; const deltaY = floatingCenterY - referenceCenterY; if (Math.abs(deltaX) >= Math.abs(deltaY)) { return deltaX >= 0 ? 'right' : 'left'; } return deltaY >= 0 ? 'bottom' : 'top'; } function getHandleCloseContext(domReferenceElement, floatingElement, nodeId) { return { placement: getPlacementFromElements(domReferenceElement, floatingElement), elements: { domReference: domReferenceElement, floating: floatingElement }, nodeId }; }