Skip to content

Commit e20d070

Browse files
authored
refactor(Collapse): Rewrite Collapse component as functional component (react-bootstrap#5210)
1 parent 2e96fff commit e20d070

File tree

4 files changed

+85
-82
lines changed

4 files changed

+85
-82
lines changed

src/Collapse.js

Lines changed: 63 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import classNames from 'classnames';
22
import css from 'dom-helpers/css';
33
import transitionEnd from 'dom-helpers/transitionEnd';
44
import PropTypes from 'prop-types';
5-
import React from 'react';
5+
import React, { useMemo } from 'react';
66
import Transition, {
77
ENTERED,
88
ENTERING,
@@ -17,7 +17,7 @@ const MARGINS = {
1717
width: ['marginLeft', 'marginRight'],
1818
};
1919

20-
function getDimensionValue(dimension, elem) {
20+
function getDefaultDimensionValue(dimension, elem) {
2121
let offset = `offset${dimension[0].toUpperCase()}${dimension.slice(1)}`;
2222
let value = elem[offset];
2323
let margins = MARGINS[dimension];
@@ -125,78 +125,81 @@ const defaultProps = {
125125
mountOnEnter: false,
126126
unmountOnExit: false,
127127
appear: false,
128-
129128
dimension: 'height',
130-
getDimensionValue,
129+
getDimensionValue: getDefaultDimensionValue,
131130
};
132131

133-
class Collapse extends React.Component {
134-
getDimension() {
135-
return typeof this.props.dimension === 'function'
136-
? this.props.dimension()
137-
: this.props.dimension;
138-
}
139-
140-
/* -- Expanding -- */
141-
handleEnter = (elem) => {
142-
elem.style[this.getDimension()] = '0';
143-
};
144-
145-
handleEntering = (elem) => {
146-
const dimension = this.getDimension();
147-
elem.style[dimension] = this._getScrollDimensionValue(elem, dimension);
148-
};
149-
150-
handleEntered = (elem) => {
151-
elem.style[this.getDimension()] = null;
152-
};
153-
154-
/* -- Collapsing -- */
155-
handleExit = (elem) => {
156-
const dimension = this.getDimension();
157-
elem.style[dimension] = `${this.props.getDimensionValue(
158-
dimension,
159-
elem,
160-
)}px`;
161-
triggerBrowserReflow(elem);
162-
};
163-
164-
handleExiting = (elem) => {
165-
elem.style[this.getDimension()] = null;
166-
};
167-
168-
// for testing
169-
_getScrollDimensionValue(elem, dimension) {
170-
const scroll = `scroll${dimension[0].toUpperCase()}${dimension.slice(1)}`;
171-
return `${elem[scroll]}px`;
172-
}
173-
174-
render() {
175-
const {
132+
const Collapse = React.forwardRef(
133+
(
134+
{
176135
onEnter,
177136
onEntering,
178137
onEntered,
179138
onExit,
180139
onExiting,
181140
className,
182141
children,
142+
dimension,
143+
getDimensionValue,
183144
...props
184-
} = this.props;
145+
},
146+
ref,
147+
) => {
148+
/* Compute dimension */
149+
const computedDimension =
150+
typeof dimension === 'function' ? dimension() : dimension;
151+
152+
/* -- Expanding -- */
153+
const handleEnter = useMemo(
154+
() =>
155+
createChainedFunction((elem) => {
156+
elem.style[computedDimension] = '0';
157+
}, onEnter),
158+
[computedDimension, onEnter],
159+
);
160+
161+
const handleEntering = useMemo(
162+
() =>
163+
createChainedFunction((elem) => {
164+
const scroll = `scroll${computedDimension[0].toUpperCase()}${computedDimension.slice(
165+
1,
166+
)}`;
167+
elem.style[dimension] = `${elem[scroll]}px`;
168+
}, onEntering),
169+
[computedDimension, onEntering, dimension],
170+
);
185171

186-
delete props.dimension;
187-
delete props.getDimensionValue;
172+
const handleEntered = useMemo(
173+
() =>
174+
createChainedFunction((elem) => {
175+
elem.style[computedDimension] = null;
176+
}, onEntered),
177+
[computedDimension, onEntered],
178+
);
188179

189-
const handleEnter = createChainedFunction(this.handleEnter, onEnter);
190-
const handleEntering = createChainedFunction(
191-
this.handleEntering,
192-
onEntering,
180+
/* -- Collapsing -- */
181+
const handleExit = useMemo(
182+
() =>
183+
createChainedFunction((elem) => {
184+
elem.style[dimension] = `${getDimensionValue(
185+
computedDimension,
186+
elem,
187+
)}px`;
188+
triggerBrowserReflow(elem);
189+
}, onExit),
190+
[dimension, onExit, getDimensionValue, computedDimension],
191+
);
192+
const handleExiting = useMemo(
193+
() =>
194+
createChainedFunction((elem) => {
195+
elem.style[computedDimension] = null;
196+
}, onExiting),
197+
[computedDimension, onExiting],
193198
);
194-
const handleEntered = createChainedFunction(this.handleEntered, onEntered);
195-
const handleExit = createChainedFunction(this.handleExit, onExit);
196-
const handleExiting = createChainedFunction(this.handleExiting, onExiting);
197199

198200
return (
199201
<Transition
202+
ref={ref}
200203
addEndListener={transitionEnd}
201204
{...props}
202205
aria-expanded={props.role ? props.in : null}
@@ -213,14 +216,14 @@ class Collapse extends React.Component {
213216
className,
214217
children.props.className,
215218
collapseStyles[state],
216-
this.getDimension() === 'width' && 'width',
219+
computedDimension === 'width' && 'width',
217220
),
218221
})
219222
}
220223
</Transition>
221224
);
222-
}
223-
}
225+
},
226+
);
224227

225228
Collapse.propTypes = propTypes;
226229
Collapse.defaultProps = defaultProps;

test/CollapseSpec.js

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ describe('<Collapse>', () => {
3030
it('Should default to collapsed', () => {
3131
wrapper = mount(<Component>Panel content</Component>);
3232

33-
assert.ok(wrapper.find('Collapse').props().in === false);
33+
assert.ok(wrapper.find(Collapse).props().in === false);
3434
});
3535

3636
it('Should have collapse class', () => {
@@ -40,11 +40,6 @@ describe('<Collapse>', () => {
4040
describe('from collapsed to expanded', () => {
4141
beforeEach(() => {
4242
wrapper = mount(<Component>Panel content</Component>);
43-
44-
// since scrollHeight is gonna be 0 detached from the DOM
45-
sinon
46-
.stub(wrapper.instance().collapse, '_getScrollDimensionValue')
47-
.returns('15px');
4843
});
4944

5045
it('Should have collapsing class', () => {
@@ -70,9 +65,8 @@ describe('<Collapse>', () => {
7065
let node = wrapper.getDOMNode();
7166

7267
assert.equal(node.style.height, '');
73-
7468
wrapper.setState({ in: true });
75-
assert.equal(node.style.height, '15px');
69+
assert.equal(node.style.height, `${node.scrollHeight}px`);
7670
});
7771

7872
it('Should transition from collapsing to not collapsing', (done) => {
@@ -99,7 +93,7 @@ describe('<Collapse>', () => {
9993
assert.equal(node.style.height, '');
10094

10195
wrapper.setState({ in: true, onEntered });
102-
assert.equal(node.style.height, '15px');
96+
assert.equal(node.style.height, `${node.scrollHeight}px`);
10397
});
10498
});
10599

@@ -173,18 +167,19 @@ describe('<Collapse>', () => {
173167
wrapper = mount(<Component>Panel content</Component>);
174168
});
175169

176-
it('Defaults to height', () => {
177-
assert.equal(wrapper.instance().collapse.getDimension(), 'height');
170+
it('Should not have width in class', () => {
171+
let node = wrapper.getDOMNode();
172+
assert.ok(node.className.indexOf('width') === -1);
178173
});
179174

180-
it('Uses getCollapsibleDimension if exists', () => {
175+
it('Should have width in class', () => {
181176
function dimension() {
182-
return 'whatevs';
177+
return 'width';
183178
}
184179

185-
wrapper.setState({ dimension });
186-
187-
assert.equal(wrapper.instance().collapse.getDimension(), 'whatevs');
180+
wrapper.setProps({ dimension });
181+
let node = wrapper.getDOMNode();
182+
assert.ok(node.className.indexOf('width') !== -1);
188183
});
189184
});
190185

test/NavbarSpec.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { mount } from 'enzyme';
33

44
import Nav from '../src/Nav';
55
import Navbar from '../src/Navbar';
6+
import Collapse from '../src/Collapse';
67

78
describe('<Navbar>', () => {
89
it('Should create nav element', () => {
@@ -100,11 +101,12 @@ describe('<Navbar>', () => {
100101
});
101102

102103
it('Should pass expanded to Collapse', () => {
103-
mount(
104+
const wrapper = mount(
104105
<Navbar defaultExpanded>
105106
<Navbar.Collapse>hello</Navbar.Collapse>
106107
</Navbar>,
107-
).assertSingle('Collapse[in]');
108+
);
109+
expect(wrapper.find(Collapse).prop('in')).to.be.true;
108110
});
109111

110112
it('Should wire the toggle to the collapse', () => {
@@ -116,15 +118,15 @@ describe('<Navbar>', () => {
116118
);
117119

118120
let toggle = wrapper.find('.navbar-toggler');
119-
let collapse = wrapper.find('Collapse');
121+
let collapse = wrapper.find(Collapse);
120122

121123
expect(collapse.is('[in=false]')).to.equal(true);
122124
expect(toggle.hasClass('collapsed')).to.equal(true);
123125

124126
toggle.simulate('click');
125127

126128
toggle = wrapper.find('.navbar-toggler');
127-
collapse = wrapper.find('Collapse');
129+
collapse = wrapper.find(Collapse);
128130

129131
expect(collapse.is('[in=true]')).to.equal(true);
130132
expect(toggle.hasClass('collapsed')).to.equal(false);

types/components/Collapse.d.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { TransitionCallbacks } from './helpers';
44

55
export interface CollapseProps
66
extends TransitionCallbacks,
7-
React.ClassAttributes<Collapse> {
7+
React.ComponentPropsWithoutRef<'div'> {
88
in?: boolean;
99
mountOnEnter?: boolean;
1010
unmountOnExit?: boolean;
@@ -18,6 +18,9 @@ export interface CollapseProps
1818
role?: string;
1919
}
2020

21-
declare class Collapse extends React.Component<CollapseProps> {}
21+
declare const Collapse: React.ForwardRefRenderFunction<
22+
HTMLDivElement,
23+
CollapseProps
24+
>;
2225

2326
export default Collapse;

0 commit comments

Comments
 (0)