-
Notifications
You must be signed in to change notification settings - Fork 2.9k
Contextual Menu: Moving Split Button to be its own component #4618
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
2301c62
922d51d
c7e22d3
e58ba9b
47e4cbd
4638505
277d73d
254d3b7
ba5c45a
87aa983
bc3ae34
f0aa3d7
8e16927
ff88ae2
c7a333a
8fba90e
364fa11
dbf85ca
a55c76c
ee4d5ec
883e727
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| { | ||
| "changes": [ | ||
| { | ||
| "packageName": "office-ui-fabric-react", | ||
| "comment": "Contextual Menu: moved out the split button to be its own component, ContextualMenuSplitButton", | ||
| "type": "minor" | ||
| } | ||
| ], | ||
| "packageName": "office-ui-fabric-react", | ||
| "email": "chiechan@microsoft.com" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,8 +6,7 @@ import { | |
| IMenuItemClassNames, | ||
| IContextualMenuClassNames, | ||
| getContextualMenuClassNames, | ||
| getItemClassNames, | ||
| getSplitButtonVerticalDividerClassNames | ||
| getItemClassNames | ||
| } from './ContextualMenu.classNames'; | ||
| import { | ||
| BaseComponent, | ||
|
|
@@ -27,14 +26,12 @@ import { | |
| css, | ||
| shouldWrapFocus | ||
| } from '../../Utilities'; | ||
| import { hasSubmenu, getIsChecked } from '../../utilities/contextualMenu/index'; | ||
| import { hasSubmenu, getIsChecked, isItemDisabled } from '../../utilities/contextualMenu/index'; | ||
| import { withResponsiveMode, ResponsiveMode } from '../../utilities/decorators/withResponsiveMode'; | ||
| import { Callout } from '../../Callout'; | ||
| import { IIconProps } from '../../Icon'; | ||
| import { | ||
| VerticalDivider | ||
| } from '../../Divider'; | ||
| import { ContextualMenuItem } from './ContextualMenuItem'; | ||
| import { ContextualMenuSplitButton } from './ContextualMenuSplitButton'; | ||
|
|
||
| export interface IContextualMenuState { | ||
| expandedMenuItemKey?: string; | ||
|
|
@@ -94,8 +91,6 @@ export class ContextualMenu extends BaseComponent<IContextualMenuProps, IContext | |
| private readonly _navigationIdleDelay: number = 250 /* ms */; | ||
| private _scrollIdleTimeoutId: number | undefined; | ||
|
|
||
| private _splitButtonContainers: Map<string, HTMLDivElement>; | ||
|
|
||
| private _adjustedFocusZoneProps: IFocusZoneProps; | ||
|
|
||
| constructor(props: IContextualMenuProps) { | ||
|
|
@@ -114,7 +109,6 @@ export class ContextualMenu extends BaseComponent<IContextualMenuProps, IContext | |
|
|
||
| this._isFocusingPreviousElement = false; | ||
| this._isScrollIdle = true; | ||
| this._splitButtonContainers = new Map(); | ||
| } | ||
|
|
||
| public dismiss = (ev?: any, dismissAll?: boolean) => { | ||
|
|
@@ -363,7 +357,7 @@ export class ContextualMenu extends BaseComponent<IContextualMenuProps, IContext | |
| const getClassNames = item.getItemClassNames || getItemClassNames; | ||
| const itemClassNames = getClassNames( | ||
| this.props.theme!, | ||
| this._isItemDisabled(item), | ||
| isItemDisabled(item), | ||
| (this.state.expandedMenuItemKey === item.key), | ||
| !!getIsChecked(item), | ||
| !!item.href, | ||
|
|
@@ -517,7 +511,7 @@ export class ContextualMenu extends BaseComponent<IContextualMenuProps, IContext | |
| aria-expanded={ itemHasSubmenu ? item.key === expandedMenuItemKey : undefined } | ||
| aria-posinset={ focusableElementIndex + 1 } | ||
| aria-setsize={ totalItemCount } | ||
| aria-disabled={ this._isItemDisabled(item) } | ||
| aria-disabled={ isItemDisabled(item) } | ||
| style={ item.style } | ||
| onClick={ this._onAnchorClick.bind(this, item) } | ||
| onMouseEnter={ this._onItemMouseEnter.bind(this, item) } | ||
|
|
@@ -581,7 +575,7 @@ export class ContextualMenu extends BaseComponent<IContextualMenuProps, IContext | |
| 'aria-checked': !!isChecked, | ||
| 'aria-posinset': focusableElementIndex + 1, | ||
| 'aria-setsize': totalItemCount, | ||
| 'aria-disabled': this._isItemDisabled(item), | ||
| 'aria-disabled': isItemDisabled(item), | ||
| role: item.role || defaultRole, | ||
| style: item.style | ||
| }; | ||
|
|
@@ -610,102 +604,30 @@ export class ContextualMenu extends BaseComponent<IContextualMenuProps, IContext | |
| totalItemCount: number, | ||
| hasCheckmarks?: boolean, | ||
| hasIcons?: boolean): JSX.Element { | ||
| const { contextualMenuItemAs } = this.props; | ||
|
|
||
| return ( | ||
| <div | ||
| ref={ (el: HTMLDivElement) => | ||
| this._splitButtonContainers.set(item.key, el) | ||
| } | ||
| role={ 'button' } | ||
| aria-labelledby={ item.ariaLabel } | ||
| className={ classNames.splitContainer } | ||
| aria-disabled={ this._isItemDisabled(item) } | ||
| aria-haspopup={ true } | ||
| aria-describedby={ item.ariaDescription } | ||
| aria-checked={ item.isChecked || item.checked } | ||
| aria-posinset={ focusableElementIndex + 1 } | ||
| aria-setsize={ totalItemCount } | ||
| onMouseEnter={ this._onItemMouseEnter.bind(this, { ...item, subMenuProps: null, items: null }) } | ||
| onMouseLeave={ this._onMouseItemLeave.bind(this, { ...item, subMenuProps: null, items: null }) } | ||
| onMouseMove={ this._onItemMouseMove.bind(this, { ...item, subMenuProps: null, items: null }) } | ||
| onKeyDown={ this._onSplitContainerItemKeyDown.bind(this, item) } | ||
| onClick={ this._executeItemClick.bind(this, item) } | ||
| tabIndex={ 0 } | ||
| data-is-focusable={ true } | ||
| > | ||
| { this._renderSplitPrimaryButton(item, classNames, index, hasCheckmarks!, hasIcons!) } | ||
| { this._renderSplitDivider(item) } | ||
| { this._renderSplitIconButton(item, classNames, index) } | ||
| </div > | ||
| ); | ||
| } | ||
|
|
||
| private _renderSplitPrimaryButton(item: IContextualMenuItem, classNames: IMenuItemClassNames, index: number, hasCheckmarks: boolean, hasIcons: boolean) { | ||
|
|
||
| const isChecked: boolean | null | undefined = getIsChecked(item); | ||
| const canCheck: boolean = isChecked !== null; | ||
| const defaultRole = canCheck ? 'menuitemcheckbox' : 'menuitem'; | ||
| const { contextualMenuItemAs: ChildrenRenderer = ContextualMenuItem } = this.props; | ||
|
|
||
| const itemProps = { | ||
| key: item.key, | ||
| disabled: this._isItemDisabled(item) || item.primaryDisabled, | ||
| name: item.name, | ||
| className: classNames.splitPrimary, | ||
| role: item.role || defaultRole, | ||
| canCheck: item.canCheck, | ||
| isChecked: item.isChecked, | ||
| checked: item.checked, | ||
| icon: item.icon, | ||
| iconProps: item.iconProps, | ||
| 'data-is-focusable': false, | ||
| 'aria-hidden': true | ||
| } as IContextualMenuItem; | ||
| return React.createElement('button', | ||
| getNativeProps(itemProps, buttonProperties), | ||
| <ChildrenRenderer item={ itemProps } classNames={ classNames } index={ index } onCheckmarkClick={ hasCheckmarks ? this._onItemClick : undefined } hasIcons={ hasIcons } />, | ||
| ); | ||
| } | ||
|
|
||
| private _onSplitContainerItemKeyDown(item: any, ev: React.KeyboardEvent<HTMLElement>) { | ||
| if (ev.which === KeyCodes.enter) { | ||
| this._executeItemClick(item, ev); | ||
| ev.preventDefault(); | ||
| ev.stopPropagation(); | ||
| } else { | ||
| this._onItemKeyDown(item, ev); | ||
| } | ||
| } | ||
|
|
||
| private _renderSplitIconButton(item: IContextualMenuItem, classNames: IMenuItemClassNames, index: number) { | ||
| const { contextualMenuItemAs: ChildrenRenderer = ContextualMenuItem } = this.props; | ||
| const itemProps = { | ||
| onClick: this._onSplitItemClick.bind(this, item), | ||
| disabled: this._isItemDisabled(item), | ||
| className: classNames.splitMenu, | ||
| subMenuProps: item.subMenuProps, | ||
| submenuIconProps: item.submenuIconProps, | ||
| split: true, | ||
| } as IContextualMenuItem; | ||
|
|
||
| return React.createElement('button', | ||
| assign({}, getNativeProps(itemProps, buttonProperties), { | ||
| onMouseEnter: this._onItemMouseEnter.bind(this, item), | ||
| onMouseLeave: this._onMouseItemLeave.bind(this, item), | ||
| onMouseDown: (ev: any) => this._onItemMouseDown(item, ev), | ||
| onMouseMove: this._onItemMouseMove.bind(this, item), | ||
| 'data-is-focusable': false, | ||
| 'aria-hidden': true | ||
| }), | ||
| <ChildrenRenderer item={ itemProps } classNames={ classNames } index={ index } hasIcons={ false } /> | ||
| <ContextualMenuSplitButton | ||
| item={ item } | ||
| classNames={ classNames } | ||
| index={ index } | ||
| focusableElementIndex={ focusableElementIndex } | ||
| totalItemCount={ totalItemCount } | ||
| hasCheckmarks={ hasCheckmarks } | ||
| hasIcons={ hasIcons } | ||
| contextualMenuItemAs={ contextualMenuItemAs } | ||
| onItemMouseEnter={ this._onItemMouseEnterBase } | ||
| onItemMouseLeave={ this._onMouseItemLeave } | ||
| onItemMouseMove={ this._onItemMouseMoveBase } | ||
| onItemMouseDown={ this._onItemMouseDown } | ||
| executeItemClick={ this._executeItemClick } | ||
| onItemClick={ this._onItemClick } | ||
| onItemClickBase={ this._onItemClickBase } | ||
| onItemKeyDown={ this._onItemKeyDown } | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| private _renderSplitDivider(item: IContextualMenuItem) { | ||
| const getDividerClassnames = item.getSplitButtonVerticalDividerClassNames || getSplitButtonVerticalDividerClassNames; | ||
| return <VerticalDivider getClassNames={ getDividerClassnames } />; | ||
| } | ||
|
|
||
| private _getIconProps(item: IContextualMenuItem): IIconProps { | ||
| const iconProps: IIconProps = item.iconProps ? item.iconProps : { | ||
| iconName: item.icon | ||
|
|
@@ -782,14 +704,22 @@ export class ContextualMenu extends BaseComponent<IContextualMenuProps, IContext | |
| } | ||
|
|
||
| private _onItemMouseEnter(item: any, ev: React.MouseEvent<HTMLElement>) { | ||
| this._onItemMouseEnterBase(item, ev, ev.target as HTMLElement); | ||
| } | ||
|
|
||
| private _onItemMouseEnterBase = (item: any, ev: React.MouseEvent<HTMLElement>, target: HTMLElement): void => { | ||
| if (!this._isScrollIdle) { | ||
| return; | ||
| } | ||
|
|
||
| this._updateFocusOnMouseEvent(item, ev); | ||
| this._updateFocusOnMouseEvent(item, ev, target); | ||
| } | ||
|
|
||
| private _onItemMouseMove(item: any, ev: React.MouseEvent<HTMLElement>) { | ||
| this._onItemMouseMoveBase(item, ev, ev.target as HTMLElement); | ||
| } | ||
|
|
||
| private _onItemMouseMoveBase = (item: any, ev: React.MouseEvent<HTMLElement>, target: HTMLElement): void => { | ||
|
|
||
| const targetElement = ev.currentTarget as HTMLElement; | ||
|
|
||
|
|
@@ -799,9 +729,8 @@ export class ContextualMenu extends BaseComponent<IContextualMenuProps, IContext | |
| return; | ||
| } | ||
|
|
||
| this._updateFocusOnMouseEvent(item, ev); | ||
| this._updateFocusOnMouseEvent(item, ev, target); | ||
| } | ||
|
|
||
| private _onMouseItemLeave = (item: any, ev: React.MouseEvent<HTMLElement>): void => { | ||
| if (!this._isScrollIdle) { | ||
| return; | ||
|
|
@@ -833,8 +762,8 @@ export class ContextualMenu extends BaseComponent<IContextualMenuProps, IContext | |
| * As part of updating focus, This function will also update | ||
| * the expand/collapse state accordingly. | ||
| */ | ||
| private _updateFocusOnMouseEvent(item: IContextualMenuItem, ev: React.MouseEvent<HTMLElement>) { | ||
| const targetElement = ev.currentTarget as HTMLElement; | ||
| private _updateFocusOnMouseEvent(item: IContextualMenuItem, ev: React.MouseEvent<HTMLElement>, target: HTMLElement) { | ||
| const targetElement = target; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You should make target optional so that it can be leveraged only when it needs to be |
||
| const { subMenuHoverDelay: timeoutDuration = this._navigationIdleDelay } = this.props; | ||
|
|
||
| if (item.key === this.state.expandedMenuItemKey) { | ||
|
|
@@ -857,9 +786,7 @@ export class ContextualMenu extends BaseComponent<IContextualMenuProps, IContext | |
| ev.stopPropagation(); | ||
| this._enterTimerId = this._async.setTimeout(() => { | ||
| targetElement.focus(); | ||
| const splitButtonContainer = this._splitButtonContainers.get(item.key); | ||
| this._onItemSubMenuExpand(item, | ||
| ((item.split && splitButtonContainer) ? splitButtonContainer : targetElement) as HTMLElement); | ||
| this._onItemSubMenuExpand(item, targetElement); | ||
| this._enterTimerId = undefined; | ||
| }, this._navigationIdleDelay); | ||
| } else { | ||
|
|
@@ -871,26 +798,25 @@ export class ContextualMenu extends BaseComponent<IContextualMenuProps, IContext | |
| } | ||
| } | ||
|
|
||
| private _onItemMouseDown(item: IContextualMenuItem, ev: React.MouseEvent<HTMLElement>) { | ||
| if (item.onMouseDown) { | ||
| private _onItemMouseDown = (item: IContextualMenuItem, ev: React.MouseEvent<HTMLElement>): void => { | ||
| if (item.onMouseDown && this._enterTimerId === undefined) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why is this change here? mousedown should not be gated by the enterTimerId
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're right, this was back when we were trying to fight the race condition caused by hovering onon the secondary button in split button vs clicking on it where we would open and close the menu. This should have been removed. |
||
| item.onMouseDown(item, ev); | ||
| } | ||
| } | ||
|
|
||
| private _onItemClick(item: IContextualMenuItem, ev: React.MouseEvent<HTMLElement>) { | ||
| private _onItemClick = (item: IContextualMenuItem, ev: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>): void => { | ||
| this._onItemClickBase(item, ev, ev.currentTarget as HTMLElement); | ||
| } | ||
|
|
||
| private _onSplitItemClick(item: IContextualMenuItem, ev: React.MouseEvent<HTMLElement>) { | ||
| const splitButtonContainer = this._splitButtonContainers.get(item.key); | ||
| // get the whole splitButton container to base the menu off of | ||
| this._onItemClickBase(item, ev, | ||
| (splitButtonContainer ? splitButtonContainer : ev.currentTarget) as HTMLElement); | ||
| } | ||
|
|
||
| private _onItemClickBase(item: IContextualMenuItem, ev: React.MouseEvent<HTMLElement>, target: HTMLElement) { | ||
| private _onItemClickBase = (item: IContextualMenuItem, ev: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>, target: HTMLElement): void => { | ||
| const items = getSubmenuItems(item); | ||
|
|
||
| // Cancel a async menu item hover timeout action from being taken and instead | ||
| // just trigger the click event instead. | ||
| if (this._enterTimerId !== undefined) { | ||
| this._async.clearTimeout(this._enterTimerId); | ||
| this._enterTimerId = undefined; | ||
| } | ||
| if (!hasSubmenu(item) && (!items || !items.length)) { // This is an item without a menu. Click it. | ||
| this._executeItemClick(item, ev); | ||
| } else { | ||
|
|
@@ -910,10 +836,11 @@ export class ContextualMenu extends BaseComponent<IContextualMenuProps, IContext | |
| ev.stopPropagation(); | ||
| } | ||
|
|
||
| private _executeItemClick(item: IContextualMenuItem, ev: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>) { | ||
| private _executeItemClick = (item: IContextualMenuItem, ev: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>): void => { | ||
| if (item.disabled || item.isDisabled) { | ||
| return; | ||
| } | ||
|
|
||
| let dismiss = false; | ||
| if (item.onClick) { | ||
| dismiss = !!item.onClick(ev, item); | ||
|
|
@@ -924,7 +851,7 @@ export class ContextualMenu extends BaseComponent<IContextualMenuProps, IContext | |
| (dismiss || !ev.defaultPrevented) && this.dismiss(ev, true); | ||
| } | ||
|
|
||
| private _onItemKeyDown(item: any, ev: React.KeyboardEvent<HTMLElement>) { | ||
| private _onItemKeyDown = (item: any, ev: React.KeyboardEvent<HTMLElement>): void => { | ||
| const openKey = getRTL() ? KeyCodes.left : KeyCodes.right; | ||
|
|
||
| if (ev.which === openKey && !item.disabled) { | ||
|
|
@@ -1032,10 +959,6 @@ export class ContextualMenu extends BaseComponent<IContextualMenuProps, IContext | |
| } | ||
| } | ||
|
|
||
| private _isItemDisabled(item: IContextualMenuItem): boolean { | ||
| return !!(item.isDisabled || item.disabled); | ||
| } | ||
|
|
||
| private _getSubMenuId(item: IContextualMenuItem): string | undefined { | ||
| let { subMenuId } = this.state; | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe let target be optional since it's optional when going into focusMouseEvent. Not sure about this though, I'll leave it up to you