Skip to content

Commit df0bdf7

Browse files
montezumekodiakhq[bot]
authored andcommitted
feat(collapsible-motion): add minHeight prop (#1042)
* feat(collapsible-motion): add minHeight prop * chore: typo * chore: add test * chore: story fix
1 parent d30fcef commit df0bdf7

File tree

4 files changed

+165
-11
lines changed

4 files changed

+165
-11
lines changed

src/components/collapsible-motion/README.md

+2
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,6 @@ There are [many existing workaround](https://css-tricks.com/using-css-transition
1111

1212
`CollapsibleMotion` uses a nice workaround which allows the browser to run this animation. `CollapsibleMotion` measures the resulting since and then animates between `height: 0` and the resulting size (at 99% of the animation). At the end of the animation, it sets the `height` back to `auto`.
1313

14+
This component also supports passing in a `minHeight` prop. By default this is 0.
15+
1416
Technically, we need to dynamically create the keyframes for this animation.

src/components/collapsible-motion/collapsible-motion.js

+20-10
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,31 @@ const collapsibleMotionPropTypes = {
1010
children: PropTypes.func.isRequired,
1111
isClosed: PropTypes.bool,
1212
onToggle: PropTypes.func,
13+
minHeight: PropTypes.number,
1314
isDefaultClosed: PropTypes.bool,
1415
};
1516

16-
const createOpeningAnimation = height =>
17+
const defaultProps = {
18+
minHeight: 0,
19+
};
20+
21+
const getMinHeight = minHeight =>
22+
minHeight !== 0 ? `${minHeight}px` : minHeight;
23+
24+
const createOpeningAnimation = (height, minHeight = 0) =>
1725
keyframes`
18-
0% { height: 0; overflow: hidden; }
26+
0% { height: ${getMinHeight(minHeight)}; overflow: hidden; }
1927
99% { height: ${height}px; overflow: hidden; }
2028
100% { height: auto; overflow: visible; }
2129
`;
2230

23-
const createClosingAnimation = height =>
31+
const createClosingAnimation = (height, minHeight) =>
2432
keyframes`
2533
from { height: ${height}px; }
26-
to { height: 0; overflow: hidden; }
34+
to { height: ${getMinHeight(minHeight)}; overflow: hidden; }
2735
`;
2836

29-
const useToggleAnimation = (isOpen, toggle) => {
37+
const useToggleAnimation = (isOpen, toggle, minHeight) => {
3038
const nodeRef = React.useRef();
3139
const prevIsOpen = usePrevious(isOpen);
3240

@@ -55,15 +63,15 @@ const useToggleAnimation = (isOpen, toggle) => {
5563

5664
const containerStyles = isOpen
5765
? { height: 'auto' }
58-
: { height: 0, overflow: 'hidden' };
66+
: { height: getMinHeight(minHeight), overflow: 'hidden' };
5967

6068
let animation = null;
6169

6270
// if state has changed
6371
if (typeof prevIsOpen !== 'undefined' && prevIsOpen !== isOpen) {
6472
animation = isOpen
65-
? createOpeningAnimation(nodeRef.current.clientHeight)
66-
: createClosingAnimation(nodeRef.current.clientHeight);
73+
? createOpeningAnimation(nodeRef.current.clientHeight, minHeight)
74+
: createClosingAnimation(nodeRef.current.clientHeight, minHeight);
6775
}
6876

6977
return [animation, containerStyles, handleToggle, nodeRef];
@@ -85,7 +93,7 @@ const ControlledCollapsibleMotion = props => {
8593
containerStyles,
8694
animationToggle,
8795
registerContentNode,
88-
] = useToggleAnimation(!props.isClosed, props.onToggle);
96+
] = useToggleAnimation(!props.isClosed, props.onToggle, props.minHeight);
8997

9098
return (
9199
<ClassNames>
@@ -128,7 +136,7 @@ const UncontrolledCollapsibleMotion = props => {
128136
containerStyles,
129137
animationToggle,
130138
registerContentNode,
131-
] = useToggleAnimation(isOpen, toggle);
139+
] = useToggleAnimation(isOpen, toggle, props.minHeight);
132140

133141
return (
134142
<ClassNames>
@@ -160,9 +168,11 @@ const UncontrolledCollapsibleMotion = props => {
160168
);
161169
};
162170

171+
UncontrolledCollapsibleMotion.defaultProps = defaultProps;
163172
UncontrolledCollapsibleMotion.displayName = 'UncontrolledCollapsibleMotion';
164173
UncontrolledCollapsibleMotion.propTypes = collapsibleMotionPropTypes;
165174

175+
CollapsibleMotion.defaultProps = defaultProps;
166176
CollapsibleMotion.displayName = 'CollapsibleMotion';
167177
CollapsibleMotion.propTypes = collapsibleMotionPropTypes;
168178

src/components/collapsible-motion/collapsible-motion.spec.js

+128
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,70 @@ describe('uncontrolled mode', () => {
6666
})
6767
);
6868
});
69+
describe('with minHeight set', () => {
70+
it('should toggle when clicked', async () => {
71+
const renderProp = jest.fn(
72+
({ isOpen, toggle, containerStyles, registerContentNode }) => (
73+
<div>
74+
<button data-testid="button" onClick={toggle}>
75+
{isOpen ? 'Close' : 'Open'}
76+
</button>
77+
<div data-testid="container-node" style={containerStyles}>
78+
<div data-testid="content-node" ref={registerContentNode}>
79+
Content
80+
</div>
81+
</div>
82+
</div>
83+
)
84+
);
85+
86+
const { getByTestId } = render(
87+
<CollapsibleMotion minHeight={50}>{renderProp}</CollapsibleMotion>
88+
);
89+
90+
expect(getByTestId('content-node')).toBeVisible();
91+
expect(renderProp).toHaveBeenLastCalledWith(
92+
expect.objectContaining({
93+
isOpen: true,
94+
// no animation here because the panel is already expanded
95+
containerStyles: { height: 'auto' },
96+
})
97+
);
98+
99+
// hide the content
100+
fireEvent.click(getByTestId('button'));
101+
102+
// ensure the container gets hidden
103+
expect(renderProp).toHaveBeenLastCalledWith(
104+
expect.objectContaining({
105+
isOpen: false,
106+
containerStyles: {
107+
animation: expect.stringMatching(
108+
/^animation-[a-z0-9]+ 200ms forwards$/
109+
),
110+
height: '50px',
111+
overflow: 'hidden',
112+
},
113+
})
114+
);
115+
116+
// show the content
117+
fireEvent.click(getByTestId('button'));
118+
119+
// ensure the container gets shown again
120+
expect(renderProp).toHaveBeenLastCalledWith(
121+
expect.objectContaining({
122+
isOpen: true,
123+
containerStyles: {
124+
height: 'auto',
125+
animation: expect.stringMatching(
126+
/^animation-[a-z0-9]+ 200ms forwards$/
127+
),
128+
},
129+
})
130+
);
131+
});
132+
});
69133
});
70134

