diff --git a/packages/labs/src/common/errors.ts b/packages/labs/src/common/errors.ts new file mode 100644 index 0000000000..9ea680374d --- /dev/null +++ b/packages/labs/src/common/errors.ts @@ -0,0 +1,13 @@ +/* + * Copyright 2015 Palantir Technologies, Inc. All rights reserved. + * Licensed under the BSD-3 License as modified (the “License”); you may obtain a copy + * of the license at https://github.com/palantir/blueprint/blob/master/LICENSE + * and https://github.com/palantir/blueprint/blob/master/PATENTS + */ + +const ns = "[Blueprint]"; +const deprec = `${ns} DEPRECATION:`; + +export const POPOVER2_WARN_DEPRECATED_IS_DISABLED = `${deprec} isDisabled is deprecated. Use disabled.`; +export const POPOVER2_WARN_DEPRECATED_IS_MODAL = `${deprec} isModal is deprecated. Use hasBackdrop.`; +export const POPOVER2_WARN_DEPRECATED_POSITION = `${deprec} position is deprecated. Use placement.`; diff --git a/packages/labs/src/components/popover/popover2.tsx b/packages/labs/src/components/popover/popover2.tsx index 1d03cdf781..0cc3c83937 100644 --- a/packages/labs/src/components/popover/popover2.tsx +++ b/packages/labs/src/components/popover/popover2.tsx @@ -20,11 +20,14 @@ import { IProps, Overlay, PopoverInteractionKind, + Position, Utils, } from "@blueprintjs/core"; +import * as Errors from "../../common/errors"; import { Tooltip2 } from "../tooltip/tooltip2"; import { getArrowAngle, PopoverArrow } from "./arrow"; +import { positionToPlacement } from "./popoverMigrationUtils"; import { arrowOffsetModifier, getTransformOrigin } from "./popperUtils"; export interface IPopover2Props extends IOverlayableProps, IProps { @@ -79,6 +82,12 @@ export interface IPopover2Props extends IOverlayableProps, IProps { */ disabled?: boolean; + /** + * Prevents the popover from appearing when `true`. + * @deprecated use `disabled` + */ + isDisabled?: boolean; + /** * Enables an invisible overlay beneath the popover that captures clicks and prevents * interaction with the rest of the document until the popover is closed. @@ -88,6 +97,13 @@ export interface IPopover2Props extends IOverlayableProps, IProps { */ hasBackdrop?: boolean; + /** + * Enables an invisible overlay beneath the popover that captures clicks and prevents + * interaction with the rest of the document until the popover is closed. + * @deprecated use `hasBackdrop` + */ + isModal?: boolean; + /** * Whether the popover is visible. Passing this prop puts the popover in * controlled mode, where the only way to change visibility is by updating this property. @@ -122,6 +138,12 @@ export interface IPopover2Props extends IOverlayableProps, IProps { */ openOnTargetFocus?: boolean; + /** + * The position (relative to the target) at which the popover should appear. + * @deprecated use `placement` + */ + position?: Position; + /** * A space-delimited string of class names that are applied to the popover (but not the target). */ @@ -174,6 +196,15 @@ export interface IPopover2State { transformOrigin?: string; isOpen?: boolean; hasDarkParent?: boolean; + + /** Migrated `disabled` value that considers the `disabled` and `isDisabled` props. */ + disabled?: boolean; + + /** Migrated `hasBackdrop` value that considers the `hasBackdrop` and `isModal` props. */ + hasBackdrop?: boolean; + + /** Migrated `placement` value that considers the `placement` and `position` props. */ + placement?: Placement; } @PureRender @@ -182,8 +213,6 @@ export class Popover2 extends AbstractComponent public static defaultProps: IPopover2Props = { defaultIsOpen: false, - disabled: false, - hasBackdrop: false, hoverCloseDelay: 300, hoverOpenDelay: 150, inheritDarkTheme: true, @@ -192,7 +221,6 @@ export class Popover2 extends AbstractComponent minimal: false, modifiers: {}, openOnTargetFocus: true, - placement: "auto", rootElementTag: "span", transitionDuration: 300, }; @@ -218,20 +246,24 @@ export class Popover2 extends AbstractComponent public constructor(props?: IPopover2Props, context?: any) { super(props, context); - let isOpen = props.defaultIsOpen && !props.disabled; + const disabled = getDisabled(props); + let isOpen = props.defaultIsOpen && !disabled; if (props.isOpen != null) { isOpen = props.isOpen; } this.state = { + disabled, + hasBackdrop: getHasBackdrop(props), hasDarkParent: false, isOpen, + placement: getPlacement(props), }; } public render() { const { className } = this.props; - const { isOpen } = this.state; + const { isOpen, disabled, hasBackdrop } = this.state; let targetProps: React.HTMLAttributes; if (this.isHoverInteractionKind()) { @@ -264,7 +296,7 @@ export class Popover2 extends AbstractComponent }); const isContentEmpty = children.content == null; - if (isContentEmpty && !this.props.disabled && isOpen !== false && !Utils.isNodeEnv("production")) { + if (isContentEmpty && !disabled && isOpen !== false && !Utils.isNodeEnv("production")) { console.warn("[Blueprint] Disabling with empty/whitespace content..."); } @@ -282,7 +314,7 @@ export class Popover2 extends AbstractComponent className={this.props.portalClassName} didOpen={this.handleContentMount} enforceFocus={this.props.enforceFocus} - hasBackdrop={this.props.hasBackdrop} + hasBackdrop={hasBackdrop} inline={this.props.inline} isOpen={isOpen && !isContentEmpty} onClose={this.handleOverlayClose} @@ -302,7 +334,9 @@ export class Popover2 extends AbstractComponent public componentWillReceiveProps(nextProps: IPopover2Props) { super.componentWillReceiveProps(nextProps); - if (nextProps.isOpen == null && nextProps.disabled && !this.props.disabled) { + const nextDisabled = getDisabled(nextProps); + + if (nextProps.isOpen == null && nextDisabled && !this.state.disabled) { // ok to use setOpenState here because disabled and isOpen are mutex. this.setOpenState(false); } else if (nextProps.isOpen !== this.props.isOpen) { @@ -310,6 +344,12 @@ export class Popover2 extends AbstractComponent // (which would be invoked if this went through setOpenState) this.setState({ isOpen: nextProps.isOpen }); } + + this.setState({ + disabled: nextDisabled, + hasBackdrop: getHasBackdrop(nextProps), + placement: getPlacement(nextProps), + }); } public componentWillUpdate(_: IPopover2Props, nextState: IPopover2State) { @@ -329,6 +369,18 @@ export class Popover2 extends AbstractComponent super.componentWillUnmount(); } + protected validateProps(props: IPopover2Props & { children?: React.ReactNode }) { + if (props.isDisabled !== undefined) { + console.warn(Errors.POPOVER2_WARN_DEPRECATED_IS_DISABLED); + } + if (props.isModal !== undefined) { + console.warn(Errors.POPOVER2_WARN_DEPRECATED_IS_MODAL); + } + if (props.position !== undefined) { + console.warn(Errors.POPOVER2_WARN_DEPRECATED_POSITION); + } + } + private updateDarkParent() { if (!this.props.inline) { const hasDarkParent = this.targetElement.closest(`.${Classes.DARK}`) != null; @@ -338,6 +390,8 @@ export class Popover2 extends AbstractComponent private renderPopper(content: JSX.Element) { const { inline, interactionKind, modifiers } = this.props; + const { placement } = this.state; + const popoverHandlers: React.HTMLAttributes = { // always check popover clicks for dismiss class onClick: this.handlePopoverClick, @@ -378,7 +432,7 @@ export class Popover2 extends AbstractComponent }; return ( - +
!this.props.openOnTargetFocus ) { this.handleMouseLeave(e); - } else if (!this.props.disabled) { + } else if (!this.state.disabled) { // only begin opening popover when it is enabled this.setOpenState(true, e, this.props.hoverOpenDelay); } @@ -470,7 +524,7 @@ export class Popover2 extends AbstractComponent private handleTargetClick = (e: React.MouseEvent) => { // ensure click did not originate from within inline popover before closing - if (!this.props.disabled && !this.isElementInPopover(e.target as HTMLElement)) { + if (!this.state.disabled && !this.isElementInPopover(e.target as HTMLElement)) { if (this.props.isOpen == null) { this.setState(prevState => ({ isOpen: !prevState.isOpen })); } else { @@ -535,3 +589,33 @@ function ensureElement(child: React.ReactChild | undefined) { return child; } } + +function getDisabled(props: IPopover2Props): boolean { + if (props.disabled !== undefined) { + return props.disabled; + } else if (props.isDisabled !== undefined) { + return props.isDisabled; + } else { + return false; + } +} + +function getHasBackdrop(props: IPopover2Props): boolean { + if (props.hasBackdrop !== undefined) { + return props.hasBackdrop; + } else if (props.isModal !== undefined) { + return props.isModal; + } else { + return false; + } +} + +function getPlacement(props: IPopover2Props): Placement { + if (props.placement !== undefined) { + return props.placement; + } else if (props.position !== undefined) { + return positionToPlacement(props.position); + } else { + return "auto"; + } +} diff --git a/packages/labs/src/components/popover/popoverMigrationUtils.ts b/packages/labs/src/components/popover/popoverMigrationUtils.ts new file mode 100644 index 0000000000..44e8131248 --- /dev/null +++ b/packages/labs/src/components/popover/popoverMigrationUtils.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2017 Palantir Technologies, Inc. All rights reserved. + * Licensed under the BSD-3 License as modified (the “License”); you may obtain a copy + * of the license at https://github.com/palantir/blueprint/blob/master/LICENSE + * and https://github.com/palantir/blueprint/blob/master/PATENTS + */ + +import { Position } from "@blueprintjs/core"; +import { Placement } from "./popover2"; + +/** + * Convert a position to a placement. + * @param position the position to convert + */ +export function positionToPlacement(position: Position): Placement { + switch (position) { + case Position.TOP_LEFT: + return "top-start"; + case Position.TOP: + return "top"; + case Position.TOP_RIGHT: + return "top-end"; + case Position.RIGHT_TOP: + return "right-start"; + case Position.RIGHT: + return "right"; + case Position.RIGHT_BOTTOM: + return "right-end"; + case Position.BOTTOM_RIGHT: + return "bottom-end"; + case Position.BOTTOM: + return "bottom"; + case Position.BOTTOM_LEFT: + return "bottom-start"; + case Position.LEFT_BOTTOM: + return "left-end"; + case Position.LEFT: + return "left"; + case Position.LEFT_TOP: + return "left-start"; + default: + return assertNever(position); + } +} + +function assertNever(x: never): never { + throw new Error("Unexpected position: " + x); +} diff --git a/packages/labs/test/popover2Tests.tsx b/packages/labs/test/popover2Tests.tsx index db9d4c5b7c..f75edbb3a6 100644 --- a/packages/labs/test/popover2Tests.tsx +++ b/packages/labs/test/popover2Tests.tsx @@ -6,15 +6,18 @@ */ import { assert } from "chai"; -import { mount, ReactWrapper, shallow } from "enzyme"; +import { mount, ReactWrapper, shallow, ShallowWrapper } from "enzyme"; import * as React from "react"; +import { Popper } from "react-popper"; -import { Classes, Keys, Overlay, PopoverInteractionKind, Tooltip, Utils } from "@blueprintjs/core"; +import { Classes, Keys, Overlay, PopoverInteractionKind, Position, Tooltip, Utils } from "@blueprintjs/core"; import * as Errors from "@blueprintjs/core/src/common/errors"; import { dispatchMouseEvent } from "@blueprintjs/core/test/common/utils"; import { Arrow } from "react-popper"; -import { IPopover2Props, IPopover2State, Popover2 } from "../src/index"; +import { IPopover2Props, IPopover2State, Placement, Popover2 } from "../src/index"; + +type ShallowPopover2Wrapper = ShallowWrapper; describe("", () => { let testsContainerElement: HTMLElement; @@ -72,7 +75,7 @@ describe("", () => { }); }); - it("propogates class names correctly", () => { + it("propagates class names correctly", () => { wrapper = renderPopover({ className: "bar", interactionKind: PopoverInteractionKind.CLICK_TARGET_ONLY, @@ -576,6 +579,81 @@ describe("", () => { }); }); + describe("deprecated prop shims", () => { + it("should convert position to placement", () => { + const popover = shallow( + + child + , + ); + assertPlacement(popover, "bottom-start"); + + popover.setProps({ position: Position.LEFT_BOTTOM }); + assertPlacement(popover, "left-end"); + }); + + it("should convert isModal to hasBackdrop", () => { + const popover = shallow( + + child + , + ); + assert.isTrue(popover.find(Overlay).prop("hasBackdrop")); + + popover.setProps({ isModal: false }); + assert.isFalse(popover.find(Overlay).prop("hasBackdrop")); + }); + + it("should convert isDisabled to disabled", () => { + renderPopover({ + interactionKind: PopoverInteractionKind.CLICK_TARGET_ONLY, + isDisabled: true, + }) + .simulateTarget("click") + .assertIsOpen(false) + .setProps({ isDisabled: false }) + .simulateTarget("click") + .assertIsOpen(true); + }); + + it("placement should take precedence over position", () => { + const popover = shallow( + + child + , + ); + assertPlacement(popover, "left-end"); + + popover.setProps({ placement: "bottom-start", position: Position.LEFT_BOTTOM }); + assertPlacement(popover, "bottom-start"); + }); + + it("hasBackdrop should take precedence over isModal", () => { + const popover = shallow( + + child + , + ); + assert.isTrue(popover.find(Overlay).prop("hasBackdrop")); + + popover.setProps({ hasBackdrop: false, isModal: true }); + assert.isFalse(popover.find(Overlay).prop("hasBackdrop")); + }); + + it("disabled should take precedence over isDisabled", () => { + renderPopover({ + disabled: true, + interactionKind: PopoverInteractionKind.CLICK_TARGET_ONLY, + isDisabled: false, + }) + .simulateTarget("click") + .assertIsOpen(false) + .setProps({ disabled: false, isDisabled: true }) + .simulateTarget("click") + .assertIsOpen(true); + }); + }); + interface IPopoverWrapper extends ReactWrapper { popover: HTMLElement; assertIsOpen(isOpen?: boolean): this; @@ -622,4 +700,8 @@ describe("", () => { function getNode(element: ReactWrapper, any>) { return (element as any).node as Element; } + + function assertPlacement(popover: ShallowPopover2Wrapper, placement: Placement) { + assert.strictEqual(popover.find(Popper).prop("placement"), placement); + } });