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

[new] ResizeSensor component #2745

Merged
merged 12 commits into from
Aug 1, 2018
Merged
Show file tree
Hide file tree
Changes from 11 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
1 change: 1 addition & 0 deletions packages/core/src/components/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
@page overflow-list
@page panel-stack
@page progress-bar
@page resize-sensor
@page skeleton
@page spinner
@page tabs
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export * from "./popover/popover";
export * from "./popover/popoverSharedProps";
export * from "./portal/portal";
export * from "./progress-bar/progressBar";
export * from "./resize-sensor/resizeSensor";
export * from "./slider/handleProps";
export * from "./slider/multiSlider";
export * from "./slider/rangeSlider";
Expand Down
59 changes: 17 additions & 42 deletions packages/core/src/components/overflow-list/overflowList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,12 @@

import classNames from "classnames";
import * as React from "react";
import ResizeObserver from "resize-observer-polyfill";

import { Boundary } from "../../common/boundary";
import * as Classes from "../../common/classes";
import { OVERFLOW_LIST_OBSERVE_PARENTS_CHANGED } from "../../common/errors";
import { DISPLAYNAME_PREFIX, IProps } from "../../common/props";
import { throttle } from "../../common/utils";
import { IResizeEntry, ResizeSensor } from "../resize-sensor/resizeSensor";

export interface IOverflowListProps<T> extends IProps {
/**
Expand Down Expand Up @@ -85,35 +84,17 @@ export class OverflowList<T> extends React.PureComponent<IOverflowListProps<T>,
return OverflowList as new (props: IOverflowListProps<T>) => OverflowList<T>;
}

private element: Element | null = null;
private spacer: Element | null = null;
private observer: ResizeObserver;
public state: IOverflowListState<T> = {
overflow: [],
visible: this.props.items,
};

/** A cache containing the widths of all elements being observed to detect growing/shrinking */
private previousWidths = new Map<Element, number>();

public constructor(props: IOverflowListProps<T>, context?: any) {
super(props, context);

// constructor is necessary to ensure observer is defined
this.observer = new ResizeObserver(throttle(this.resize));
this.state = {
overflow: [],
visible: props.items,
};
}
private spacer: Element | null = null;

public componentDidMount() {
if (this.element != null) {
// observer callback is invoked immediately when observing new elements
this.observer.observe(this.element);
if (this.props.observeParents) {
for (let element: Element | null = this.element; element != null; element = element.parentElement) {
this.observer.observe(element);
}
}
this.repartition(false);
}
this.repartition(false);
}

public componentWillReceiveProps(nextProps: IOverflowListProps<T>) {
Expand Down Expand Up @@ -147,24 +128,18 @@ export class OverflowList<T> extends React.PureComponent<IOverflowListProps<T>,
this.repartition(false);
}

public componentWillUnmount() {
this.observer.disconnect();
}

public render() {
const { className, collapseFrom, style, visibleItemRenderer } = this.props;
const { className, collapseFrom, observeParents, style, visibleItemRenderer } = this.props;
const overflow = this.maybeRenderOverflow();
return (
<div
className={classNames(Classes.OVERFLOW_LIST, className)}
ref={ref => (this.element = ref)}
style={style}
>
{collapseFrom === Boundary.START ? overflow : null}
{this.state.visible.map(visibleItemRenderer)}
{collapseFrom === Boundary.END ? overflow : null}
<div className={Classes.OVERFLOW_LIST_SPACER} ref={ref => (this.spacer = ref)} />
</div>
<ResizeSensor onResize={this.resize} observeParents={observeParents}>
<div className={classNames(Classes.OVERFLOW_LIST, className)} style={style}>
{collapseFrom === Boundary.START ? overflow : null}
{this.state.visible.map(visibleItemRenderer)}
{collapseFrom === Boundary.END ? overflow : null}
<div className={Classes.OVERFLOW_LIST_SPACER} ref={ref => (this.spacer = ref)} />
</div>
</ResizeSensor>
);
}

Expand All @@ -176,7 +151,7 @@ export class OverflowList<T> extends React.PureComponent<IOverflowListProps<T>,
return this.props.overflowRenderer(overflow);
}

