Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"packageName": "office-ui-fabric-react",
"comment": "ContextualMenu/Button: Improve perf and add hiddenvariable to buttons",
"type": "minor"
}
],
"packageName": "office-ui-fabric-react",
"email": "joschect@microsoft.com"
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ export class BaseButton extends BaseComponent<IBaseButtonProps, IBaseButtonState
}

private get _isExpanded(): boolean {
if (this.props.persistMenu) {
return !this.state.menuProps!.hidden;
}
return !!this.state.menuProps;
}

Expand Down Expand Up @@ -65,8 +68,13 @@ export class BaseButton extends BaseComponent<IBaseButtonProps, IBaseButtonState
this._labelId = getId();
this._descriptionId = getId();
this._ariaDescriptionId = getId();
let menuProps = null;
if (props.persistMenu && props.menuProps) {
menuProps = props.menuProps;
menuProps.hidden = true;
}
this.state = {
menuProps: null
menuProps: menuProps
};
}

Expand Down Expand Up @@ -401,12 +409,21 @@ export class BaseButton extends BaseComponent<IBaseButtonProps, IBaseButtonState
}

private _dismissMenu = (): void => {
this.setState({ menuProps: null });
let menuProps = null;
if (this.props.persistMenu && this.state.menuProps) {
menuProps = this.state.menuProps;
menuProps.hidden = true;
}
this.setState({ menuProps: menuProps });
}

private _openMenu = (): void => {
if (this.props.menuProps) {
this.setState({ menuProps: this.props.menuProps });
const menuProps = this.props.menuProps;
if (this.props.persistMenu) {
menuProps.hidden = false;
}
this.setState({ menuProps: menuProps });
}
}

Expand All @@ -416,7 +433,11 @@ export class BaseButton extends BaseComponent<IBaseButtonProps, IBaseButtonState
}
const { menuProps } = this.props;
const currentMenuProps = this.state.menuProps;
currentMenuProps ? this._dismissMenu() : this._openMenu();
if (this.props.persistMenu) {
currentMenuProps && currentMenuProps.hidden ? this._openMenu() : this._dismissMenu();
} else {
currentMenuProps ? this._dismissMenu() : this._openMenu();
}
}

private _onRenderSplitButtonContent(tag: any, buttonProps: IButtonProps): JSX.Element {
Expand Down Expand Up @@ -517,7 +538,7 @@ export class BaseButton extends BaseComponent<IBaseButtonProps, IBaseButtonState
'aria-expanded': this._isExpanded,
'data-is-focusable': false
};
return <BaseButton {...splitButtonProps} onMouseDown={ this._onMouseDown } tabIndex={ -1 } />;
return <BaseButton { ...splitButtonProps } onMouseDown={ this._onMouseDown } tabIndex={ -1 } />;
}

private _onMouseDown = (ev: React.MouseEvent<BaseButton>) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,15 @@ export interface IButtonProps extends React.AllHTMLAttributes<HTMLAnchorElement
* The default KeyCode is the down arrow. A value of null can be provided to disable the key codes for opening the button menu.
*/
menuTriggerKeyCode?: KeyCodes | null;

/**
* Menu will not be created or destroyed when opened or closed, instead it
* will be hidden. This will improve perf of the menu opening but could potentially
* impact overall perf by having more elemnts in the dom. Should only be used

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be expected to impact the time it takes to mount the button, perhaps that is worth calling out?

* when perf is important.
* Note: This may increase the amount of time it takes for the button itself to mount.
*/
persistMenu?: boolean;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not quite sure what to name this and I am open to suggestions.

}

export enum ElementType {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ export class ButtonContextualMenuExample extends React.Component<IButtonProps, {
key: 'calendarEvent',
name: 'Calendar event',
icon: 'Calendar'
}
]
},
],
directionalHintFixed: true
}
}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ export interface ICalloutContentStyleProps {
overflowYHidden?: boolean;

/**
* @deprecated will be removed in v6. Do not use.
* Max height applied to the content of a callout.
*/
contentMaxHeight?: number;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export interface ICalloutState {
slideDirectionalClassName?: string;
calloutElementRect?: ClientRect;
heightOffset?: number;
maxHeight?: number;
}

@customizable('CalloutContent', ['theme'])
Expand All @@ -68,13 +69,12 @@ export class CalloutContentBase extends BaseComponent<ICalloutProps, ICalloutSta
directionalHint: DirectionalHint.bottomAutoEdge
};

