diff --git a/change/@fluentui-react-next-2020-08-06-20-30-37-react-next-copy-ContextualMenu.json b/change/@fluentui-react-next-2020-08-06-20-30-37-react-next-copy-ContextualMenu.json new file mode 100644 index 0000000000000..08d5c98e42fb2 --- /dev/null +++ b/change/@fluentui-react-next-2020-08-06-20-30-37-react-next-copy-ContextualMenu.json @@ -0,0 +1,8 @@ +{ + "type": "prerelease", + "comment": "Copy ContextualMenu to react-next", + "packageName": "@fluentui/react-next", + "email": "miclo@microsoft.com", + "dependentChangeType": "patch", + "date": "2020-08-06T20:30:37.314Z" +} diff --git a/packages/react-next/etc/react-next.api.md b/packages/react-next/etc/react-next.api.md index e50fc0a8fa3a4..135ead8ccfeec 100644 --- a/packages/react-next/etc/react-next.api.md +++ b/packages/react-next/etc/react-next.api.md @@ -6,15 +6,17 @@ import { BaseSlots } from '@fluentui/react-compose'; import { ComposePreparedOptions } from '@fluentui/react-compose'; +import { DirectionalHint } from 'office-ui-fabric-react/lib/common/DirectionalHint'; import { IBaseFloatingPickerProps } from 'office-ui-fabric-react/lib/FloatingPicker'; import { IBaseProps } from 'office-ui-fabric-react/lib/Utilities'; import { IButtonProps } from '@fluentui/react-next/lib/compat/Button'; import { IButtonProps as IButtonProps_2 } from 'office-ui-fabric-react/lib/components/Button/Button.types'; -import { IButtonStyles } from '@fluentui/react-next/lib/compat/Button'; +import { IButtonStyles } from 'office-ui-fabric-react/lib/Button'; +import { IButtonStyles as IButtonStyles_2 } from '@fluentui/react-next/lib/compat/Button'; import { ICalloutPositionedInfo } from 'office-ui-fabric-react/lib/utilities/positioning'; import { IComponentAs } from 'office-ui-fabric-react/lib/Utilities'; -import { IContextualMenuProps } from 'office-ui-fabric-react/lib/ContextualMenu'; -import { IFocusZoneProps } from '@fluentui/react-focus'; +import { IFocusZoneProps } from 'office-ui-fabric-react/lib/FocusZone'; +import { IFocusZoneProps as IFocusZoneProps_2 } from '@fluentui/react-focus'; import { IIconProps } from 'office-ui-fabric-react/lib/Icon'; import { IKeytipProps } from 'office-ui-fabric-react/lib/Keytip'; import { ILayerProps } from 'office-ui-fabric-react/lib/Layer'; @@ -32,6 +34,7 @@ import { ISuggestionModel } from 'office-ui-fabric-react/lib/Pickers'; import { ISvgIconProps } from '@fluentui/react-icons'; import { ITeachingBubble } from 'office-ui-fabric-react/lib/TeachingBubble'; import { ITheme } from 'office-ui-fabric-react/lib/Styling'; +import { IVerticalDividerClassNames } from 'office-ui-fabric-react/src/components/Divider/VerticalDivider.types'; import { IWithResponsiveModeState } from 'office-ui-fabric-react/lib/utilities/decorators/withResponsiveMode'; import { Point } from 'office-ui-fabric-react/lib/Utilities'; import { Position } from 'office-ui-fabric-react/lib/utilities/positioning'; @@ -99,6 +102,9 @@ export class BaseSelectedItemsList> // @public (undocumented) export const Callout: React.ForwardRefExoticComponent>; +// @public +export function canAnyMenuItemsCheck(items: IContextualMenuItem[]): boolean; + // @public (undocumented) export const Checkbox: import("@fluentui/react-compose").ComponentWithAs<"div", ICheckboxProps>; @@ -125,9 +131,63 @@ export class ColorPickerGridCellBase extends React.PureComponent; + +// @public (undocumented) +export class ContextualMenuBase extends React.Component { + constructor(props: IContextualMenuProps); + // (undocumented) + componentDidMount(): void; + // (undocumented) + componentWillUnmount(): void; + // (undocumented) + static defaultProps: IContextualMenuProps; + // (undocumented) + dismiss: (ev?: any, dismissAll?: boolean | undefined) => void; + // (undocumented) + render(): JSX.Element | null; + // (undocumented) + shouldComponentUpdate(newProps: IContextualMenuProps, newState: IContextualMenuState): boolean; + // (undocumented) + UNSAFE_componentWillMount(): void; + // (undocumented) + UNSAFE_componentWillUpdate(newProps: IContextualMenuProps): void; + } + +// @public +export const ContextualMenuItem: React.FunctionComponent; + +// @public (undocumented) +export class ContextualMenuItemBase extends React.Component { + constructor(props: IContextualMenuItemProps); + // (undocumented) + dismissMenu: (dismissAll?: boolean | undefined) => void; + // (undocumented) + dismissSubMenu: () => void; + // (undocumented) + openSubMenu: () => void; + // (undocumented) + render(): JSX.Element; +} + +// @public (undocumented) +export enum ContextualMenuItemType { + // (undocumented) + Divider = 1, + // (undocumented) + Header = 2, + // (undocumented) + Normal = 0, + // (undocumented) + Section = 3 +} + // @public (undocumented) export const DEFAULT_MASK_CHAR = "_"; +export { DirectionalHint } + // @public (undocumented) export class ExtendedSelectedItem extends React.Component { constructor(props: ISelectedPeopleItemProps); @@ -182,6 +242,9 @@ export const getNextResizeGroupStateProvider: (measurementCache?: { // @public export function getPersonaInitialsColor(props: Pick): string; +// @public (undocumented) +export function getSubmenuItems(item: IContextualMenuItem): IContextualMenuItem[] | undefined; + // @public (undocumented) export interface IAccessiblePopupProps { closeButtonAriaLabel?: string; @@ -260,9 +323,9 @@ export interface ICalloutProps extends React.HTMLAttributes { className?: string; coverTarget?: boolean; // Warning: (ae-forgotten-export) The symbol "DirectionalHint" needs to be exported by the entry point index.d.ts - directionalHint?: DirectionalHint; + directionalHint?: DirectionalHint_2; directionalHintFixed?: boolean; - directionalHintForRTL?: DirectionalHint; + directionalHintForRTL?: DirectionalHint_2; doNotLayer?: boolean; finalHeight?: number; gapSpace?: number; @@ -527,6 +590,248 @@ export interface IColorPickerGridCellStyles { svg: IStyle; } +// @public (undocumented) +export interface IContextualMenu { +} + +// @public (undocumented) +export interface IContextualMenuItem { + [propertyName: string]: any; + ariaLabel?: string; + canCheck?: boolean; + checked?: boolean; + className?: string; + componentRef?: IRefObject; + customOnRenderListLength?: number; + data?: any; + disabled?: boolean; + // Warning: (ae-forgotten-export) The symbol "IMenuItemClassNames" needs to be exported by the entry point index.d.ts + // + // @deprecated + getItemClassNames?: (theme: ITheme, disabled: boolean, expanded: boolean, checked: boolean, isAnchorLink: boolean, knownIcon: boolean, itemClassName?: string, dividerClassName?: string, iconClassName?: string, subMenuClassName?: string, primaryDisabled?: boolean) => IMenuItemClassNames; + getSplitButtonVerticalDividerClassNames?: (theme: ITheme) => IVerticalDividerClassNames; + href?: string; + iconProps?: IIconProps; + // @deprecated + inactive?: boolean; + itemProps?: Partial; + // (undocumented) + itemType?: ContextualMenuItemType; + key: string; + keytipProps?: IKeytipProps; + // @deprecated + name?: string; + onClick?: (ev?: React.MouseEvent | React.KeyboardEvent, item?: IContextualMenuItem) => boolean | void; + onMouseDown?: (item: IContextualMenuItem, event: React.MouseEvent) => void; + onRender?: (item: any, dismissMenu: (ev?: any, dismissAll?: boolean) => void) => React.ReactNode; + onRenderIcon?: IRenderFunction; + primaryDisabled?: boolean; + rel?: string; + role?: string; + secondaryText?: string; + sectionProps?: IContextualMenuSection; + // @deprecated (undocumented) + shortCut?: string; + split?: boolean; + // @deprecated + style?: React.CSSProperties; + submenuIconProps?: IIconProps; + subMenuProps?: IContextualMenuProps; + target?: string; + text?: string; + title?: string; +} + +// @public (undocumented) +export interface IContextualMenuItemProps extends React.HTMLAttributes { + className?: string; + classNames: IMenuItemClassNames; + componentRef?: IRefObject; + dismissMenu?: (ev?: any, dismissAll?: boolean) => void; + dismissSubMenu?: () => void; + getSubmenuTarget?: () => HTMLElement | undefined; + hasIcons: boolean | undefined; + index: number; + item: IContextualMenuItem; + onCheckmarkClick?: (item: IContextualMenuItem, ev: React.MouseEvent) => void; + openSubMenu?: (item: any, target: HTMLElement) => void; + styles?: IStyleFunctionOrObject; + theme?: ITheme; +} + +// @public (undocumented) +export interface IContextualMenuItemRenderProps extends IContextualMenuItem { + // (undocumented) + focusableElementIndex: number; + // (undocumented) + hasCheckmarks: boolean; + // (undocumented) + hasIcons: boolean; + // (undocumented) + index: number; + // (undocumented) + totalItemCount: number; +} + +// @public (undocumented) +export interface IContextualMenuItemStyleProps { + checked: boolean; + className?: string; + disabled: boolean; + dividerClassName?: string; + expanded: boolean; + iconClassName?: string; + isAnchorLink: boolean; + itemClassName?: string; + knownIcon: boolean; + primaryDisabled?: boolean; + subMenuClassName?: string; + theme: ITheme; +} + +// @public (undocumented) +export interface IContextualMenuItemStyles extends IButtonStyles { + anchorLink: IStyle; + checkmarkIcon: IStyle; + divider: IStyle; + icon: IStyle; + iconColor: IStyle; + item: IStyle; + label: IStyle; + linkContent: IStyle; + linkContentMenu: IStyle; + root: IStyle; + secondaryText: IStyle; + splitContainer: IStyle; + splitMenu: IStyle; + splitPrimary: IStyle; + subMenuIcon: IStyle; +} + +// @public (undocumented) +export interface IContextualMenuListProps { + // (undocumented) + defaultMenuItemRenderer: (item: IContextualMenuItemRenderProps) => React.ReactNode; + // (undocumented) + hasCheckmarks: boolean; + // (undocumented) + hasIcons: boolean; + // (undocumented) + items: IContextualMenuItem[]; + // (undocumented) + role?: string; + // (undocumented) + totalItemCount: number; +} + +// @public (undocumented) +export interface IContextualMenuProps extends IBaseProps, IWithResponsiveModeState { + alignTargetEdge?: boolean; + ariaLabel?: string; + beakWidth?: number; + bounds?: IRectangle | ((target?: Target, targetWindow?: Window) => IRectangle | undefined); + calloutProps?: ICalloutProps; + className?: string; + componentRef?: IRefObject; + contextualMenuItemAs?: React.ComponentClass | React.FunctionComponent; + coverTarget?: boolean; + delayUpdateFocusOnHover?: boolean; + directionalHint?: DirectionalHint_2; + directionalHintFixed?: boolean; + directionalHintForRTL?: DirectionalHint_2; + doNotLayer?: boolean; + focusZoneProps?: IFocusZoneProps; + gapSpace?: number; + // Warning: (ae-forgotten-export) The symbol "IContextualMenuClassNames" needs to be exported by the entry point index.d.ts + // + // @deprecated + getMenuClassNames?: (theme: ITheme, className?: string) => IContextualMenuClassNames; + hidden?: boolean; + id?: string; + isBeakVisible?: boolean; + isSubMenu?: boolean; + items: IContextualMenuItem[]; + labelElementId?: string; + onDismiss?: (ev?: Event | React.MouseEvent | React.KeyboardEvent, dismissAll?: boolean) => void; + onItemClick?: (ev?: React.MouseEvent | React.KeyboardEvent, item?: IContextualMenuItem) => boolean | void; + onMenuDismissed?: (contextualMenu?: IContextualMenuProps) => void; + onMenuOpened?: (contextualMenu?: IContextualMenuProps) => void; + onRenderMenuList?: IRenderFunction; + onRenderSubMenu?: IRenderFunction; + shouldFocusOnContainer?: boolean; + shouldFocusOnMount?: boolean; + shouldUpdateWhenHidden?: boolean; + styles?: IStyleFunctionOrObject; + subMenuHoverDelay?: number; + target?: Target; + theme?: ITheme; + title?: string; + useTargetAsMinWidth?: boolean; + useTargetWidth?: boolean; +} + +// @public (undocumented) +export interface IContextualMenuRenderItem { + dismissMenu: (dismissAll?: boolean) => void; + dismissSubMenu: () => void; + openSubMenu: () => void; +} + +// @public (undocumented) +export interface IContextualMenuSection extends React.ClassAttributes { + bottomDivider?: boolean; + items: IContextualMenuItem[]; + title?: string; + topDivider?: boolean; +} + +// @public (undocumented) +export interface IContextualMenuState { + // (undocumented) + contextualMenuItems?: IContextualMenuItem[]; + // (undocumented) + contextualMenuTarget?: Element; + // (undocumented) + dismissedMenuItemKey?: string; + expandedByMouseClick?: boolean; + // (undocumented) + expandedMenuItemKey?: string; + // (undocumented) + positions?: any; + // (undocumented) + slideDirectionalClassName?: string; + // (undocumented) + submenuDirection?: DirectionalHint_2; + // (undocumented) + subMenuId?: string; + // (undocumented) + submenuTarget?: Element; +} + +// @public (undocumented) +export interface IContextualMenuStyleProps { + // (undocumented) + className?: string; + // (undocumented) + theme: ITheme; +} + +// @public (undocumented) +export interface IContextualMenuStyles { + container: IStyle; + header: IStyle; + list: IStyle; + root: IStyle; + subComponentStyles: IContextualMenuSubComponentStyles; + title: IStyle; +} + +// @public (undocumented) +export interface IContextualMenuSubComponentStyles { + callout: IStyleFunctionOrObject; + menuItem: IStyleFunctionOrObject; +} + // @public (undocumented) export interface IDialogState { // (undocumented) @@ -835,6 +1140,17 @@ export interface IMaskedTextFieldState { maskCursorPosition?: number; } +// @public (undocumented) +export interface IMenuItemStyles extends IButtonStyles { + anchorLink: IStyle; + checkmarkIcon: IStyle; + divider: IStyle; + iconColor: IStyle; + item: IStyle; + linkContent: IStyle; + subMenuIcon: IStyle; +} + // @public (undocumented) export interface IModal { focus: () => void; @@ -912,7 +1228,7 @@ export interface IOverflowSetProps extends React.ClassAttributes any[] | undefined; keytipSequences?: string[]; @@ -1106,9 +1422,9 @@ export interface IPositioningContainerProps extends IBaseProps; coverTarget?: boolean; - directionalHint?: DirectionalHint; + directionalHint?: DirectionalHint_2; directionalHintFixed?: boolean; - directionalHintForRTL?: DirectionalHint; + directionalHintForRTL?: DirectionalHint_2; doNotLayer?: boolean; finalHeight?: number; minPagePadding?: number; @@ -1347,7 +1663,7 @@ export interface ISpinButtonProps extends React.HTMLAttributes { decrementButtonIcon?: IIconProps; defaultValue?: string; disabled?: boolean; - downArrowButtonStyles?: Partial; + downArrowButtonStyles?: Partial; iconButtonProps?: IButtonProps_2; iconProps?: IIconProps; incrementButtonAriaLabel?: string; @@ -1368,7 +1684,7 @@ export interface ISpinButtonProps extends React.HTMLAttributes { styles?: IStyleFunctionOrObject; theme?: ITheme; title?: string; - upArrowButtonStyles?: Partial; + upArrowButtonStyles?: Partial; value?: string; } @@ -2040,7 +2356,6 @@ export * from "office-ui-fabric-react/lib/Color"; export * from "office-ui-fabric-react/lib/ColorPicker"; export * from "office-ui-fabric-react/lib/ComboBox"; export * from "office-ui-fabric-react/lib/CommandBar"; -export * from "office-ui-fabric-react/lib/ContextualMenu"; export * from "office-ui-fabric-react/lib/DetailsList"; export * from "office-ui-fabric-react/lib/Dialog"; export * from "office-ui-fabric-react/lib/Divider"; diff --git a/packages/react-next/src/ContextualMenu.ts b/packages/react-next/src/ContextualMenu.ts index bc523a7109ee5..0e2e266ca80d1 100644 --- a/packages/react-next/src/ContextualMenu.ts +++ b/packages/react-next/src/ContextualMenu.ts @@ -1 +1 @@ -export * from 'office-ui-fabric-react/lib/ContextualMenu'; +export * from './components/ContextualMenu'; diff --git a/packages/react-next/src/components/ContextualMenu/ContextualMenu.base.tsx b/packages/react-next/src/components/ContextualMenu/ContextualMenu.base.tsx new file mode 100644 index 0000000000000..1405a63a66985 --- /dev/null +++ b/packages/react-next/src/components/ContextualMenu/ContextualMenu.base.tsx @@ -0,0 +1,1382 @@ +import * as React from 'react'; +import { + IContextualMenuProps, + IContextualMenuItem, + ContextualMenuItemType, + IContextualMenuListProps, + IContextualMenuStyleProps, + IContextualMenuStyles, + IContextualMenuItemRenderProps, +} from './ContextualMenu.types'; +import { DirectionalHint } from '../../common/DirectionalHint'; +import { FocusZone, FocusZoneDirection, IFocusZoneProps, FocusZoneTabbableElements } from '../../FocusZone'; +import { IMenuItemClassNames, IContextualMenuClassNames } from './ContextualMenu.classNames'; +import { + divProperties, + getNativeProps, + shallowCompare, + warnDeprecations, + Async, + EventGroup, + assign, + classNamesFunction, + css, + getDocument, + getFirstFocusable, + getId, + getLastFocusable, + getRTL, + getWindow, + IRenderFunction, + Point, + KeyCodes, + shouldWrapFocus, + IStyleFunctionOrObject, + isIOS, + isMac, + initializeComponentRef, + memoizeFunction, +} from '../../Utilities'; +import { hasSubmenu, getIsChecked, isItemDisabled } from '../../utilities/contextualMenu/index'; +import { withResponsiveMode, ResponsiveMode } from 'office-ui-fabric-react/lib/utilities/decorators/withResponsiveMode'; +import { Callout, ICalloutContentStyleProps, ICalloutContentStyles, Target } from '../../Callout'; +import { ContextualMenuItem } from './ContextualMenuItem'; +import { + ContextualMenuSplitButton, + ContextualMenuButton, + ContextualMenuAnchor, +} from './ContextualMenuItemWrapper/index'; +import { IProcessedStyleSet, concatStyleSetsWithProps } from '../../Styling'; +import { IContextualMenuItemStyleProps, IContextualMenuItemStyles } from './ContextualMenuItem.types'; +import { getItemStyles } from './ContextualMenu.classNames'; + +const getClassNames = classNamesFunction(); +const getContextualMenuItemClassNames = classNamesFunction(); + +export interface IContextualMenuState { + expandedMenuItemKey?: string; + /** True if the menu was expanded by mouse click OR hover (as opposed to by keyboard) */ + expandedByMouseClick?: boolean; + dismissedMenuItemKey?: string; + contextualMenuItems?: IContextualMenuItem[]; + contextualMenuTarget?: Element; + submenuTarget?: Element; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + positions?: any; + slideDirectionalClassName?: string; + subMenuId?: string; + submenuDirection?: DirectionalHint; +} + +export function getSubmenuItems(item: IContextualMenuItem): IContextualMenuItem[] | undefined { + return item.subMenuProps ? item.subMenuProps.items : item.items; +} + +/** + * 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 | undefined)[] + ): IStyleFunctionOrObject => { + return (styleProps: IContextualMenuItemStyleProps) => + concatStyleSetsWithProps(styleProps, getItemStyles, ...styles); + }, +); + +@withResponsiveMode +export class ContextualMenuBase extends React.Component { + // The default ContextualMenu properties have no items and beak, the default submenu direction is right and top. + public static defaultProps: IContextualMenuProps = { + items: [], + shouldFocusOnMount: true, + gapSpace: 0, + directionalHint: DirectionalHint.bottomAutoEdge, + beakWidth: 16, + }; + + private _async: Async; + private _events: EventGroup; + private _id: string; + private _host: HTMLElement; + private _previousActiveElement: HTMLElement | undefined; + private _enterTimerId: number | undefined; + private _targetWindow: Window; + private _target: Element | MouseEvent | Point | null; + private _isScrollIdle: boolean; + private _scrollIdleTimeoutId: number | undefined; + /** True if the most recent keydown event was for alt (option) or meta (command). */ + private _lastKeyDownWasAltOrMeta: boolean | undefined; + private _shouldUpdateFocusOnMouseEvent: boolean; + private _gotMouseMove: boolean; + private _mounted = false; + private _focusingPreviousElement: boolean; + + private _adjustedFocusZoneProps: IFocusZoneProps; + + // eslint-disable-next-line deprecation/deprecation + private _classNames: IProcessedStyleSet | IContextualMenuClassNames; + + constructor(props: IContextualMenuProps) { + super(props); + + this._async = new Async(this); + this._events = new EventGroup(this); + initializeComponentRef(this); + + warnDeprecations(COMPONENT_NAME, props, { + getMenuClassNames: 'styles', + }); + + this.state = { + contextualMenuItems: undefined, + subMenuId: getId('ContextualMenu'), + }; + + this._id = props.id || getId('ContextualMenu'); + this._focusingPreviousElement = false; + this._isScrollIdle = true; + this._shouldUpdateFocusOnMouseEvent = !this.props.delayUpdateFocusOnHover; + this._gotMouseMove = false; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public dismiss = (ev?: any, dismissAll?: boolean) => { + const { onDismiss } = this.props; + + if (onDismiss) { + onDismiss(ev, dismissAll); + } + }; + + public shouldComponentUpdate(newProps: IContextualMenuProps, newState: IContextualMenuState): boolean { + if (!newProps.shouldUpdateWhenHidden && this.props.hidden && newProps.hidden) { + // Do not update when hidden. + return false; + } + + return !shallowCompare(this.props, newProps) || !shallowCompare(this.state, newState); + } + + public UNSAFE_componentWillUpdate(newProps: IContextualMenuProps): void { + if (newProps.target !== this.props.target) { + const newTarget = newProps.target; + this._setTargetWindowAndElement(newTarget!); + } + + if (this._isHidden(newProps) !== this._isHidden(this.props)) { + if (this._isHidden(newProps)) { + this._onMenuClosed(); + } else { + this._onMenuOpened(); + this._previousActiveElement = this._targetWindow + ? (this._targetWindow.document.activeElement as HTMLElement) + : undefined; + } + } + if (newProps.delayUpdateFocusOnHover !== this.props.delayUpdateFocusOnHover) { + // update shouldUpdateFocusOnMouseEvent to follow what was passed in + this._shouldUpdateFocusOnMouseEvent = !newProps.delayUpdateFocusOnHover; + + // If shouldUpdateFocusOnMouseEvent is false, we need to reset gotMouseMove to false + this._gotMouseMove = this._shouldUpdateFocusOnMouseEvent && this._gotMouseMove; + } + } + + // Invoked once, both on the client and server, immediately before the initial rendering occurs. + public UNSAFE_componentWillMount() { + const target = this.props.target; + this._setTargetWindowAndElement(target!); + if (!this.props.hidden) { + this._previousActiveElement = this._targetWindow + ? (this._targetWindow.document.activeElement as HTMLElement) + : undefined; + } + } + + // Invoked once, only on the client (not on the server), immediately after the initial rendering occurs. + public componentDidMount(): void { + if (!this.props.hidden) { + this._onMenuOpened(); + } + + this._mounted = true; + } + + // Invoked immediately before a component is unmounted from the DOM. + public componentWillUnmount() { + if (this.props.onMenuDismissed) { + this.props.onMenuDismissed(this.props); + } + + this._events.dispose(); + this._async.dispose(); + this._mounted = false; + } + + public render(): JSX.Element | null { + let { isBeakVisible } = this.props; + + const { + items, + labelElementId, + id, + className, + beakWidth, + directionalHint, + directionalHintForRTL, + alignTargetEdge, + gapSpace, + coverTarget, + ariaLabel, + doNotLayer, + target, + bounds, + useTargetWidth, + useTargetAsMinWidth, + directionalHintFixed, + shouldFocusOnMount, + shouldFocusOnContainer, + title, + styles, + theme, + calloutProps, + onRenderSubMenu = this._onRenderSubMenu, + onRenderMenuList = this._onRenderMenuList, + focusZoneProps, + // eslint-disable-next-line deprecation/deprecation + getMenuClassNames, + } = this.props; + + this._classNames = getMenuClassNames + ? getMenuClassNames(theme!, className) + : getClassNames(styles, { + theme: theme!, + className: className, + }); + + const hasIcons = itemsHaveIcons(items); + + function itemsHaveIcons(contextualMenuItems: IContextualMenuItem[]): boolean { + for (const item of contextualMenuItems) { + if (item.iconProps) { + return true; + } + + if ( + item.itemType === ContextualMenuItemType.Section && + item.sectionProps && + itemsHaveIcons(item.sectionProps.items) + ) { + return true; + } + } + + return false; + } + + this._adjustedFocusZoneProps = { ...focusZoneProps, direction: this._getFocusZoneDirection() }; + + const hasCheckmarks = canAnyMenuItemsCheck(items); + const submenuProps = this.state.expandedMenuItemKey && this.props.hidden !== true ? this._getSubmenuProps() : null; + + isBeakVisible = isBeakVisible === undefined ? this.props.responsiveMode! <= ResponsiveMode.medium : isBeakVisible; + /** + * When useTargetWidth is true, get the width of the target element and apply it for the context menu container + */ + let contextMenuStyle; + const targetAsHtmlElement = this._target as HTMLElement; + if ((useTargetWidth || useTargetAsMinWidth) && targetAsHtmlElement && targetAsHtmlElement.offsetWidth) { + const targetBoundingRect = targetAsHtmlElement.getBoundingClientRect(); + const targetWidth = targetBoundingRect.width - 2 /* Accounts for 1px border */; + + if (useTargetWidth) { + contextMenuStyle = { + width: targetWidth, + }; + } else if (useTargetAsMinWidth) { + contextMenuStyle = { + minWidth: targetWidth, + }; + } + } + + // The menu should only return if items were provided, if no items were provided then it should not appear. + if (items && items.length > 0) { + let totalItemCount = 0; + for (const item of items) { + if (item.itemType !== ContextualMenuItemType.Divider && item.itemType !== ContextualMenuItemType.Header) { + const itemCount = item.customOnRenderListLength ? item.customOnRenderListLength : 1; + totalItemCount += itemCount; + } + } + + const calloutStyles = this._classNames.subComponentStyles + ? (this._classNames.subComponentStyles.callout as IStyleFunctionOrObject< + ICalloutContentStyleProps, + ICalloutContentStyles + >) + : undefined; + + return ( + + ); + } else { + return null; + } + } + + /** + * Return whether the contextual menu is hidden. + * Undefined value for hidden is equivalent to hidden being false. + * @param props - Props for the component + */ + private _isHidden(props: IContextualMenuProps) { + return !!props.hidden; + } + + private _onMenuOpened() { + this._events.on(this._targetWindow, 'resize', this.dismiss); + this._shouldUpdateFocusOnMouseEvent = !this.props.delayUpdateFocusOnHover; + this._gotMouseMove = false; + this.props.onMenuOpened && this.props.onMenuOpened(this.props); + } + + private _onMenuClosed() { + this._events.off(this._targetWindow, 'resize', this.dismiss); + + // This is kept for backwards compatability with hidden for right now. + // This preserves the way that this behaved in the past + // TODO find a better way to handle this by using the same conventions that + // Popup uses to determine if focus is contained when dismissal occurs + this._tryFocusPreviousActiveElement({ + containsFocus: this._focusingPreviousElement, + originalElement: this._previousActiveElement, + }); + this._focusingPreviousElement = false; + + if (this.props.onMenuDismissed) { + this.props.onMenuDismissed(this.props); + } + + this._shouldUpdateFocusOnMouseEvent = !this.props.delayUpdateFocusOnHover; + + // We need to dismiss any submenu related state properties, + // so that when the menu is shown again, the submenu is collapsed + this.setState({ + expandedByMouseClick: undefined, + dismissedMenuItemKey: undefined, + expandedMenuItemKey: undefined, + submenuTarget: undefined, + }); + } + + private _tryFocusPreviousActiveElement = (options: { + containsFocus: boolean; + originalElement: HTMLElement | Window | undefined; + }) => { + if (options && options.containsFocus && this._previousActiveElement) { + // 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. + if (this._previousActiveElement.focus) { + this._previousActiveElement.focus(); + } + } + }; + + /** + * Gets the focusZoneDirection by using the arrowDirection if specified, + * the direction specificed in the focusZoneProps, or defaults to FocusZoneDirection.vertical + */ + private _getFocusZoneDirection() { + const { focusZoneProps } = this.props; + return focusZoneProps && focusZoneProps.direction !== undefined + ? focusZoneProps.direction + : FocusZoneDirection.vertical; + } + + private _onRenderSubMenu( + subMenuProps: IContextualMenuProps, + defaultRender?: IRenderFunction, + ): JSX.Element { + throw Error( + 'ContextualMenuBase: onRenderSubMenu callback is null or undefined. ' + + 'Please ensure to set `onRenderSubMenu` property either manually or with `styled` helper.', + ); + } + + private _onRenderMenuList = ( + menuListProps: IContextualMenuListProps, + defaultRender?: IRenderFunction, + ): JSX.Element => { + let indexCorrection = 0; + const { items, totalItemCount, hasCheckmarks, hasIcons, role } = menuListProps; + return ( +
    + {items.map((item, index) => { + const menuItem = this._renderMenuItem(item, index, indexCorrection, totalItemCount, hasCheckmarks, hasIcons); + if (item.itemType !== ContextualMenuItemType.Divider && item.itemType !== ContextualMenuItemType.Header) { + const indexIncrease = item.customOnRenderListLength ? item.customOnRenderListLength : 1; + indexCorrection += indexIncrease; + } + return menuItem; + })} +
