/*
* Copyright 2015 Palantir Technologies, Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import classNames from "classnames";
import * as React from "react";
import { polyfill } from "react-lifecycles-compat";
import { AbstractPureComponent2, Classes } from "../../common";
import { DISPLAYNAME_PREFIX, Props } from "../../common/props";
// eslint-disable-next-line deprecation/deprecation
export type CollapseProps = ICollapseProps;
/** @deprecated use CollapseProps */
export interface ICollapseProps extends Props {
/**
* Component to render as the root element.
* Useful when rendering a `Collapse` inside a `
`, for instance.
*
* @default "div"
*/
component?: React.ElementType;
/**
* Whether the component is open or closed.
*
* @default false
*/
isOpen?: boolean;
/**
* Whether the child components will remain mounted when the `Collapse` is closed.
* Setting to true may improve performance by avoiding re-mounting children.
*
* @default false
*/
keepChildrenMounted?: boolean;
/**
* The length of time the transition takes, in milliseconds. This must match
* the duration of the animation in CSS. Only set this prop if you override
* Blueprint's default transitions with new transitions of a different
* length.
*
* @default 200
*/
transitionDuration?: number;
}
export interface ICollapseState {
/** The state the element is currently in. */
animationState: AnimationStates;
/** The height that should be used for the content animations. This is a CSS value, not just a number. */
height: string | undefined;
/**
* The most recent non-zero height (once a height has been measured upon first open; it is undefined until then)
*/
heightWhenOpen: number | undefined;
}
/**
* `Collapse` can be in one of six states, enumerated here.
* When changing the `isOpen` prop, the following happens to the states:
* isOpen={true} : CLOSED -> OPEN_START -> OPENING -> OPEN
* isOpen={false} : OPEN -> CLOSING_START -> CLOSING -> CLOSED
*/
export enum AnimationStates {
/**
* The body is re-rendered, height is set to the measured body height and
* the body Y is set to 0.
*/
OPEN_START,
/**
* Animation begins, height is set to auto. This is all animated, and on
* complete, the state changes to OPEN.
*/
OPENING,
/**
* The collapse height is set to auto, and the body Y is set to 0 (so the
* element can be seen as normal).
*/
OPEN,
/**
* Height has been changed from auto to the measured height of the body to
* prepare for the closing animation in CLOSING.
*/
CLOSING_START,
/**
* Height is set to 0 and the body Y is at -height. Both of these properties
* are transformed, and then after the animation is complete, the state
* changes to CLOSED.
*/
CLOSING,
/**
* The contents of the collapse is not rendered, the collapse height is 0,
* and the body Y is at -height (so that the bottom of the body is at Y=0).
*/
CLOSED,
}
@polyfill
export class Collapse extends AbstractPureComponent2 {
public static displayName = `${DISPLAYNAME_PREFIX}.Collapse`;
public static defaultProps: CollapseProps = {
component: "div",
isOpen: false,
keepChildrenMounted: false,
transitionDuration: 200,
};
public static getDerivedStateFromProps(props: CollapseProps, state: ICollapseState) {
const { isOpen } = props;
const { animationState } = state;
if (isOpen) {
switch (animationState) {
case AnimationStates.OPEN:
// no-op
break;
case AnimationStates.OPENING:
// allow Collapse#onDelayedStateChange() to handle the transition here
break;
default:
return { animationState: AnimationStates.OPEN_START };
}
} else {
switch (animationState) {
case AnimationStates.CLOSED:
// no-op
break;
case AnimationStates.CLOSING:
// allow Collapse#onDelayedStateChange() to handle the transition here
break;
default:
// need to set an explicit height so that transition can work
return {
animationState: AnimationStates.CLOSING_START,
height: `${state.heightWhenOpen}px`,
};
}
}
return null;
}
public state: ICollapseState = {
animationState: this.props.isOpen ? AnimationStates.OPEN : AnimationStates.CLOSED,
height: undefined,
heightWhenOpen: undefined,
};
// The element containing the contents of the collapse.
private contents: HTMLElement | null = null;
public render() {
const isContentVisible = this.state.animationState !== AnimationStates.CLOSED;
const shouldRenderChildren = isContentVisible || this.props.keepChildrenMounted;
const displayWithTransform = isContentVisible && this.state.animationState !== AnimationStates.CLOSING;
const isAutoHeight = this.state.height === "auto";
const containerStyle = {
height: isContentVisible ? this.state.height : undefined,
overflowY: isAutoHeight ? "visible" : undefined,
// transitions don't work with height: auto
transition: isAutoHeight ? "none" : undefined,
};
const contentsStyle = {
// only use heightWhenOpen while closing
transform: displayWithTransform ? "translateY(0)" : `translateY(-${this.state.heightWhenOpen}px)`,
// transitions don't work with height: auto
transition: isAutoHeight ? "none" : undefined,
};
return React.createElement(
this.props.component!,
{
className: classNames(Classes.COLLAPSE, this.props.className),
style: containerStyle,
},
{shouldRenderChildren ? this.props.children : null}
,
);
}
public componentDidMount() {
this.forceUpdate();
// HACKHACK: this should probably be done in getSnapshotBeforeUpdate
/* eslint-disable react/no-did-mount-set-state */
if (this.props.isOpen) {
this.setState({ animationState: AnimationStates.OPEN, height: "auto" });
} else {
this.setState({ animationState: AnimationStates.CLOSED, height: "0px" });
}
/* eslint-disable react/no-did-mount-set-state */
}
public componentDidUpdate() {
if (this.contents == null) {
return;
}
const { transitionDuration } = this.props;
const { animationState } = this.state;
if (animationState === AnimationStates.OPEN_START) {
const { clientHeight } = this.contents;
this.setState({
animationState: AnimationStates.OPENING,
height: `${clientHeight}px`,
heightWhenOpen: clientHeight,
});
this.setTimeout(() => this.onDelayedStateChange(), transitionDuration);
} else if (animationState === AnimationStates.CLOSING_START) {
const { clientHeight } = this.contents;
this.setTimeout(() =>
this.setState({
animationState: AnimationStates.CLOSING,
height: "0px",
heightWhenOpen: clientHeight,
}),
);
this.setTimeout(() => this.onDelayedStateChange(), transitionDuration);
}
}
private contentsRefHandler = (el: HTMLElement | null) => {
this.contents = el;
if (this.contents != null) {
const height = this.contents.clientHeight;
this.setState({
animationState: this.props.isOpen ? AnimationStates.OPEN : AnimationStates.CLOSED,
height: height === 0 ? undefined : `${height}px`,
heightWhenOpen: height === 0 ? undefined : height,
});
}
};
private onDelayedStateChange() {
switch (this.state.animationState) {
case AnimationStates.OPENING:
this.setState({ animationState: AnimationStates.OPEN, height: "auto" });
break;
case AnimationStates.CLOSING:
this.setState({ animationState: AnimationStates.CLOSED });
break;
default:
break;
}
}
}