private _classNames: {[key in keyof ICalloutContentStyles]: string };
private _classNames: { [key in keyof ICalloutContentStyles]: string };
private _didSetInitialFocus: boolean;
private _hostElement = createRef<HTMLDivElement>();
private _calloutElement = createRef<HTMLDivElement>();
private _targetWindow: Window;
private _bounds: IRectangle;
private _maxHeight: number | undefined;
private _positionAttempts: number;
private _target: Element | MouseEvent | IPoint | null;
private _setHeightOffsetTimer: number;
Expand Down Expand Up @@ -119,11 +119,15 @@ export class CalloutContentBase extends BaseComponent<ICalloutProps, ICalloutSta
const newTarget = this._getTarget(newProps);
const oldTarget = this._getTarget();
if (newTarget !== oldTarget || typeof (newTarget) === 'string' || newTarget instanceof String) {
this._maxHeight = undefined;
this.setState({
maxHeight: undefined
});
this._setTargetWindowAndElement(newTarget!);
}
if (newProps.gapSpace !== this.props.gapSpace || this.props.beakWidth !== newProps.beakWidth) {
this._maxHeight = undefined;
this.setState({
maxHeight: undefined
});
}

if (newProps.finalHeight !== this.props.finalHeight) {
Expand All @@ -136,6 +140,7 @@ export class CalloutContentBase extends BaseComponent<ICalloutProps, ICalloutSta
positions: undefined
});
}

}

public componentDidMount() {
Expand Down Expand Up @@ -172,8 +177,8 @@ export class CalloutContentBase extends BaseComponent<ICalloutProps, ICalloutSta
target = this._getTarget();
const { positions } = this.state;

const getContentMaxHeight: number = this._getMaxHeight() + this.state.heightOffset!;
const contentMaxHeight: number = calloutMaxHeight! && (calloutMaxHeight! < getContentMaxHeight) ? calloutMaxHeight! : getContentMaxHeight!;
const getContentMaxHeight: number | undefined = this._getMaxHeight() ? this._getMaxHeight() + this.state.heightOffset! : undefined;
const contentMaxHeight: number | undefined = calloutMaxHeight! && getContentMaxHeight && (calloutMaxHeight! < getContentMaxHeight) ? calloutMaxHeight! : getContentMaxHeight!;
const overflowYHidden = !!finalHeight;

const beakVisible = isBeakVisible && (!!target);
Expand All @@ -184,15 +189,14 @@ export class CalloutContentBase extends BaseComponent<ICalloutProps, ICalloutSta
className,
overflowYHidden: overflowYHidden,
calloutWidth,
contentMaxHeight,
positions,
beakWidth,
backgroundColor,
beakStyle
}
);

const overflowStyle: React.CSSProperties = overflowYHidden ? { overflowY: 'hidden' } : {};
const overflowStyle: React.CSSProperties = overflowYHidden ? { overflowY: 'hidden', maxHeight: contentMaxHeight } : { maxHeight: contentMaxHeight };
const visibilityStyle: React.CSSProperties | undefined = this.props.hidden ? { visibility: 'hidden' } : undefined;
// React.CSSProperties does not understand IRawStyle, so the inline animations will need to be cast as any for now.
const content = (
Expand Down Expand Up @@ -377,19 +381,23 @@ export class CalloutContentBase extends BaseComponent<ICalloutProps, ICalloutSta
}

