summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2017-12-13 14:55:19 +0100
committerAndrej Mihajlov <and@mullvad.net>2017-12-18 18:23:22 +0100
commit5709f4834b639b11d70797154df7eeb4fb523483 (patch)
tree27827c52d9a78ffbcc65e27424149aa8a3d56c21
parentf5d2f70b62f435f85f1f3bc1ded25e101d145442 (diff)
downloadmullvadvpn-5709f4834b639b11d70797154df7eeb4fb523483.tar.xz
mullvadvpn-5709f4834b639b11d70797154df7eeb4fb523483.zip
Add Accordion
-rw-r--r--app/components/Accordion.js146
-rw-r--r--test/components/Accordion.spec.js95
2 files changed, 241 insertions, 0 deletions
diff --git a/app/components/Accordion.js b/app/components/Accordion.js
new file mode 100644
index 0000000000..456a95fe05
--- /dev/null
+++ b/app/components/Accordion.js
@@ -0,0 +1,146 @@
+// @flow
+
+import React, { Component } from 'react';
+
+export type AccordionProps = {
+ height?: number | string,
+ transitionStyle?: string,
+ children?: Array<React.Element<*>> | React.Element<*> // see https://github.com/facebook/flow/issues/1964
+};
+
+export type AccordionState = {
+ computedHeight: ?number | ?string,
+};
+
+export default class Accordion extends Component {
+ props: AccordionProps;
+ static defaultProps: $Shape<AccordionProps> = {
+ height: 'auto',
+ transitionStyle: 'height 0.25s ease-in-out'
+ };
+
+ state: AccordionState = {
+ computedHeight: null,
+ };
+
+ _containerElement: ?HTMLElement;
+ _contentElement: ?HTMLElement;
+
+ componentDidMount() {
+ const containerElement = this._containerElement;
+ if(!containerElement) {
+ throw new Error('containerElement cannot be null');
+ }
+
+ // update initial state
+ if(this.props.height !== Accordion.defaultProps.height) {
+ this._updateHeight();
+ }
+
+ containerElement.addEventListener('transitionend', this._onTransitionEnd);
+ }
+
+ componentWillUnmount() {
+ const containerElement = this._containerElement;
+ if(!containerElement) {
+ throw new Error('containerElement cannot be null');
+ }
+ containerElement.removeEventListener('transitionend', this._onTransitionEnd);
+ }
+
+ componentDidUpdate(prevProps: AccordionProps, _prevState: AccordionState) {
+ if(prevProps.height !== this.props.height) {
+ (async () => {
+ const { transitionStyle } = this.props;
+
+ // make sure to warm up CSS transition before updating height
+ // do not warm up transitions if they are not expected to run
+ if(transitionStyle && transitionStyle.toLowerCase() !== 'none') {
+ await this._warmupTransition();
+ this._updateHeight();
+ } else {
+ this._updateHeight();
+ this._onTransitionEnd();
+ }
+
+ })();
+ }
+ }
+
+ render() {
+ const { height: _height, children, transitionStyle, ...otherProps } = this.props;
+ let style = {
+ transition: transitionStyle,
+ };
+
+ if(typeof(this.state.computedHeight) === 'number') {
+ style = {
+ ...style,
+ overflow: 'hidden',
+ height: this.state.computedHeight.toString() + 'px',
+ };
+ }
+
+ return (
+ <div { ...otherProps } style={ style } ref={ this._onContainerRef }>
+ <div ref={ this._onContentRef }>
+ { children }
+ </div>
+ </div>
+ );
+ }
+
+ // Sets initial height and delays transition until next runloop
+ // to make sure CSS transitions properly kick in.
+ // This method resolves immediately if the height is already set.
+ _warmupTransition() {
+ const contentElement = this._contentElement;
+ if(!contentElement) {
+ throw new Error('contentElement cannot be null');
+ }
+ return new Promise((resolve, _) => {
+ // CSS transition always needs the initial height
+ // to perform the animation
+ if(this.state.computedHeight === null) {
+ this.setState({
+ computedHeight: contentElement.clientHeight
+ }, () => {
+ // important to skip a run loop
+ // for CSS transition to kick in
+ setTimeout(resolve, 0);
+ });
+ } else {
+ resolve();
+ }
+ });
+ }
+
+ _updateHeight() {
+ const contentElement = this._contentElement;
+ if(!contentElement) {
+ throw new Error('contentElement cannot be null');
+ }
+ this.setState({
+ computedHeight: this.props.height === 'auto' ?
+ contentElement.clientHeight :
+ this.props.height
+ });
+ }
+
+ _onTransitionEnd = () => {
+ // reset height after transition to let element layout naturally
+ if(this.props.height === 'auto') {
+ this.setState({
+ computedHeight: null,
+ });
+ }
+ }
+
+ _onContainerRef = (element) => {
+ this._containerElement = element;
+ }
+
+ _onContentRef = (element) => {
+ this._contentElement = element;
+ }
+} \ No newline at end of file
diff --git a/test/components/Accordion.spec.js b/test/components/Accordion.spec.js
new file mode 100644
index 0000000000..0a5e6a857f
--- /dev/null
+++ b/test/components/Accordion.spec.js
@@ -0,0 +1,95 @@
+// @flow
+
+import { expect } from 'chai';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import Accordion from '../../app/components/Accordion';
+
+import type { AccordionProps } from '../../app/components/Accordion';
+
+describe('components/Accordion', () => {
+
+ let container: ?HTMLElement;
+
+ function renderIntoDocument(instance: React.Element<AccordionProps>) {
+ if(!container) {
+ container = document.createElement('div');
+ if(!document.documentElement) {
+ throw new Error('document.documentElement cannot be null.');
+ }
+ document.documentElement.appendChild(container);
+ }
+ return ReactDOM.render(instance, container);
+ }
+
+ // unmount container and clean up DOM
+ afterEach(() => {
+ if(container) {
+ ReactDOM.unmountComponentAtNode(container);
+ container = null;
+ }
+ });
+
+ it('should be collapsed upon mount', () => {
+ const component = renderIntoDocument(
+ <Accordion height={ 0 }>
+ <div style={{ height: 100 }}></div>
+ </Accordion>
+ );
+ const domNode = ReactDOM.findDOMNode(component);
+ expect(domNode).to.have.property('clientHeight', 0);
+ });
+
+ it('should be expanded to provided height upon mount', () => {
+ const component = renderIntoDocument(
+ <Accordion height={ 100 } />
+ );
+ const domNode = ReactDOM.findDOMNode(component);
+ expect(domNode).to.have.property('clientHeight', 100);
+ });
+
+ it('should be expanded using layout upon mount', () => {
+ const component = renderIntoDocument(
+ <Accordion height={ 'auto' }>
+ <div style={{ height: 100 }}></div>
+ </Accordion>
+ );
+ const domNode = ReactDOM.findDOMNode(component);
+ expect(domNode).to.have.property('clientHeight', 100);
+ });
+
+ it('should collapse', () => {
+ const component = renderIntoDocument(
+ <Accordion height={ 'auto' }>
+ <div style={{ height: 100 }}></div>
+ </Accordion>
+ );
+
+ renderIntoDocument(
+ <Accordion height={ 0 } transitionStyle="none">
+ <div style={{ height: 100 }}></div>
+ </Accordion>
+ );
+
+ const domNode = ReactDOM.findDOMNode(component);
+ expect(domNode).to.have.property('clientHeight', 0);
+ });
+
+ it('should expand', () => {
+ const component = renderIntoDocument(
+ <Accordion height={ 0 }>
+ <div style={{ height: 100 }}></div>
+ </Accordion>
+ );
+
+ renderIntoDocument(
+ <Accordion height="auto" transitionStyle="none">
+ <div style={{ height: 100 }}></div>
+ </Accordion>
+ );
+
+ const domNode = ReactDOM.findDOMNode(component);
+ expect(domNode).to.have.property('clientHeight', 100);
+ });
+
+}); \ No newline at end of file