summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2017-12-19 10:31:24 +0100
committerAndrej Mihajlov <and@mullvad.net>2017-12-19 10:31:24 +0100
commit383a48638198accef99e987cd3297283ed25398b (patch)
tree4e1f1c3e0b6421d36d2c6ab159f9adc1b4e33d5d
parentf5d2f70b62f435f85f1f3bc1ded25e101d145442 (diff)
parentd2a472b0aee0822277c1b9ae3b6bba18beb51697 (diff)
downloadmullvadvpn-383a48638198accef99e987cd3297283ed25398b.tar.xz
mullvadvpn-383a48638198accef99e987cd3297283ed25398b.zip
Merge branch 'add-accordion'
-rw-r--r--.eslintrc6
-rw-r--r--app/components/Accordion.js146
-rw-r--r--test/components/Accordion.spec.js96
3 files changed, 247 insertions, 1 deletions
diff --git a/.eslintrc b/.eslintrc
index 35c8fb9bbb..0349c65939 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -25,7 +25,11 @@
"comma-dangle": "off",
"comma-spacing": "warn",
"no-unused-expressions" : "off", // until fixed https://github.com/babel/babel-eslint/issues/158
- "no-unused-vars": ["error", {"args": "all", "argsIgnorePattern": "_.*"}],
+ "no-unused-vars": ["error", {
+ "args": "all",
+ "argsIgnorePattern": "_.*",
+ "varsIgnorePattern": "_.*"
+ }],
"block-scoped-var": "off", // until fixed https://github.com/eslint/eslint/issues/2253
"react/prop-types": "off",
"flowtype/define-flow-type": "warn",
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..2357e27dfc
--- /dev/null
+++ b/test/components/Accordion.spec.js
@@ -0,0 +1,96 @@
+// @flow
+/* eslint react/no-find-dom-node: off */
+
+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