private _getMaxHeight(): number {
if (!this._maxHeight) {
if (!this.state.maxHeight) {
if (this.props.directionalHintFixed && this._target) {
const beakWidth = this.props.isBeakVisible ? this.props.beakWidth : 0;
const gapSpace = this.props.gapSpace ? this.props.gapSpace : 0;
// Since the callout cannot measure it's border size it must be taken into account here. Otherwise it will
// overlap with the target.
const totalGap = gapSpace + beakWidth! + BORDER_WIDTH * 2;
this._maxHeight = getMaxHeight(this._target, this.props.directionalHint!, totalGap, this._getBounds());
this._async.requestAnimationFrame(() => {
if (this._target) {
this.setState({ maxHeight: getMaxHeight(this._target, this.props.directionalHint!, totalGap, this._getBounds()) });
}
});
} else {
this._maxHeight = this._getBounds().height! - BORDER_WIDTH * 2;
this.setState({ maxHeight: this._getBounds().height! - BORDER_WIDTH * 2 });
}
}
return this._maxHeight!;
return this.state.maxHeight!;
}

private _arePositionsEqual(positions: ICalloutPositionedInfo, newPosition: ICalloutPositionedInfo) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,20 +130,29 @@ export class ContextualMenu extends BaseComponent<IContextualMenuProps, IContext
const newTarget = newProps.target;
this._setTargetWindowAndElement(newTarget!);
}
if (newProps.hidden !== this.props.hidden) {
if (newProps.hidden) {
this._onMenuClosed();
} else {
this._onMenuOpened();
this._previousActiveElement = this._targetWindow ? this._targetWindow.document.activeElement as HTMLElement : null;
}
}
}

// Invoked once, both on the client and server, immediately before the initial rendering occurs.
public componentWillMount() {
const target = this.props.target;
this._setTargetWindowAndElement(target!);
this._previousActiveElement = this._targetWindow ? this._targetWindow.document.activeElement as HTMLElement : null;
if (!this.props.hidden) {
this._previousActiveElement = this._targetWindow ? this._targetWindow.document.activeElement as HTMLElement : null;
}
}

// Invoked once, only on the client (not on the server), immediately after the initial rendering occurs.
public componentDidMount() {
this._events.on(this._targetWindow, 'resize', this.dismiss);
if (this.props.onMenuOpened) {
this.props.onMenuOpened(this.props);
if (!this.props.hidden) {
this._onMenuOpened();
}
}

Expand Down Expand Up @@ -270,6 +279,7 @@ export class ContextualMenu extends BaseComponent<IContextualMenuProps, IContext
onScroll={ this._onScroll }
bounds={ bounds }
directionalHintFixed={ directionalHintFixed }
hidden={ this.props.hidden }
>
<div
role='menu'
Expand Down Expand Up @@ -316,6 +326,16 @@ export class ContextualMenu extends BaseComponent<IContextualMenuProps, IContext
}
}

private _onMenuOpened() {
this._events.on(this._targetWindow, 'resize', this.dismiss);
this.props.onMenuOpened && this.props.onMenuOpened(this.props);
}

private _onMenuClosed() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need a similar call to this.props.onMenuDismissed here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do not, onDismiss is a callback that would have triggered the hidden prop change farther up the tree.

this._events.off(this._targetWindow, 'resize', this.dismiss);
this._previousActiveElement && setTimeout(() => this._previousActiveElement!.focus(), 0);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we do a null check here on _previousActiveElement?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do, that's what this._previousActiveElement && does.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But the value can change when the callback in the set timeout gets invoked, cant it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point. I'll fix it.

}

/**
* Gets the focusZoneDirection by using the arrowDirection if specified,
* the direction specificed in the focusZoneProps, or defaults to FocusZoneDirection.vertical
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,15 @@ export interface IContextualMenuProps extends React.Props<ContextualMenu>, IWith
* @default {direction: FocusZoneDirection.vertical}
*/
focusZoneProps?: IFocusZoneProps;

/**
* If specified, renders the ContextualMenu in a hidden state.
* Use this flag, rather than rendering a ContextualMenu conditionally based on visibility,
* to improve rendering performance when it becomes visible.
* Note: When ContextualMenu is hidden its content will not be rendered. It will only render
* once the ContextualMenu is visible.
*/
hidden?: boolean;
}

export interface IContextualMenuItem {
Expand Down