345 lines
14 KiB
JavaScript
345 lines
14 KiB
JavaScript
'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 <body> 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
|
|
];
|
|
};
|