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

inline=X ⇒ usePortal=!X #2045

Merged
merged 13 commits into from Feb 1, 2018
2 changes: 1 addition & 1 deletion packages/core/src/common/errors.ts
Expand Up @@ -53,7 +53,7 @@ export const POPOVER_WARN_DOUBLE_CONTENT =
export const POPOVER_WARN_DOUBLE_TARGET =
ns + ` <Popover> with children ignores target prop; use either prop or children.`;
export const POPOVER_WARN_EMPTY_CONTENT = ns + ` Disabling <Popover> with empty/whitespace content...`;
export const POPOVER_WARN_HAS_BACKDROP_INLINE = ns + ` <Popover inline={true}> ignores hasBackdrop`;
export const POPOVER_WARN_HAS_BACKDROP_INLINE = ns + ` <Popover usePortal={false}> ignores hasBackdrop`;
export const POPOVER_WARN_UNCONTROLLED_ONINTERACTION = ns + ` <Popover> onInteraction is ignored when uncontrolled.`;

export const PORTAL_CONTEXT_CLASS_NAME_STRING = ns + ` <Portal> context blueprintPortalClassName must be string`;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/dialog/dialog.md
Expand Up @@ -22,7 +22,7 @@ There are two ways to render dialogs:

- injected into a newly created element attached to `document.body` using `<Portal>`.
This is the default behavior.
- in-place in the DOM tree. Set `inline={true}` to enable this behavior.
- in-place in the DOM tree. Set `usePortal={false}` to enable this behavior.

