Skip to content
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

ContextMenu fixes & Overlay didClose #2399

Merged
merged 5 commits into from
Apr 18, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 30 additions & 16 deletions packages/core/src/components/context-menu/contextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,34 +19,38 @@ export interface IOffset {
top: number;
}

interface IContextMenuProps {
/** Callback invoked after the menu has finished transitioning to a closed state. */
didClose: () => void;
}

interface IContextMenuState {
isOpen?: boolean;
isDarkTheme?: boolean;
menu?: JSX.Element;
offset?: IOffset;
isOpen: boolean;
isDarkTheme: boolean;
menu: JSX.Element;
offset: IOffset;
onClose?: () => void;
}

const POPPER_MODIFIERS: PopperModifiers = {
preventOverflow: { boundariesElement: "window" },
preventOverflow: { boundariesElement: "viewport" },
};
const TRANSITION_DURATION = 100;

/* istanbul ignore next */
class ContextMenu extends AbstractPureComponent<{}, IContextMenuState> {
class ContextMenu extends AbstractPureComponent<IContextMenuProps, IContextMenuState> {
public state: IContextMenuState = {
isDarkTheme: false,
isOpen: false,
menu: null,
offset: null,
};

public render() {
// prevent right-clicking in a context menu
const content = <div onContextMenu={this.cancelContextMenu}>{this.state.menu}</div>;
const popoverClassName = classNames({ [Classes.DARK]: this.state.isDarkTheme });

// the target isn't relevant in this case. the context menu simply
// appears overlayed at the desired offset on the screen.
const emptyTarget = <div />;

// wrap the popover in a positioned div to make sure it is properly
// offset on the screen.
return (
Expand All @@ -62,10 +66,10 @@ class ContextMenu extends AbstractPureComponent<{}, IContextMenuState> {
onInteraction={this.handlePopoverInteraction}
position={Position.RIGHT_TOP}
popoverClassName={popoverClassName}
popoverDidClose={this.props.didClose}
target={<div />}
Copy link
Contributor

Choose a reason for hiding this comment

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

is it better to make this "div" so as not to break shallow comparison of props for updates? though you'd also need to change backdropProps above

Copy link
Contributor Author

Choose a reason for hiding this comment

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

i don't understand the action item here. i could make it a static constant?

Copy link
Contributor

Choose a reason for hiding this comment

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

or just make it "div", right?

Copy link
Contributor

Choose a reason for hiding this comment

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

but either way, prop comparison will never be equal because of backdropProps above

Copy link
Contributor Author

Choose a reason for hiding this comment

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

"div" is quite different from <div /> here... one would appear as three letters in the DOM, the other is an empty element.

Copy link
Contributor

Choose a reason for hiding this comment

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

ah, I see, you have to use targetElementTag for that?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

targetElementTag is actually the parent target prop, and a target is required so you can't just set that prop

transitionDuration={TRANSITION_DURATION}
>
{emptyTarget}
</Popover>
/>
</div>
);
}
Expand Down Expand Up @@ -105,6 +109,7 @@ class ContextMenu extends AbstractPureComponent<{}, IContextMenuState> {
};
}

let contextMenuElement: HTMLElement;
let contextMenu: ContextMenu;

/**
Expand All @@ -113,11 +118,11 @@ let contextMenu: ContextMenu;
* room onscreen. The optional callback will be invoked when this menu closes.
*/
export function show(menu: JSX.Element, offset: IOffset, onClose?: () => void, isDarkTheme?: boolean) {
if (contextMenu == null) {
const contextMenuElement = document.createElement("div");
if (contextMenuElement == null) {
contextMenuElement = document.createElement("div");
contextMenuElement.classList.add(Classes.CONTEXT_MENU);
document.body.appendChild(contextMenuElement);
contextMenu = ReactDOM.render(<ContextMenu />, contextMenuElement) as ContextMenu;
contextMenu = ReactDOM.render(<ContextMenu didClose={remove} />, contextMenuElement) as ContextMenu;
}

contextMenu.show(menu, offset, onClose, isDarkTheme);
Expand All @@ -134,3 +139,12 @@ export function hide() {
export function isOpen() {
return contextMenu != null && contextMenu.state.isOpen;
}

function remove() {
if (contextMenuElement != null) {
ReactDOM.unmountComponentAtNode(contextMenuElement);
contextMenuElement.remove();
contextMenuElement = null;
contextMenu = null;
}
}
10 changes: 1 addition & 9 deletions packages/core/src/components/context-menu/contextMenuTarget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,7 @@ export function ContextMenuTarget<T extends IConstructor<IContextMenuTarget>>(Wr
const htmlElement = ReactDOM.findDOMNode(this);
const darkTheme = htmlElement != null && isDarkTheme(htmlElement);
e.preventDefault();
ContextMenu.show(
menu,
{
left: e.clientX,
top: e.clientY,
},
this.onContextMenuClose,
darkTheme,
);
ContextMenu.show(menu, { left: e.clientX, top: e.clientY }, this.onContextMenuClose, darkTheme);
}
}

Expand Down
21 changes: 15 additions & 6 deletions packages/core/src/components/overlay/overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ export interface IOverlayProps extends IOverlayableProps, IBackdropProps, IProps
/** Lifecycle callback invoked after the overlay opens and is mounted in the DOM. */
didOpen?: () => any;

/**
* Lifecycle callback invoked after a child element finishes exiting the DOM.
* This will be invoked for each child of the `Overlay` except for the backdrop element.
* The argument is the underlying HTML element that left the DOM.
*/
didClose?: (node: HTMLElement) => any;

/**
* Toggles the visibility of the overlay and its children.
* This prop is required because the component is controlled.
Expand Down Expand Up @@ -261,7 +268,7 @@ export class Overlay extends React.PureComponent<IOverlayProps, IOverlayState> {
);
const { transitionDuration, transitionName } = this.props;
return (
<CSSTransition classNames={transitionName} timeout={transitionDuration}>
<CSSTransition classNames={transitionName} onExited={this.props.didClose} timeout={transitionDuration}>
{decoratedChild}
</CSSTransition>
);
Expand Down Expand Up @@ -356,12 +363,14 @@ export class Overlay extends React.PureComponent<IOverlayProps, IOverlayState> {
const { canOutsideClickClose, isOpen, onClose } = this.props;
const eventTarget = e.target as HTMLElement;

const { openStack } = Overlay;
const stackIndex = openStack.indexOf(this);

const isClickInThisOverlayOrDescendant = openStack
const stackIndex = Overlay.openStack.indexOf(this);
const isClickInThisOverlayOrDescendant = Overlay.openStack
.slice(stackIndex)
.some(({ containerElement }) => containerElement && containerElement.contains(eventTarget));
.some(({ containerElement: elem }) => {
// `elem` is the container of backdrop & content, so clicking on that container
// should not count as being "inside" the overlay.
return elem && elem.contains(eventTarget) && !elem.isSameNode(eventTarget);
});

if (isOpen && canOutsideClickClose && !isClickInThisOverlayOrDescendant) {
// casting to any because this is a native event
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/components/popover/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,11 @@ export interface IPopoverProps extends IOverlayableProps, IProps {
*/
popoverClassName?: string;

/**
* Callback invoked after the popover closes and has been removed from the DOM.
*/
popoverDidClose?: () => void;

/**
* Callback invoked when the popover opens after it is added to the DOM.
*/
Expand Down Expand Up @@ -322,6 +327,7 @@ export class Popover extends AbstractPureComponent<IPopoverProps, IPopoverState>
canEscapeKeyClose={this.props.canEscapeKeyClose}
canOutsideClickClose={this.props.interactionKind === PopoverInteractionKind.CLICK}
className={this.props.portalClassName}
didClose={this.props.popoverDidClose}
didOpen={this.handleContentMount}
enforceFocus={this.props.enforceFocus}
hasBackdrop={hasBackdrop}
Expand Down
15 changes: 11 additions & 4 deletions packages/core/test/context-menu/contextMenuTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,18 @@ describe("ContextMenu", () => {
assertContextMenuWasRendered();
});

it("invokes onClose callback when menu closed", () => {
const onClose = spy();
ContextMenu.show(MENU, { left: 0, top: 0 }, onClose);
it("invokes onClose callback when menu closed", done => {
ContextMenu.show(MENU, { left: 0, top: 0 }, done);
Copy link
Contributor

Choose a reason for hiding this comment

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

feels a little odd to me to do things this way w/o an assertion

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this is a pattern i see frequently in other test suites: it essentially asserts that the callback is called at all. if it's not called then it will time out.

Copy link
Contributor

Choose a reason for hiding this comment

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

👍 seems reasonable, I didn't know the exact timeout behavior

ContextMenu.hide();
});

it("removes element from the DOM after closing", done => {
ContextMenu.show(MENU, { left: 0, top: 0 });
ContextMenu.hide();
assert.isTrue(onClose.calledOnce, "onClose not called");
setTimeout(() => {
assert.isTrue(document.querySelector(`.${Classes.CONTEXT_MENU}`) == null);
done();
}, 110);
});

it("does not invoke previous onClose callback", () => {
Expand Down
36 changes: 36 additions & 0 deletions packages/core/test/overlay/overlayTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,42 @@ describe("<Overlay>", () => {
assert.isTrue(didOpen.calledOnce, "didOpen not invoked when overlay open");
});

it("invokes didClose when Overlay is closed", done => {
const didClose = spy();
mountWrapper(
<Overlay didClose={didClose} isOpen={true} transitionDuration={1}>
{createOverlayContents()}
</Overlay>,
);
assert.isTrue(didClose.notCalled, "didClose invoked when overlay open");

wrapper.setProps({ isOpen: false });
// didClose relies on transition onExited so we go async for a sec
setTimeout(() => {
wrapper.update();
assert.isTrue(didClose.calledOnce, "didClose not invoked when overlay closed");
assert.isFalse(wrapper.find("h1").exists(), "no content");
done();
});
});

it("invokes didClose when inline Overlay is closed", done => {
const didClose = spy();
mountWrapper(
<Overlay didClose={didClose} isOpen={true} usePortal={false} transitionDuration={1}>
{createOverlayContents()}
</Overlay>,
);
assert.isTrue(didClose.notCalled, "didClose invoked when overlay open");

wrapper.setProps({ isOpen: false });
// didClose relies on transition onExited so we go async for a sec
setTimeout(() => {
assert.isTrue(didClose.calledOnce, "didClose not invoked when overlay closed");
done();
});
});

it("renders portal attached to body when not inline after first opened", () => {
mountWrapper(<Overlay isOpen={false}>{createOverlayContents()}</Overlay>);
assert.lengthOf(wrapper.find(Portal), 0, "unexpected Portal");
Expand Down
18 changes: 14 additions & 4 deletions packages/core/test/popover/popoverTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ describe("<Popover>", () => {
warnSpy.restore();
});

it("lifecycle methods are called appropriately", () => {
it("lifecycle methods are called appropriately", done => {
const popoverWillOpen = sinon.spy(() =>
assert.lengthOf(testsContainerElement.getElementsByClassName(Classes.POPOVER), 0),
);
Expand All @@ -183,21 +183,31 @@ describe("<Popover>", () => {
const popoverWillClose = sinon.spy(() =>
assert.lengthOf(testsContainerElement.getElementsByClassName(Classes.POPOVER), 1),
);
// DOM is still present in didClose; element is removed shortly after
const popoverDidClose = sinon.spy();

wrapper = renderPopover({
interactionKind: PopoverInteractionKind.CLICK_TARGET_ONLY,
popoverDidClose,
popoverDidOpen,
popoverWillClose,
popoverWillOpen,
transitionDuration: 1,
}).simulateTarget("click");
assert.isTrue(popoverWillOpen.calledOnce);
assert.isTrue(popoverDidOpen.calledOnce);
assert.isTrue(popoverWillClose.notCalled);
assert.isTrue(popoverDidClose.notCalled);

wrapper.simulateTarget("click");
assert.isTrue(popoverDidOpen.calledOnce);
assert.isTrue(popoverWillOpen.calledOnce);
assert.isTrue(popoverWillClose.calledOnce);
setTimeout(() => {
assert.isTrue(popoverDidOpen.calledOnce);
assert.isTrue(popoverWillOpen.calledOnce);
assert.isTrue(popoverWillClose.calledOnce);
assert.isTrue(popoverDidClose.calledOnce);
assert.lengthOf(testsContainerElement.getElementsByClassName(Classes.POPOVER), 0);
done();
});
});

it("popoverDidOpen is called even if popoverWillOpen is not specified", () => {
Expand Down