import classNames from "classnames"; import * as React from "react"; import { AbstractPureComponent } from "../../common/abstractPureComponent"; import * as Classes from "../../common/classes"; import { DISPLAYNAME_PREFIX, IProps } from "../../common/props"; export interface ICollapseProps extends IProps { /** * Component to render as the root element. * Useful when rendering a `Collapse` inside a ``, for instance. * @default "div" */ component?: React.ReactType; /** * 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 height that should be used for the content animations. This is a CSS value, not just a number. */ height: string; /** The state the element is currently in. */ animationState: AnimationStates; } export enum AnimationStates { CLOSED, OPENING, OPEN, CLOSING_START, CLOSING_END, } /* * A collapse can be in one of 5 states: * CLOSED * When in this state, 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). * * OPEN * When in this state, the collapse height is set to auto, and the body Y is set to 0 (so the element can be seen * as normal). * * CLOSING_START * When in this state, height has been changed from auto to the measured height of the body to prepare for the * closing animation in CLOSING_END. * * CLOSING_END * When in this state, the 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. * * OPENING * When in this state, the body is re-rendered, height is set to the measured body height and the body Y is set to 0. * This is all animated, and on complete, the state changes to OPEN. * * When changing the isOpen prop, the following happens to the states: * isOpen = true : CLOSED -> OPENING -> OPEN * isOpen = false: OPEN -> CLOSING_START -> CLOSING_END -> CLOSED * These are all animated. */ export class Collapse extends AbstractPureComponent { public static displayName = `${DISPLAYNAME_PREFIX}.Collapse`; public static defaultProps: ICollapseProps = { component: "div", isOpen: false, keepChildrenMounted: false, transitionDuration: 200, }; public state = { animationState: this.props.isOpen ? AnimationStates.OPEN : AnimationStates.CLOSED, height: "0px", }; // The element containing the contents of the collapse. private contents: HTMLElement; // The most recent non-0 height (once a height has been measured - is 0 until then) private height: number = 0; public componentWillReceiveProps(nextProps: ICollapseProps) { if (this.contents != null && this.contents.clientHeight !== 0) { this.height = this.contents.clientHeight; } if (this.props.isOpen !== nextProps.isOpen) { this.clearTimeouts(); if (this.state.animationState !== AnimationStates.CLOSED && !nextProps.isOpen) { this.setState({ animationState: AnimationStates.CLOSING_START, height: `${this.height}px`, }); } else if (this.state.animationState !== AnimationStates.OPEN && nextProps.isOpen) { this.setState({ animationState: AnimationStates.OPENING, height: `${this.height}px`, }); this.setTimeout(() => this.onDelayedStateChange(), this.props.transitionDuration); } } } public render() { const isContentVisible = this.state.animationState !== AnimationStates.CLOSED; const shouldRenderChildren = isContentVisible || this.props.keepChildrenMounted; const displayWithTransform = isContentVisible && this.state.animationState !== AnimationStates.CLOSING_END; const isAutoHeight = this.state.height === "auto"; const containerStyle = { height: isContentVisible ? this.state.height : undefined, overflowY: (isAutoHeight ? "visible" : undefined) as "visible" | undefined, transition: isAutoHeight ? "none" : undefined, }; const contentsStyle = { transform: displayWithTransform ? "translateY(0)" : `translateY(-${this.height}px)`, 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(); if (this.props.isOpen) { this.setState({ animationState: AnimationStates.OPEN, height: "auto" }); } else { this.setState({ animationState: AnimationStates.CLOSED }); } } public componentDidUpdate() { if (this.state.animationState === AnimationStates.CLOSING_START) { this.setTimeout(() => this.setState({ animationState: AnimationStates.CLOSING_END, height: "0px", }), ); this.setTimeout(() => this.onDelayedStateChange(), this.props.transitionDuration); } } private contentsRefHandler = (el: HTMLElement) => { this.contents = el; if (el != null) { this.height = this.contents.clientHeight; this.setState({ animationState: this.props.isOpen ? AnimationStates.OPEN : AnimationStates.CLOSED, height: `${this.height}px`, }); } }; private onDelayedStateChange() { switch (this.state.animationState) { case AnimationStates.OPENING: this.setState({ animationState: AnimationStates.OPEN, height: "auto" }); break; case AnimationStates.CLOSING_END: this.setState({ animationState: AnimationStates.CLOSED }); break; default: break; } } }