Skip to content

Commit

Permalink
[new] ResizeSensor component (#2745)
Browse files Browse the repository at this point in the history
* ResizeSensor is a React wrapper for ResizeObserver

* use ResizeSensor in OverflowList & Popover

* export component

* enable Promise in tests

* add test suite

* re-observe DOM node in DidUpdate (if necessary)

* don't expose external types

* allow changing props 👍

* add docs page

* fix isotest

* docs per comments

* docs cleanup
  • Loading branch information
giladgray authored Aug 1, 2018
1 parent 5175c10 commit cf37dea
Show file tree
Hide file tree
Showing 10 changed files with 293 additions and 72 deletions.
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}>
<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
35 changes: 35 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,35 @@
---
tag: new
---

@# Resize sensor

`ResizeSensor` is a component that provides a `"resize"` event for its single
DOM element child. 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

<div class="@ns-callout @ns-intent-warning @ns-icon-warning-sign">
<h4 class="@ns-heading">Asynchronous behavior</h4>
The `onResize` callback is invoked asynchronously after a resize is detected
and typically happens at the end of a frame (after layout, before paint).
Therefore, testing behavior that relies on this component involves setting a
timeout for the next frame.
</div>

@interface IResizeSensorProps
109 changes: 109 additions & 0 deletions packages/core/src/components/resize-sensor/resizeSensor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
* 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`.
*
* Note that this method is called _asynchronously_ after a resize is
* detected and typically it will be called no more than once per frame.
*/
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

1 comment on commit cf37dea

@blueprint-bot
Copy link

Choose a reason for hiding this comment

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

[new] ResizeSensor component (#2745)

Preview: documentation | landing | table

Please sign in to comment.