@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.
413 lines (409 loc) • 16.8 kB
JavaScript
;
'use client';
var _interopRequireWildcard = require("@babel/runtime/helpers/interopRequireWildcard").default;
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.NavigationMenuTrigger = void 0;
var React = _interopRequireWildcard(require("react"));
var ReactDOM = _interopRequireWildcard(require("react-dom"));
var _tabbable = require("tabbable");
var _useStableCallback = require("@base-ui-components/utils/useStableCallback");
var _useIsoLayoutEffect = require("@base-ui-components/utils/useIsoLayoutEffect");
var _useTimeout = require("@base-ui-components/utils/useTimeout");
var _useAnimationFrame = require("@base-ui-components/utils/useAnimationFrame");
var _useValueAsRef = require("@base-ui-components/utils/useValueAsRef");
var _floatingUiReact = require("../../floating-ui-react");
var _utils = require("../../floating-ui-react/utils");
var _NavigationMenuItemContext = require("../item/NavigationMenuItemContext");
var _NavigationMenuRootContext = require("../root/NavigationMenuRootContext");
var _createBaseUIEventDetails = require("../../utils/createBaseUIEventDetails");
var _reasons = require("../../utils/reasons");
var _constants = require("../../utils/constants");
var _FocusGuard = require("../../utils/FocusGuard");
var _popupStateMapping = require("../../utils/popupStateMapping");
var _isOutsideMenuEvent = require("../utils/isOutsideMenuEvent");
var _useAnimationsFinished = require("../../utils/useAnimationsFinished");
var _NavigationMenuPopupCssVars = require("../popup/NavigationMenuPopupCssVars");
var _NavigationMenuPositionerCssVars = require("../positioner/NavigationMenuPositionerCssVars");
var _CompositeItem = require("../../composite/item/CompositeItem");
var _useButton = require("../../use-button");
var _getCssDimensions = require("../../utils/getCssDimensions");
var _constants2 = require("../utils/constants");
var _NavigationMenuDismissContext = require("../list/NavigationMenuDismissContext");
var _jsxRuntime = require("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)
*/
const NavigationMenuTrigger = exports.NavigationMenuTrigger = /*#__PURE__*/React.forwardRef(function NavigationMenuTrigger(componentProps, forwardedRef) {
const {
className,
render,
nativeButton = true,
disabled,
...elementProps
} = componentProps;
const {
value,
setValue,
mounted,
open,
positionerElement,
setActivationDirection,
setFloatingRootContext,
popupElement,
viewportElement,
rootRef,
beforeOutsideRef,
afterOutsideRef,
afterInsideRef,
beforeInsideRef,
prevTriggerElementRef,
delay,
closeDelay,
orientation,
setViewportInert,
nested
} = (0, _NavigationMenuRootContext.useNavigationMenuRootContext)();
const {
value: itemValue
} = (0, _NavigationMenuItemContext.useNavigationMenuItemContext)();
const nodeId = (0, _NavigationMenuRootContext.useNavigationMenuTreeContext)();
const tree = (0, _floatingUiReact.useFloatingTree)();
const dismissProps = (0, _NavigationMenuDismissContext.useNavigationMenuDismissContext)();
const stickIfOpenTimeout = (0, _useTimeout.useTimeout)();
const focusFrame = (0, _useAnimationFrame.useAnimationFrame)();
const sizeFrame1 = (0, _useAnimationFrame.useAnimationFrame)();
const sizeFrame2 = (0, _useAnimationFrame.useAnimationFrame)();
const [triggerElement, setTriggerElement] = React.useState(null);
const [stickIfOpen, setStickIfOpen] = React.useState(true);
const [pointerType, setPointerType] = React.useState('');
const allowFocusRef = React.useRef(false);
const prevSizeRef = React.useRef(DEFAULT_SIZE);
const animationAbortControllerRef = React.useRef(null);
const isActiveItem = open && value === itemValue;
const isActiveItemRef = (0, _useValueAsRef.useValueAsRef)(isActiveItem);
const interactionsEnabled = positionerElement ? true : !value;
const runOnceAnimationsFinish = (0, _useAnimationsFinished.useAnimationsFinished)(popupElement);
React.useEffect(() => {
animationAbortControllerRef.current?.abort();
}, [isActiveItem]);
function setAutoSizes() {
if (!popupElement) {
return;
}
popupElement.style.setProperty(_NavigationMenuPopupCssVars.NavigationMenuPopupCssVars.popupWidth, 'auto');
popupElement.style.setProperty(_NavigationMenuPopupCssVars.NavigationMenuPopupCssVars.popupHeight, 'auto');
}
const handleValueChange = (0, _useStableCallback.useStableCallback)((currentWidth, currentHeight) => {
if (!popupElement || !positionerElement) {
return;
}
popupElement.style.removeProperty(_NavigationMenuPopupCssVars.NavigationMenuPopupCssVars.popupWidth);
popupElement.style.removeProperty(_NavigationMenuPopupCssVars.NavigationMenuPopupCssVars.popupHeight);
positionerElement.style.removeProperty(_NavigationMenuPositionerCssVars.NavigationMenuPositionerCssVars.positionerWidth);
positionerElement.style.removeProperty(_NavigationMenuPositionerCssVars.NavigationMenuPositionerCssVars.positionerHeight);
const {
width,
height
} = (0, _getCssDimensions.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.NavigationMenuPopupCssVars.popupWidth, `${currentWidth}px`);
popupElement.style.setProperty(_NavigationMenuPopupCssVars.NavigationMenuPopupCssVars.popupHeight, `${currentHeight}px`);
positionerElement.style.setProperty(_NavigationMenuPositionerCssVars.NavigationMenuPositionerCssVars.positionerWidth, `${measuredWidth}px`);
positionerElement.style.setProperty(_NavigationMenuPositionerCssVars.NavigationMenuPositionerCssVars.positionerHeight, `${measuredHeight}px`);
sizeFrame1.request(() => {
popupElement.style.setProperty(_NavigationMenuPopupCssVars.NavigationMenuPopupCssVars.popupWidth, `${measuredWidth}px`);
popupElement.style.setProperty(_NavigationMenuPopupCssVars.NavigationMenuPopupCssVars.popupHeight, `${measuredHeight}px`);
sizeFrame2.request(() => {
animationAbortControllerRef.current = new AbortController();
runOnceAnimationsFinish(setAutoSizes, animationAbortControllerRef.current.signal);
});
});
});
React.useEffect(() => {
if (!open) {
stickIfOpenTimeout.clear();
sizeFrame1.cancel();
sizeFrame2.cancel();
}
}, [stickIfOpenTimeout, open, sizeFrame1, sizeFrame2]);
React.useEffect(() => {
if (!mounted) {
prevSizeRef.current = DEFAULT_SIZE;
}
}, [mounted]);
React.useEffect(() => {
if (!popupElement || typeof ResizeObserver !== 'function') {
return undefined;
}
const resizeObserver = new ResizeObserver(() => {
// Using `getCssDimensions` here causes issues due to fractional values.
prevSizeRef.current = {
width: popupElement.offsetWidth,
height: popupElement.offsetHeight
};
});
resizeObserver.observe(popupElement);
return () => {
resizeObserver.disconnect();
};
}, [popupElement]);
React.useEffect(() => {
if (!popupElement || !isActiveItem || typeof MutationObserver !== 'function') {
return undefined;
}
const mutationObserver = new MutationObserver(() => {
animationAbortControllerRef.current?.abort();
handleValueChange(prevSizeRef.current.width, prevSizeRef.current.height);
});
mutationObserver.observe(popupElement, {
childList: true,
subtree: true,
characterData: true
});
return () => {
mutationObserver.disconnect();
};
}, [popupElement, positionerElement, isActiveItem, handleValueChange]);
React.useEffect(() => {
if (isActiveItem && open && popupElement && allowFocusRef.current) {
allowFocusRef.current = false;
focusFrame.request(() => {
beforeOutsideRef.current?.focus();
});
}
return () => {
focusFrame.cancel();
};
}, [beforeOutsideRef, focusFrame, handleValueChange, isActiveItem, open, popupElement]);
(0, _useIsoLayoutEffect.useIsoLayoutEffect)(() => {
if (isActiveItemRef.current && open && popupElement) {
handleValueChange(0, 0);
}
}, [isActiveItemRef, open, popupElement, handleValueChange]);
function handleOpenChange(nextOpen, eventDetails) {
const isHover = eventDetails.reason === _reasons.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(_constants.PATIENT_CLICK_THRESHOLD, () => {
setStickIfOpen(false);
});
}
if (nextOpen) {
setValue(itemValue, eventDetails);
} else {
setValue(null, eventDetails);
setPointerType('');
}
}
if (isHover) {
ReactDOM.flushSync(changeState);
} else {
changeState();
}
}
const context = (0, _floatingUiReact.useFloatingRootContext)({
open,
onOpenChange: handleOpenChange,
elements: {
reference: triggerElement,
floating: positionerElement || viewportElement
}
});
const hover = (0, _floatingUiReact.useHover)(context, {
move: false,
handleClose: (0, _floatingUiReact.safePolygon)({
blockPointerEvents: pointerType !== 'touch'
}),
restMs: mounted && positionerElement ? 0 : delay,
delay: {
close: closeDelay
}
});
const click = (0, _floatingUiReact.useClick)(context, {
enabled: interactionsEnabled,
stickIfOpen,
toggle: isActiveItem
});
(0, _useIsoLayoutEffect.useIsoLayoutEffect)(() => {
if (isActiveItem) {
setFloatingRootContext(context);
prevTriggerElementRef.current = triggerElement;
}
}, [isActiveItem, context, setFloatingRootContext, prevTriggerElementRef, triggerElement]);
const {
getReferenceProps
} = (0, _floatingUiReact.useInteractions)([hover, click]);
function handleActivation(event) {
ReactDOM.flushSync(() => {
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') {
context.context.dataRef.current.openEvent = undefined;
}
if (pointerType === 'touch' && event.type !== 'click') {
return;
}
if (value != null) {
setValue(itemValue, (0, _createBaseUIEventDetails.createChangeEventDetails)(event.type === 'mouseenter' ? _reasons.REASONS.triggerHover : _reasons.REASONS.triggerPress, event.nativeEvent));
}
});
}
const handleOpenEvent = (0, _useStableCallback.useStableCallback)(event => {
// For nested scenarios without positioner/popup, we can still open the menu
// but we can't do size calculations
if (!popupElement || !positionerElement) {
handleActivation(event);
return;
}
const {
width,
height
} = (0, _getCssDimensions.getCssDimensions)(popupElement);
handleActivation(event);
handleValueChange(width, height);
});
const state = React.useMemo(() => ({
open: isActiveItem
}), [isActiveItem]);
function handleSetPointerType(event) {
setPointerType(event.pointerType);
}
const defaultProps = {
tabIndex: 0,
onMouseEnter: handleOpenEvent,
onClick: handleOpenEvent,
onPointerEnter: handleSetPointerType,
onPointerDown: handleSetPointerType,
'aria-expanded': isActiveItem,
'aria-controls': isActiveItem ? popupElement?.id : undefined,
[_constants2.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, (0, _createBaseUIEventDetails.createChangeEventDetails)(_reasons.REASONS.listNavigation, event.nativeEvent));
handleOpenEvent(event);
(0, _utils.stopEvent)(event);
}
},
onBlur(event) {
if (positionerElement && popupElement && (0, _isOutsideMenuEvent.isOutsideMenuEvent)({
currentTarget: event.currentTarget,
relatedTarget: event.relatedTarget
}, {
popupElement,
rootRef,
tree,
nodeId
})) {
setValue(null, (0, _createBaseUIEventDetails.createChangeEventDetails)(_reasons.REASONS.focusOut, event.nativeEvent));
}
}
};
const {
getButtonProps,
buttonRef
} = (0, _useButton.useButton)({
disabled,
focusableWhenDisabled: true,
native: nativeButton
});
const referenceElement = positionerElement || viewportElement;
return /*#__PURE__*/(0, _jsxRuntime.jsxs)(React.Fragment, {
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_CompositeItem.CompositeItem, {
tag: "button",
render: render,
className: className,
state: state,
stateAttributesMapping: _popupStateMapping.pressableTriggerOpenStateMapping,
refs: [forwardedRef, setTriggerElement, buttonRef],
props: [getReferenceProps, dismissProps?.reference || _constants.EMPTY_ARRAY, defaultProps, elementProps, getButtonProps]
}), isActiveItem && /*#__PURE__*/(0, _jsxRuntime.jsxs)(React.Fragment, {
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_FocusGuard.FocusGuard, {
ref: beforeOutsideRef,
onFocus: event => {
if (referenceElement && (0, _utils.isOutsideEvent)(event, referenceElement)) {
beforeInsideRef.current?.focus();
} else {
const prevTabbable = (0, _utils.getPreviousTabbable)(triggerElement);
prevTabbable?.focus();
}
}
}), /*#__PURE__*/(0, _jsxRuntime.jsx)("span", {
"aria-owns": viewportElement?.id,
style: _constants.ownerVisuallyHidden
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(_FocusGuard.FocusGuard, {
ref: afterOutsideRef,
onFocus: event => {
if (referenceElement && (0, _utils.isOutsideEvent)(event, referenceElement)) {
const elementToFocus = afterInsideRef.current && (0, _tabbable.isTabbable)(afterInsideRef.current) ? afterInsideRef.current : triggerElement;
elementToFocus?.focus();
} else {
const nextTabbable = (0, _utils.getNextTabbable)(triggerElement);
nextTabbable?.focus();
if (!(0, _utils.contains)(rootRef.current, nextTabbable)) {
setValue(null, (0, _createBaseUIEventDetails.createChangeEventDetails)(_reasons.REASONS.focusOut, event.nativeEvent));
}
}
}
})]
})]
});
});
if (process.env.NODE_ENV !== "production") NavigationMenuTrigger.displayName = "NavigationMenuTrigger";