import { Accessibility, submenuBehavior, menuItemBehavior, MenuItemBehaviorProps } from '@fluentui/accessibility'; import { compose, focusAsync, mergeVariablesOverrides, useTelemetry, useAutoControlled, useFluentContext, getElementType, useUnhandledProps, useAccessibility, useStyles, ComponentWithAs, ShorthandConfig, } from '@fluentui/react-bindings'; import { EventListener } from '@fluentui/react-component-event-listener'; import { Ref, handleRef } from '@fluentui/react-component-ref'; import * as customPropTypes from '@fluentui/react-proptypes'; import * as _ from 'lodash'; import * as PropTypes from 'prop-types'; import * as React from 'react'; import { childrenExist, createShorthand, doesNodeContainClick, UIComponentProps, ChildrenComponentProps, ContentComponentProps, commonPropTypes, isFromKeyboard as isEventFromKeyboard, } from '../../utils'; import { Menu, MenuProps, MenuShorthandKinds } from './Menu'; import { MenuItemIcon, MenuItemIconProps } from './MenuItemIcon'; import { MenuItemContent, MenuItemContentProps } from './MenuItemContent'; import { MenuItemIndicator, MenuItemIndicatorProps } from './MenuItemIndicator'; import { MenuItemWrapper, MenuItemWrapperProps } from './MenuItemWrapper'; import { ComponentEventHandler, ShorthandValue, ShorthandCollection } from '../../types'; import { Popper, PopperShorthandProps, partitionPopperPropsFromShorthand } from '../../utils/positioner'; import { MenuContext, MenuItemSubscribedValue } from './menuContext'; import { useContextSelectors } from '@fluentui/react-context-selector'; export interface MenuItemSlotClassNames { submenu: string; } export interface MenuItemProps extends UIComponentProps, ChildrenComponentProps, ContentComponentProps> { /** * Accessibility behavior if overridden by the user. * @available menuItemAsToolbarButtonBehavior, tabBehavior */ accessibility?: Accessibility; /** A menu item can be active. */ active?: boolean; /** A menu item can show it is currently unable to be interacted with. */ disabled?: boolean; /** Name or shorthand for Menu Item Icon */ icon?: ShorthandValue; /** A menu may have just icons. */ iconOnly?: boolean; /** MenuItem index inside Menu. */ index?: number; /** MenuItem position inside Menu (skipping separators). */ itemPosition?: number; /** MenuItem count inside Menu. */ itemsCount?: number; /** * Called on click. * * @param event - React's original SyntheticEvent. * @param data - All props. */ onClick?: ComponentEventHandler; /** * Called after user's focus. * @param event - React's original SyntheticEvent. * @param data - All props. */ onFocus?: ComponentEventHandler; /** * Called after item blur. * @param event - React's original SyntheticEvent. * @param data - All props. */ onBlur?: ComponentEventHandler; /** A menu can adjust its appearance to de-emphasize its contents. */ pills?: boolean; /** * A menu can point to show its relationship to nearby content. * For vertical menu, it can point to the start of the item or to the end. */ pointing?: boolean | 'start' | 'end'; /** The menu item can have primary type. */ primary?: boolean; /** The menu item can have secondary type. */ secondary?: boolean; /** Menu items can by highlighted using underline. */ underlined?: boolean; /** A vertical menu displays elements vertically. */ vertical?: boolean; /** Shorthand for the wrapper component. */ wrapper?: ShorthandValue; /** Shorthand for the submenu. */ menu?: | ShorthandValue | ShorthandCollection; /** Indicates if the menu inside the item is open. */ menuOpen?: boolean; /** Default menu open */ defaultMenuOpen?: boolean; /** Callback for setting the current menu item as active element in the menu. */ onActiveChanged?: ComponentEventHandler; /** Indicates whether the menu item is part of submenu. */ inSubmenu?: boolean; /** Shorthand for the submenu indicator. */ indicator?: ShorthandValue; /** * Event for request to change 'open' value. * @param event - React's original SyntheticEvent. * @param data - All props and proposed value. */ onMenuOpenChange?: ComponentEventHandler; } export type MenuItemStylesProps = Required< Pick< MenuItemProps, | 'primary' | 'underlined' | 'active' | 'vertical' | 'pointing' | 'secondary' | 'disabled' | 'iconOnly' | 'pills' | 'inSubmenu' > > & { isFromKeyboard: boolean }; export type MenuItemState = { isFromKeyboard: boolean; menuOpen: boolean }; export const menuItemClassName = 'ui-menu__item'; export const menuItemSlotClassNames: MenuItemSlotClassNames = { submenu: `${menuItemClassName}__submenu`, }; /** * A MenuItem is an actionable item within a Menu. */ export const MenuItem = compose<'a', MenuItemProps, MenuItemStylesProps, {}, {}>( (inputProps, ref, composeOptions) => { const context = useFluentContext(); const { setStart, setEnd } = useTelemetry(composeOptions.displayName, context.telemetry); setStart(); const parentProps = (useContextSelectors(MenuContext, { active: v => v.activeIndex === inputProps.index, onItemClick: v => v.onItemClick, variables: v => v.variables, menuSlot: v => v.slots.menu, slotProps: v => v.slotProps.item, accessibility: v => v.behaviors.item, }) as unknown) as MenuItemSubscribedValue; // TODO: we should improve typings for the useContextSelectors const props = { ...parentProps.slotProps, active: parentProps.active, variables: parentProps.variables, accessibility: parentProps.accessibility, ...inputProps, }; const { accessibility = menuItemBehavior, children, content, icon, wrapper, primary, secondary, active, vertical, indicator, disabled, underlined, iconOnly, inSubmenu, pills, pointing, className, design, styles, variables, } = props; const [menu, positioningProps] = partitionPopperPropsFromShorthand(props.menu); const [menuOpen, setMenuOpen] = useAutoControlled({ defaultValue: props.defaultMenuOpen, value: props.menuOpen, initialValue: false, }); const [isFromKeyboard, setIsFromKeyboard] = React.useState(false); const ElementType = getElementType(props); const unhandledProps = useUnhandledProps(composeOptions.handledProps, props); const slotProps = composeOptions.resolveSlotProps({ ...props, accessibility, variables: mergeVariablesOverrides(variables, parentProps.variables), isFromKeyboard, menuOpen, }); const getA11yProps = useAccessibility(accessibility, { debugName: composeOptions.displayName, actionHandlers: { performClick: event => !event.defaultPrevented && handleClick(event), openMenu: event => openMenu(event), closeAllMenusAndFocusNextParentItem: event => closeAllMenus(event), closeMenu: event => closeMenu(event), closeMenuAndFocusTrigger: event => closeMenu(event, true), doNotNavigateNextParentItem: event => { event.stopPropagation(); }, closeAllMenus: event => closeAllMenus(event), }, mapPropsToBehavior: () => ({ menuOpen, hasMenu: !!menu, disabled, vertical, }), rtl: context.rtl, }); const { classes, styles: resolvedStyles } = useStyles(MenuItem.displayName, { className: composeOptions.className, mapPropsToStyles: () => ({ primary, underlined, active, vertical, pointing, secondary, disabled, iconOnly, pills, inSubmenu, isFromKeyboard, }), mapPropsToInlineStyles: () => ({ className, design, styles, variables: mergeVariablesOverrides(parentProps.variables, variables), }), rtl: context.rtl, composeOptions, unstable_props: { ...props, menuOpen, isFromKeyboard }, }); const menuRef = React.useRef(); const itemRef = React.useRef(); const handleWrapperBlur = (e: React.FocusEvent) => { if (!props.inSubmenu && !e.currentTarget.contains(e.relatedTarget as Node)) { trySetMenuOpen(false, e); } }; const outsideClickHandler = (e: MouseEvent) => { if (!isSubmenuOpen()) return; if ( !doesNodeContainClick(itemRef.current, e, context.target) && !doesNodeContainClick(menuRef.current, e, context.target) ) { trySetMenuOpen(false, e); } }; const performClick = (e: React.MouseEvent | React.KeyboardEvent) => { if (menu) { if (doesNodeContainClick(menuRef.current, (e as unknown) as MouseEvent, context.target)) { // submenu was clicked => close it and propagate trySetMenuOpen(false, e, () => focusAsync(itemRef.current)); } else { // the menuItem element was clicked => toggle the open/close and stop propagation trySetMenuOpen(active ? !menuOpen : true, e); e.stopPropagation(); e.preventDefault(); } } }; const handleClick = (e: React.MouseEvent | React.KeyboardEvent) => { if (disabled) { e.preventDefault(); return; } performClick(e); _.invoke({ onClick: parentProps.onItemClick, ...props }, 'onClick', e, props); }; const handleBlur = (e: React.FocusEvent) => { setIsFromKeyboard(false); _.invoke(props, 'onBlur', e, props); }; const handleFocus = (e: React.FocusEvent) => { setIsFromKeyboard(isEventFromKeyboard()); _.invoke(props, 'onFocus', e, props); }; const isSubmenuOpen = (): boolean => { return !!(menu && menuOpen); }; const closeAllMenus = (e: React.MouseEvent | React.KeyboardEvent) => { if (!isSubmenuOpen()) { return; } trySetMenuOpen(false, e, () => { if (!inSubmenu) { focusAsync(itemRef.current); } }); // avoid spacebar scrolling the page if (!inSubmenu) { e.preventDefault(); } }; const closeMenu = (e: React.MouseEvent | React.KeyboardEvent, forceTriggerFocus?: boolean) => { if (!isSubmenuOpen()) { return; } const shouldStopPropagation = inSubmenu || props.vertical; trySetMenuOpen(false, e, () => { if (forceTriggerFocus || shouldStopPropagation) { focusAsync(itemRef.current); } }); if (forceTriggerFocus || shouldStopPropagation) { e.stopPropagation(); } }; const openMenu = (e: React.MouseEvent | React.KeyboardEvent) => { if (menu && !menuOpen) { trySetMenuOpen(true, e); _.invoke(props, 'onActiveChanged', e, { ...props, active: true }); e.stopPropagation(); e.preventDefault(); } }; const trySetMenuOpen = ( newValue: boolean, e: MouseEvent | React.FocusEvent | React.KeyboardEvent | React.MouseEvent, onStateChanged?: any, ) => { setMenuOpen(newValue); // The reason why post-effect is not passed as callback to trySetState method // is that in 'controlled' mode the post-effect is applied before final re-rendering // which cause a broken behavior: for e.g. when it is needed to focus submenu trigger on ESC. // TODO: all DOM post-effects should be applied at componentDidMount & componentDidUpdated stages. onStateChanged && onStateChanged(); _.invoke(props, 'onMenuOpenChange', e, { ...props, menuOpen: newValue, }); }; const menuItemInner = ( { itemRef.current = node; handleRef(ref, node); }} > {childrenExist(children) ? ( children ) : ( <> {createShorthand(composeOptions.slots.icon, icon, { defaultProps: () => getA11yProps('icon', slotProps.icon), })} {createShorthand(composeOptions.slots.content, content, { defaultProps: () => getA11yProps('content', slotProps.content), })} {menu && createShorthand(composeOptions.slots.indicator, indicator, { defaultProps: () => getA11yProps('indicator', slotProps.indicator), })} )} ); const maybeSubmenu = menu && active && menuOpen ? ( <> {createShorthand(parentProps.menuSlot || composeOptions.slots.menu || Menu, menu, { defaultProps: () => ({ ...slotProps.menu, styles: resolvedStyles.menu, }), })} ) : null; if (wrapper) { const wrapperElement = createShorthand(composeOptions.slots.wrapper, wrapper, { defaultProps: () => getA11yProps('wrapper', slotProps.wrapper), overrideProps: () => ({ children: ( <> {menuItemInner} {maybeSubmenu} ), onClick: handleClick, onBlur: handleWrapperBlur, }), }); setEnd(); return wrapperElement; } setEnd(); return menuItemInner; }, { displayName: 'MenuItem', className: menuItemClassName, slots: { icon: MenuItemIcon, indicator: MenuItemIndicator, content: MenuItemContent, wrapper: MenuItemWrapper, }, slotProps: (props: MenuItemProps & MenuItemState) => ({ icon: { hasContent: !!props.content, iconOnly: props.iconOnly, }, content: { hasIcon: !!props.icon, hasMenu: !!props.menu, inSubmenu: props.inSubmenu, vertical: props.vertical, }, indicator: { iconOnly: props.iconOnly, vertical: props.vertical, inSubmenu: props.inSubmenu, active: props.active, primary: props.primary, underlined: props.underlined, }, wrapper: { active: props.active, disabled: props.disabled, iconOnly: props.iconOnly, isFromKeyboard: props.isFromKeyboard, pills: props.pills, pointing: props.pointing, secondary: props.secondary, underlined: props.underlined, vertical: props.vertical, primary: props.primary, }, menu: { accessibility: submenuBehavior, className: menuItemSlotClassNames.submenu, vertical: true, primary: props.primary, secondary: props.secondary, submenu: true, indicator: props.indicator, }, }), handledProps: [ 'accessibility', 'as', 'children', 'className', 'content', 'design', 'active', 'disabled', 'icon', 'iconOnly', 'index', 'itemPosition', 'itemsCount', 'onClick', 'onFocus', 'onBlur', 'pills', 'pointing', 'primary', 'secondary', 'underlined', 'vertical', 'wrapper', 'menu', 'menuOpen', 'defaultMenuOpen', 'onActiveChanged', 'inSubmenu', 'indicator', 'onMenuOpenChange', 'styles', 'variables', ], shorthandConfig: { mappedProp: 'content', }, }, ) as ComponentWithAs<'a', MenuItemProps> & { shorthandConfig: ShorthandConfig; }; MenuItem.propTypes = { ...commonPropTypes.createCommon({ content: 'shorthand', }), active: PropTypes.bool, disabled: PropTypes.bool, icon: customPropTypes.shorthandAllowingChildren, iconOnly: PropTypes.bool, index: PropTypes.number, itemPosition: PropTypes.number, itemsCount: PropTypes.number, onClick: PropTypes.func, onFocus: PropTypes.func, onBlur: PropTypes.func, pills: PropTypes.bool, pointing: PropTypes.oneOf(['start', 'end', true, false]), primary: customPropTypes.every([customPropTypes.disallow(['secondary']), PropTypes.bool]), secondary: customPropTypes.every([customPropTypes.disallow(['primary']), PropTypes.bool]), underlined: PropTypes.bool, vertical: PropTypes.bool, wrapper: PropTypes.oneOfType([PropTypes.node, PropTypes.object]), menu: PropTypes.oneOfType([customPropTypes.itemShorthand, customPropTypes.collectionShorthand]), menuOpen: PropTypes.bool, defaultMenuOpen: PropTypes.bool, onActiveChanged: PropTypes.func, inSubmenu: PropTypes.bool, indicator: customPropTypes.shorthandAllowingChildren, onMenuOpenChange: PropTypes.func, }; MenuItem.defaultProps = { as: 'a', wrapper: {}, indicator: {}, };