'use client'; import * as React from 'react'; import { resolvePositioningShorthand, usePositioningMouseTarget, usePositioning, useSafeZoneArea, usePositioningSlideDirection } from '@fluentui/react-positioning'; import { presenceMotionSlot } from '@fluentui/react-motion'; import { useControllableState, useId, useOnClickOutside, useEventCallback, useOnScrollOutside, elementContains, useTimeout, useFirstMount, useMergedRefs } from '@fluentui/react-utilities'; import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; import { useFocusFinders } from '@fluentui/react-tabster'; import { useMenuContext_unstable } from '../../contexts/menuContext'; import { MENU_SAFEZONE_TIMEOUT_EVENT, MENU_ENTER_EVENT, useOnMenuMouseEnter, useIsSubmenu } from '../../utils'; import { menuItemClassNames } from '../MenuItem/useMenuItemStyles.styles'; import { MenuSurfaceMotion } from './MenuSurfaceMotion'; // If it's not possible to position the submenu in smaller viewports, try // and fallback to this order of positions const submenuFallbackPositions = [ 'after', 'after-bottom', 'before-top', 'before', 'before-bottom', 'above' ]; /** * Create the state required to render Menu. * * The returned state can be modified with hooks such as useMenuStyles, * before being passed to renderMenu_unstable. * * @param props - props from this instance of Menu */ export const useMenu_unstable = (props)=>{ const isSubmenu = useIsSubmenu(); const { hoverDelay = 500, inline = false, hasCheckmarks = false, hasIcons = false, closeOnScroll = false, openOnContext = false, persistOnItemClick = false, openOnHover = isSubmenu, defaultCheckedValues, mountNode = null, safeZone } = props; const { targetDocument } = useFluent(); const triggerId = useId('menu'); const [contextTarget, setContextTarget] = usePositioningMouseTarget(); const resolvedPositioning = resolvePositioningShorthand(props.positioning); const handlePositionEnd = usePositioningSlideDirection({ targetDocument, onPositioningEnd: resolvedPositioning.onPositioningEnd }); const positioningOptions = { position: isSubmenu ? 'after' : 'below', align: isSubmenu ? 'top' : 'start', target: props.openOnContext ? contextTarget : undefined, fallbackPositions: isSubmenu ? submenuFallbackPositions : undefined, ...resolvedPositioning, onPositioningEnd: handlePositionEnd }; const children = React.Children.toArray(props.children); if (process.env.NODE_ENV !== 'production') { if (children.length === 0) { // eslint-disable-next-line no-console console.warn('Menu must contain at least one child'); } if (children.length > 2) { // eslint-disable-next-line no-console console.warn('Menu must contain at most two children'); } } let menuTrigger = undefined; let menuPopover = undefined; if (children.length === 2) { menuTrigger = children[0]; menuPopover = children[1]; } else if (children.length === 1) { menuPopover = children[0]; } const { targetRef, containerRef } = usePositioning(positioningOptions); const enableSafeZone = safeZone && openOnHover; const safeZoneDescriptorRef = React.useRef({ isInside: false, mouseCoordinates: { x: 0, y: 0 } }); const safeZoneHandle = useSafeZoneArea({ disabled: !enableSafeZone, timeout: typeof safeZone === 'object' ? safeZone.timeout : 300, onSafeZoneEnter: (e)=>{ setOpen(e, { open: true, keyboard: false, type: 'menuSafeZoneMouseEnter', event: e }); safeZoneDescriptorRef.current.isInside = true; }, onSafeZoneLeave: ()=>{ safeZoneDescriptorRef.current.isInside = false; }, onSafeZoneMove: (e)=>{ safeZoneDescriptorRef.current.mouseCoordinates = { x: e.clientX, y: e.clientY }; }, onSafeZoneTimeout: ()=>{ const event = new CustomEvent(MENU_SAFEZONE_TIMEOUT_EVENT); setOpen(event, { open: false, keyboard: false, type: 'menuSafeZoneTimeout', event }); if (safeZoneDescriptorRef.current.isInside && targetDocument) { const elementsInPoint = targetDocument.elementsFromPoint(safeZoneDescriptorRef.current.mouseCoordinates.x, safeZoneDescriptorRef.current.mouseCoordinates.y); const menuItemEl = elementsInPoint.find((el)=>{ return el.classList.contains(menuItemClassNames.root); }); menuItemEl === null || menuItemEl === void 0 ? void 0 : menuItemEl.dispatchEvent(event); } } }); const triggerRef = useMergedRefs(targetRef, safeZoneHandle.targetRef); const menuPopoverRef = useMergedRefs(containerRef, safeZoneHandle.containerRef); // TODO Better way to narrow types ? const [open, setOpen] = useMenuOpenState({ hoverDelay, isSubmenu, setContextTarget, closeOnScroll, menuPopoverRef, triggerRef, open: props.open, defaultOpen: props.defaultOpen, onOpenChange: props.onOpenChange, openOnContext }); const [checkedValues, onCheckedValueChange] = useMenuSelectableState({ checkedValues: props.checkedValues, defaultCheckedValues, onCheckedValueChange: props.onCheckedValueChange }); return { inline, hoverDelay, triggerId, isSubmenu, openOnHover, contextTarget, setContextTarget, hasCheckmarks, hasIcons, closeOnScroll, menuTrigger, menuPopover, mountNode, triggerRef, menuPopoverRef, components: { surfaceMotion: MenuSurfaceMotion }, openOnContext, open, setOpen, checkedValues, onCheckedValueChange, persistOnItemClick, safeZone: safeZoneHandle.elementToRender, surfaceMotion: presenceMotionSlot(props.surfaceMotion, { elementType: MenuSurfaceMotion, defaultProps: { visible: open, appear: true, unmountOnExit: true } }) }; }; /** * Adds appropriate state values and handlers for selectable items * i.e checkboxes and radios */ const useMenuSelectableState = (props)=>{ const [checkedValues, setCheckedValues] = useControllableState({ state: props.checkedValues, defaultState: props.defaultCheckedValues, initialState: {} }); const onCheckedValueChange = useEventCallback((e, { name, checkedItems })=>{ var _props_onCheckedValueChange; (_props_onCheckedValueChange = props.onCheckedValueChange) === null || _props_onCheckedValueChange === void 0 ? void 0 : _props_onCheckedValueChange.call(props, e, { name, checkedItems }); setCheckedValues((currentValue)=>({ ...currentValue, [name]: checkedItems })); }); return [ checkedValues, onCheckedValueChange ]; }; const useMenuOpenState = (state)=>{ 'use no memo'; const { targetDocument } = useFluent(); const parentSetOpen = useMenuContext_unstable((context)=>context.setOpen); const onOpenChange = useEventCallback((e, data)=>{ var _state_onOpenChange; return (_state_onOpenChange = state.onOpenChange) === null || _state_onOpenChange === void 0 ? void 0 : _state_onOpenChange.call(state, e, data); }); const enteringTriggerRef = React.useRef(false); const [open, setOpenState] = useControllableState({ state: state.open, defaultState: state.defaultOpen, initialState: false }); const trySetOpen = useEventCallback((e, data)=>{ const event = e instanceof CustomEvent && e.type === MENU_ENTER_EVENT ? e.detail.nativeEvent : e; onOpenChange === null || onOpenChange === void 0 ? void 0 : onOpenChange(event, { ...data }); if (data.open && e.type === 'contextmenu') { state.setContextTarget(e); } if (!data.open) { state.setContextTarget(undefined); } if (data.bubble) { parentSetOpen(e, { ...data }); } setOpenState(data.open); }); const [setOpenTimeout, clearOpenTimeout] = useTimeout(); const setOpen = useEventCallback((e, data)=>{ clearOpenTimeout(); if (!(e instanceof Event) && e.persist) { // < React 17 still uses pooled synthetic events e.persist(); } const shouldUseDelay = !data.ignoreHoverDelay && (e.type === 'mouseleave' || e.type === 'mouseover' || e.type === 'mousemove' || e.type === MENU_ENTER_EVENT); if (shouldUseDelay) { var _state_triggerRef_current; if ((_state_triggerRef_current = state.triggerRef.current) === null || _state_triggerRef_current === void 0 ? void 0 : _state_triggerRef_current.contains(e.target)) { enteringTriggerRef.current = e.type === 'mouseover' || e.type === 'mousemove'; } setOpenTimeout(()=>trySetOpen(e, data), state.hoverDelay); } else { trySetOpen(e, data); } }); useOnClickOutside({ contains: elementContains, disabled: !open, element: targetDocument, refs: [ state.menuPopoverRef, !state.openOnContext && state.triggerRef ].filter(Boolean), callback: (event)=>setOpen(event, { open: false, type: 'clickOutside', event }) }); // only close on scroll for context, or when closeOnScroll is specified const closeOnScroll = state.openOnContext || state.closeOnScroll; useOnScrollOutside({ contains: elementContains, element: targetDocument, callback: (event)=>setOpen(event, { open: false, type: 'scrollOutside', event }), refs: [ state.menuPopoverRef, !state.openOnContext && state.triggerRef ].filter(Boolean), disabled: !open || !closeOnScroll }); useOnMenuMouseEnter({ element: targetDocument, callback: (event)=>{ // When moving from a menu directly back to its trigger, this handler can close the menu // Explicitly check a flag to see if this situation happens if (!enteringTriggerRef.current) { setOpen(event, { open: false, type: 'menuMouseEnter', event }); } }, disabled: !open, refs: [ state.menuPopoverRef ] }); // Manage focus for open state const { findFirstFocusable } = useFocusFinders(); const focusFirst = React.useCallback(()=>{ const firstFocusable = findFirstFocusable(state.menuPopoverRef.current); firstFocusable === null || firstFocusable === void 0 ? void 0 : firstFocusable.focus(); }, [ findFirstFocusable, state.menuPopoverRef ]); const firstMount = useFirstMount(); React.useEffect(()=>{ if (open) { focusFirst(); } else { // Skip the initial render — focus should only be restored when the menu // transitions from open → closed, not on mount. if (!firstMount) { var // The surfaceMotion presence component delays unmounting the popover // (e.g. during an exit animation), so focus may still be inside the // popover even though `open` is already false. Proactively move it // to the trigger before the DOM element is eventually removed. _state_menuPopoverRef_current; var _targetDocument_activeElement; if (// Focus landed on after the popover was removed from the DOM, // meaning the user's focus has nowhere meaningful to go. (targetDocument === null || targetDocument === void 0 ? void 0 : targetDocument.activeElement) === (targetDocument === null || targetDocument === void 0 ? void 0 : targetDocument.body) || ((_state_menuPopoverRef_current = state.menuPopoverRef.current) === null || _state_menuPopoverRef_current === void 0 ? void 0 : _state_menuPopoverRef_current.contains((_targetDocument_activeElement = targetDocument === null || targetDocument === void 0 ? void 0 : targetDocument.activeElement) !== null && _targetDocument_activeElement !== void 0 ? _targetDocument_activeElement : null))) { var // We know that React effects are sync so we focus the trigger here // after any event handler (event handlers will update state and re-render). // Since the browser only performs the default behaviour for the Tab key once // keyboard events have fully bubbled up the window, the browser will move // focus to the next tabbable element before/after the trigger if needed. // If the Tab key was not pressed, focus will remain on the trigger as expected. _state_triggerRef_current; (_state_triggerRef_current = state.triggerRef.current) === null || _state_triggerRef_current === void 0 ? void 0 : _state_triggerRef_current.focus(); } } } // firstMount change should not re-run this effect // eslint-disable-next-line react-hooks/exhaustive-deps }, [ state.triggerRef, state.isSubmenu, open, focusFirst, targetDocument, state.menuPopoverRef ]); return [ open, setOpen ]; };