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;
}

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 @@ -68,17 +68,17 @@ 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;
private _hasListeners = false;
private _maxHeight: number | undefined;

constructor(props: ICalloutProps) {
super(props);
Expand Down Expand Up @@ -136,6 +136,7 @@ export class CalloutContentBase extends BaseComponent<ICalloutProps, ICalloutSta
positions: undefined
});
}

}

public componentDidMount() {
Expand Down Expand Up @@ -172,8 +173,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 +185,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 @@ -376,15 +376,22 @@ export class CalloutContentBase extends BaseComponent<ICalloutProps, ICalloutSta
return this._bounds;
}

private _getMaxHeight(): number {
// Max height should remain as synchronous as possible, which is why it is not done using set state.
// It needs to be synchronous since it will impact the ultimate position of the callout.
private _getMaxHeight(): number | undefined {
if (!this._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._maxHeight = getMaxHeight(this._target, this.props.directionalHint!, totalGap, this._getBounds());
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 to trigger a new render after this RAF call to actually set the max height?

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.

Good point, I really should have had this been setstate.

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.

So I ended up reverting this, to not use setstate. Ideally the maxHeight should be set as soon as possible, which can happen during the first available animation frame.

this.forceUpdate();
}
});
} else {
this._maxHeight = this._getBounds().height! - BORDER_WIDTH * 2;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ exports[`Callout renders Callout correctly 1`] = `
ms-Callout-main
{
background-color: #ffffff;
max-height: 750px;
overflow-x: hidden;
overflow-y: auto;
position: relative;
Expand All @@ -55,6 +54,7 @@ exports[`Callout renders Callout correctly 1`] = `
role={undefined}
style={
Object {
"maxHeight": 750,
"overflowY": "auto",
}
}
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 All @@ -154,7 +163,8 @@ export class ContextualMenu extends BaseComponent<IContextualMenuProps, IContext
// This slight delay is required so that we can unwind the stack, const react try to mess with focus, and then
// apply the correct focus. Without the setTimeout, we end up focusing the correct thing, and then React wants
// to reset the focus back to the thing it thinks should have been focused.
setTimeout(() => this._previousActiveElement!.focus(), 0);
// Note: Cannot be replaced by this._async.setTimout because those will be removed by the time this is called.
setTimeout(() => { this._previousActiveElement && this._previousActiveElement!.focus(); }, 0);
}

if (this.props.onMenuDismissed) {
Expand Down Expand Up @@ -270,6 +280,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 +327,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 && this._async.setTimeout(() => { this._previousActiveElement && this._previousActiveElement!.focus(); }, 0);
}

/**
* 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