import { Accessibility, toolbarMenuItemBehavior, ToolbarMenuItemBehaviorProps, indicatorBehavior, } from '@fluentui/accessibility'; import * as React from 'react'; import * as _ from 'lodash'; import cx from 'classnames'; import * as PropTypes from 'prop-types'; import { EventListener } from '@fluentui/react-component-event-listener'; import { Ref } from '@fluentui/react-component-ref'; import * as customPropTypes from '@fluentui/react-proptypes'; import { compose, focusAsync, mergeVariablesOverrides, useTelemetry, useStyles, useAutoControlled, useFluentContext, getElementType, useUnhandledProps, useAccessibility, } from '@fluentui/react-bindings'; import { GetRefs, NodeRef, Unstable_NestingAuto } from '@fluentui/react-component-nesting-registry'; import { useContextSelectors } from '@fluentui/react-context-selector'; import { createShorthand, ChildrenComponentProps, commonPropTypes, ContentComponentProps, UIComponentProps, childrenExist, doesNodeContainClick, } from '../../utils'; import { ComponentEventHandler, ShorthandValue, ShorthandCollection } from '../../types'; import { partitionPopperPropsFromShorthand, Popper, PopperShorthandProps } from '../../utils/positioner'; import { Box, BoxProps } from '../Box/Box'; import { Popup, PopupProps } from '../Popup/Popup'; import { ToolbarMenu, ToolbarMenuProps, ToolbarMenuItemShorthandKinds } from './ToolbarMenu'; import { ToolbarMenuItemIcon, ToolbarMenuItemIconProps } from './ToolbarMenuItemIcon'; import { ToolbarVariablesContext, ToolbarVariablesProvider } from './toolbarVariablesContext'; import { ToolbarMenuItemSubmenuIndicator } from './ToolbarMenuItemSubmenuIndicator'; import { ToolbarMenuItemActiveIndicator } from './ToolbarMenuItemActiveIndicator'; import { ToolbarItemSubscribedValue, ToolbarMenuContext } from './toolbarMenuContext'; export interface ToolbarMenuItemProps extends UIComponentProps, ChildrenComponentProps, ContentComponentProps { /** * Accessibility behavior if overridden by the user. */ accessibility?: Accessibility; /** A toolbar item can be active. */ active?: boolean; /** A slot for a selected indicator in the dropdown list. */ activeIndicator?: ShorthandValue; /** A toolbar item can show it is currently unable to be interacted with. */ disabled?: boolean; /** Name or shorthand for Toolbar Item Icon */ icon?: ShorthandValue; /** ToolbarMenuItem index inside ToolbarMenu. */ index?: number; /** Shorthand for the submenu indicator. */ submenuIndicator?: ShorthandValue; /** Indicates whether the menu item is part of submenu. */ inSubmenu?: boolean; /** Shorthand for the submenu. */ menu?: | ShorthandValue | ShorthandCollection; /** Indicates if the menu inside the item is open. */ menuOpen?: boolean; /** Default menu open */ defaultMenuOpen?: boolean; /** * Called on click. * * @param event - React's original SyntheticEvent. * @param data - All props. */ onClick?: ComponentEventHandler; /** * Called when the menu inside the item opens or closes. * @param event - React's original SyntheticEvent. * @param data - All props, with `menuOpen` reflecting the new state. */ onMenuOpenChange?: ComponentEventHandler; /** * Attaches a `Popup` component to the ToolbarMenuItem. * Accepts all props as a `Popup`, except `trigger` and `children`. * Traps focus by default. * @see PopupProps */ popup?: Omit | string; /** Shorthand for the wrapper component. */ wrapper?: ShorthandValue; } export type ToolbarMenuItemStylesProps = Pick & { hasContent: boolean }; export interface ToolbarMenuItemSlotClassNames { wrapper: string; submenu: string; } export const toolbarMenuItemClassName = 'ui-toolbar__menuitem'; export const toolbarMenuItemSlotClassNames: ToolbarMenuItemSlotClassNames = { wrapper: `${toolbarMenuItemClassName}__wrapper`, submenu: `${toolbarMenuItemClassName}__submenu`, }; /** * A ToolbarMenuItem renders ToolbarMenu item as button. */ export const ToolbarMenuItem = compose<'button', ToolbarMenuItemProps, ToolbarMenuItemStylesProps, {}, {}>( (props, ref, composeOptions) => { const context = useFluentContext(); const { setStart, setEnd } = useTelemetry(composeOptions.displayName, context.telemetry); setStart(); const { active, activeIndicator, children, content, disabled, submenuIndicator, icon, popup, wrapper, inSubmenu, className, design, styles, variables, } = props; const [menu, menuPositioningProps] = partitionPopperPropsFromShorthand(props.menu); const [menuOpen, setMenuOpen] = useAutoControlled({ defaultValue: props.defaultMenuOpen, value: props.menuOpen, initialValue: false, }); const itemRef = React.useRef(); const menuRef = React.useRef(); const { menuSlot } = (useContextSelectors(ToolbarMenuContext, { menuSlot: v => v.slots.menu, }) as unknown) as ToolbarItemSubscribedValue; // TODO: we should improve typings for the useContextSelectors const parentVariables = React.useContext(ToolbarVariablesContext); const mergedVariables = mergeVariablesOverrides(parentVariables, variables); const ElementType = getElementType(props); const slotProps = composeOptions.resolveSlotProps(props); const unhandledProps = useUnhandledProps(composeOptions.handledProps, props); const getA11yProps = useAccessibility(props.accessibility, { debugName: composeOptions.displayName, mapPropsToBehavior: () => ({ menu, active, menuOpen, disabled, 'aria-label': props['aria-label'], 'aria-labelledby': props['aria-labelledby'], 'aria-describedby': props['aria-describedby'], }), actionHandlers: { performClick: event => { event.preventDefault(); handleClick(event); }, openMenu: event => openMenu(event), closeAllMenusAndFocusNextParentItem: event => closeAllMenus(event), closeMenu: event => closeMenu(event), closeMenuAndFocusTrigger: event => closeMenu(event), doNotNavigateNextParentItem: event => { event.stopPropagation(); }, closeAllMenus: event => closeAllMenus(event), }, rtl: context.rtl, }); const { classes, styles: resolvedStyles } = useStyles(composeOptions.displayName, { className: composeOptions.className, composeOptions, mapPropsToStyles: () => ({ disabled, hasContent: !!content, }), mapPropsToInlineStyles: () => ({ className, design, styles, variables: mergedVariables, }), rtl: context.rtl, unstable_props: props, }); const openMenu = (e: React.KeyboardEvent) => { if (menu && !menuOpen) { trySetMenuOpen(true, e); e.stopPropagation(); e.preventDefault(); } }; const closeMenu = (e: React.KeyboardEvent) => { if (!isSubmenuOpen()) { return; } trySetMenuOpen(false, e, () => { focusAsync(itemRef.current); }); e.stopPropagation(); }; const closeAllMenus = (e: React.KeyboardEvent) => { if (!isSubmenuOpen()) { return; } trySetMenuOpen(false, e, () => { if (!inSubmenu) { focusAsync(itemRef.current); } }); // avoid spacebar scrolling the page if (!inSubmenu) { e.preventDefault(); } }; const isSubmenuOpen = (): boolean => { return !!(menu && menuOpen); }; const trySetMenuOpen = (newValue: boolean, e: Event | React.SyntheticEvent, 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 outsideClickHandler = (getRefs: GetRefs) => (e: MouseEvent) => { const isItemClick = doesNodeContainClick(itemRef.current, e, context.target); const isNestedClick = _.some(getRefs(), (childRef: NodeRef) => { return doesNodeContainClick(childRef.current as HTMLElement, e, context.target); }); const isInside = isItemClick || isNestedClick; if (!isInside) { trySetMenuOpen(false, e); } }; const handleMenuOverrides = (predefinedProps: ToolbarMenuProps) => ({ onItemClick: (e, itemProps: ToolbarMenuItemProps) => { const { popup, menuOpen } = itemProps; _.invoke(predefinedProps, 'onItemClick', e, itemProps); if (popup) { return; } trySetMenuOpen(menuOpen, e); if (!menuOpen) { _.invoke(itemRef.current, 'focus'); } }, }); const handleClick = (e: React.MouseEvent | React.KeyboardEvent) => { if (disabled) { e.preventDefault(); return; } if (menu) { // the menuItem element was clicked => toggle the open/close and stop propagation trySetMenuOpen(!menuOpen, e); e.stopPropagation(); e.preventDefault(); } if (popup) { e.stopPropagation(); e.preventDefault(); return; } _.invoke(props, 'onClick', e, props); }; const element = ( {childrenExist(children) ? ( children ) : ( <> {createShorthand(composeOptions.slots.icon, icon, { defaultProps: () => slotProps.icon })} {content} {active && createShorthand(composeOptions.slots.activeIndicator, activeIndicator, { defaultProps: () => slotProps.activeIndicator, })} {menu && createShorthand(composeOptions.slots.submenuIndicator, submenuIndicator, { defaultProps: () => slotProps.submenuIndicator, })} )} ); const hasChildren = childrenExist(children); if (popup && !hasChildren) { const popupElement = createShorthand(composeOptions.slots.popup, popup, { defaultProps: () => ({ ...slotProps.popup, onOpenChange: e => { e.stopPropagation(); }, }), overrideProps: { trigger: element, children: undefined, // force-reset `children` defined for `Popup` as it collides with the `trigger` }, }); setEnd(); return popupElement; } const menuItemInner = hasChildren ? (children as React.ReactElement) : {element}; const maybeSubmenu = menu && menuOpen ? ( {(getRefs, nestingRef) => { return ( <> { nestingRef.current = node; menuRef.current = node; }} > {createShorthand(composeOptions.slots.menu || menuSlot || ToolbarMenu, menu, { defaultProps: () => ({ className: toolbarMenuItemSlotClassNames.submenu, styles: resolvedStyles.menu, submenu: true, submenuIndicator, ...slotProps.menu, }), overrideProps: handleMenuOverrides, })} ); }} ) : null; if (!wrapper) { setEnd(); return menuItemInner; } const wrapperElement = Box.create(wrapper, { defaultProps: () => getA11yProps('wrapper', { className: cx(toolbarMenuItemSlotClassNames.wrapper, classes.wrapper), }), overrideProps: () => ({ children: ( <> {menuItemInner} {maybeSubmenu} ), }), }); setEnd(); return wrapperElement; }, { className: toolbarMenuItemClassName, displayName: 'ToolbarMenuItem', slots: { icon: ToolbarMenuItemIcon, submenuIndicator: ToolbarMenuItemSubmenuIndicator, activeIndicator: ToolbarMenuItemActiveIndicator, popup: Popup, }, slotProps: props => ({ icon: { hasContent: !!props.content, }, submenuIndicator: { accessibility: indicatorBehavior, }, activeIndicator: { accessibility: indicatorBehavior, }, popup: { trapFocus: true, }, }), shorthandConfig: { mappedProp: 'content', }, handledProps: [ 'accessibility', 'as', 'children', 'className', 'content', 'design', 'styles', 'variables', 'active', 'activeIndicator', 'defaultMenuOpen', 'disabled', 'icon', 'index', 'submenuIndicator', 'inSubmenu', 'menu', 'menuOpen', 'onClick', 'onMenuOpenChange', 'popup', 'wrapper', ], }, ); ToolbarMenuItem.propTypes = { ...commonPropTypes.createCommon(), active: PropTypes.bool, activeIndicator: customPropTypes.shorthandAllowingChildren, defaultMenuOpen: PropTypes.bool, disabled: PropTypes.bool, icon: customPropTypes.shorthandAllowingChildren, index: PropTypes.number, submenuIndicator: customPropTypes.shorthandAllowingChildren, inSubmenu: PropTypes.bool, menu: PropTypes.oneOfType([customPropTypes.itemShorthand, customPropTypes.collectionShorthand]), menuOpen: PropTypes.bool, onClick: PropTypes.func, onMenuOpenChange: PropTypes.func, popup: PropTypes.oneOfType([ PropTypes.shape({ ...Popup.propTypes, trigger: customPropTypes.never, children: customPropTypes.never, }), PropTypes.string, ]), wrapper: customPropTypes.itemShorthand, }; ToolbarMenuItem.defaultProps = { as: 'button', accessibility: toolbarMenuItemBehavior, activeIndicator: {}, submenuIndicator: {}, wrapper: { as: 'li' }, };