import * as React from 'react'; import * as PropTypes from 'prop-types'; import * as _ from 'lodash'; import { Accessibility, menuButtonBehavior, MenuButtonBehaviorProps } from '@fluentui/accessibility'; import { Ref } from '@fluentui/react-component-ref'; import * as customPropTypes from '@fluentui/react-proptypes'; import { commonPropTypes, StyledComponentProps, getOrGenerateIdFromShorthand } from '../../utils'; import { ShorthandValue, ComponentEventHandler, ShorthandCollection, FluentComponentStaticProps } from '../../types'; import { createShorthandFactory } from '../../utils/factories'; import { Popup, PopupProps, PopupEvents, PopupEventsArray } from '../Popup/Popup'; import { Menu, MenuProps } from '../Menu/Menu'; import { MenuItemProps } from '../Menu/MenuItem'; import { focusMenuItem } from './focusUtils'; import { ALIGNMENTS, POSITIONS, PositioningProps } from '../../utils/positioner'; import { ComponentWithAs, useAccessibility, useTelemetry, getElementType, useUnhandledProps, useFluentContext, useAutoControlled, useStyles, } from '@fluentui/react-bindings'; export interface MenuButtonSlotClassNames { menu: string; } export interface MenuButtonProps extends StyledComponentProps, PositioningProps { /** * Accessibility behavior if overridden by the user. * @default menuButtonBehavior */ accessibility?: Accessibility; /** Additional CSS class name(s) to apply. */ className?: string; /** Initial value for 'open'. */ defaultOpen?: boolean; /** Existing element the popup should be bound to. */ mountNode?: HTMLElement; /** Delay in ms for the mouse leave event, before the popup will be closed. */ mouseLeaveDelay?: number; /** Events triggering the popup. */ on?: PopupEvents | PopupEventsArray; /** Defines whether popup is displayed. */ open?: boolean; /** * Called after user's click on a menu item. * * @param event - React's original SyntheticEvent. * @param data - All props. */ onMenuItemClick?: ComponentEventHandler; /** * Event for request to change 'open' value. * @param event - React's original SyntheticEvent. * @param data - All props and proposed value. */ onOpenChange?: ComponentEventHandler; /** A popup can show a pointer to trigger. */ pointing?: boolean; /** * DOM element that should be used as popup's target - instead of 'trigger' element that is used by default. */ target?: HTMLElement; /** Element to be rendered in-place where the popup is defined. */ trigger?: JSX.Element; /** Whether the trigger should be tabbable */ tabbableTrigger?: boolean; /** Shorthand for menu configuration */ menu?: ShorthandValue | ShorthandCollection; /** Determines if the MenuButton behaves as context menu */ contextMenu?: boolean; } export interface MenuButtonState { open: boolean; menuId: string; triggerId: string; } export const menuButtonClassName = 'ui-menubutton'; export const menuButtonSlotClassNames: MenuButtonSlotClassNames = { menu: `${menuButtonClassName}__menu`, }; export type MenuButtonStylesProps = never; /** * A MenuButton displays a menu connected to trigger element. * @accessibility */ export const MenuButton: ComponentWithAs<'div', MenuButtonProps> & FluentComponentStaticProps = props => { const context = useFluentContext(); const { setStart, setEnd } = useTelemetry(MenuButton.displayName, context.telemetry); setStart(); const { // MenuButton props: contextMenu, menu, // Popup props: accessibility, align, className, defaultOpen, flipBoundary, mountNode, mouseLeaveDelay, offset, on, onOpenChange, overflowBoundary, pointing, popperRef, position, positionFixed, tabbableTrigger, target, trigger, unstable_pinned, variables, } = props; const [open, setOpen] = useAutoControlled({ defaultValue: props.defaultOpen, value: props.open, initialValue: false, }); const menuId = React.useRef(); menuId.current = getOrGenerateIdFromShorthand('menubutton-menu-', menu, menuId.current); const triggerId = React.useRef(); triggerId.current = getOrGenerateIdFromShorthand('menubutton-trigger-', trigger, triggerId.current); const triggerRef = React.useRef(); const menuRef = React.useRef(); const ElementType = getElementType(props); const unhandledProps = useUnhandledProps(MenuButton.handledProps, props); const getA11yProps = useAccessibility(accessibility, { debugName: MenuButton.displayName, actionHandlers: { closeMenu: e => closeMenu(e), openAndFocusFirst: e => openAndFocus(e, 'first'), openAndFocusLast: e => openAndFocus(e, 'last'), }, mapPropsToBehavior: () => ({ menuId: menuId.current, triggerId: triggerId.current, open, trigger: props.trigger, contextMenu, on, tabbableTrigger, }), rtl: context.rtl, }); const popupProps: PopupProps = { accessibility, align, className, defaultOpen, mountNode, mouseLeaveDelay, flipBoundary, offset, on, onOpenChange, open, overflowBoundary, pointing, popperRef, position, positionFixed, tabbableTrigger, styles: props.styles, target, trigger, unstable_pinned, variables, }; const { classes, styles: resolvedStyles } = useStyles(MenuButton.displayName, { className: menuButtonClassName, mapPropsToInlineStyles: () => ({ className, styles: props.styles, variables, }), rtl: context.rtl, }); const closeMenu = (e: React.KeyboardEvent) => { handleOpenChange(e, false); }; const openAndFocus = (e: React.KeyboardEvent, which: 'first' | 'last') => { e.preventDefault(); handleOpenChange(e, true, () => menuRef.current && focusMenuItem(menuRef.current, which)); }; const handleOpenChange = (e: React.SyntheticEvent, open: boolean, callback?: () => void) => { _.invoke(props, 'onOpenChange', e, { ...props, open }); setOpen(open); callback && callback(); }; const handleMenuOverrides = (predefinedProps: MenuProps) => ({ onItemClick: (e: React.SyntheticEvent, itemProps: MenuItemProps) => { _.invoke(predefinedProps, 'onItemClick', e, itemProps); _.invoke(props, 'onMenuItemClick', e, itemProps); if (!itemProps || !itemProps.menu) { // do not close if clicked on item with submenu handleOpenChange(e, false); } }, }); const content = Menu.create(menu, { defaultProps: () => getA11yProps('menu', { vertical: true, className: menuButtonSlotClassNames.menu, }), overrideProps: handleMenuOverrides, }); const overrideProps: PopupProps = { accessibility: getA11yProps.unstable_behaviorDefinition, open, onOpenChange: (e, { open }) => { handleOpenChange(e, open); }, content: { styles: resolvedStyles.popupContent, content: content && {content}, }, children: undefined, // force-reset `children` defined for `Popup` as it collides with the `trigger ...(contextMenu ? { on: 'context', trapFocus: true, tabbableTrigger: false, } : { inline: true, autoFocus: true, }), }; const popup = Popup.create(popupProps, { overrideProps }); if (contextMenu) { setEnd(); return popup; } const element = getA11yProps.unstable_wrapWithFocusZone( {popup} , ); setEnd(); return element; }; MenuButton.displayName = 'MenuButton'; MenuButton.propTypes = { ...commonPropTypes.createCommon({ content: false, }), align: PropTypes.oneOf(ALIGNMENTS), defaultOpen: PropTypes.bool, mountNode: customPropTypes.domNode, mouseLeaveDelay: PropTypes.number, offset: PropTypes.oneOfType([ PropTypes.func, PropTypes.arrayOf(PropTypes.number) as PropTypes.Requireable<[number, number]>, ]), on: PropTypes.oneOfType([ PropTypes.oneOf(['hover', 'click', 'focus', 'context']), PropTypes.arrayOf(PropTypes.oneOf(['click', 'focus', 'context'])), PropTypes.arrayOf(PropTypes.oneOf(['hover', 'focus', 'context'])), ]) as any, flipBoundary: PropTypes.oneOfType([ PropTypes.object as PropTypes.Requireable, PropTypes.arrayOf(PropTypes.object) as PropTypes.Requireable, PropTypes.oneOf<'clippingParents' | 'window' | 'scrollParent'>(['clippingParents', 'window', 'scrollParent']), ]), overflowBoundary: PropTypes.oneOfType([ PropTypes.object as PropTypes.Requireable, PropTypes.arrayOf(PropTypes.object) as PropTypes.Requireable, PropTypes.oneOf<'clippingParents' | 'window' | 'scrollParent'>(['clippingParents', 'window', 'scrollParent']), ]), open: PropTypes.bool, onMenuItemClick: PropTypes.func, onOpenChange: PropTypes.func, popperRef: customPropTypes.ref, position: PropTypes.oneOf(POSITIONS), positionFixed: PropTypes.bool, target: PropTypes.any, trigger: customPropTypes.every([customPropTypes.disallow(['children']), PropTypes.any]), tabbableTrigger: PropTypes.bool, unstable_pinned: PropTypes.bool, menu: PropTypes.oneOfType([ customPropTypes.itemShorthandWithoutJSX, PropTypes.arrayOf(customPropTypes.itemShorthandWithoutJSX), ]), contextMenu: PropTypes.bool, }; MenuButton.defaultProps = { accessibility: menuButtonBehavior, align: 'start', position: 'below', }; MenuButton.handledProps = Object.keys(MenuButton.propTypes) as any; MenuButton.create = createShorthandFactory({ Component: MenuButton, mappedProp: 'menu' });