private resize = (entries: ResizeObserverEntry[]) => {
private resize = (entries: IResizeEntry[]) => {
// if any parent is growing, assume we have more room than before
const growing = entries.some(entry => {
const previousWidth = this.previousWidths.get(entry.target) || 0;
Expand Down
43 changes: 17 additions & 26 deletions packages/core/src/components/popover/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ import classNames from "classnames";
import { ModifierFn } from "popper.js";
import * as React from "react";
import { Manager, Popper, PopperChildrenProps, Reference, ReferenceChildrenProps } from "react-popper";
import ResizeObserver from "resize-observer-polyfill";

import { AbstractPureComponent } from "../../common/abstractPureComponent";
import * as Classes from "../../common/classes";
import * as Errors from "../../common/errors";
import { DISPLAYNAME_PREFIX, HTMLDivProps } from "../../common/props";
import * as Utils from "../../common/utils";
import { Overlay } from "../overlay/overlay";
import { ResizeSensor } from "../resize-sensor/resizeSensor";
import { Tooltip } from "../tooltip/tooltip";
import { PopoverArrow } from "./popoverArrow";
import { positionToPlacement } from "./popoverMigrationUtils";
Expand Down Expand Up @@ -128,9 +128,6 @@ export class Popover extends AbstractPureComponent<IPopoverProps, IPopoverState>
// element on the same page.
private lostFocusOnSamePage = true;

// ResizeObserver instance to monitor for content size changes on the popover
private popperObserver: ResizeObserver;

// Reference to the Poppper.scheduleUpdate() function, this changes every time the popper is mounted
private popperScheduleUpdate: () => void;

Expand Down Expand Up @@ -208,7 +205,6 @@ export class Popover extends AbstractPureComponent<IPopoverProps, IPopoverState>

public componentDidMount() {
this.updateDarkParent();
this.popperObserver = new ResizeObserver(() => Utils.safeInvoke(this.popperScheduleUpdate));
}

public componentWillReceiveProps(nextProps: IPopoverProps) {
Expand All @@ -229,19 +225,6 @@ export class Popover extends AbstractPureComponent<IPopoverProps, IPopoverState>

public componentDidUpdate() {
this.updateDarkParent();

if (this.popoverElement != null) {
// Clear active observations to avoid the list growing.
this.popperObserver.disconnect();

// Ensure our observer has an up-to-date reference to popoverElement
this.popperObserver.observe(this.popoverElement);
}
}

public componentWillUnmount() {
super.componentWillUnmount();
this.popperObserver.disconnect();
}

protected validateProps(props: IPopoverProps & { children?: React.ReactNode }) {
Expand Down Expand Up @@ -310,18 +293,20 @@ export class Popover extends AbstractPureComponent<IPopoverProps, IPopoverState>

return (
<div className={Classes.TRANSITION_CONTAINER} ref={popperProps.ref} style={popperProps.style}>
<div className={popoverClasses} style={{ transformOrigin }} {...popoverHandlers}>
{this.isArrowEnabled() && (
<PopoverArrow arrowProps={popperProps.arrowProps} placement={popperProps.placement} />
)}
<div className={Classes.POPOVER_CONTENT}>{this.understandChildren().content}</div>
</div>
<ResizeSensor onResize={this.handlePopoverResize}>
<div className={popoverClasses} style={{ transformOrigin }} {...popoverHandlers}>
{this.isArrowEnabled() && (
<PopoverArrow arrowProps={popperProps.arrowProps} placement={popperProps.placement} />
)}
<div className={Classes.POPOVER_CONTENT}>{this.understandChildren().content}</div>
</div>
</ResizeSensor>
</div>
);
};

private renderTarget = (referenceProps: ReferenceChildrenProps) => {
const { targetClassName, targetTagName } = this.props;
const { targetClassName, targetTagName: TagName } = this.props;
const { isOpen } = this.state;
const isHoverInteractionKind = this.isHoverInteractionKind();

Expand Down Expand Up @@ -350,7 +335,11 @@ export class Popover extends AbstractPureComponent<IPopoverProps, IPopoverState>
disabled: isOpen && Utils.isElementOfType(rawTarget, Tooltip) ? true : rawTarget.props.disabled,
tabIndex: this.props.openOnTargetFocus && isHoverInteractionKind ? tabIndex : undefined,
});
return React.createElement(targetTagName, targetProps, clonedTarget);
return (
<ResizeSensor onResize={this.handlePopoverResize}>
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 new and awesome and so simple. measures the target too!

<TagName {...targetProps}>{clonedTarget}</TagName>
</ResizeSensor>
);
};

// content and target can be specified as props or as children. this method
Expand Down Expand Up @@ -440,6 +429,8 @@ export class Popover extends AbstractPureComponent<IPopoverProps, IPopoverState>
}
};

private handlePopoverResize = () => Utils.safeInvoke(this.popperScheduleUpdate);

private handleOverlayClose = (e: React.SyntheticEvent<HTMLElement>) => {
const eventTarget = e.target as HTMLElement;
// if click was in target, target event listener will handle things, so don't close
Expand Down
27 changes: 27 additions & 0 deletions packages/core/src/components/resize-sensor/resize-sensor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
tag: new
---

@# Resize sensor

`ResizeSensor` is a higher-order component that effectively provides a
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: this is a little wordy and im not sure it's even correct. Maybe rephrase:

`ResizeSensor` is a component that provides a `"resize"` event for its single child.

But even then it's not "providing an event" in the sense of .addEventListener.

`"resize"` event for a single DOM element. It is a thin wrapper around
[`ResizeObserver`][resizeobserver] to provide React bindings.

[resizeobserver]: https://developers.google.com/web/updates/2016/10/resizeobserver

```tsx
import { IResizeEntry, ResizeSensor } from "@blueprintjs/core";

function handleResize(entries: IResizeEntry[]) {
console.log(entries.map(e => `${e.contentRect.width} x ${e.contentRect.height}`));
}

<ResizeSensor onChange={handleResize}>
<div style={{ width: this.props.width }} />
</ResizeSensor>
```

@## Props

@interface IResizeSensorProps
106 changes: 106 additions & 0 deletions packages/core/src/components/resize-sensor/resizeSensor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Copyright 2018 Palantir Technologies, Inc. All rights reserved.
*
* Licensed under the terms of the LICENSE file distributed with this project.
*/

import * as React from "react";
import ResizeObserver from "resize-observer-polyfill";

import { findDOMNode } from "react-dom";
import { DISPLAYNAME_PREFIX } from "../../common/props";
import { safeInvoke } from "../../common/utils";

/** A parallel type to `ResizeObserverEntry` (from resize-observer-polyfill). */
export interface IResizeEntry {
/** Measured dimensions of the target. */
contentRect: DOMRectReadOnly;

/** The resized element. */
target: Element;
}

/** `ResizeSensor` requires a single DOM element child and will error otherwise. */
export interface IResizeSensorProps {
/**
* Callback invoked when the wrapped element resizes.
*
* The `entries` array contains an entry for each observed element. In the
* default case (no `observeParents`), the array will contain only one
* element: the single child of the `ResizeSensor`.
*/
onResize: (entries: IResizeEntry[]) => void;

/**
* If `true`, all parent DOM elements of the container will also be
* observed for size changes. The array of entries passed to `onResize`
* will now contain an entry for each parent element up to the root of the
* document.
*
* Only enable this prop if a parent element resizes in a way that does
* not also cause the child element to resize.
* @default false
*/
observeParents?: boolean;
}

export class ResizeSensor extends React.PureComponent<IResizeSensorProps> {
public static displayName = `${DISPLAYNAME_PREFIX}.ResizeSensor`;

private element: Element | null = null;
private observer = new ResizeObserver(entries => safeInvoke(this.props.onResize, entries));

public render() {
// pass-through render of single child
return React.Children.only(this.props.children);
}

public componentDidMount() {
// using findDOMNode for two reasons:
// 1. cloning to insert a ref is unwieldy and not performant.
// 2. ensure that we get an actual DOM node for observing.
this.observeElement(findDOMNode(this));
}

public componentDidUpdate(prevProps: IResizeSensorProps) {
this.observeElement(findDOMNode(this), this.props.observeParents !== prevProps.observeParents);
}

public componentWillUnmount() {
this.observer.disconnect();
}

/**
* Observe the given element, if defined and different from the currently
* observed element. Pass `force` argument to skip element checks and always
* re-observe.
*/
private observeElement(element: Element | null, force = false) {
if (element == null) {
// stop everything if not defined
this.observer.disconnect();
return;
}

if (element === this.element && !force) {
// quit if given same element -- nothing to update (unless forced)
return;
} else {
// clear observer list if new element
this.observer.disconnect();
// remember element reference for next time
this.element = element;
}

// observer callback is invoked immediately when observing new elements
this.observer.observe(element);

if (this.props.observeParents) {
let parent = element.parentElement;
while (parent != null) {
this.observer.observe(parent);
parent = parent.parentElement;
}
}
}
}
1 change: 1 addition & 0 deletions packages/core/test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import "./popover/popoverTests";
import "./popover/popperUtilTests";
import "./portal/portalTests";
import "./progress/progressBarTests";
import "./resize-sensor/resizeSensorTests";
import "./slider/handleTests";
import "./slider/multiSliderTests";
import "./slider/rangeSliderTests";
Expand Down
7 changes: 4 additions & 3 deletions packages/core/test/isotest.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@ const customProps = {
Toaster: { usePortal: false },
};

const popoverTarget = React.createElement("button");
const requiredChild = React.createElement("button");
const customChildren = {
Hotkeys: React.createElement(Core.Hotkey, customProps.Hotkey),
Popover: popoverTarget,
Popover: requiredChild,
ResizeSensor: requiredChild,
Tabs: React.createElement(Core.Tab, { key: 1, id: 1, title: "Tab one" }),
Tooltip: popoverTarget,
Tooltip: requiredChild,
Toaster: React.createElement(Core.Toast, { message: "Toast" }),
};

Expand Down
Loading