Skip to content

Commit ddbf0dd

Browse files
LiJinyaoeddywashere
authored andcommitted
feat(Collapse): add Collapse component #79 (#201)
* init collapse * add collapse animation * add margin between toggle button and collapse * disable lint on force refresh DOM line. * add test to Collapse * remove height after shown * Revert "remove height after shown" This reverts commit eff9353. * remove height after shown. * add more test * use setState() to set inline height style * remove custom tag in doc * add inline style test * remove comment * set height to null when isOpen is true * add initial state test
1 parent 0ad0f98 commit ddbf0dd

8 files changed

Lines changed: 323 additions & 1 deletion

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/* eslint react/no-multi-comp: 0, react/prop-types: 0 */
2+
import React from 'react';
3+
import { PrismCode } from 'react-prism';
4+
import { Alert } from 'reactstrap';
5+
import Helmet from 'react-helmet';
6+
7+
import CollapseExample from '../examples/Collapse';
8+
const CollapseExampleSource = require('!!raw!../examples/Collapse');
9+
10+
export default class AlertsPage extends React.Component {
11+
render() {
12+
return (
13+
<div>
14+
<Helmet title="Collapse" />
15+
16+
<h3>Collapse</h3>
17+
<div className="docs-example">
18+
<CollapseExample />
19+
</div>
20+
<pre>
21+
<PrismCode className="language-jsx">
22+
{CollapseExampleSource}
23+
</PrismCode>
24+
</pre>
25+
26+
<h3>Properties</h3>
27+
<pre>
28+
<PrismCode className="language-jsx">
29+
{`Collapse.propTypes = {
30+
isOpen: PropTypes.bool,
31+
className: PropTypes.node
32+
}`}
33+
</PrismCode>
34+
</pre>
35+
</div>
36+
);
37+
}
38+
}

docs/lib/Components/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@ class Components extends React.Component {
108108
{
109109
name: 'Alerts',
110110
to: '/components/alerts/'
111+
},
112+
{
113+
name: 'Collapse',
114+
to: '/components/collapse/',
111115
}
112116
]
113117
};

docs/lib/examples/Collapse.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import React, { Component } from 'react';
2+
import { Collapse, Button, CardBlock, Card } from 'reactstrap';
3+
4+
class Example extends Component {
5+
constructor(props) {
6+
super(props);
7+
this.toggle = this.toggle.bind(this);
8+
this.state = { collapse: false };
9+
}
10+
11+
toggle() {
12+
this.setState({ collapse: !this.state.collapse });
13+
}
14+
15+
render() {
16+
return (
17+
<div>
18+
<Button color="primary" onClick={this.toggle} style={{ marginBottom: '1rem' }}>Toggle</Button>
19+
<Collapse isOpen={this.state.collapse}>
20+
<Card>
21+
<CardBlock>
22+
Anim pariatur cliche reprehenderit,
23+
enim eiusmod high life accusamus terry richardson ad squid. Nihil
24+
anim keffiyeh helvetica, craft beer labore wes anderson cred
25+
nesciunt sapiente ea proident.
26+
</CardBlock>
27+
</Card>
28+
</Collapse>
29+
</div>
30+
);
31+
}
32+
}
33+
34+
export default Example;

