in packages/react/src/components/ContextualMenu/ContextualMenu.base.tsx [86:768]
onClick: getOnClickWithOverrideTarget(onClick, target),
});
} else {
overrideItems.push(subItem);
}
}
return overrideItems;
}
}
/**
* Returns true if a list of menu items can contain a checkbox
*/
export function canAnyMenuItemsCheck(items: IContextualMenuItem[]): boolean {
return items.some(item => {
if (item.canCheck) {
return true;
}
// If the item is a section, check if any of the items in the section can check.
if (item.sectionProps && item.sectionProps.items.some(submenuItem => submenuItem.canCheck === true)) {
return true;
}
return false;
});
}
const NavigationIdleDelay = 250; /* ms */
const COMPONENT_NAME = 'ContextualMenu';
const _getMenuItemStylesFunction = memoizeFunction(
(
...styles: (IStyleFunctionOrObject<IContextualMenuItemStyleProps, IContextualMenuItemStyles> | undefined)[]
): IStyleFunctionOrObject<IContextualMenuItemStyleProps, IContextualMenuItemStyles> => {
return (styleProps: IContextualMenuItemStyleProps) =>
concatStyleSetsWithProps(styleProps, getItemStyles, ...styles);
},
);
//#region Custom hooks
function useVisibility(props: IContextualMenuProps, targetWindow: Window | undefined) {
const { hidden = false, onMenuDismissed, onMenuOpened } = props;
const previousHidden = usePrevious(hidden);
const onMenuOpenedRef = React.useRef(onMenuOpened);
const onMenuClosedRef = React.useRef(onMenuDismissed);
const propsRef = React.useRef(props);
onMenuOpenedRef.current = onMenuOpened;
onMenuClosedRef.current = onMenuDismissed;
propsRef.current = props;
React.useEffect(() => {
// Don't issue dismissed callbacks on initial mount
if (hidden && previousHidden === false) {
onMenuClosedRef.current?.(propsRef.current);
} else if (!hidden && previousHidden !== false) {
onMenuOpenedRef.current?.(propsRef.current);
}
}, [hidden, previousHidden]);
// Issue onDismissedCallback on unmount
React.useEffect(() => () => onMenuClosedRef.current?.(propsRef.current), []);
}
function useSubMenuState(
{ hidden, items, theme, className, id, target: menuTarget }: IContextualMenuProps,
dismiss: () => void,
) {
const [expandedMenuItemKey, setExpandedMenuItemKey] = React.useState<string>();
const [submenuTarget, setSubmenuTarget] = React.useState<HTMLElement>();
/** True if the menu was expanded by mouse click OR hover (as opposed to by keyboard) */
const [expandedByMouseClick, setExpandedByMouseClick] = React.useState<boolean>();
const subMenuId = useId(COMPONENT_NAME, id);
const closeSubMenu = React.useCallback(() => {
setExpandedByMouseClick(undefined);
setExpandedMenuItemKey(undefined);
setSubmenuTarget(undefined);
}, []);
const openSubMenu = React.useCallback(
({ key: submenuItemKey }: IContextualMenuItem, target: HTMLElement, openedByMouseClick?: boolean) => {
if (expandedMenuItemKey === submenuItemKey) {
return;
}
target.focus();
setExpandedByMouseClick(openedByMouseClick);
setExpandedMenuItemKey(submenuItemKey);
setSubmenuTarget(target);
},
[expandedMenuItemKey],
);
React.useEffect(() => {
if (hidden) {
closeSubMenu();
}
}, [hidden, closeSubMenu]);
const onSubMenuDismiss = useOnSubmenuDismiss(dismiss, closeSubMenu);
const getSubmenuProps = (): IContextualMenuProps | null => {
const item = findItemByKeyFromItems(expandedMenuItemKey!, items);
let submenuProps: IContextualMenuProps | null = null;
if (item) {
submenuProps = {
items: getSubmenuItems(item, { target: menuTarget })!,
target: submenuTarget,
onDismiss: onSubMenuDismiss,
isSubMenu: true,
id: subMenuId,
shouldFocusOnMount: true,
shouldFocusOnContainer: expandedByMouseClick,
directionalHint: getRTL(theme) ? DirectionalHint.leftTopEdge : DirectionalHint.rightTopEdge,
className,
gapSpace: 0,
isBeakVisible: false,
};
if (item.subMenuProps) {
assign(submenuProps, item.subMenuProps);
}
if (item.preferMenuTargetAsEventTarget) {
const { onItemClick } = item;
submenuProps.onItemClick = getOnClickWithOverrideTarget(onItemClick, menuTarget);
}
}
return submenuProps;
};
return [expandedMenuItemKey, openSubMenu, getSubmenuProps, onSubMenuDismiss] as const;
}
function useShouldUpdateFocusOnMouseMove({ delayUpdateFocusOnHover, hidden }: IContextualMenuProps) {
const shouldUpdateFocusOnMouseEvent = React.useRef<boolean>(!delayUpdateFocusOnHover);
const gotMouseMove = React.useRef<boolean>(false);
React.useEffect(() => {
shouldUpdateFocusOnMouseEvent.current = !delayUpdateFocusOnHover;
gotMouseMove.current = hidden ? false : !delayUpdateFocusOnHover && gotMouseMove.current;
}, [delayUpdateFocusOnHover, hidden]);
const onMenuFocusCapture = React.useCallback(() => {
if (delayUpdateFocusOnHover) {
shouldUpdateFocusOnMouseEvent.current = true;
}
}, [delayUpdateFocusOnHover]);
return [shouldUpdateFocusOnMouseEvent, gotMouseMove, onMenuFocusCapture] as const;
}
function usePreviousActiveElement({ hidden, onRestoreFocus }: IContextualMenuProps, targetWindow: Window | undefined) {
const previousActiveElement = React.useRef<undefined | HTMLElement>();
const tryFocusPreviousActiveElement = React.useCallback(
(options: IPopupRestoreFocusParams) => {
if (onRestoreFocus) {
onRestoreFocus(options);
} else if (options?.documentContainsFocus) {
// Make sure that the focus method actually exists
// In some cases the object might exist but not be a real element.
// This is primarily for IE 11 and should be removed once IE 11 is no longer in use.
previousActiveElement.current?.focus?.();
}
},
[onRestoreFocus],
);
// eslint-disable-next-line no-restricted-properties
React.useLayoutEffect(() => {
if (!hidden) {
previousActiveElement.current = targetWindow?.document.activeElement as HTMLElement;
} else if (previousActiveElement.current) {
tryFocusPreviousActiveElement({
originalElement: previousActiveElement.current,
containsFocus: true,
documentContainsFocus: getDocument()?.hasFocus() || false,
});
previousActiveElement.current = undefined;
}
}, [hidden, targetWindow?.document.activeElement, tryFocusPreviousActiveElement]);
return [tryFocusPreviousActiveElement] as const;
}
function useKeyHandlers(
{
theme,
isSubMenu,
focusZoneProps: { checkForNoWrap, direction: focusZoneDirection = FocusZoneDirection.vertical } = {},
}: IContextualMenuProps,
dismiss: (ev?: any, dismissAll?: boolean | undefined) => void | undefined,
hostElement: React.RefObject<HTMLDivElement>,
openSubMenu: (submenuItemKey: IContextualMenuItem, target: HTMLElement, openedByMouseClick?: boolean) => void,
) {
/** True if the most recent keydown event was for alt (option) or meta (command). */
const lastKeyDownWasAltOrMeta = React.useRef<boolean | undefined>();
/**
* Calls `shouldHandleKey` to determine whether the keyboard event should be handled;
* if so, stops event propagation and dismisses menu(s).
* @param ev - The keyboard event.
* @param shouldHandleKey - Returns whether we should handle this keyboard event.
* @param dismissAllMenus - If true, dismiss all menus. Otherwise, dismiss only the current menu.
* Only does anything if `shouldHandleKey` returns true.
* @returns Whether the event was handled.
*/
const keyHandler = (
ev: React.KeyboardEvent<HTMLElement>,
shouldHandleKey: (ev: React.KeyboardEvent<HTMLElement>) => boolean,
dismissAllMenus?: boolean,
): boolean => {
let handled = false;
if (shouldHandleKey(ev)) {
dismiss(ev, dismissAllMenus);
ev.preventDefault();
ev.stopPropagation();
handled = true;
}
return handled;
};
/**
* Checks if the submenu should be closed
*/
const shouldCloseSubMenu = (ev: React.KeyboardEvent<HTMLElement>): boolean => {
const submenuCloseKey = getRTL(theme) ? KeyCodes.right : KeyCodes.left;
// eslint-disable-next-line deprecation/deprecation
if (ev.which !== submenuCloseKey || !isSubMenu) {
return false;
}
return !!(
focusZoneDirection === FocusZoneDirection.vertical ||
(checkForNoWrap && !shouldWrapFocus(ev.target as HTMLElement, 'data-no-horizontal-wrap'))
);
};
const shouldHandleKeyDown = (ev: React.KeyboardEvent<HTMLElement>) => {
return (
// eslint-disable-next-line deprecation/deprecation
ev.which === KeyCodes.escape ||
shouldCloseSubMenu(ev) ||
// eslint-disable-next-line deprecation/deprecation
(ev.which === KeyCodes.up && (ev.altKey || ev.metaKey))
);
};
const onKeyDown = (ev: React.KeyboardEvent<HTMLElement>): boolean => {
// Take note if we are processing an alt (option) or meta (command) keydown.
// See comment in shouldHandleKeyUp for reasoning.
lastKeyDownWasAltOrMeta.current = isAltOrMeta(ev);
// On Mac, pressing escape dismisses all levels of native context menus
// eslint-disable-next-line deprecation/deprecation
const dismissAllMenus = ev.which === KeyCodes.escape && (isMac() || isIOS());
return keyHandler(ev, shouldHandleKeyDown, dismissAllMenus);
};
/**
* We close the menu on key up only if ALL of the following are true:
* - Most recent key down was alt or meta (command)
* - The alt/meta key down was NOT followed by some other key (such as down/up arrow to
* expand/collapse the menu)
* - We're not on a Mac (or iOS)
*
* This is because on Windows, pressing alt moves focus to the application menu bar or similar,
* closing any open context menus. There is not a similar behavior on Macs.
*/
const shouldHandleKeyUp = (ev: React.KeyboardEvent<HTMLElement>) => {
const keyPressIsAltOrMetaAlone = lastKeyDownWasAltOrMeta.current && isAltOrMeta(ev);
lastKeyDownWasAltOrMeta.current = false;
return !!keyPressIsAltOrMetaAlone && !(isIOS() || isMac());
};
const onKeyUp = (ev: React.KeyboardEvent<HTMLElement>): boolean => {
return keyHandler(ev, shouldHandleKeyUp, true /* dismissAllMenus */);
};
const onMenuKeyDown = (ev: React.KeyboardEvent<HTMLElement>) => {
// Mark as handled if onKeyDown returns true (for handling collapse cases)
// or if we are attempting to expand a submenu
const handled = onKeyDown(ev);
if (handled || !hostElement.current) {
return;
}
// If we have a modifier key being pressed, we do not want to move focus.
// Otherwise, handle up and down keys.
const hasModifier = !!(ev.altKey || ev.metaKey);
// eslint-disable-next-line deprecation/deprecation
const isUp = ev.which === KeyCodes.up;
// eslint-disable-next-line deprecation/deprecation
const isDown = ev.which === KeyCodes.down;
if (!hasModifier && (isUp || isDown)) {
const elementToFocus = isUp
? getLastFocusable(hostElement.current, hostElement.current.lastChild as HTMLElement, true)
: getFirstFocusable(hostElement.current, hostElement.current.firstChild as HTMLElement, true);
if (elementToFocus) {
elementToFocus.focus();
ev.preventDefault();
ev.stopPropagation();
}
}
};
const onItemKeyDown = (item: any, ev: React.KeyboardEvent<HTMLElement>): void => {
const openKey = getRTL(theme) ? KeyCodes.left : KeyCodes.right;
if (
!item.disabled &&
// eslint-disable-next-line deprecation/deprecation
(ev.which === openKey || ev.which === KeyCodes.enter || (ev.which === KeyCodes.down && (ev.altKey || ev.metaKey)))
) {
openSubMenu(item, ev.currentTarget as HTMLElement, false);
ev.preventDefault();
}
};
return [onKeyDown, onKeyUp, onMenuKeyDown, onItemKeyDown] as const;
}
function useScrollHandler(asyncTracker: Async) {
const isScrollIdle = React.useRef<boolean>(true);
const scrollIdleTimeoutId = React.useRef<number | undefined>();
/**
* Scroll handler for the callout to make sure the mouse events
* for updating focus are not interacting during scroll
*/
const onScroll = (): void => {
if (!isScrollIdle.current && scrollIdleTimeoutId.current !== undefined) {
asyncTracker.clearTimeout(scrollIdleTimeoutId.current);
scrollIdleTimeoutId.current = undefined;
} else {
isScrollIdle.current = false;
}
scrollIdleTimeoutId.current = asyncTracker.setTimeout(() => {
isScrollIdle.current = true;
}, NavigationIdleDelay);
};
return [onScroll, isScrollIdle] as const;
}
function useOnSubmenuDismiss(dismiss: (ev?: any, dismissAll?: boolean) => void, closeSubMenu: () => void) {
const isMountedRef = React.useRef(false);
React.useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
/**
* This function is called ASYNCHRONOUSLY, and so there is a chance it is called
* after the component is unmounted. The isMountedRef is added to prevent
* from calling setState() after unmount. Do NOT copy this pattern in synchronous
* code.
*/
const onSubMenuDismiss = (ev?: any, dismissAll?: boolean): void => {
if (dismissAll) {
dismiss(ev, dismissAll);
} else if (isMountedRef.current) {
closeSubMenu();
}
};
return onSubMenuDismiss;
}
function useSubmenuEnterTimer({ subMenuHoverDelay = NavigationIdleDelay }: IContextualMenuProps, asyncTracker: Async) {
const enterTimerRef = React.useRef<number | undefined>(undefined);
const cancelSubMenuTimer = () => {
if (enterTimerRef.current !== undefined) {
asyncTracker.clearTimeout(enterTimerRef.current);
enterTimerRef.current = undefined;
}
};
const startSubmenuTimer = (onTimerExpired: () => void) => {
enterTimerRef.current = asyncTracker.setTimeout(() => {
onTimerExpired();
cancelSubMenuTimer();
}, subMenuHoverDelay);
};
return [cancelSubMenuTimer, startSubmenuTimer, enterTimerRef as React.RefObject<number | undefined>] as const;
}
function useMouseHandlers(
props: IContextualMenuProps,
isScrollIdle: React.MutableRefObject<boolean>,
subMenuEntryTimer: React.RefObject<number | undefined>,
targetWindow: Window | undefined,
shouldUpdateFocusOnMouseEvent: React.MutableRefObject<boolean>,
gotMouseMove: React.MutableRefObject<boolean>,
expandedMenuItemKey: string | undefined,
hostElement: React.RefObject<HTMLDivElement>,
startSubmenuTimer: (onTimerExpired: () => void) => void,
cancelSubMenuTimer: () => void,
openSubMenu: (submenuItemKey: IContextualMenuItem, target: HTMLElement, openedByMouseClick?: boolean) => void,
onSubMenuDismiss: (ev?: any, dismissAll?: boolean) => void,
dismiss: (ev?: any, dismissAll?: boolean) => void,
) {
const { target: menuTarget } = props;
const onItemMouseEnterBase = (item: any, ev: React.MouseEvent<HTMLElement>, target?: HTMLElement): void => {
if (shouldIgnoreMouseEvent()) {
return;
}
updateFocusOnMouseEvent(item, ev, target);
};
const onItemMouseMoveBase = (item: any, ev: React.MouseEvent<HTMLElement>, target: HTMLElement): void => {
const targetElement = ev.currentTarget as HTMLElement;
// Always do this check to make sure we record a mouseMove if needed (even if we are timed out)
if (shouldUpdateFocusOnMouseEvent.current) {
gotMouseMove.current = true;
} else {
return;
}
if (
!isScrollIdle.current ||
subMenuEntryTimer.current !== undefined ||
targetElement === (targetWindow?.document.activeElement as HTMLElement)
) {
return;
}
updateFocusOnMouseEvent(item, ev, target);
};
const shouldIgnoreMouseEvent = (): boolean => {
return !isScrollIdle.current || !gotMouseMove.current;
};
const onMouseItemLeave = (item: any, ev: React.MouseEvent<HTMLElement>): void => {
if (shouldIgnoreMouseEvent()) {
return;
}
cancelSubMenuTimer();
if (expandedMenuItemKey !== undefined) {
return;
}
/**
* IE11 focus() method forces parents to scroll to top of element.
* Edge and IE expose a setActive() function for focusable divs that
* sets the page focus but does not scroll the parent element.
*/
if ((hostElement.current as any).setActive) {
try {
(hostElement.current as any).setActive();
} catch (e) {
/* no-op */
}
} else {
hostElement.current?.focus();
}
};
/**
* Handles updating focus when mouseEnter or mouseMove fire.
* As part of updating focus, This function will also update
* the expand/collapse state accordingly.
*/
const updateFocusOnMouseEvent = (
item: IContextualMenuItem,
ev: React.MouseEvent<HTMLElement>,
target?: HTMLElement,
) => {
const targetElement = target ? target : (ev.currentTarget as HTMLElement);
if (item.key === expandedMenuItemKey) {
return;
}
cancelSubMenuTimer();
// If the menu is not expanded we can update focus without any delay
if (expandedMenuItemKey === undefined) {
targetElement.focus();
}
// Delay updating expanding/dismissing the submenu
// and only set focus if we have not already done so
if (hasSubmenu(item)) {
ev.stopPropagation();
startSubmenuTimer(() => {
targetElement.focus();
openSubMenu(item, targetElement, true);
});
} else {
startSubmenuTimer(() => {
onSubMenuDismiss(ev);
targetElement.focus();
});
}
};
const onItemClick = (
item: IContextualMenuItem,
ev: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>,
): void => {
onItemClickBase(item, ev, ev.currentTarget as HTMLElement);
};
const onItemClickBase = (
item: IContextualMenuItem,
ev: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>,
target: HTMLElement,
): void => {
const items = getSubmenuItems(item, { target: menuTarget });
// Cancel an async menu item hover timeout action from being taken and instead
// just trigger the click event instead.
cancelSubMenuTimer();
if (!hasSubmenu(item) && (!items || !items.length)) {
// This is an item without a menu. Click it.
executeItemClick(item, ev);
} else {
if (item.key !== expandedMenuItemKey) {
// This has a collapsed sub menu. Expand it.
openSubMenu(
item,
target,
// When Edge + Narrator are used together (regardless of if the button is in a form or not), pressing
// "Enter" fires this method and not _onMenuKeyDown. Checking ev.nativeEvent.detail differentiates
// between a real click event and a keypress event (detail should be the number of mouse clicks).
// ...Plot twist! For a real click event in IE 11, detail is always 0 (Edge sets it properly to 1).
// So we also check the pointerType property, which both Edge and IE set to "mouse" for real clicks
// and "" for pressing "Enter" with Narrator on.
ev.nativeEvent.detail !== 0 || (ev.nativeEvent as PointerEvent).pointerType === 'mouse',
);
}
}
ev.stopPropagation();
ev.preventDefault();
};
const onAnchorClick = (item: IContextualMenuItem, ev: React.MouseEvent<HTMLElement>) => {
executeItemClick(item, ev);
ev.stopPropagation();
};
const executeItemClick = (
item: IContextualMenuItem,
ev: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>,
): void => {
if (item.disabled || item.isDisabled) {
return;
}
if (item.preferMenuTargetAsEventTarget) {
overrideTarget(ev, menuTarget);
}
let shouldDismiss = false;
if (item.onClick) {
shouldDismiss = !!item.onClick(ev, item);
} else if (props.onItemClick) {
shouldDismiss = !!props.onItemClick(ev, item);
}
if (shouldDismiss || !ev.defaultPrevented) {
dismiss(ev, true);
}
};
return [
onItemMouseEnterBase,
onItemMouseMoveBase,
onMouseItemLeave,
onItemClick,
onAnchorClick,
executeItemClick,
onItemClickBase,
] as const;
}
//#endregion
export const ContextualMenuBase: React.FunctionComponent<IContextualMenuProps> = React.memo(
React.forwardRef<HTMLDivElement, IContextualMenuProps>((propsWithoutDefaults, forwardedRef) => {
const { ref, ...props } = getPropsWithDefaults(DEFAULT_PROPS, propsWithoutDefaults);
const hostElement = React.useRef<HTMLDivElement>(null);
const asyncTracker = useAsync();
const menuId = useId(COMPONENT_NAME, props.id);
useWarnings({
name: COMPONENT_NAME,
props,
deprecations: {
getMenuClassNames: 'styles',
},
});
const dismiss = (ev?: any, dismissAll?: boolean) => props.onDismiss?.(ev, dismissAll);
const [targetRef, targetWindow] = useTarget(props.target, hostElement);
const [tryFocusPreviousActiveElement] = usePreviousActiveElement(props, targetWindow);
const [expandedMenuItemKey, openSubMenu, getSubmenuProps, onSubMenuDismiss] = useSubMenuState(props, dismiss);
const [shouldUpdateFocusOnMouseEvent, gotMouseMove, onMenuFocusCapture] = useShouldUpdateFocusOnMouseMove(props);
const [onScroll, isScrollIdle] = useScrollHandler(asyncTracker);
const [cancelSubMenuTimer, startSubmenuTimer, subMenuEntryTimer] = useSubmenuEnterTimer(props, asyncTracker);
const responsiveMode = useResponsiveMode(hostElement, props.responsiveMode);
useVisibility(props, targetWindow);
const [onKeyDown, onKeyUp, onMenuKeyDown, onItemKeyDown] = useKeyHandlers(props, dismiss, hostElement, openSubMenu);
const [
onItemMouseEnterBase,
onItemMouseMoveBase,
onMouseItemLeave,
onItemClick,
onAnchorClick,
executeItemClick,
onItemClickBase,
] = useMouseHandlers(
props,
isScrollIdle,
subMenuEntryTimer,
targetWindow,
shouldUpdateFocusOnMouseEvent,
gotMouseMove,
expandedMenuItemKey,
hostElement,
startSubmenuTimer,
cancelSubMenuTimer,
openSubMenu,
onSubMenuDismiss,
dismiss,
);
//#region Render helpers
const onDefaultRenderMenuList = (
menuListProps: IContextualMenuListProps,
// eslint-disable-next-line deprecation/deprecation
menuClassNames: IProcessedStyleSet<IContextualMenuStyles> | IContextualMenuClassNames,
defaultRender?: IRenderFunction<IContextualMenuListProps>,
): JSX.Element => {
let indexCorrection = 0;
const { items, totalItemCount, hasCheckmarks, hasIcons } = menuListProps;
return (
<ul className={menuClassNames.list} onKeyDown={onKeyDown} onKeyUp={onKeyUp} role={'presentation'}>
{items.map((item, index) => {
const menuItem = renderMenuItem(
item,
index,
indexCorrection,
totalItemCount,
hasCheckmarks,
hasIcons,
menuClassNames,
);
if (item.itemType !== ContextualMenuItemType.Divider && item.itemType !== ContextualMenuItemType.Header) {