`Dialog` is a stateless React component. The children you provide to this component
are rendered as contents inside the `.pt-dialog` element.
Expand Down
1 change: 0 additions & 1 deletion packages/core/src/components/menu/menuItem.tsx
Expand Up @@ -150,7 +150,6 @@ export class MenuItem extends AbstractPureComponent<IMenuItemProps, IMenuItemSta
disabled={disabled}
enforceFocus={false}
hoverCloseDelay={0}
inline={true}
interactionKind={PopoverInteractionKind.HOVER}
position={this.state.alignLeft ? Position.LEFT_TOP : Position.RIGHT_TOP}
{...popoverProps}
Expand Down
52 changes: 28 additions & 24 deletions packages/core/src/components/overlay/overlay.tsx
Expand Up @@ -31,23 +31,13 @@ export interface IOverlayableProps {
* Whether the overlay should prevent focus from leaving itself. That is, if the user attempts
* to focus an element outside the overlay and this prop is enabled, then the overlay will
* immediately bring focus back to itself. If you are nesting overlay components, either disable
* this prop on the "outermost" overlays or mark the nested ones `inline={true}`.
* this prop on the "outermost" overlays or mark the nested ones `usePortal={false}`.
* @default true
*/
enforceFocus?: boolean;

/**
* Whether the overlay should be rendered inline or into a new element on `document.body`.
* This prop essentially determines which element is covered by the backdrop: if `true`,
* then only its parent is covered; otherwise, the entire application is covered.
* Set this prop to `true` when this component is used inside an `Overlay` (such as
* `Dialog` or `Popover`) to ensure that this component is rendered above its parent.
* @default false
*/
inline?: boolean;

/**
* If `true` and not `inline`, the `Portal` containing the children is created and attached
* If `true` and `usePortal={true}`, the `Portal` containing the children is created and attached
* to the DOM when the overlay is opened for the first time; otherwise this happens when the
* component mounts. Lazy mounting provides noticeable performance improvements if you have lots
* of overlays at once, such as on each row of a table.
Expand All @@ -64,6 +54,20 @@ export interface IOverlayableProps {
*/
transitionDuration?: number;

/**
* Whether the overlay should be wrapped in a `Portal`, which renders its contents in a new
* element attached to `document.body`.
*
* This prop essentially determines which element is covered by the backdrop: if `false`,
* then only its parent is covered; otherwise, the entire page is covered (because the parent
* of the `Portal` is the `<body>` itself).
*
* Set this prop to `false` on nested overlays (such as `Dialog` or `Popover`) to ensure that they
* are rendered above their parents.
* @default true
*/
usePortal?: boolean;

/**
* A callback that is invoked when user interaction causes the overlay to close, such as
* clicking on the overlay or pressing the `esc` key (if enabled).
Expand Down Expand Up @@ -127,11 +131,11 @@ export class Overlay extends React.PureComponent<IOverlayProps, IOverlayState> {
canOutsideClickClose: true,
enforceFocus: true,
hasBackdrop: true,
inline: false,
isOpen: false,
lazy: true,
transitionDuration: 300,
transitionName: "pt-overlay",
usePortal: true,
};

private static openStack: Overlay[] = [];
Expand All @@ -154,7 +158,7 @@ export class Overlay extends React.PureComponent<IOverlayProps, IOverlayState> {
return null;
}

const { children, className, inline, isOpen, transitionDuration, transitionName } = this.props;
const { children, className, usePortal, isOpen, transitionDuration, transitionName } = this.props;

const childrenWithTransitions = React.Children.map(children, (child: React.ReactElement<any>) => {
// add a special class to each child that will automatically set the appropriate
Expand Down Expand Up @@ -182,7 +186,7 @@ export class Overlay extends React.PureComponent<IOverlayProps, IOverlayState> {
Classes.OVERLAY,
{
[Classes.OVERLAY_OPEN]: isOpen,
[Classes.OVERLAY_INLINE]: inline,
[Classes.OVERLAY_INLINE]: !usePortal,
},
className,
);
Expand All @@ -192,13 +196,7 @@ export class Overlay extends React.PureComponent<IOverlayProps, IOverlayState> {
onKeyDown: this.handleKeyDown,
};

if (inline) {
return (
<span {...elementProps} ref={this.refHandlers.container}>
{transitionGroup}
</span>
);
} else {
if (usePortal) {
return (
<Portal
{...elementProps}
Expand All @@ -208,6 +206,12 @@ export class Overlay extends React.PureComponent<IOverlayProps, IOverlayState> {
{transitionGroup}
</Portal>
);
} else {
return (
<span {...elementProps} ref={this.refHandlers.container}>
{transitionGroup}
</span>
);
}
}

Expand Down Expand Up @@ -301,7 +305,7 @@ export class Overlay extends React.PureComponent<IOverlayProps, IOverlayState> {
}
}

if (openStack.filter(o => !o.props.inline && o.props.hasBackdrop).length === 0) {
if (openStack.filter(o => o.props.usePortal && o.props.hasBackdrop).length === 0) {
document.body.classList.remove(Classes.OVERLAY_OPEN);
}
}
Expand All @@ -325,7 +329,7 @@ export class Overlay extends React.PureComponent<IOverlayProps, IOverlayState> {
document.addEventListener("mousedown", this.handleDocumentClick);
}

if (this.props.inline) {
if (!this.props.usePortal) {
safeInvoke(this.props.didOpen);
} else if (this.props.hasBackdrop) {
// add a class to the body to prevent scrolling of content below the overlay
Expand Down
12 changes: 4 additions & 8 deletions packages/core/src/components/popover/popover.md
Expand Up @@ -285,15 +285,11 @@ The backdrop element has the same opacity-fade transition as the `Dialog` backdr
must be handled by your application code or simply avoided if possible.
</div>

@### Inline rendering
@### Portal rendering

By default, popover contents are rendered in a newly created [`Portal`](#core/components/portal) appended to `document.body`. This works well for most layouts, because popovers by default will appear above everything else on the page without needing to manually adjust z-indices.
By default, popover contents are rendered in the normal document flow as a sibling of the target. This works well for most layouts, because popovers by default will appear above everything else on the page without needing to manually adjust z-indices, and Popper.js will keep them nicely positioned.

However, there are cases where it's preferable to render the popover contents inline in the DOM.

For example, consider a scrolling table where cells have tooltips attached to them. As row items go out of view, cell tooltips should slide out of the viewport as well. This is best accomplished with inline popovers.

Setting `inline={true}` will enable inline rendering.
However, there are cases where it's preferable for the popover contents to "escape" the application DOM tree to avoid incompatible styles on ancestor elements. (Incompatible styles typically include hidden `overflow` or complex `position` logic.) In these cases, simply set `usePortal={true}` to wrap the popover contents in a [`Portal`](#core/components/portal) that renders its contents into a DOM element appended to `document.body`.

@reactExample PopoverInlineExample

Expand Down Expand Up @@ -373,7 +369,7 @@ import { mount } from "enzyme";
import { Target } from "react-popper";

wrapper = mount(
<Popover inline={true} interactionKind={PopoverInteractionKind.HOVER}>
<Popover usePortal={false} interactionKind={PopoverInteractionKind.HOVER}>
<div>Target</div>
<div>Content</div>
</Popover>
Expand Down
32 changes: 22 additions & 10 deletions packages/core/src/components/popover/popover.tsx
Expand Up @@ -65,7 +65,8 @@ export interface IPopoverProps extends IOverlayableProps, IProps {
hoverOpenDelay?: number;

/**
* Whether a non-inline popover should automatically inherit the dark theme from its parent.
* Whether a popover should automatically inherit the dark theme from its parent.
* Note that this prop is ignored if `usePortal={false}`, as the Popover will inherit dark theme via CSS.
* @default true
*/
inheritDarkTheme?: boolean;
Expand Down Expand Up @@ -153,7 +154,7 @@ export interface IPopoverProps extends IOverlayableProps, IProps {

/**
* Space-delimited string of class names applied to the
* portal that holds the popover if `inline = false`.
* portal that holds the popover if `usePortal={true}`.
*/
portalClassName?: string;

Expand Down Expand Up @@ -182,6 +183,17 @@ export interface IPopoverProps extends IOverlayableProps, IProps {
* Space-delimited string of class names applied to the target.
*/
targetClassName?: string;

/**
* Whether the popover should be rendered inside a `Portal` attached to `document.body`.
* In most cases, Popovers will position themselves correctly without this prop, so it is disabled by default.
*
* Enabling this prop allows the popover content to escape the physical bounds of its parent
* while still being positioned correctly relative to its target. Using a `Portal` becomes
* necessary if any ancestor of the target hides overflow or uses very complex positioning.
* @default false
*/
usePortal?: boolean;
}

export interface IPopoverState {
Expand All @@ -201,19 +213,19 @@ export class Popover extends AbstractPureComponent<IPopoverProps, IPopoverState>
hoverCloseDelay: 300,
hoverOpenDelay: 150,
inheritDarkTheme: true,
inline: false,
interactionKind: PopoverInteractionKind.CLICK,
minimal: false,
modifiers: {},
openOnTargetFocus: true,
position: "auto",
rootElementTag: "span",
transitionDuration: 300,
usePortal: false,
};

/**
* DOM element that contains the popover.
* When `inline={false}`, this element will be portaled outside the usual DOM flow,
* When `usePortal={true}`, this element will be portaled outside the usual DOM flow,
* so this reference can be very useful for testing.
*/
public popoverElement: HTMLElement;
Expand Down Expand Up @@ -304,7 +316,7 @@ export class Popover extends AbstractPureComponent<IPopoverProps, IPopoverState>
didOpen={this.handleContentMount}
enforceFocus={this.props.enforceFocus}
hasBackdrop={hasBackdrop}
inline={this.props.inline}
usePortal={this.props.usePortal}
isOpen={isOpen && !isContentEmpty}
onClose={this.handleOverlayClose}
transitionDuration={this.props.transitionDuration}
Expand Down Expand Up @@ -357,7 +369,7 @@ export class Popover extends AbstractPureComponent<IPopoverProps, IPopoverState>
if (props.isOpen == null && props.onInteraction != null) {
console.warn(Errors.POPOVER_WARN_UNCONTROLLED_ONINTERACTION);
}
if (props.hasBackdrop && props.inline) {
if (props.hasBackdrop && !props.usePortal) {
console.warn(Errors.POPOVER_WARN_HAS_BACKDROP_INLINE);
}
if (props.hasBackdrop && props.interactionKind !== PopoverInteractionKind.CLICK) {
Expand All @@ -383,22 +395,22 @@ export class Popover extends AbstractPureComponent<IPopoverProps, IPopoverState>
}

private updateDarkParent() {
if (!this.props.inline && this.state.isOpen) {
if (this.props.usePortal && this.state.isOpen) {
const hasDarkParent = this.targetElement != null && this.targetElement.closest(`.${Classes.DARK}`) != null;
this.setState({ hasDarkParent });
}
}

private renderPopper(content: JSX.Element) {
const { inline, interactionKind, modifiers } = this.props;
const { usePortal, interactionKind, modifiers } = this.props;

const popoverHandlers: React.HTMLAttributes<HTMLDivElement> = {
// always check popover clicks for dismiss class
onClick: this.handlePopoverClick,
};
if (
interactionKind === PopoverInteractionKind.HOVER ||
(inline && interactionKind === PopoverInteractionKind.HOVER_TARGET_ONLY)
(!usePortal && interactionKind === PopoverInteractionKind.HOVER_TARGET_ONLY)
) {
popoverHandlers.onMouseEnter = this.handleMouseEnter;
popoverHandlers.onMouseLeave = this.handleMouseLeave;
Expand Down Expand Up @@ -503,7 +515,7 @@ export class Popover extends AbstractPureComponent<IPopoverProps, IPopoverState>
// if we're entering the popover, and the mode is set to be HOVER_TARGET_ONLY, we want to manually
// trigger the mouse leave event, as hovering over the popover shouldn't count.
if (
this.props.inline &&
!this.props.usePortal &&
this.isElementInPopover(e.target as Element) &&
this.props.interactionKind === PopoverInteractionKind.HOVER_TARGET_ONLY &&
!this.props.openOnTargetFocus
Expand Down
16 changes: 8 additions & 8 deletions packages/core/src/components/toast/toaster.tsx
Expand Up @@ -61,14 +61,14 @@ export interface IToasterProps extends IProps {
canEscapeKeyClear?: boolean;

/**
* Whether the toaster should be rendered inline or into a new element on `document.body`.
* If `true`, then positioning will be relative to the parent element.
* Whether the toaster should be rendered into a new element attached to `document.body`.
* If `false`, then positioning will be relative to the parent element.
*
* This prop is ignored by `Toaster.create()` as that method always appends a new element
* to the container.
* @default false
* @default true
*/
inline?: boolean;
usePortal?: boolean;

/**
* Position of `Toaster` within its container.
Expand All @@ -88,21 +88,21 @@ export class Toaster extends AbstractPureComponent<IToasterProps, IToasterState>
public static defaultProps: IToasterProps = {
autoFocus: false,
canEscapeKeyClear: true,
inline: false,
position: Position.TOP,
usePortal: false,
};

/**
* Create a new `Toaster` instance that can be shared around your application.
* The `Toaster` will be rendered into a new element appended to the given container.
*/
public static create(props?: IToasterProps, container = document.body): IToaster {
if (props != null && props.inline != null && !isNodeEnv("production")) {
if (props != null && props.usePortal != null && !isNodeEnv("production")) {
console.warn(TOASTER_WARN_INLINE);
}
const containerElement = document.createElement("div");
container.appendChild(containerElement);
return ReactDOM.render(<Toaster {...props} inline={true} />, containerElement) as Toaster;
return ReactDOM.render(<Toaster {...props} usePortal={false} />, containerElement) as Toaster;
}

public state = {
Expand Down Expand Up @@ -158,7 +158,7 @@ export class Toaster extends AbstractPureComponent<IToasterProps, IToasterState>
className={classes}
enforceFocus={false}
hasBackdrop={false}
inline={this.props.inline}
usePortal={this.props.usePortal}
isOpen={this.state.toasts.length > 0}
onClose={this.handleClose}
transitionDuration={350}
Expand Down
18 changes: 9 additions & 9 deletions packages/core/src/components/tooltip/tooltip.tsx
Expand Up @@ -48,18 +48,11 @@ export interface ITooltipProps extends IProps, IIntentProps {
hoverOpenDelay?: number;

/**
* Whether a non-inline tooltip should automatically inherit the dark theme from its parent.
* Whether a tooltip that uses a `Portal` should automatically inherit the dark theme from its parent.
* @default true
*/
inheritDarkTheme?: boolean;

/**
* Whether the tooltip is rendered inline (as a sibling of the target element).
* If false, it is attached to a new element appended to `<body>`.
* @default false
*/
inline?: 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.
Expand Down Expand Up @@ -88,7 +81,7 @@ export interface ITooltipProps extends IProps, IIntentProps {

/**
* Space-delimited string of class names applied to the
* portal which holds the tooltip if `inline` is set to `false`.
* portal which holds the tooltip if `usePortal={true}`.
*/
portalClassName?: string;

Expand Down Expand Up @@ -121,6 +114,13 @@ export interface ITooltipProps extends IProps, IIntentProps {
* @default 100
*/
transitionDuration?: number;

/**
* Whether the tooltip is rendered inside a `Portal` so it can escape the usual DOM flow.
* If false, it is rendered as a sibling of the target element.
* @default false
*/
usePortal?: boolean;
}

export class Tooltip extends React.PureComponent<ITooltipProps, {}> {
Expand Down
Expand Up @@ -78,7 +78,7 @@ describe("<CollapsibleList>", () => {

it("hides some items when visibleItemCount < number of children", () => {
const list = renderCollapsibleList(5, {
dropdownProps: { inline: true, isOpen: true },
dropdownProps: { isOpen: true },
visibleItemCount: 2,
});
assertListItems(list, 2, 3);
Expand Down