diff --git a/common/changes/office-ui-fabric-react/keyou-contextual-menu-item-functions_2018-05-02-18-14.json b/common/changes/office-ui-fabric-react/keyou-contextual-menu-item-functions_2018-05-02-18-14.json new file mode 100644 index 0000000000000..c413bf6f9c006 --- /dev/null +++ b/common/changes/office-ui-fabric-react/keyou-contextual-menu-item-functions_2018-05-02-18-14.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "office-ui-fabric-react", + "comment": "Add ContextualMenuItem functions to open and close menus", + "type": "minor" + } + ], + "packageName": "office-ui-fabric-react", + "email": "keyou@microsoft.com" +} \ No newline at end of file diff --git a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenu.test.tsx b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenu.test.tsx index ea9b03ff896d1..3fa6126bc0ed1 100644 --- a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenu.test.tsx +++ b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenu.test.tsx @@ -2,12 +2,14 @@ import * as React from 'react'; import { Promise } from 'es6-promise'; import * as ReactTestUtils from 'react-dom/test-utils'; import { - KeyCodes + KeyCodes, + createRef } from '../../Utilities'; import { FocusZoneDirection } from '../../FocusZone'; import { ContextualMenu, canAnyMenuItemsCheck } from './ContextualMenu'; import { IContextualMenuItem, ContextualMenuItemType } from './ContextualMenu.types'; +import { IContextualMenuRenderItem } from './ContextualMenuItem.types'; import { LayerBase as Layer } from '../Layer/Layer.base'; describe('ContextualMenu', () => { @@ -859,4 +861,162 @@ describe('ContextualMenu', () => { expect(canAnyMenuItemsCheck(items)).toEqual(true); }); }); + + describe('IContextualMenuRenderItem function tests', () => { + const contextualItem = createRef(); + let menuDismissed: boolean; + const onDismiss = (ev?: any, dismissAll?: boolean) => { menuDismissed = true; }; + + describe('for a button element', () => { + beforeEach(() => { + menuDismissed = false; + const menu: IContextualMenuItem[] = [ + { + name: 'Test1', + key: 'Test1', + componentRef: contextualItem, + subMenuProps: { + items: [ + { + name: 'Test2', + key: 'Test2', + className: 'SubMenuClass' + }, + { + name: 'Test3', + key: 'Test3', + className: 'SubMenuClass' + } + ], + } + } + ]; + ReactTestUtils.renderIntoDocument( + + ); + }); + + it('openSubMenu will open the item`s submenu if present', () => { + contextualItem.value!.openSubMenu(); + expect(document.querySelector('.SubMenuClass')).not.toEqual(null); + }); + + it('dismissSubMenu will close the item`s submenu if present', () => { + contextualItem.value!.openSubMenu(); + expect(document.querySelector('.SubMenuClass')).not.toEqual(null); + contextualItem.value!.dismissSubMenu(); + expect(document.querySelector('.SubMenuClass')).toEqual(null); + }); + + it('dismissMenu will close the item`s menu', () => { + contextualItem.value!.dismissMenu(); + expect(menuDismissed).toEqual(true); + }); + }); + + describe('for a split button element', () => { + beforeEach(() => { + menuDismissed = false; + const menu: IContextualMenuItem[] = [ + { + name: 'Test1', + key: 'Test1', + componentRef: contextualItem, + split: true, + subMenuProps: { + items: [ + { + name: 'Test2', + key: 'Test2', + className: 'SubMenuClass' + }, + { + name: 'Test3', + key: 'Test3', + className: 'SubMenuClass' + } + ], + } + } + ]; + ReactTestUtils.renderIntoDocument( + + ); + }); + + it('openSubMenu will open the item`s submenu if present', () => { + contextualItem.value!.openSubMenu(); + expect(document.querySelector('.SubMenuClass')).not.toEqual(null); + }); + + it('dismissSubMenu will close the item`s submenu if present', () => { + contextualItem.value!.openSubMenu(); + expect(document.querySelector('.SubMenuClass')).not.toEqual(null); + contextualItem.value!.dismissSubMenu(); + expect(document.querySelector('.SubMenuClass')).toEqual(null); + }); + + it('dismissMenu will close the item`s menu', () => { + contextualItem.value!.dismissMenu(); + expect(menuDismissed).toEqual(true); + }); + }); + + describe('for an anchor element', () => { + beforeEach(() => { + menuDismissed = false; + const menu: IContextualMenuItem[] = [ + { + name: 'Test1', + key: 'Test1', + componentRef: contextualItem, + href: '#test', + subMenuProps: { + items: [ + { + name: 'Test2', + key: 'Test2', + className: 'SubMenuClass' + }, + { + name: 'Test3', + key: 'Test3', + className: 'SubMenuClass' + } + ], + } + } + ]; + ReactTestUtils.renderIntoDocument( + + ); + }); + + it('openSubMenu will open the item`s submenu if present', () => { + contextualItem.value!.openSubMenu(); + expect(document.querySelector('.SubMenuClass')).not.toEqual(null); + }); + + it('dismissSubMenu will close the item`s submenu if present', () => { + contextualItem.value!.openSubMenu(); + expect(document.querySelector('.SubMenuClass')).not.toEqual(null); + contextualItem.value!.dismissSubMenu(); + expect(document.querySelector('.SubMenuClass')).toEqual(null); + }); + + it('dismissMenu will close the item`s menu', () => { + contextualItem.value!.dismissMenu(); + expect(menuDismissed).toEqual(true); + }); + }); + }); }); diff --git a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenu.tsx b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenu.tsx index 6617366ba7739..010ffcf65223a 100644 --- a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenu.tsx +++ b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenu.tsx @@ -11,9 +11,6 @@ import { import { BaseComponent, IPoint, - anchorProperties, - buttonProperties, - getNativeProps, assign, getId, getRTL, @@ -31,8 +28,7 @@ import { withResponsiveMode, ResponsiveMode } from '../../utilities/decorators/w import { Callout } from '../../Callout'; import { IIconProps } from '../../Icon'; import { ContextualMenuItem } from './ContextualMenuItem'; -import { KeytipData } from '../../KeytipData'; -import { ContextualMenuSplitButton } from './ContextualMenuSplitButton'; +import { ContextualMenuSplitButton, ContextualMenuButton, ContextualMenuAnchor } from './ContextualMenuItemWrapper'; export interface IContextualMenuState { expandedMenuItemKey?: string; @@ -485,58 +481,32 @@ export class ContextualMenu extends BaseComponent - - { (keytipAttributes: any): JSX.Element => ( - - - - ) } - - ); + + ); } private _renderButtonItem( @@ -547,79 +517,33 @@ export class ContextualMenu extends BaseComponent this._onItemMouseDown(item, ev), - onMouseMove: this._onItemMouseMove.bind(this, item), - href: item.href, - title: item.title, - 'aria-label': ariaLabel, - 'aria-haspopup': itemHasSubmenu || undefined, - 'aria-owns': item.key === expandedMenuItemKey ? subMenuId : undefined, - 'aria-expanded': itemHasSubmenu ? item.key === expandedMenuItemKey : undefined, - 'aria-checked': !!isChecked, - 'aria-posinset': focusableElementIndex + 1, - 'aria-setsize': totalItemCount, - 'aria-disabled': isItemDisabled(item), - role: item.role || defaultRole, - style: item.style - }; - - let { keytipProps } = item; - if (keytipProps && itemHasSubmenu) { - keytipProps = { - ...keytipProps, - hasMenu: true - }; - } return ( - - { (keytipAttributes: any): JSX.Element => ( - - ) } - + ); } @@ -651,6 +575,9 @@ export class ContextualMenu extends BaseComponent ); @@ -731,10 +658,6 @@ export class ContextualMenu extends BaseComponent { this._isScrollIdle = true; }, NavigationIdleDelay); } - private _onItemMouseEnter = (item: any, ev: React.MouseEvent): void => { - this._onItemMouseEnterBase(item, ev, ev.currentTarget as HTMLElement); - } - private _onItemMouseEnterBase = (item: any, ev: React.MouseEvent, target?: HTMLElement): void => { if (!this._isScrollIdle) { return; @@ -743,10 +666,6 @@ export class ContextualMenu extends BaseComponent) { - this._onItemMouseMoveBase(item, ev, ev.currentTarget as HTMLElement); - } - private _onItemMouseMoveBase = (item: any, ev: React.MouseEvent, target: HTMLElement): void => { const targetElement = ev.currentTarget as HTMLElement; @@ -857,7 +776,7 @@ export class ContextualMenu extends BaseComponent) { + private _onAnchorClick = (item: IContextualMenuItem, ev: React.MouseEvent) => { this._executeItemClick(item, ev); ev.stopPropagation(); } @@ -895,7 +814,7 @@ export class ContextualMenu extends BaseComponent { if (this.state.expandedMenuItemKey !== item.key) { if (this.state.expandedMenuItemKey) { @@ -994,7 +913,7 @@ export class ContextualMenu extends BaseComponent { let { subMenuId } = this.state; if (item.subMenuProps && item.subMenuProps.id) { diff --git a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenu.types.ts b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenu.types.ts index b9c260cb25661..0041d785c5c81 100644 --- a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenu.types.ts +++ b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenu.types.ts @@ -15,7 +15,7 @@ import { IWithResponsiveModeState } from '../../utilities/decorators/withRespons import { IContextualMenuClassNames, IMenuItemClassNames } from './ContextualMenu.classNames'; export { DirectionalHint } from '../../common/DirectionalHint'; import { IVerticalDividerClassNames } from '../Divider/VerticalDivider.types'; -import { IContextualMenuItemProps } from './ContextualMenuItem.types'; +import { IContextualMenuItemProps, IContextualMenuRenderItem } from './ContextualMenuItem.types'; import { IKeytipProps } from '../../Keytip'; export enum ContextualMenuItemType { @@ -255,6 +255,11 @@ export interface IContextualMenuProps extends React.Props, IWith } export interface IContextualMenuItem { + /** + * Optional callback to access the IContextualMenuRenderItem interface. This will get passed down to ContextualMenuItem. + */ + componentRef?: (component: IContextualMenuRenderItem | null) => void; + /** * Unique id to identify the item */ @@ -471,7 +476,6 @@ export interface IContextualMenuItem { * Optional prop to make an item readonly which is disabled but visitable by keyboard, will apply aria-readonly and some styling. Not supported by all components */ inactive?: boolean; - } export interface IContextualMenuSection extends React.Props { diff --git a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItem.tsx b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItem.tsx index 49171cbb87383..5de1b09a30b8f 100644 --- a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItem.tsx +++ b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItem.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { hasSubmenu, getIsChecked } from '../../utilities/contextualMenu/index'; -import { getRTL } from '../../Utilities'; +import { BaseComponent, getRTL } from '../../Utilities'; import { Icon } from '../../Icon'; import { IContextualMenuItemProps } from './ContextualMenuItem.types'; @@ -67,19 +67,46 @@ const renderSubMenuIcon = ({ item, classNames }: IContextualMenuItemProps) => { return null; }; -export const ContextualMenuItem: React.StatelessComponent = (props) => { - const { item, classNames } = props; +export class ContextualMenuItem extends BaseComponent { - return ( -
+ { renderCheckMarkIcon(this.props) } + { renderItemIcon(this.props) } + { renderItemName(this.props) } + { renderSubMenuIcon(this.props) } +
+ ); + } + + public openSubMenu = (): void => { + const { item, openSubMenu, getSubmenuTarget } = this.props; + if (getSubmenuTarget) { + const submenuTarget = getSubmenuTarget(); + if (hasSubmenu(item) && openSubMenu && submenuTarget) { + openSubMenu(item, submenuTarget); } - > - { renderCheckMarkIcon(props) } - { renderItemIcon(props) } - { renderItemName(props) } - { renderSubMenuIcon(props) } - - ); -}; + } + } + + public dismissSubMenu = (): void => { + const { item, dismissSubMenu } = this.props; + if (hasSubmenu(item) && dismissSubMenu) { + dismissSubMenu(); + } + } + + public dismissMenu = (dismissAll?: boolean): void => { + const { dismissMenu } = this.props; + if (dismissMenu) { + dismissMenu(undefined /* ev */, dismissAll); + } + } +} \ No newline at end of file diff --git a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItem.types.ts b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItem.types.ts index 189a54047b7d5..7bbedea0c442e 100644 --- a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItem.types.ts +++ b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItem.types.ts @@ -1,8 +1,29 @@ import { IContextualMenuItem } from './ContextualMenu.types'; import { IMenuItemClassNames } from './ContextualMenu.classNames'; -export interface IContextualMenuItemProps - extends React.HTMLAttributes { +export interface IContextualMenuRenderItem { + /** + * Function to open this item's subMenu, if present. + */ + openSubMenu: () => void; + + /** + * Function to close this item's subMenu, if present. + */ + dismissSubMenu: () => void; + + /** + * Dismiss the menu this item belongs to. + */ + dismissMenu: (dismissAll?: boolean) => void; +} + +export interface IContextualMenuItemProps extends React.HTMLAttributes { + /** + * Optional callback to access the IContextualMenuRenderItem interface. Use this instead of ref for accessing + * the public methods and properties of the component. + */ + componentRef?: (component: IContextualMenuRenderItem | null) => void; /** * The item to display @@ -28,4 +49,26 @@ export interface IContextualMenuItemProps * Click handler for the checkmark */ onCheckmarkClick?: ((item: IContextualMenuItem, ev: React.MouseEvent) => void); + + /** + * This prop will get set by ContextualMenu and can be called to open this item's subMenu, if present. + */ + openSubMenu?: (item: any, target: HTMLElement) => void; + + /** + * This prop will get set by ContextualMenu and can be called to close this item's subMenu, if present. + */ + dismissSubMenu?: () => void; + + /** + * This prop will get set by ContextualMenu and can be called to close the menu this item belongs to. + * If dismissAll is true, all menus will be closed. + */ + dismissMenu?: (ev?: any, dismissAll?: boolean) => void; + + /** + * This prop will get set by the wrapping component and will return the element that wraps this ContextualMenuItem. + * Used for openSubMenu. + */ + getSubmenuTarget?: () => HTMLElement | undefined; } diff --git a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/ContextualMenuAnchor.test.tsx b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/ContextualMenuAnchor.test.tsx new file mode 100644 index 0000000000000..b225c5d81d03d --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/ContextualMenuAnchor.test.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import * as renderer from 'react-test-renderer'; +import { IContextualMenuItem } from '../ContextualMenu.types'; +import { IMenuItemClassNames } from '../ContextualMenu.classNames'; +import { ContextualMenuAnchor } from './ContextualMenuAnchor'; + +describe('ContextualMenuButton', () => { + describe('creates a normal button', () => { + let menuItem: IContextualMenuItem; + let menuClassNames: IMenuItemClassNames; + + beforeEach(() => { + menuItem = { key: '123' }; + menuClassNames = getMenuItemClassNames(); + }); + + it('renders the contextual menu split button correctly', () => { + const component = renderer.create( + ); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + }); +}); + +function getMenuItemClassNames(): IMenuItemClassNames { + return { + item: 'item', + divider: '---', + root: 'root', + linkContent: 'linkContent', + icon: 'icon', + checkmarkIcon: 'checkmarkIcon', + subMenuIcon: 'subMenuIcon', + label: 'label', + splitContainer: 'splitContainer', + splitPrimary: 'splitPrimary', + splitMenu: 'splitMenu', + linkContentMenu: 'linkContentMenu', + }; +} diff --git a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/ContextualMenuAnchor.tsx b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/ContextualMenuAnchor.tsx new file mode 100644 index 0000000000000..f1ab2089c8815 --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/ContextualMenuAnchor.tsx @@ -0,0 +1,100 @@ +import * as React from 'react'; +import { + anchorProperties, + getNativeProps, + createRef +} from '../../../Utilities'; +import { ContextualMenuItemWrapper } from './ContextualMenuItemWrapper'; +import { KeytipData } from '../../KeytipData'; +import { isItemDisabled, hasSubmenu } from '../../../utilities/contextualMenu/index'; +import { ContextualMenuItem } from '../../ContextualMenu'; + +export class ContextualMenuAnchor extends ContextualMenuItemWrapper { + private _anchor = createRef(); + + public render() { + const { + item, + classNames, + index, + focusableElementIndex, + totalItemCount, + hasCheckmarks, + hasIcons, + contextualMenuItemAs: ChildrenRenderer = ContextualMenuItem, + expandedMenuItemKey, + onItemClick, + openSubMenu, + dismissSubMenu, + dismissMenu + } = this.props; + + let anchorRel = item.rel; + if (item.target && item.target.toLowerCase() === '_blank') { + anchorRel = anchorRel ? anchorRel : 'nofollow noopener noreferrer'; // Safe default to prevent tabjacking + } + + const subMenuId = this._getSubMenuId(item); + const itemHasSubmenu = hasSubmenu(item); + const nativeProps = getNativeProps(item, anchorProperties); + const disabled = isItemDisabled(item); + + let { keytipProps } = item; + if (keytipProps && itemHasSubmenu) { + keytipProps = { + ...keytipProps, + hasMenu: true + }; + } + + return ( +
+ + { (keytipAttributes: any): JSX.Element => ( + + + + ) } + +
); + } + + protected _getSubmenuTarget = (): HTMLElement | undefined => { + return this._anchor.current ? this._anchor.current : undefined; + } +} \ No newline at end of file diff --git a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/ContextualMenuButton.test.tsx b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/ContextualMenuButton.test.tsx new file mode 100644 index 0000000000000..d78a6eabf2e80 --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/ContextualMenuButton.test.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import * as renderer from 'react-test-renderer'; +import { IContextualMenuItem } from '../ContextualMenu.types'; +import { IMenuItemClassNames } from '../ContextualMenu.classNames'; +import { ContextualMenuButton } from './ContextualMenuButton'; + +describe('ContextualMenuButton', () => { + describe('creates a normal button', () => { + let menuItem: IContextualMenuItem; + let menuClassNames: IMenuItemClassNames; + + beforeEach(() => { + menuItem = { key: '123' }; + menuClassNames = getMenuItemClassNames(); + }); + + it('renders the contextual menu split button correctly', () => { + const component = renderer.create( + ); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + }); +}); + +function getMenuItemClassNames(): IMenuItemClassNames { + return { + item: 'item', + divider: '---', + root: 'root', + linkContent: 'linkContent', + icon: 'icon', + checkmarkIcon: 'checkmarkIcon', + subMenuIcon: 'subMenuIcon', + label: 'label', + splitContainer: 'splitContainer', + splitPrimary: 'splitPrimary', + splitMenu: 'splitMenu', + linkContentMenu: 'linkContentMenu', + }; +} diff --git a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/ContextualMenuButton.tsx b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/ContextualMenuButton.tsx new file mode 100644 index 0000000000000..dd01a524cdffe --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/ContextualMenuButton.tsx @@ -0,0 +1,115 @@ +import * as React from 'react'; +import { + buttonProperties, + getNativeProps, + createRef +} from '../../../Utilities'; +import { ContextualMenuItemWrapper } from './ContextualMenuItemWrapper'; +import { KeytipData } from '../../KeytipData'; +import { getIsChecked, isItemDisabled, hasSubmenu } from '../../../utilities/contextualMenu/index'; +import { ContextualMenuItem } from '../../ContextualMenu'; + +export class ContextualMenuButton extends ContextualMenuItemWrapper { + private _btn = createRef(); + + public render() { + const { + item, + classNames, + index, + focusableElementIndex, + totalItemCount, + hasCheckmarks, + hasIcons, + contextualMenuItemAs: ChildrenRenderer = ContextualMenuItem, + expandedMenuItemKey, + onItemMouseDown, + onItemClick, + openSubMenu, + dismissSubMenu, + dismissMenu + } = this.props; + + const subMenuId = this._getSubMenuId(item); + let ariaLabel = ''; + + if (item.ariaLabel) { + ariaLabel = item.ariaLabel; + } else if (item.name) { + ariaLabel = item.name; + } + + const isChecked: boolean | null | undefined = getIsChecked(item); + const canCheck: boolean = isChecked !== null; + const defaultRole = canCheck ? 'menuitemcheckbox' : 'menuitem'; + const itemHasSubmenu = hasSubmenu(item); + + const buttonNativeProperties = getNativeProps(item, buttonProperties); + // Do not add the disabled attribute to the button so that it is focusable + delete (buttonNativeProperties as any).disabled; + + const itemButtonProperties = { + className: classNames.root, + onClick: this._onItemClick, + onKeyDown: itemHasSubmenu ? this._onItemKeyDown : null, + onMouseEnter: this._onItemMouseEnter, + onMouseLeave: this._onItemMouseLeave, + onMouseDown: (ev: any) => onItemMouseDown ? onItemMouseDown(item, ev) : undefined, + onMouseMove: this._onItemMouseMove, + href: item.href, + title: item.title, + 'aria-label': ariaLabel, + 'aria-haspopup': itemHasSubmenu || undefined, + 'aria-owns': item.key === expandedMenuItemKey ? subMenuId : undefined, + 'aria-expanded': itemHasSubmenu ? item.key === expandedMenuItemKey : undefined, + 'aria-checked': !!isChecked, + 'aria-posinset': focusableElementIndex + 1, + 'aria-setsize': totalItemCount, + 'aria-disabled': isItemDisabled(item), + role: item.role || defaultRole, + style: item.style + }; + + let { keytipProps } = item; + if (keytipProps && itemHasSubmenu) { + keytipProps = { + ...keytipProps, + hasMenu: true + }; + } + + return ( + + { (keytipAttributes: any): JSX.Element => ( + + ) } + + ); + } + + protected _getSubmenuTarget = (): HTMLElement | undefined => { + return this._btn.current ? this._btn.current : undefined; + } +} \ No newline at end of file diff --git a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/ContextualMenuItemWrapper.tsx b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/ContextualMenuItemWrapper.tsx new file mode 100644 index 0000000000000..687a9fc0b4b8e --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/ContextualMenuItemWrapper.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { + BaseComponent +} from '../../../Utilities'; +import { IContextualMenuItemWrapperProps } from './ContextualMenuItemWrapper.types'; +import { IContextualMenuItem } from '../../ContextualMenu'; + +export class ContextualMenuItemWrapper extends BaseComponent { + + protected _onItemMouseEnter = (ev: React.MouseEvent): void => { + const { item, onItemMouseEnter } = this.props; + if (onItemMouseEnter) { + onItemMouseEnter(item, ev, ev.currentTarget as HTMLElement); + } + } + + protected _onItemClick = (ev: React.MouseEvent): void => { + const { item, onItemClickBase } = this.props; + if (onItemClickBase) { + onItemClickBase(item, ev, ev.currentTarget as HTMLElement); + } + } + + protected _onItemMouseLeave = (ev: React.MouseEvent): void => { + const { item, onItemMouseLeave } = this.props; + if (onItemMouseLeave) { + onItemMouseLeave(item, ev); + } + } + + protected _onItemKeyDown = (ev: React.KeyboardEvent): void => { + const { item, onItemKeyDown } = this.props; + if (onItemKeyDown) { + onItemKeyDown(item, ev); + } + } + + protected _onItemMouseMove = (ev: React.MouseEvent): void => { + const { item, onItemMouseMove } = this.props; + if (onItemMouseMove) { + onItemMouseMove(item, ev, ev.currentTarget as HTMLElement); + } + } + + protected _getSubMenuId = (item: IContextualMenuItem): string | undefined => { + const { getSubMenuId } = this.props; + if (getSubMenuId) { + return getSubMenuId(item); + } + } + + protected _getSubmenuTarget = (): HTMLElement | undefined => { + return undefined; + } +} \ No newline at end of file diff --git a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuSplitButton.types.ts b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/ContextualMenuItemWrapper.types.ts similarity index 54% rename from packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuSplitButton.types.ts rename to packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/ContextualMenuItemWrapper.types.ts index c13e199231c7b..75d95c01acdad 100644 --- a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuSplitButton.types.ts +++ b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/ContextualMenuItemWrapper.types.ts @@ -1,18 +1,16 @@ +import { IContextualMenuItem, IContextualMenuItemProps } from '../../ContextualMenu'; +import { IMenuItemClassNames } from '../ContextualMenu.classNames'; +import { ContextualMenuItemWrapper } from './ContextualMenuItemWrapper'; -import { IContextualMenuItem } from '../../ContextualMenu'; -import { IMenuItemClassNames } from './ContextualMenu.classNames'; -import { IContextualMenuItemProps } from './ContextualMenuItem.types'; -import { ContextualMenuSplitButton } from './ContextualMenuSplitButton'; - -export interface IContextualMenuSplitButtonProps extends React.Props { +export interface IContextualMenuItemWrapperProps extends React.Props { /** - * Optional callback to access the ContextualmenuSplitButton interface. Use this instead of ref for accessing + * Optional callback to access the ContextualMenuSplitButton interface. Use this instead of ref for accessing * the public methods and properties of the component. */ - componentRef?: (component: ContextualMenuSplitButton | null) => void; + componentRef?: (component: ContextualMenuItemWrapper | null) => void; /** - * The item that is used to render the split button. + * The IContextualMenuItem that is used to render the item in the menu. */ item: IContextualMenuItem; @@ -22,12 +20,12 @@ export interface IContextualMenuSplitButtonProps extends React.Props | React.StatelessComponent; /** - * Callback for when the user's mouse enters the split button. + * Callback for when the user's mouse enters the wrapper. */ onItemMouseEnter?: (item: IContextualMenuItem, ev: React.MouseEvent, target: HTMLElement) => boolean | void; /** - * Callback for when the user's mouse leaves the split button. + * Callback for when the user's mouse leaves the wrapper. */ onItemMouseLeave?: (item: IContextualMenuItem, ev: React.MouseEvent) => void; /** - * Callback for when the user's mouse moves in the split button. + * Callback for when the user's mouse moves in the wrapper. */ onItemMouseMove?: (item: IContextualMenuItem, ev: React.MouseEvent, target: HTMLElement) => void; /** - * Callback for the mousedown event on the icon button in the split menu. + * Callback for the mousedown event on the icon button in the wrapper. */ onItemMouseDown?: (item: IContextualMenuItem, ev: React.MouseEvent) => void; @@ -78,7 +76,7 @@ export interface IContextualMenuSplitButtonProps extends React.Props | React.KeyboardEvent) => void; /** - * Callback for when the click event on the icon button from the split button. + * Callback for when the click event on the icon button from the wrapper. */ onItemClick?: (item: IContextualMenuItem, ev: React.MouseEvent | React.KeyboardEvent) => void; @@ -88,12 +86,38 @@ export interface IContextualMenuSplitButtonProps extends React.Props | React.KeyboardEvent, target: HTMLElement) => void; /** - * Callback for keyboard events on the split button. + * Callback for keyboard events on the wrapper. */ onItemKeyDown?: (item: IContextualMenuItem, ev: React.KeyboardEvent) => void; + /** + * Callback to get the subMenu ID for an IContextualMenuItem. + */ + getSubMenuId?: (item: IContextualMenuItem) => string | undefined; + + /** + * Key of the currently expanded subMenu. + */ + expandedMenuItemKey?: string; + /** * Callback for touch/pointer events on the split button. */ onTap?: (ev: React.TouchEvent | PointerEvent) => void; + + /** + * This prop will get set by ContextualMenu and can be called to open this item's subMenu, if present. + */ + openSubMenu?: (item: any, target: HTMLElement) => void; + + /** + * This prop will get set by ContextualMenu and can be called to close this item's subMenu, if present. + */ + dismissSubMenu?: () => void; + + /** + * This prop will get set by ContextualMenu and can be called to close the menu this item belongs to. + * If dismissAll is true, all menus will be closed. + */ + dismissMenu?: (ev?: any, dismissAll?: boolean) => void; } \ No newline at end of file diff --git a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuSplitButton.test.tsx b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/ContextualMenuSplitButton.test.tsx similarity index 90% rename from packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuSplitButton.test.tsx rename to packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/ContextualMenuSplitButton.test.tsx index 9e8ae71f134aa..93d4bbe181307 100644 --- a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuSplitButton.test.tsx +++ b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/ContextualMenuSplitButton.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as renderer from 'react-test-renderer'; -import { IContextualMenuItem } from './ContextualMenu.types'; -import { IMenuItemClassNames } from './ContextualMenu.classNames'; +import { IContextualMenuItem } from '../ContextualMenu.types'; +import { IMenuItemClassNames } from '../ContextualMenu.classNames'; import { ContextualMenuSplitButton } from './ContextualMenuSplitButton'; describe('ContextualMenuSplitButton', () => { diff --git a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuSplitButton.tsx b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/ContextualMenuSplitButton.tsx similarity index 88% rename from packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuSplitButton.tsx rename to packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/ContextualMenuSplitButton.tsx index 29082a3d99cd1..f6c3c9decff34 100644 --- a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuSplitButton.tsx +++ b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/ContextualMenuSplitButton.tsx @@ -1,27 +1,25 @@ import * as React from 'react'; import { - BaseComponent, assign, buttonProperties, getNativeProps, KeyCodes -} from '../../Utilities'; -import { IContextualMenuItem } from '../../ContextualMenu'; +} from '../../../Utilities'; +import { IContextualMenuItem, ContextualMenuItem } from '../../ContextualMenu'; import { IMenuItemClassNames, getSplitButtonVerticalDividerClassNames -} from './ContextualMenu.classNames'; -import { ContextualMenuItem } from './ContextualMenuItem'; +} from '../ContextualMenu.classNames'; import { KeytipData } from '../../KeytipData'; -import { getIsChecked, isItemDisabled } from '../../utilities/contextualMenu/index'; +import { getIsChecked, isItemDisabled } from '../../../utilities/contextualMenu/index'; import { VerticalDivider } from '../../Divider'; -import { IContextualMenuSplitButtonProps } from './ContextualMenuSplitButton.types'; +import { ContextualMenuItemWrapper } from './ContextualMenuItemWrapper'; export interface IContextualMenuSplitButtonState { } const TouchIdleDelay = 500; /* ms */ -export class ContextualMenuSplitButton extends BaseComponent { +export class ContextualMenuSplitButton extends ContextualMenuItemWrapper { private _splitButton: HTMLDivElement; private _lastTouchTimeoutId: number | undefined; private _processingTouch: boolean; @@ -85,6 +83,21 @@ export class ContextualMenuSplitButton extends BaseComponent): void => { + const { item, onItemKeyDown } = this.props; + if (ev.which === KeyCodes.enter) { + this._executeItemClick(ev); + ev.preventDefault(); + ev.stopPropagation(); + } else if (onItemKeyDown) { + onItemKeyDown(item, ev); + } + } + + protected _getSubmenuTarget = (): HTMLElement | undefined => { + return this._splitButton; + } + private _renderSplitPrimaryButton(item: IContextualMenuItem, classNames: IMenuItemClassNames, index: number, hasCheckmarks: boolean, hasIcons: boolean) { const isChecked: boolean | null | undefined = getIsChecked(item); const canCheck: boolean = isChecked !== null; @@ -131,7 +144,10 @@ export class ContextualMenuSplitButton extends BaseComponent - + ); } @@ -227,17 +254,6 @@ export class ContextualMenuSplitButton extends BaseComponent): void => { - const { item, onItemKeyDown } = this.props; - if (ev.which === KeyCodes.enter) { - this._executeItemClick(ev); - ev.preventDefault(); - ev.stopPropagation(); - } else if (onItemKeyDown) { - onItemKeyDown(item, ev); - } - } - private _onTouchStart = (ev: React.TouchEvent): void => { if (this._splitButton && !('onpointerdown' in this._splitButton)) { this._handleTouchAndPointerEvent(ev); diff --git a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/__snapshots__/ContextualMenuAnchor.test.tsx.snap b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/__snapshots__/ContextualMenuAnchor.test.tsx.snap new file mode 100644 index 0000000000000..79afd17750ee4 --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/__snapshots__/ContextualMenuAnchor.test.tsx.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ContextualMenuButton creates a normal button renders the contextual menu split button correctly 1`] = ` +
+ + +`; diff --git a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/__snapshots__/ContextualMenuButton.test.tsx.snap b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/__snapshots__/ContextualMenuButton.test.tsx.snap new file mode 100644 index 0000000000000..6cc1f76a5a875 --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/__snapshots__/ContextualMenuButton.test.tsx.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ContextualMenuButton creates a normal button renders the contextual menu split button correctly 1`] = ` + +`; diff --git a/packages/office-ui-fabric-react/src/components/ContextualMenu/__snapshots__/ContextualMenuSplitButton.test.tsx.snap b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/__snapshots__/ContextualMenuSplitButton.test.tsx.snap similarity index 100% rename from packages/office-ui-fabric-react/src/components/ContextualMenu/__snapshots__/ContextualMenuSplitButton.test.tsx.snap rename to packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/__snapshots__/ContextualMenuSplitButton.test.tsx.snap diff --git a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/index.ts b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/index.ts new file mode 100644 index 0000000000000..e110536ca4813 --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuItemWrapper/index.ts @@ -0,0 +1,5 @@ +export * from './ContextualMenuAnchor'; +export * from './ContextualMenuButton'; +export * from './ContextualMenuSplitButton'; +export * from './ContextualMenuItemWrapper'; +export * from './ContextualMenuItemWrapper.types'; \ No newline at end of file diff --git a/packages/office-ui-fabric-react/src/components/ContextualMenu/__snapshots__/ContextualMenuItem.test.tsx.snap b/packages/office-ui-fabric-react/src/components/ContextualMenu/__snapshots__/ContextualMenuItem.test.tsx.snap index caf1028a96c2e..8561a5bbb3357 100644 --- a/packages/office-ui-fabric-react/src/components/ContextualMenu/__snapshots__/ContextualMenuItem.test.tsx.snap +++ b/packages/office-ui-fabric-react/src/components/ContextualMenu/__snapshots__/ContextualMenuItem.test.tsx.snap @@ -4,7 +4,7 @@ exports[`ContextMenuItemChildren when a checkmark icon renders the component wit ShallowWrapper { "length": 1, Symbol(enzyme.__root__): [Circular], - Symbol(enzyme.__unrendered__):