71135
describe('controlled mode', () => {
@@ -148,4 +212,68 @@ describe('controlled mode', () => {
148212
})
149213
);
150214
});
215+
describe('with minHeight set', () => {
216+
it('should toggle when clicked', async () => {
217+
const renderProp = jest.fn(
218+
({ isOpen, toggle, containerStyles, registerContentNode }) => (
219+
<div>
220+
<button data-testid="button" onClick={toggle}>
221+
{isOpen ? 'Close' : 'Open'}
222+
</button>
223+
<div data-testid="container-node" style={containerStyles}>
224+
<div data-testid="content-node" ref={registerContentNode}>
225+
Content
226+
</div>
227+
</div>
228+
</div>
229+
)
230+
);
231+
232+
const { getByTestId } = render(
233+
<CollapsibleMotion minHeight={50}>{renderProp}</CollapsibleMotion>
234+
);
235+
236+
expect(getByTestId('content-node')).toBeVisible();
237+
expect(renderProp).toHaveBeenLastCalledWith(
238+
expect.objectContaining({
239+
isOpen: true,
240+
// no animation here because the panel is already expanded
241+
containerStyles: { height: 'auto' },
242+
})
243+
);
244+
245+
// hide the content
246+
fireEvent.click(getByTestId('button'));
247+
248+
// ensure the container gets hidden
249+
expect(renderProp).toHaveBeenLastCalledWith(
250+
expect.objectContaining({
251+
isOpen: false,
252+
containerStyles: {
253+
animation: expect.stringMatching(
254+
/^animation-[a-z0-9]+ 200ms forwards$/
255+
),
256+
height: '50px',
257+
overflow: 'hidden',
258+
},
259+
})
260+
);
261+
262+
// show the content
263+
fireEvent.click(getByTestId('button'));
264+
265+
// ensure the container gets shown again
266+
expect(renderProp).toHaveBeenLastCalledWith(
267+
expect.objectContaining({
268+
isOpen: true,
269+
containerStyles: {
270+
height: 'auto',
271+
animation: expect.stringMatching(
272+
/^animation-[a-z0-9]+ 200ms forwards$/
273+
),
274+
},
275+
})
276+
);
277+
});
278+
});
151279
});

src/components/collapsible-motion/collapsible-motion.story.js

+15-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,15 @@ class CollapsibleMotionStory extends React.Component {
2020
<h2>Uncontrolled example</h2>
2121
<div key={isDefaultClosed}>
2222
<div>Some content before</div>
23-
<CollapsibleMotion isDefaultClosed={isDefaultClosed}>
23+
<CollapsibleMotion
24+
isDefaultClosed={isDefaultClosed}
25+
minHeight={number('minHeight (unControlled)', 0, {
26+
range: true,
27+
min: 0,
28+
max: 500,
29+
step: 5,
30+
})}
31+
>
2432
{({ isOpen, toggle, containerStyles, registerContentNode }) => (
2533
<div>
2634
<div>
@@ -65,6 +73,12 @@ class CollapsibleMotionStory extends React.Component {
6573
<CollapsibleMotion
6674
isClosed={this.state.isClosed}
6775
onToggle={this.handleToggle}
76+
minHeight={number('minHeight (controlled)', 0, {
77+
range: true,
78+
min: 0,
79+
max: 500,
80+
step: 5,
81+
})}
6882
>
6983
{({ toggle, containerStyles, registerContentNode }) => (
7084
<div>

0 commit comments

Comments
 (0)