docs/lib/routes.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import PaginationPage from './Components/PaginationPage';
2323
import TabsPage from './Components/TabsPage';
2424
import JumbotronPage from './Components/JumbotronPage';
2525
import AlertsPage from './Components/AlertsPage';
26+
import CollapsePage from './Components/CollapsePage';
2627
import NotFound from './NotFound';
2728
import Components from './Components';
2829
import UI from './UI';
@@ -54,6 +55,7 @@ const routes = (
5455
<Route path="tabs/" component={TabsPage} />
5556
<Route path="alerts/" component={AlertsPage} />
5657
<Route path="jumbotron/" component={JumbotronPage} />
58+
<Route path="collapse/" component={CollapsePage} />
5759
</Route>
5860
<Route path="*" component={NotFound} />
5961
</Route>

src/Collapse.js

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import React, { Component, PropTypes } from 'react';
2+
import classNames from 'classnames';
3+
import omit from 'lodash.omit';
4+
5+
const SHOW = 'SHOW';
6+
const SHOWN = 'SHOWN';
7+
const HIDE = 'HIDE';
8+
const HIDDEN = 'HIDDEN';
9+
10+
const propTypes = {
11+
isOpen: PropTypes.bool,
12+
className: PropTypes.node,
13+
tag: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
14+
};
15+
16+
const defaultProps = {
17+
isOpen: false,
18+
tag: 'div'
19+
};
20+
21+
class Collapse extends Component {
22+
constructor(props) {
23+
super(props);
24+
this.state = {
25+
collapse: props.isOpen ? SHOWN : HIDDEN,
26+
height: props.isOpen ? null : 0
27+
};
28+
this.element = null;
29+
}
30+
31+
componentWillReceiveProps(nextProps) {
32+
const willOpen = nextProps.isOpen;
33+
const collapse = this.state.collapse;
34+
35+
if (willOpen && collapse === HIDDEN) {
36+
// will open
37+
this.setState({ collapse: SHOW }, () => {
38+
// the height transition will work after class "collapsing" applied
39+
this.setState({ height: this.getHeight() });
40+
this.transitionTag = setTimeout(() => {
41+
this.setState({
42+
collapse: SHOWN,
43+
height: null
44+
});
45+
}, 350);
46+
});
47+
} else if (!willOpen && collapse === SHOWN) {
48+
// will hide
49+
this.setState({ height: this.getHeight() }, () => {
50+
this.setState({
51+
collapse: HIDE,
52+
height: this.getHeight()
53+
}, () => {
54+
this.setState({ height: 0 });
55+
});
56+
});
57+
58+
this.transitionTag = setTimeout(() => {
59+
this.setState({
60+
collapse: HIDDEN,
61+
height: null
62+
});
63+
}, 350);
64+
}
65+
// else: do nothing.
66+
}
67+
68+
componentWillUnmount() {
69+
clearTimeout(this.transitionTag);
70+
}
71+
72+
getHeight() {
73+
return this.element.scrollHeight;
74+
}
75+
76+
render() {
77+
const {
78+
className,
79+
tag: Tag,
80+
...attributes
81+
} = omit(this.props, ['isOpen']);
82+
const { collapse, height } = this.state;
83+
let collapseClass;
84+
switch (collapse) {
85+
case SHOW:
86+
collapseClass = 'collapsing';
87+
break;
88+
case SHOWN:
89+
collapseClass = 'collapse in';
90+
break;
91+
case HIDE:
92+
collapseClass = 'collapsing';
93+
break;
94+
case HIDDEN:
95+
collapseClass = 'collapse';
96+
break;
97+
default:
98+
// HIDDEN
99+
collapseClass = 'collapse';
100+
}
101+
102+
const classes = classNames(
103+
className,
104+
collapseClass
105+
);
106+
const style = height === null ? null : { height };
107+
return (
108+
<Tag
109+
{...attributes}
110+
style={{ ...attributes.style, ...style }}
111+
className={classes}
112+
ref={(c) => { this.element = c; }}
113+
/>
114+
);
115+
}
116+
}
117+
118+
Collapse.propTypes = propTypes;
119+
Collapse.defaultProps = defaultProps;
120+
export default Collapse;

src/__tests__/Collapse.spec.js

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import React from 'react';
2+
import { shallow, mount } from 'enzyme';
3+
import Collapse from '../Collapse';
4+
5+
describe('Collapse', () => {
6+
let isOpen;
7+
let toggle;
8+
9+
beforeEach(() => {
10+
isOpen = false;
11+
toggle = () => { isOpen = !isOpen; };
12+
jasmine.clock().install();
13+
});
14+
15+
afterEach(() => {
16+
// fast forward time for collapse to fade out
17+
jasmine.clock().tick(400);
18+
jasmine.clock().uninstall();
19+
});
20+
21+
it('should render children', () => {
22+
const wrapper = shallow(<Collapse><p>hello</p></Collapse>).find('p');
23+
expect(wrapper.text()).toBe('hello');
24+
});
25+
26+
it('should have default isOpen value', () => {
27+
const wrapper = shallow(<Collapse />);
28+
expect(wrapper.instance().props.isOpen).toEqual(false);
29+
});
30+
31+
it('should render with class "collapse"', () => {
32+
const wrapper = shallow(<Collapse />);
33+
expect(wrapper.hasClass('collapse')).toEqual(true);
34+
});
35+
36+
it('should render with class "in" when isOpen is true', () => {
37+
const wrapper = shallow(<Collapse isOpen />);
38+
expect(wrapper.hasClass('in')).toEqual(true);
39+
});
40+
41+
it('should set height to null when isOpen is true', () => {
42+
isOpen = true;
43+
const wrapper = shallow(<Collapse isOpen={isOpen} />);
44+
expect(wrapper.state('height')).toBe(null);
45+
});
46+
47+
it('should set height to 0 when isOpen is false', () => {
48+
const wrapper = shallow(<Collapse isOpen={isOpen} />);
49+
expect(wrapper.state('height')).toBe(0);
50+
});
51+
52+
it('should render with class "collapse" with default collapse state', () => {
53+
const wrapper = mount(<Collapse isOpen={isOpen} />);
54+
wrapper.setState({ collapse: null });
55+
jasmine.clock().tick(360);
56+
wrapper.update();
57+
expect(wrapper.find('.collapse').length).toBe(1);
58+
wrapper.unmount();
59+
});
60+
61+
it('should change state with { collapse: ${State} } when isOpen change to true before transition', () => {
62+
const wrapper = mount(<Collapse isOpen={isOpen} />);
63+
toggle();
64+
wrapper.setProps({ isOpen: isOpen });
65+
expect(wrapper.state('collapse')).toEqual('SHOW');
66+
wrapper.unmount();
67+
});
68+
69+
it('should change state with { collapse: ${State} } when isOpen change to true after transition', () => {
70+
const wrapper = mount(<Collapse isOpen={isOpen} />);
71+
toggle();
72+
wrapper.setProps({ isOpen: isOpen });
73+
jasmine.clock().tick(350);
74+
expect(wrapper.state('collapse')).toEqual('SHOWN');
75+
wrapper.unmount();
76+
});
77+
78+
it('should change state with { collapse: ${State} } when isOpen change to false before transition', () => {
79+
isOpen = true;
80+
const wrapper = mount(<Collapse isOpen={isOpen} />);
81+
toggle();
82+
wrapper.setProps({ isOpen: isOpen });
83+
expect(wrapper.state('collapse')).toEqual('HIDE');
84+
wrapper.unmount();
85+
});
86+
87+
it('should change state with { collapse: ${State} } when isOpen change to false after transition', () => {
88+
isOpen = true;
89+
const wrapper = mount(<Collapse isOpen={isOpen} />);
90+
toggle();
91+
wrapper.setProps({ isOpen: isOpen });
92+
jasmine.clock().tick(360);
93+
expect(wrapper.state('collapse')).toEqual('HIDDEN');
94+
wrapper.unmount();
95+
});
96+
97+
it('should set inline style to 0 when isOpen change to false', () => {
98+
isOpen = true;
99+
const wrapper = mount(<Collapse isOpen={isOpen} />);
100+
toggle();
101+
wrapper.setProps({ isOpen: isOpen });
102+
expect(wrapper.state('height')).toBe(0);
103+
wrapper.unmount();
104+
});
105+
106+
it('should remove inline style when isOpen change to true after transition', () => {
107+
const wrapper = mount(<Collapse isOpen={isOpen} />);
108+
toggle();
109+
wrapper.setProps({ isOpen: isOpen });
110+
jasmine.clock().tick(380);
111+
expect(wrapper.state('height')).toBe(null);
112+
wrapper.unmount();
113+
});
114+
115+
it('should remove timeout tag after unmount', () => {
116+
spyOn(Collapse.prototype, 'componentWillUnmount').and.callThrough();
117+
const wrapper = mount(<Collapse isOpen={isOpen} />);
118+
wrapper.unmount();
119+
expect(Collapse.prototype.componentWillUnmount).toHaveBeenCalled();
120+
});
121+
});

src/index.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import TabContent from './TabContent';
6262
import TabPane from './TabPane';
6363
import Jumbotron from './Jumbotron';
6464
import Alert from './Alert';
65+
import Collapse from './Collapse';
6566

6667
export {
6768
Alert,
@@ -127,5 +128,6 @@ export {
127128
PaginationLink,
128129
TabContent,
129130
TabPane,
130-
Jumbotron
131+
Jumbotron,
132+
Collapse
131133
};

webpack.dev.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ var paths = [
3535
'/components/tabs/',
3636
'/components/jumbotron/',
3737
'/components/alerts/',
38+
'/components/collapse/',
3839
'/404.html'
3940
];
4041

0 commit comments

Comments
 (0)