+ ); + }; + + /** + * !!!IMPORTANT!!! Avoid mutating `item: IContextualMenuItem` argument. It will + * cause the menu items to always re-render because the component update is based on shallow comparison. + */ + private _renderMenuItem = ( + item: IContextualMenuItem, + index: number, + focusableElementIndex: number, + totalItemCount: number, + hasCheckmarks: boolean, + hasIcons: boolean, + ): JSX.Element => { + const renderedItems: React.ReactNode[] = []; + const iconProps = item.iconProps || { iconName: 'None' }; + const { + getItemClassNames, // eslint-disable-line deprecation/deprecation + itemProps, + } = item; + const styles = itemProps ? itemProps.styles : undefined; + + // We only send a dividerClassName when the item to be rendered is a divider. + // For all other cases, the default divider style is used. + const dividerClassName = item.itemType === ContextualMenuItemType.Divider ? item.className : undefined; + const subMenuIconClassName = item.submenuIconProps ? item.submenuIconProps.className : ''; + + // eslint-disable-next-line deprecation/deprecation + let itemClassNames: IMenuItemClassNames; + + // IContextualMenuItem#getItemClassNames for backwards compatibility + // otherwise uses mergeStyles for class names. + if (getItemClassNames) { + itemClassNames = getItemClassNames( + this.props.theme!, + isItemDisabled(item), + this.state.expandedMenuItemKey === item.key, + !!getIsChecked(item), + !!item.href, + iconProps.iconName !== 'None', + item.className, + dividerClassName, + iconProps.className, + subMenuIconClassName, + item.primaryDisabled, + ); + } else { + const itemStyleProps: IContextualMenuItemStyleProps = { + theme: this.props.theme!, + disabled: isItemDisabled(item), + expanded: this.state.expandedMenuItemKey === item.key, + checked: !!getIsChecked(item), + isAnchorLink: !!item.href, + knownIcon: iconProps.iconName !== 'None', + itemClassName: item.className, + dividerClassName, + iconClassName: iconProps.className, + subMenuClassName: subMenuIconClassName, + primaryDisabled: item.primaryDisabled, + }; + + // We need to generate default styles then override if styles are provided + // since the ContextualMenu currently handles item classNames. + itemClassNames = getContextualMenuItemClassNames( + _getMenuItemStylesFunction(this._classNames.subComponentStyles?.menuItem, styles), + itemStyleProps, + ); + } + + // eslint-disable-next-line deprecation/deprecation + if (item.text === '-' || item.name === '-') { + item.itemType = ContextualMenuItemType.Divider; + } + switch (item.itemType) { + case ContextualMenuItemType.Divider: + renderedItems.push(this._renderSeparator(index, itemClassNames)); + break; + case ContextualMenuItemType.Header: + renderedItems.push(this._renderSeparator(index, itemClassNames)); + const headerItem = this._renderHeaderMenuItem(item, itemClassNames, index, hasCheckmarks, hasIcons); + renderedItems.push(this._renderListItem(headerItem, item.key || index, itemClassNames, item.title)); + break; + case ContextualMenuItemType.Section: + renderedItems.push(this._renderSectionItem(item, itemClassNames, index, hasCheckmarks, hasIcons)); + break; + default: + const menuItem = this._renderNormalItem( + item, + itemClassNames, + index, + focusableElementIndex, + totalItemCount, + hasCheckmarks, + hasIcons, + ); + renderedItems.push(this._renderListItem(menuItem, item.key || index, itemClassNames, item.title)); + break; + } + + // Since multiple nodes *could* be rendered, wrap them all in a fragment with this item's key. + // This ensures the reconciler handles multi-item output per-node correctly and does not re-mount content. + return {renderedItems}; + }; + + private _defaultMenuItemRenderer = (item: IContextualMenuItemRenderProps): React.ReactNode => { + const { index, focusableElementIndex, totalItemCount, hasCheckmarks, hasIcons } = item; + return this._renderMenuItem(item, index, focusableElementIndex, totalItemCount, hasCheckmarks, hasIcons); + }; + + private _renderSectionItem( + sectionItem: IContextualMenuItem, + // eslint-disable-next-line deprecation/deprecation + menuClassNames: IMenuItemClassNames, + index: number, + hasCheckmarks: boolean, + hasIcons: boolean, + ) { + const sectionProps = sectionItem.sectionProps; + if (!sectionProps) { + return; + } + + let headerItem; + let groupProps; + if (sectionProps.title) { + // Since title is a user-facing string, it needs to be stripped of whitespace in order to build a valid element ID + const id = this._id + sectionProps.title.replace(/\s/g, ''); + const headerContextualMenuItem: IContextualMenuItem = { + key: `section-${sectionProps.title}-title`, + itemType: ContextualMenuItemType.Header, + text: sectionProps.title, + id: id, + }; + groupProps = { + role: 'group', + 'aria-labelledby': id, + }; + headerItem = this._renderHeaderMenuItem(headerContextualMenuItem, menuClassNames, index, hasCheckmarks, hasIcons); + } + + if (sectionProps.items && sectionProps.items.length > 0) { + return ( +
  • +
    +
      + {sectionProps.topDivider && this._renderSeparator(index, menuClassNames, true, true)} + {headerItem && + this._renderListItem(headerItem, sectionItem.key || index, menuClassNames, sectionItem.title)} + {sectionProps.items.map((contextualMenuItem, itemsIndex) => + this._renderMenuItem( + contextualMenuItem, + itemsIndex, + itemsIndex, + sectionProps.items.length, + hasCheckmarks, + hasIcons, + ), + )} + {sectionProps.bottomDivider && this._renderSeparator(index, menuClassNames, false, true)} +
    +
    +
  • + ); + } + } + + private _renderListItem( + content: React.ReactNode, + key: string | number, + classNames: IMenuItemClassNames, // eslint-disable-line deprecation/deprecation + title?: string, + ) { + return ( +
  • + {content} +
  • + ); + } + + private _renderSeparator( + index: number, + classNames: IMenuItemClassNames, // eslint-disable-line deprecation/deprecation + top?: boolean, + fromSection?: boolean, + ): React.ReactNode { + if (fromSection || index > 0) { + return ( + + + + + + + + + + + +`; diff --git a/packages/react-next/src/components/ContextualMenu/__snapshots__/ContextualMenuItem.test.tsx.snap b/packages/react-next/src/components/ContextualMenu/__snapshots__/ContextualMenuItem.test.tsx.snap new file mode 100644 index 0000000000000..fccc4fec3e110 --- /dev/null +++ b/packages/react-next/src/components/ContextualMenu/__snapshots__/ContextualMenuItem.test.tsx.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ContextMenuItemChildren when a checkmark icon renders the component with the checkmark 1`] = `ShallowWrapper {}`; + +exports[`ContextMenuItemChildren when hide checkmark icon for toggle command renders the component with the checkmark 1`] = `ShallowWrapper {}`; + +exports[`ContextMenuItemChildren when it has a sub menu renders the menu icon 1`] = `ShallowWrapper {}`; + +exports[`ContextMenuItemChildren when it has icons when it doesnt have iconProps renders the icon with iconName 1`] = `ShallowWrapper {}`; + +exports[`ContextMenuItemChildren when it has icons when it has iconProps renders the icon 1`] = `ShallowWrapper {}`; diff --git a/packages/react-next/src/components/ContextualMenu/docs/ContextualMenuBestPractices.md b/packages/react-next/src/components/ContextualMenu/docs/ContextualMenuBestPractices.md new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/packages/react-next/src/components/ContextualMenu/docs/ContextualMenuDonts.md b/packages/react-next/src/components/ContextualMenu/docs/ContextualMenuDonts.md new file mode 100644 index 0000000000000..5c641e2dd5d6f --- /dev/null +++ b/packages/react-next/src/components/ContextualMenu/docs/ContextualMenuDonts.md @@ -0,0 +1,4 @@ +- Use them to display content. +- Show commands as one large group. +- Mix checks and icons. +- Create submenus of submenus. diff --git a/packages/react-next/src/components/ContextualMenu/docs/ContextualMenuDos.md b/packages/react-next/src/components/ContextualMenu/docs/ContextualMenuDos.md new file mode 100644 index 0000000000000..f629a0f175b3e --- /dev/null +++ b/packages/react-next/src/components/ContextualMenu/docs/ContextualMenuDos.md @@ -0,0 +1,4 @@ +- Use to display commands. +- Divide groups of commands with rules. +- Use selection checks without icons. +- Provide submenus for sets of related commands that aren’t as critical as others. diff --git a/packages/react-next/src/components/ContextualMenu/docs/ContextualMenuOverview.md b/packages/react-next/src/components/ContextualMenu/docs/ContextualMenuOverview.md new file mode 100644 index 0000000000000..5fe6dc43f8b9a --- /dev/null +++ b/packages/react-next/src/components/ContextualMenu/docs/ContextualMenuOverview.md @@ -0,0 +1,5 @@ +ContextualMenus are lists of commands that are based on the context of selection, mouse hover or keyboard focus. They are one of the most effective and highly used command surfaces, and can be used in a variety of places. + +There are variants that originate from a command bar, or from cursor or focus. Those that come from CommandBars use a beak that is horizontally centered on the button. Ones that come from right click and menu button do not have a beak, but appear to the right and below the cursor. ContextualMenus can have submenus from commands, show selection checks, and icons. + +Organize commands in groups divided by rules. This helps users remember command locations, or find less used commands based on proximity to others. One should also group sets of mutually exclusive or multiple selectable options. Use icons sparingly, for high value commands, and don’t mix icons with selection checks, as it makes parsing commands difficult. Avoid submenus of submenus as they can be difficult to invoke or remember. diff --git a/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Basic.Example.tsx b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Basic.Example.tsx new file mode 100644 index 0000000000000..22bfa6b7da497 --- /dev/null +++ b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Basic.Example.tsx @@ -0,0 +1,85 @@ +import * as React from 'react'; +import { ContextualMenu, ContextualMenuItemType, IContextualMenuItem } from '@fluentui/react-next/lib/ContextualMenu'; +import { useConstCallback } from '@uifabric/react-hooks'; + +export const ContextualMenuBasicExample: React.FunctionComponent = () => { + const linkRef = React.useRef(null); + const [showContextualMenu, setShowContextualMenu] = React.useState(false); + const onShowContextualMenu = useConstCallback(() => setShowContextualMenu(true)); + const onHideContextualMenu = useConstCallback(() => setShowContextualMenu(false)); + + return ( +
    + This example directly uses ContextualMenu to show how it can be attached to arbitrary elements. The remaining + examples use ContextualMenu through Fluent UI Button components. +

    + + + Click for ContextualMenu + + +

    +
    + ); +}; + +const menuItems: IContextualMenuItem[] = [ + { + key: 'newItem', + text: 'New', + onClick: () => console.log('New clicked'), + }, + { + key: 'divider_1', + itemType: ContextualMenuItemType.Divider, + }, + { + key: 'rename', + text: 'Rename', + onClick: () => console.log('Rename clicked'), + }, + { + key: 'edit', + text: 'Edit', + onClick: () => console.log('Edit clicked'), + }, + { + key: 'properties', + text: 'Properties', + onClick: () => console.log('Properties clicked'), + }, + { + key: 'linkNoTarget', + text: 'Link same window', + href: 'http://bing.com', + }, + { + key: 'linkWithTarget', + text: 'Link new window', + href: 'http://bing.com', + target: '_blank', + }, + { + key: 'linkWithOnClick', + name: 'Link click', + href: 'http://bing.com', + onClick: (ev: React.MouseEvent) => { + alert('Link clicked'); + ev.preventDefault(); + }, + target: '_blank', + }, + { + key: 'disabled', + text: 'Disabled item', + disabled: true, + onClick: () => console.error('Disabled item should not be clickable.'), + }, +]; diff --git a/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Checkmarks.Example.tsx b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Checkmarks.Example.tsx new file mode 100644 index 0000000000000..67443d7194df9 --- /dev/null +++ b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Checkmarks.Example.tsx @@ -0,0 +1,216 @@ +import * as React from 'react'; +import { + ContextualMenuItemType, + DirectionalHint, + IContextualMenuItem, + IContextualMenuProps, +} from '@fluentui/react-next/lib/ContextualMenu'; +import { DefaultButton } from '@fluentui/react-next/lib/compat/Button'; + +const keys: string[] = [ + 'newItem', + 'share', + 'mobile', + 'enablePrint', + 'enableMusic', + 'newSub', + 'emailMessage', + 'calendarEvent', + 'disabledNewSub', + 'disabledEmailMessage', + 'disabledCalendarEvent', + 'splitButtonSubMenuLeftDirection', + 'emailMessageLeft', + 'calendarEventLeft', + 'disabledPrimarySplit', +]; + +export const ContextualMenuCheckmarksExample: React.FunctionComponent = () => { + const [selection, setSelection] = React.useState<{ [key: string]: boolean }>({}); + + const onToggleSelect = React.useCallback( + (ev?: React.MouseEvent, item?: IContextualMenuItem): void => { + ev && ev.preventDefault(); + + if (item) { + setSelection({ ...selection, [item.key]: selection[item.key] === undefined ? true : !selection[item.key] }); + } + }, + [selection], + ); + + const menuItems: IContextualMenuItem[] = React.useMemo( + () => [ + { + key: keys[0], + text: 'New', + canCheck: true, + isChecked: selection[keys[0]], + onClick: onToggleSelect, + }, + { + key: keys[1], + text: 'Share', + canCheck: true, + isChecked: selection[keys[1]], + onClick: onToggleSelect, + }, + { + key: keys[2], + text: 'Mobile', + canCheck: true, + isChecked: selection[keys[2]], + onClick: onToggleSelect, + }, + { + key: 'divider_1', + itemType: ContextualMenuItemType.Divider, + }, + + { + key: keys[3], + text: 'Print', + canCheck: true, + isChecked: selection[keys[3]], + onClick: onToggleSelect, + }, + { + key: keys[4], + text: 'Music', + canCheck: true, + isChecked: selection[keys[4]], + onClick: onToggleSelect, + }, + { + key: keys[5], + iconProps: { + iconName: 'MusicInCollectionFill', + }, + subMenuProps: { + items: [ + { + key: keys[6], + text: 'Email message', + canCheck: true, + isChecked: selection[keys[6]], + onClick: onToggleSelect, + }, + { + key: keys[7], + text: 'Calendar event', + canCheck: true, + isChecked: selection[keys[7]], + onClick: onToggleSelect, + }, + ], + }, + text: 'Split Button', + canCheck: true, + isChecked: selection[keys[5]], + split: true, + onClick: onToggleSelect, + }, + { + key: keys[8], + iconProps: { + iconName: 'MusicInCollectionFill', + }, + subMenuProps: { + items: [ + { + key: keys[9], + text: 'Email message', + canCheck: true, + isChecked: selection[keys[9]], + onClick: onToggleSelect, + }, + { + key: keys[10], + text: 'Calendar event', + canCheck: true, + isChecked: selection[keys[10]], + onClick: onToggleSelect, + }, + ], + }, + text: 'Split Button', + canCheck: true, + isChecked: selection[keys[8]], + split: true, + onClick: onToggleSelect, + disabled: true, + }, + { + key: keys[11], + iconProps: { + iconName: 'MusicInCollectionFill', + }, + subMenuProps: { + directionalHint: DirectionalHint.leftCenter, + items: [ + { + key: keys[12], + text: 'Email message', + canCheck: true, + isChecked: selection[keys[12]], + onClick: onToggleSelect, + }, + { + key: keys[13], + text: 'Calendar event', + canCheck: true, + isChecked: selection[keys[13]], + onClick: onToggleSelect, + }, + ], + }, + text: 'Split Button Left Menu', + canCheck: true, + isChecked: selection[keys[11]], + split: true, + onClick: onToggleSelect, + }, + { + key: keys[12], + iconProps: { + iconName: 'MusicInCollectionFill', + }, + subMenuProps: { + items: [ + { + key: keys[12], + name: 'Email message', + canCheck: true, + isChecked: selection[keys[12]], + onClick: onToggleSelect, + }, + ], + }, + name: 'Split Button Disabled Primary', + split: true, + primaryDisabled: true, + }, + { + key: keys[13], + iconProps: { + iconName: selection![keys[13]] ? 'SingleBookmarkSolid' : 'SingleBookmark', + }, + name: selection![keys[13]] ? 'Toogled command no checkmark' : 'Toogle command no checkmark', + canCheck: false, + isChecked: selection![keys[13]], + onClick: onToggleSelect, + }, + ], + [selection, onToggleSelect], + ); + + const menuProps: IContextualMenuProps = React.useMemo( + () => ({ + shouldFocusOnMount: true, + items: menuItems, + }), + [menuItems], + ); + + return ; +}; diff --git a/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.CustomMenuItem.Example.tsx b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.CustomMenuItem.Example.tsx new file mode 100644 index 0000000000000..beee235416820 --- /dev/null +++ b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.CustomMenuItem.Example.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { + ContextualMenuItemType, + IContextualMenuProps, + IContextualMenuItem, + IContextualMenuItemProps, +} from '@fluentui/react-next/lib/ContextualMenu'; +import { DefaultButton } from '@fluentui/react-next/lib/compat/Button'; +import { useConst } from '@uifabric/react-hooks'; + +export const ContextualMenuWithCustomMenuItemExample: React.FunctionComponent = () => { + const menuProps: IContextualMenuProps = useConst(() => ({ + items: menuItems, + shouldFocusOnMount: true, + contextualMenuItemAs: (props: IContextualMenuItemProps) =>
    Custom rendered {props.item.text}
    , + })); + + return ; +}; + +const menuItems: IContextualMenuItem[] = [ + { + key: 'newItem', + text: 'New', + }, + { + key: 'divider_1', + itemType: ContextualMenuItemType.Divider, + }, + { + key: 'rename', + text: 'Rename', + }, + { + key: 'edit', + text: 'Edit', + }, + { + key: 'properties', + text: 'Properties', + }, + { + key: 'disabled', + text: 'Disabled item', + disabled: true, + }, +]; diff --git a/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.CustomMenuList.Example.tsx b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.CustomMenuList.Example.tsx new file mode 100644 index 0000000000000..ed5b4c90192c9 --- /dev/null +++ b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.CustomMenuList.Example.tsx @@ -0,0 +1,125 @@ +import * as React from 'react'; +import { useConstCallback, useConst } from '@uifabric/react-hooks'; +import { DefaultButton } from '@fluentui/react-next/lib/compat/Button'; +import { ISearchBoxStyles, SearchBox } from '@fluentui/react-next/lib/SearchBox'; +import { Icon } from '@fluentui/react-next/lib/Icon'; +import { IContextualMenuListProps, IContextualMenuItem } from '@fluentui/react-next/lib/ContextualMenu'; +import { IRenderFunction } from '@fluentui/react-next/lib/Utilities'; + +export const ContextualMenuWithCustomMenuListExample: React.FunctionComponent = () => { + const [items, setItems] = React.useState(menuItems); + + const onAbort = useConstCallback(() => { + setItems(menuItems); + }); + + const onChange = useConstCallback((ev: React.ChangeEvent, newValue: string) => { + const filteredItems = menuItems.filter( + item => item.text && item.text.toLowerCase().indexOf(newValue.toLowerCase()) !== -1, + ); + + if (!filteredItems || !filteredItems.length) { + filteredItems.push({ + key: 'no_results', + onRender: (item, dismissMenu) => ( +
    + + No actions found +
    + ), + }); + } + + setItems(filteredItems); + }); + + const renderMenuList = useConstCallback( + (menuListProps: IContextualMenuListProps, defaultRender: IRenderFunction) => { + return ( +
    +
    + +
    + {defaultRender(menuListProps)} +
    + ); + }, + ); + + const menuProps = useConst(() => ({ + onRenderMenuList: renderMenuList, + title: 'Actions', + shouldFocusOnMount: true, + items, + })); + + return ; +}; + +const wrapperStyle: React.CSSProperties = { borderBottom: '1px solid #ccc' }; +const filteredItemsStyle: React.CSSProperties = { + width: '100%', + height: '100px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}; +const searchBoxStyles: ISearchBoxStyles = { + root: { margin: '8px' }, +}; + +const menuItems: IContextualMenuItem[] = [ + { + key: 'newItem', + text: 'New', + onClick: () => console.log('New clicked'), + }, + { + key: 'rename', + text: 'Rename', + onClick: () => console.log('Rename clicked'), + }, + { + key: 'edit', + text: 'Edit', + onClick: () => console.log('Edit clicked'), + }, + { + key: 'properties', + text: 'Properties', + onClick: () => console.log('Properties clicked'), + }, + { + key: 'linkNoTarget', + text: 'Link same window', + href: 'http://bing.com', + }, + { + key: 'linkWithTarget', + text: 'Link new window', + href: 'http://bing.com', + target: '_blank', + }, + { + key: 'linkWithOnClick', + name: 'Link click', + href: 'http://bing.com', + onClick: (ev: React.MouseEvent) => { + alert('Link clicked'); + ev.preventDefault(); + }, + target: '_blank', + }, + { + key: 'disabled', + text: 'Disabled item', + disabled: true, + onClick: () => console.error('Disabled item should not be clickable.'), + }, +]; diff --git a/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Customization.Example.tsx b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Customization.Example.tsx new file mode 100644 index 0000000000000..3dc3bfce0fa61 --- /dev/null +++ b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Customization.Example.tsx @@ -0,0 +1,225 @@ +import * as React from 'react'; +import { + ContextualMenuItemType, + DirectionalHint, + IContextualMenuProps, + IContextualMenuItem, +} from '@fluentui/react-next/lib/ContextualMenu'; +import { DefaultButton, IconButton } from '@fluentui/react-next/lib/compat/Button'; +import { FocusZoneDirection } from '@fluentui/react-next/lib/FocusZone'; +import './ContextualMenuExample.scss'; + +export const ContextualMenuCustomizationExample: React.FunctionComponent = () => { + return ; +}; + +function renderCharmMenuItem(item: IContextualMenuItem, dismissMenu: () => void): JSX.Element { + return ( + + ); +} + +function renderCategoriesList(item: IContextualMenuItem): JSX.Element { + return ( +
      +
    • + {item.categoryList.map((category: ICategoryList) => ( + +
      + + {category.name} +
      +
      + ))} +
    • +
    + ); +} + +interface ICategoryList { + name: string; + color: string; +} + +const menuItems: IContextualMenuItem[] = [ + { + key: 'newItem', + text: 'New', + }, + { + key: 'upload', + text: 'Upload', + }, + { + key: 'divider_1', + itemType: ContextualMenuItemType.Divider, + }, + { + key: 'charm', + text: 'Charm', + className: 'Charm-List', + subMenuProps: { + focusZoneProps: { direction: FocusZoneDirection.bidirectional }, + items: [ + { + key: 'none', + text: 'None', + }, + { + key: 'bulb', + text: 'Lightbulb', + onRender: renderCharmMenuItem, + className: 'ms-ContextualMenu-customizationExample-item', + }, + { + key: 'run', + text: 'Running', + onRender: renderCharmMenuItem, + className: 'ms-ContextualMenu-customizationExample-item', + }, + { + key: 'plane', + text: 'Airplane', + onRender: renderCharmMenuItem, + className: 'ms-ContextualMenu-customizationExample-item', + }, + { + key: 'page', + text: 'Page', + onRender: renderCharmMenuItem, + className: 'ms-ContextualMenu-customizationExample-item', + }, + { + key: 'cake', + text: 'Cake', + onRender: renderCharmMenuItem, + className: 'ms-ContextualMenu-customizationExample-item', + }, + { + key: 'soccer', + text: 'Soccer', + onRender: renderCharmMenuItem, + className: 'ms-ContextualMenu-customizationExample-item', + }, + { + key: 'home', + text: 'Home', + onRender: renderCharmMenuItem, + className: 'ms-ContextualMenu-customizationExample-item', + }, + { + key: 'emoji', + text: 'Emoji2', + onRender: renderCharmMenuItem, + className: 'ms-ContextualMenu-customizationExample-item', + }, + { + key: 'work', + text: 'Work', + onRender: renderCharmMenuItem, + className: 'ms-ContextualMenu-customizationExample-item', + }, + { + key: 'coffee', + text: 'Coffee', + onRender: renderCharmMenuItem, + className: 'ms-ContextualMenu-customizationExample-item', + }, + { + key: 'people', + text: 'People', + onRender: renderCharmMenuItem, + className: 'ms-ContextualMenu-customizationExample-item', + }, + { + key: 'stopwatch', + text: 'Stopwatch', + onRender: renderCharmMenuItem, + className: 'ms-ContextualMenu-customizationExample-item', + }, + { + key: 'music', + text: 'MusicInCollectionFill', + onRender: renderCharmMenuItem, + className: 'ms-ContextualMenu-customizationExample-item', + }, + { + key: 'lock', + text: 'Lock', + onRender: renderCharmMenuItem, + className: 'ms-ContextualMenu-customizationExample-item', + }, + ], + }, + }, + { + key: 'categories', + text: 'Categorize', + subMenuProps: { + items: [ + { + key: 'categories', + text: 'categories', + categoryList: [ + { + name: 'Personal', + color: 'yellow', + }, + { + name: 'Work', + color: 'green', + }, + { + name: 'Birthday', + color: 'blue', + }, + { + name: 'Spam', + color: 'grey', + }, + { + name: 'Urgent', + color: 'red', + }, + { + name: 'Hobbies', + color: 'black', + }, + ], + onRender: renderCategoriesList, + }, + { + key: 'divider_1', + itemType: ContextualMenuItemType.Divider, + }, + { + key: 'clear', + text: 'Clear categories', + }, + { + key: 'manage', + text: 'Manage categories', + }, + ], + }, + }, +]; + +const menuProps: IContextualMenuProps = { + shouldFocusOnMount: true, + directionalHint: DirectionalHint.bottomLeftEdge, + className: 'ms-ContextualMenu-customizationExample', + items: menuItems, +}; diff --git a/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.CustomizationWithNoWrap.Example.tsx b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.CustomizationWithNoWrap.Example.tsx new file mode 100644 index 0000000000000..2ae022febc105 --- /dev/null +++ b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.CustomizationWithNoWrap.Example.tsx @@ -0,0 +1,238 @@ +import * as React from 'react'; +import { + ContextualMenuItemType, + DirectionalHint, + IContextualMenuProps, + IContextualMenuItem, +} from '@fluentui/react-next/lib/ContextualMenu'; +import { DefaultButton, IconButton } from '@fluentui/react-next/lib/compat/Button'; +import { FocusZoneDirection } from '@fluentui/react-next/lib/FocusZone'; +import './ContextualMenuExample.scss'; + +export const ContextualMenuCustomizationWithNoWrapExample: React.FunctionComponent = () => { + return ; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function renderCharmMenuItem(item: any, dismissMenu: () => void): JSX.Element { + return ( + + ); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function renderCategoriesList(item: any): JSX.Element { + return ( +
      +
    • + {item.categoryList.map((category: ICategoryList) => ( + +
      + + {category.name} +
      +
      + ))} +
    • +
    + ); +} + +interface ICategoryList { + name: string; + color: string; +} + +const menuItems: IContextualMenuItem[] = [ + { + key: 'newItem', + text: 'New', + }, + { + key: 'upload', + text: 'Upload', + }, + { + key: 'divider_1', + itemType: ContextualMenuItemType.Divider, + }, + { + key: 'charm', + text: 'Charm', + className: 'Charm-List', + subMenuProps: { + focusZoneProps: { + direction: FocusZoneDirection.bidirectional, + checkForNoWrap: true, + }, + items: [ + { + key: 'bulb', + text: 'Lightbulb', + onRender: renderCharmMenuItem, + className: 'ms-ContextualMenu-customizationExample-item', + }, + { + key: 'run', + text: 'Running', + onRender: renderCharmMenuItem, + className: 'ms-ContextualMenu-customizationExample-item', + }, + { + key: 'plane', + text: 'Airplane', + onRender: renderCharmMenuItem, + className: 'ms-ContextualMenu-customizationExample-item', + }, + { + key: 'page', + text: 'Page', + onRender: renderCharmMenuItem, + className: 'ms-ContextualMenu-customizationExample-item', + }, + { + key: 'cake', + text: 'Cake', + onRender: renderCharmMenuItem, + className: 'ms-ContextualMenu-customizationExample-item', + }, + { + key: 'soccer', + text: 'Soccer', + onRender: renderCharmMenuItem, + className: 'ms-ContextualMenu-customizationExample-item', + }, + { + key: 'home', + text: 'Home', + onRender: renderCharmMenuItem, + className: 'ms-ContextualMenu-customizationExample-item', + }, + { + key: 'emoji', + text: 'Emoji2', + onRender: renderCharmMenuItem, + className: 'ms-ContextualMenu-customizationExample-item', + }, + { + key: 'work', + text: 'Work', + onRender: renderCharmMenuItem, + className: 'ms-ContextualMenu-customizationExample-item', + }, + { + key: 'coffee', + text: 'Coffee', + onRender: renderCharmMenuItem, + className: 'ms-ContextualMenu-customizationExample-item', + }, + { + key: 'people', + text: 'People', + onRender: renderCharmMenuItem, + className: 'ms-ContextualMenu-customizationExample-item', + }, + { + key: 'stopwatch', + text: 'Stopwatch', + onRender: renderCharmMenuItem, + className: 'ms-ContextualMenu-customizationExample-item', + }, + { + key: 'music', + text: 'MusicInCollectionFill', + onRender: renderCharmMenuItem, + className: 'ms-ContextualMenu-customizationExample-item', + }, + { + key: 'lock', + text: 'Lock', + onRender: renderCharmMenuItem, + className: 'ms-ContextualMenu-customizationExample-item', + }, + { + key: 'item3', + text: 'Item 3', + 'data-no-horizontal-wrap': true, + }, + { + key: 'item4', + text: 'Item 4', + 'data-no-horizontal-wrap': true, + }, + ], + }, + }, + { + key: 'categories', + text: 'Categorize', + subMenuProps: { + items: [ + { + key: 'categories', + text: 'categories', + categoryList: [ + { + name: 'Personal', + color: 'yellow', + }, + { + name: 'Work', + color: 'green', + }, + { + name: 'Birthday', + color: 'blue', + }, + { + name: 'Spam', + color: 'grey', + }, + { + name: 'Urgent', + color: 'red', + }, + { + name: 'Hobbies', + color: 'black', + }, + ], + onRender: renderCategoriesList, + }, + { + key: 'divider_1', + itemType: ContextualMenuItemType.Divider, + }, + { + key: 'clear', + text: 'Additional Item', + }, + { + key: 'manage', + text: 'Additional Item', + }, + ], + }, + }, +]; + +const menuProps: IContextualMenuProps = { + shouldFocusOnMount: true, + directionalHint: DirectionalHint.bottomLeftEdge, + className: 'ms-ContextualMenu-customizationExample', + items: menuItems, +}; diff --git a/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Default.Example.tsx b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Default.Example.tsx new file mode 100644 index 0000000000000..6a023d607350a --- /dev/null +++ b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Default.Example.tsx @@ -0,0 +1,70 @@ +import * as React from 'react'; +import { + ContextualMenuItemType, + IContextualMenuProps, + IContextualMenuItem, +} from '@fluentui/react-next/lib/ContextualMenu'; +import { DefaultButton } from '@fluentui/react-next/lib/compat/Button'; + +export const ContextualMenuDefaultExample: React.FunctionComponent = () => { + return ; +}; + +const menuItems: IContextualMenuItem[] = [ + { + key: 'newItem', + text: 'New', + onClick: () => console.log('New clicked'), + }, + { + key: 'divider_1', + itemType: ContextualMenuItemType.Divider, + }, + { + key: 'rename', + text: 'Rename', + onClick: () => console.log('Rename clicked'), + }, + { + key: 'edit', + text: 'Edit', + onClick: () => console.log('Edit clicked'), + }, + { + key: 'properties', + text: 'Properties', + onClick: () => console.log('Properties clicked'), + }, + { + key: 'linkNoTarget', + text: 'Link same window', + href: 'http://bing.com', + }, + { + key: 'linkWithTarget', + text: 'Link new window', + href: 'http://bing.com', + target: '_blank', + }, + { + key: 'linkWithOnClick', + name: 'Link click', + href: 'http://bing.com', + onClick: (ev: React.MouseEvent) => { + alert('Link clicked'); + ev.preventDefault(); + }, + target: '_blank', + }, + { + key: 'disabled', + text: 'Disabled item', + disabled: true, + onClick: () => console.error('Disabled item should not be clickable.'), + }, +]; + +const menuProps: IContextualMenuProps = { + shouldFocusOnMount: true, + items: menuItems, +}; diff --git a/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Directional.Example.tsx b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Directional.Example.tsx new file mode 100644 index 0000000000000..58c25f0b03d91 --- /dev/null +++ b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Directional.Example.tsx @@ -0,0 +1,131 @@ +import * as React from 'react'; +import { useConstCallback } from '@uifabric/react-hooks'; +import { DefaultButton } from '@fluentui/react-next/lib/compat/Button'; +import { Checkbox, ICheckboxStyles } from '@fluentui/react-next/lib/Checkbox'; +import { + ContextualMenuItemType, + DirectionalHint, + IContextualMenuProps, + IContextualMenuItem, +} from '@fluentui/react-next/lib/ContextualMenu'; +import { Dropdown, IDropdownOption } from '@fluentui/react-next/lib/Dropdown'; +import { getRTL } from '@fluentui/react-next/lib/Utilities'; +import './ContextualMenuExample.scss'; + +const DIRECTION_OPTIONS = [ + { key: DirectionalHint.topLeftEdge, text: 'Top Left Edge' }, + { key: DirectionalHint.topCenter, text: 'Top Center' }, + { key: DirectionalHint.topRightEdge, text: 'Top Right Edge' }, + { key: DirectionalHint.topAutoEdge, text: 'Top Auto Edge' }, + { key: DirectionalHint.bottomLeftEdge, text: 'Bottom Left Edge' }, + { key: DirectionalHint.bottomCenter, text: 'Bottom Center' }, + { key: DirectionalHint.bottomRightEdge, text: 'Bottom Right Edge' }, + { key: DirectionalHint.bottomAutoEdge, text: 'Bottom Auto Edge' }, + { key: DirectionalHint.leftTopEdge, text: 'Left Top Edge' }, + { key: DirectionalHint.leftCenter, text: 'Left Center' }, + { key: DirectionalHint.leftBottomEdge, text: 'Left Bottom Edge' }, + { key: DirectionalHint.rightTopEdge, text: 'Right Top Edge' }, + { key: DirectionalHint.rightCenter, text: 'Right Center' }, + { key: DirectionalHint.rightBottomEdge, text: 'Right Bottom Edge' }, +]; + +const checkboxStyles: Partial = { root: { margin: '10px 0' } }; + +export const ContextualMenuDirectionalExample: React.FunctionComponent = () => { + const [isBeakVisible, setIsBeakVisible] = React.useState(false); + const [useDirectionalHintForRTL, setUseDirectionalHintForRTL] = React.useState(false); + const [directionalHint, setDirectionalHint] = React.useState(DirectionalHint.bottomLeftEdge); + const [directionalHintForRTL, setDirectionalHintForRTL] = React.useState( + DirectionalHint.bottomLeftEdge, + ); + + const onShowBeakChange = useConstCallback((event: React.FormEvent, isVisible: boolean): void => { + setIsBeakVisible(isVisible); + }); + + const onUseRtlHintChange = useConstCallback((event: React.FormEvent, isVisible: boolean): void => { + setUseDirectionalHintForRTL(isVisible); + }); + + const onDirectionalChanged = useConstCallback( + (event: React.FormEvent, option: IDropdownOption): void => { + setDirectionalHint(option.key as DirectionalHint); + }, + ); + + const onDirectionalRtlChanged = useConstCallback( + (event: React.FormEvent, option: IDropdownOption): void => { + setDirectionalHintForRTL(option.key as DirectionalHint); + }, + ); + + const menuProps: IContextualMenuProps = React.useMemo( + () => ({ + isBeakVisible: isBeakVisible, + directionalHint: directionalHint, + directionalHintForRTL: useDirectionalHintForRTL ? directionalHintForRTL : undefined, + gapSpace: 0, + beakWidth: 20, + directionalHintFixed: false, + items: menuItems, + }), + [isBeakVisible, directionalHint, directionalHintForRTL, useDirectionalHintForRTL], + ); + + return ( +
    +
    + + + {getRTL() && ( + + )} + {getRTL() && ( + + )} +
    +
    + +
    +
    + ); +}; + +const menuItems: IContextualMenuItem[] = [ + { + key: 'newItem', + text: 'New', + }, + { + key: 'divider_1', + itemType: ContextualMenuItemType.Divider, + }, + { + key: 'rename', + text: 'Rename', + }, + { + key: 'edit', + text: 'Edit', + }, + { + key: 'properties', + text: 'Properties', + }, + { + key: 'disabled', + text: 'Disabled item', + disabled: true, + }, +]; diff --git a/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Header.Example.tsx b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Header.Example.tsx new file mode 100644 index 0000000000000..ee6625ac05d46 --- /dev/null +++ b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Header.Example.tsx @@ -0,0 +1,94 @@ +import * as React from 'react'; +import { DefaultButton } from '@fluentui/react-next/lib/compat/Button'; +import { + ContextualMenuItemType, + IContextualMenuProps, + IContextualMenuItem, +} from '@fluentui/react-next/lib/ContextualMenu'; + +export const ContextualMenuHeaderExample: React.FunctionComponent = () => { + return ; +}; + +const menuItems: IContextualMenuItem[] = [ + { + key: 'Actions', + itemType: ContextualMenuItemType.Header, + text: 'Actions', + itemProps: { + lang: 'en-us', + }, + }, + { + key: 'upload', + iconProps: { + iconName: 'Upload', + style: { + color: 'salmon', + }, + }, + text: 'Upload', + title: 'Upload a file', + }, + { + key: 'rename', + text: 'Rename', + }, + { + key: 'share', + iconProps: { + iconName: 'Share', + }, + subMenuProps: { + items: [ + { + key: 'sharetoemail', + text: 'Share to Email', + iconProps: { + iconName: 'Mail', + }, + }, + { + key: 'sharetofacebook', + text: 'Share to Facebook', + }, + { + key: 'sharetotwitter', + text: 'Share to Twitter', + iconProps: { + iconName: 'Share', + }, + }, + ], + }, + text: 'Sharing', + }, + { + key: 'navigation', + itemType: ContextualMenuItemType.Header, + text: 'Navigation', + }, + { + key: 'properties', + text: 'Properties', + }, + { + key: 'print', + iconProps: { + iconName: 'Print', + }, + text: 'Print', + }, + + { + key: 'Bing', + text: 'Go to Bing', + href: 'http://www.bing.com', + target: '_blank', + }, +]; + +const menuProps: IContextualMenuProps = { + shouldFocusOnMount: true, + items: menuItems, +}; diff --git a/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Icon.Example.tsx b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Icon.Example.tsx new file mode 100644 index 0000000000000..e370c4d660a71 --- /dev/null +++ b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Icon.Example.tsx @@ -0,0 +1,96 @@ +import * as React from 'react'; +import { useConst, useConstCallback } from '@uifabric/react-hooks'; +import { DefaultButton } from '@fluentui/react-next/lib/compat/Button'; +import { Callout } from '@fluentui/react-next/lib/Callout'; +import { + ContextualMenuItemType, + IContextualMenuProps, + IContextualMenuItem, + IContextualMenuItemProps, +} from '@fluentui/react-next/lib/ContextualMenu'; +import { Icon } from '@fluentui/react-next/lib/Icon'; +import * as stylesImport from './ContextualMenuExample.scss'; + +const styles = stylesImport; + +export const ContextualMenuIconExample: React.FunctionComponent = () => { + const [showCallout, setShowCallout] = React.useState(false); + + const onShowCallout = useConstCallback(() => setShowCallout(true)); + const onHideCallout = useConstCallback(() => setShowCallout(false)); + + const menuItems: IContextualMenuItem[] = useConst([ + { + key: 'openInWord', + text: 'Open in Word', + onRenderIcon: (props: IContextualMenuItemProps) => { + return ( + + + + + ); + }, + }, + { + key: 'newItem', + iconProps: { + iconName: 'Add', + }, + text: 'New', + }, + { + key: 'upload', + onClick: onShowCallout, + iconProps: { + iconName: 'Upload', + style: { + color: 'salmon', + }, + }, + text: 'Upload (Click for popup)', + title: 'Upload a file', + }, + { + key: 'divider_1', + itemType: ContextualMenuItemType.Divider, + }, + { + key: 'share', + iconProps: { + iconName: 'Share', + }, + text: 'Share', + }, + { + key: 'print', + iconProps: { + iconName: 'Print', + }, + text: 'Print', + }, + { + key: 'music', + iconProps: { + iconName: 'MusicInCollectionFill', + }, + text: 'Music', + }, + ]); + + const menuProps: IContextualMenuProps = useConst({ + shouldFocusOnMount: true, + items: menuItems, + }); + + return ( +
    + + {showCallout && ( + + + + )} +
    + ); +}; diff --git a/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Icon.SecondaryText.Example.tsx b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Icon.SecondaryText.Example.tsx new file mode 100644 index 0000000000000..38c2b543d05a1 --- /dev/null +++ b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Icon.SecondaryText.Example.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { DefaultButton } from '@fluentui/react-next/lib/compat/Button'; +import { IContextualMenuProps, IContextualMenuItem } from '@fluentui/react-next/lib/ContextualMenu'; + +export const ContextualMenuIconSecondaryTextExample: React.FunctionComponent = () => { + return ; +}; + +const menuItems: IContextualMenuItem[] = [ + { + key: 'Later Today', + iconProps: { + iconName: 'Clock', + }, + text: 'Later Today', + secondaryText: '7:00 PM', + }, + { + key: 'Tomorrow', + iconProps: { + iconName: 'Coffeescript', + }, + text: 'Tomorrow', + secondaryText: 'Thu. 8:00 AM', + }, + { + key: 'This Weekend', + iconProps: { + iconName: 'Vacation', + }, + text: 'This Weekend', + secondaryText: 'Sat. 10:00 AM', + }, + { + key: 'Next Week', + iconProps: { + iconName: 'Suitcase', + }, + text: 'Next Week', + secondaryText: 'Mon. 8:00 AM', + }, +]; + +const menuProps: IContextualMenuProps = { + shouldFocusOnMount: true, + items: menuItems, +}; diff --git a/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Persisted.Example.tsx b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Persisted.Example.tsx new file mode 100644 index 0000000000000..33fbe0a4bd9fe --- /dev/null +++ b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Persisted.Example.tsx @@ -0,0 +1,86 @@ +import * as React from 'react'; +import { + ContextualMenuItemType, + IContextualMenuProps, + IContextualMenuItem, +} from '@fluentui/react-next/lib/ContextualMenu'; +import { DefaultButton } from '@fluentui/react-next/lib/compat/Button'; + +export const ContextualMenuPersistedExample: React.FunctionComponent = () => { + return ; +}; + +const menuItems: IContextualMenuItem[] = [ + { + key: 'newItem', + subMenuProps: { + items: [ + { + key: 'emailMessage', + text: 'Email message', + title: 'Create an email', + }, + { + key: 'calendarEvent', + text: 'Calendar event', + title: 'Create a calendar event', + }, + ], + }, + href: 'https://bing.com', + text: 'New', + target: '_blank', + }, + { + key: 'divider_1', + itemType: ContextualMenuItemType.Divider, + }, + { + key: 'rename', + text: 'Rename', + onClick: () => console.log('Rename clicked'), + }, + { + key: 'edit', + text: 'Edit', + onClick: () => console.log('Edit clicked'), + }, + { + key: 'properties', + text: 'Properties', + onClick: () => console.log('Properties clicked'), + }, + { + key: 'linkNoTarget', + text: 'Link same window', + href: 'http://bing.com', + }, + { + key: 'linkWithTarget', + text: 'Link new window', + href: 'http://bing.com', + target: '_blank', + }, + { + key: 'linkWithOnClick', + name: 'Link click', + href: 'http://bing.com', + onClick: (ev: React.MouseEvent) => { + alert('Link clicked'); + ev.preventDefault(); + }, + target: '_blank', + }, + { + key: 'disabled', + text: 'Disabled item', + disabled: true, + onClick: () => console.error('Disabled item should not be clickable.'), + }, +]; + +const menuProps: IContextualMenuProps = { + shouldFocusOnMount: true, + shouldFocusOnContainer: true, + items: menuItems, +}; diff --git a/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.ScrollBar.Example.tsx b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.ScrollBar.Example.tsx new file mode 100644 index 0000000000000..d65a5278531e6 --- /dev/null +++ b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.ScrollBar.Example.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import { DirectionalHint, IContextualMenuProps, IContextualMenuItem } from '@fluentui/react-next/lib/ContextualMenu'; +import { DefaultButton } from '@fluentui/react-next/lib/compat/Button'; + +export const ContextualMenuWithScrollBarExample: React.FunctionComponent = () => { + return ; +}; + +const menuItems: IContextualMenuItem[] = [ + { + key: 'newItem', + text: 'New', + }, + { + key: 'item 2', + text: 'Item with a very long label text', + }, + { + key: 'edit', + text: 'Edit', + }, + { + key: 'properties', + text: 'Properties', + }, + { + key: 'disabled', + text: 'Disabled item', + disabled: true, + }, +]; + +const menuProps: IContextualMenuProps = { + shouldFocusOnMount: true, + directionalHint: DirectionalHint.bottomRightEdge, + directionalHintFixed: true, + items: menuItems, + calloutProps: { + calloutMaxHeight: 65, + }, +}; diff --git a/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Section.Example.tsx b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Section.Example.tsx new file mode 100644 index 0000000000000..f263342fd4a31 --- /dev/null +++ b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Section.Example.tsx @@ -0,0 +1,71 @@ +import * as React from 'react'; +import { + ContextualMenuItemType, + IContextualMenuProps, + IContextualMenuItem, +} from '@fluentui/react-next/lib/ContextualMenu'; +import { DefaultButton } from '@fluentui/react-next/lib/compat/Button'; + +export const ContextualMenuSectionExample: React.FunctionComponent = () => { + return ; +}; + +const menuItems: IContextualMenuItem[] = [ + { + key: 'section1', + itemType: ContextualMenuItemType.Section, + sectionProps: { + topDivider: true, + bottomDivider: true, + title: 'Actions', + items: [ + { + key: 'newItem', + text: 'New', + }, + { + key: 'deleteItem', + text: 'Delete', + }, + ], + }, + }, + { + key: 'section2', + itemType: ContextualMenuItemType.Section, + sectionProps: { + title: 'Social', + items: [ + { + key: 'share', + text: 'Share', + }, + { + key: 'print', + text: 'Print', + }, + { + key: 'music', + text: 'Music', + }, + ], + }, + }, + { + key: 'section3', + itemType: ContextualMenuItemType.Section, + sectionProps: { + title: 'Navigation', + items: [ + { + key: 'Bing', + text: 'Go to Bing', + href: 'http://www.bing.com', + target: '_blank', + }, + ], + }, + }, +]; + +const menuProps: IContextualMenuProps = { items: menuItems }; diff --git a/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Submenu.Example.tsx b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Submenu.Example.tsx new file mode 100644 index 0000000000000..05dab927f03f9 --- /dev/null +++ b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenu.Submenu.Example.tsx @@ -0,0 +1,142 @@ +import * as React from 'react'; +import { useConstCallback } from '@uifabric/react-hooks'; +import { DefaultButton } from '@fluentui/react-next/lib/compat/Button'; +import { IContextualMenuProps, IContextualMenuItem } from '@fluentui/react-next/lib/ContextualMenu'; +import { TextField, ITextFieldStyles } from '@fluentui/react-next/lib/TextField'; + +const textFieldStyles: Partial = { + subComponentStyles: { + label: { root: { display: 'inline-block', marginRight: '10px' } }, + }, + fieldGroup: { display: 'inline-flex', maxWidth: '100px' }, + wrapper: { display: 'block', marginBottom: '10px' }, +}; + +export interface IContextualMenuSubmenuExampleState { + hoverDelay: number; +} + +export const ContextualMenuSubmenuExample: React.FunctionComponent = () => { + const [hoverDelay, setHoverDelay] = React.useState(250); + + const onHoverDelayChanged = useConstCallback( + (ev: React.FormEvent, newValue: string) => { + setHoverDelay(Number(newValue) || 0); + }, + ); + + const menuProps: IContextualMenuProps = React.useMemo( + () => ({ + shouldFocusOnMount: true, + subMenuHoverDelay: hoverDelay, + items: menuItems, + }), + [hoverDelay], + ); + + return ( +
    + + +
    + ); +}; + +const menuItems: IContextualMenuItem[] = [ + { + key: 'newItem', + subMenuProps: { + items: [ + { + key: 'emailMessage', + text: 'Email message', + title: 'Create an email', + }, + { + key: 'calendarEvent', + text: 'Calendar event', + title: 'Create a calendar event', + }, + ], + }, + href: 'https://bing.com', + text: 'New', + target: '_blank', + }, + { + key: 'share', + subMenuProps: { + items: [ + { + key: 'sharetotwitter', + text: 'Share to Twitter', + }, + { + key: 'sharetofacebook', + text: 'Share to Facebook', + }, + { + key: 'sharetoemail', + text: 'Share to Email', + subMenuProps: { + items: [ + { + key: 'sharetooutlook_1', + text: 'Share to Outlook', + title: 'Share to Outlook', + }, + { + key: 'sharetogmail_1', + text: 'Share to Gmail', + title: 'Share to Gmail', + }, + ], + }, + }, + ], + }, + text: 'Share', + }, + { + key: 'shareSplit', + split: true, + 'aria-roledescription': 'split button', + subMenuProps: { + items: [ + { + key: 'sharetotwittersplit', + text: 'Share to Twitter', + }, + { + key: 'sharetofacebooksplit', + text: 'Share to Facebook', + }, + { + key: 'sharetoemailsplit', + text: 'Share to Email', + subMenuProps: { + items: [ + { + key: 'sharetooutlooksplit_1', + text: 'Share to Outlook', + title: 'Share to Outlook', + }, + { + key: 'sharetogmailsplit_1', + text: 'Share to Gmail', + title: 'Share to Gmail', + }, + ], + }, + }, + ], + }, + text: 'Share w/ Split', + }, +]; diff --git a/packages/react-next/src/components/ContextualMenu/examples/ContextualMenuExample.scss b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenuExample.scss new file mode 100644 index 0000000000000..5b7b7db15cb0c --- /dev/null +++ b/packages/react-next/src/components/ContextualMenu/examples/ContextualMenuExample.scss @@ -0,0 +1,94 @@ +@import '~@fluentui/common-styles/dist/sass/ThemingSass'; + +:global { + .ms-ContextualMenuDirectionalExample { + position: relative; + } + + .ms-ContextualMenuDirectionalExample-configArea, + .ms-ContextualMenuDirectionalExample-buttonArea { + display: inline-block; + min-width: 300px; + } + + .ms-ContextualMenuDirectionalExample-buttonArea { + position: absolute; + vertical-align: top; + width: 300px; + top: 70px; + margin: 0 100px; + text-align: center; + } + + .ms-ContextualMenuDirectionalExample-buttonArea .ms-Button { + text-align: center; + width: 100%; + } + + .ms-ContextualMenu-customizationExample-item { + display: inline-block; + width: 40px; + height: 40px; + line-height: 40px; + text-align: center; + vertical-align: middle; + margin-bottom: 8px; + cursor: pointer; + + &:hover { + background-color: #eaeaea; + } + } + + .ms-ContextualMenu-customizationExample-categoriesList { + margin: 0px; + padding: 0; + list-style-type: none; + } + + .ms-ContextualMenu-customizationExample-categorySwatch { + width: 24px; + height: 24px; + vertical-align: top; + } + + .ms-ContextualMenu-example-clickableArea { + background: lightblue; + border: none; + font-size: 0; + height: 200px; + width: 100%; + } + + .ms-ContextualMenu-customizationExample { + text-align: center; + max-width: 180px; + .ms-ContextualMenu-item { + height: auto; + } + } + + .ms-ContextualMenu-customizationExample-button { + width: 40%; + margin: 2%; + } +} + +.iconContainer { + position: relative; + margin: 0 4px; + height: 32px; + width: 14px; +} + +.logoFillIcon, +.logoIcon { + position: absolute; + left: 0; + right: 0; + color: $ms-color-themeDarkAlt; +} + +.logoFillIcon { + color: #ffffff; +} diff --git a/packages/react-next/src/components/ContextualMenu/index.ts b/packages/react-next/src/components/ContextualMenu/index.ts new file mode 100644 index 0000000000000..1874e3d39d1c9 --- /dev/null +++ b/packages/react-next/src/components/ContextualMenu/index.ts @@ -0,0 +1,6 @@ +export * from './ContextualMenu'; +export * from './ContextualMenu.base'; +export * from './ContextualMenu.types'; +export * from './ContextualMenuItem'; +export * from './ContextualMenuItem.base'; +export * from './ContextualMenuItem.types'; diff --git a/packages/react-next/src/utilities/contextualMenu/contextualMenuUtility.test.ts b/packages/react-next/src/utilities/contextualMenu/contextualMenuUtility.test.ts new file mode 100644 index 0000000000000..373382b8b3728 --- /dev/null +++ b/packages/react-next/src/utilities/contextualMenu/contextualMenuUtility.test.ts @@ -0,0 +1,116 @@ +import { getIsChecked, hasSubmenu, getMenuItemAriaRole } from './contextualMenuUtility'; +import { IContextualMenuItem } from '../../index'; + +describe('getIsChecked', () => { + describe('when item can be checked', () => { + let menuItem: IContextualMenuItem; + + beforeEach(() => { + menuItem = { key: '123', canCheck: true }; + }); + + it('returns true when isChecked', () => { + menuItem.isChecked = true; + expect(getIsChecked(menuItem)).toBe(true); + }); + + it('returns true when checked', () => { + menuItem.checked = true; + expect(getIsChecked(menuItem)).toBe(true); + }); + }); + + it('when item cannot be checked', () => { + const menuItem: IContextualMenuItem = { + key: '123', + canCheck: false, + }; + expect(getIsChecked(menuItem)).toBe(null); + }); + + describe('when item isChecked', () => { + let menuItem: IContextualMenuItem; + + beforeEach(() => { + menuItem = { key: '123', isChecked: true }; + }); + + it('returns true', () => { + expect(getIsChecked(menuItem)).toBe(true); + }); + }); + + describe('when item checked flag is true', () => { + let menuItem: IContextualMenuItem; + + beforeEach(() => { + menuItem = { key: '123', checked: true }; + }); + + it('returns true', () => { + expect(getIsChecked(menuItem)).toBe(true); + }); + }); + + describe('when it is not checked', () => { + let menuItem: IContextualMenuItem; + + beforeEach(() => { + menuItem = { key: '123' }; + }); + + it('returns false', () => { + expect(getIsChecked(menuItem)).toBeFalsy(); + }); + }); +}); + +describe('getMenuItemAriaRole', () => { + it('menu item is checkbox', () => { + const menuItem: IContextualMenuItem = { key: '123', canCheck: true, checked: false }; + expect(getMenuItemAriaRole(menuItem)).toBe('menuitemcheckbox'); + }); + + it('menu item is not checkbox', () => { + const menuItem: IContextualMenuItem = { key: '123', canCheck: false }; + expect(getMenuItemAriaRole(menuItem)).toBe('menuitem'); + }); +}); + +describe('hasSubmenu', () => { + describe('when there is a submenu props', () => { + let menuItem: IContextualMenuItem; + + beforeEach(() => { + menuItem = { key: '123', subMenuProps: { items: [] } }; + }); + + it('returns true', () => { + expect(hasSubmenu(menuItem)).toBe(true); + }); + }); + + describe('when there are items', () => { + let menuItem: IContextualMenuItem; + + beforeEach(() => { + menuItem = { key: '123', items: [] }; + }); + + it('returns true', () => { + expect(hasSubmenu(menuItem)).toBe(true); + }); + }); + + describe('when there are no submenu items', () => { + let menuItem: IContextualMenuItem; + + beforeEach(() => { + menuItem = { key: '123' }; + }); + + it('returns false', () => { + expect(hasSubmenu(menuItem)).toBe(false); + }); + }); +}); diff --git a/packages/react-next/src/utilities/contextualMenu/contextualMenuUtility.ts b/packages/react-next/src/utilities/contextualMenu/contextualMenuUtility.ts new file mode 100644 index 0000000000000..6464665e55704 --- /dev/null +++ b/packages/react-next/src/utilities/contextualMenu/contextualMenuUtility.ts @@ -0,0 +1,40 @@ +import { IContextualMenuItem } from '../../index'; + +/** + * Determines the effective checked state of a menu item. + * + * @param item {IContextualMenuItem} to get the check state of. + * @returns {true} if the item is checked. + * @returns {false} if the item is unchecked. + * @returns {null} if the item is not checkable. + */ +export function getIsChecked(item: IContextualMenuItem): boolean | null { + if (item.canCheck) { + return !!(item.isChecked || item.checked); + } + + if (typeof item.isChecked === 'boolean') { + return item.isChecked; + } + + if (typeof item.checked === 'boolean') { + return item.checked; + } + + // Item is not checkable. + return null; +} + +export function hasSubmenu(item: IContextualMenuItem): boolean { + return !!(item.subMenuProps || item.items); +} + +export function isItemDisabled(item: IContextualMenuItem): boolean { + return !!(item.isDisabled || item.disabled); +} + +export function getMenuItemAriaRole(item: IContextualMenuItem): string { + const isChecked = getIsChecked(item); + const canCheck: boolean = isChecked !== null; + return canCheck ? 'menuitemcheckbox' : 'menuitem'; +} diff --git a/packages/react-next/src/utilities/contextualMenu/index.ts b/packages/react-next/src/utilities/contextualMenu/index.ts new file mode 100644 index 0000000000000..5ab11502d63a0 --- /dev/null +++ b/packages/react-next/src/utilities/contextualMenu/index.ts @@ -0,0 +1 @@ +export * from './contextualMenuUtility';