Commit 8273a9ec authored by Robert Knight's avatar Robert Knight

Add Slider component for animated slide-in transitions

This transition is not as simple as applying a CSS transition
property to an element since browsers do not support animating `width`
/ `height` from 0px to "auto" to fit the content. Therefore add a
component to handle the steps in the transition.
parent f08e7eae
'use strict';
const propTypes = require('prop-types');
const { createElement } = require('preact');
const { useCallback, useEffect, useRef, useState } = require('preact/hooks');
/**
* A container which reveals its content when `visible` is `true` using
* a sliding animation.
*
* Currently the only reveal/expand direction supported is top-down.
*/
function Slider({ children, visible, queueTask = setTimeout }) {
const containerRef = useRef(null);
const [containerHeight, setContainerHeight] = useState(visible ? 'auto' : 0);
// Adjust the container height when the `visible` prop changes.
useEffect(() => {
const isVisible = containerHeight !== 0;
if (visible === isVisible) {
// Do nothing after the initial mount.
return null;
}
const el = containerRef.current;
if (visible) {
// When expanding, transition the container to the current fixed height
// of the content. After the transition completes, we'll reset to "auto"
// height to adapt to future content changes.
setContainerHeight(el.scrollHeight);
return null;
} else {
// When collapsing, immediately change the current height to a fixed height
// (in case it is currently "auto"), and then one tick later, transition
// to 0.
//
// These steps are needed because browsers will not animate transitions
// from "auto" => "0" and may not animate "auto" => fixed height => 0
// if the first transition happens in the same tick.
el.style.height = `${el.scrollHeight}px`;
const timer = queueTask(() => {
setContainerHeight(0);
});
return () => clearTimeout(timer);
}
}, [containerHeight, queueTask, visible]);
const handleTransitionEnd = useCallback(() => {
if (visible) {
setContainerHeight('auto');
}
}, [setContainerHeight, visible]);
return (
<div
// nb. Preact uses "ontransitionend" rather than "onTransitionEnd".
// See https://bugs.chromium.org/p/chromium/issues/detail?id=961193
//
// eslint-disable-next-line react/no-unknown-property
ontransitionend={handleTransitionEnd}
ref={containerRef}
style={{
height: containerHeight,
overflow: 'hidden',
transition: 'height 0.15s ease-in',
}}
>
{children}
</div>
);
}
Slider.propTypes = {
children: propTypes.any,
/**
* Whether the content should be visible or not.
*/
visible: propTypes.bool,
/**
* Test seam.
*
* Schedule a callback to run on the next tick.
*/
queueTask: propTypes.func,
};
module.exports = Slider;
'use strict';
const { mount } = require('enzyme');
const { createElement } = require('preact');
const Slider = require('../slider');
describe('Slider', () => {
let container;
const createSlider = (props = {}) => {
// Use a fake `setTimeout` in tests that runs the callback immediately
// to avoid an issue with async state updates
// (see https://github.com/preactjs/preact/issues/1794).
const fakeSetTimeout = callback => {
callback();
return null;
};
return mount(
<Slider visible={false} queueTask={fakeSetTimeout} {...props}>
<div style={{ width: 100, height: 200 }}>Test content</div>
</Slider>,
{ attachTo: container }
);
};
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it('should render collapsed if `visible` is false on mount', () => {
const wrapper = createSlider({ visible: false });
const { height } = wrapper.getDOMNode().getBoundingClientRect();
assert.equal(height, 0);
});
it('should render expanded if `visible` is true on mount', () => {
const wrapper = createSlider({ visible: true });
const { height } = wrapper.getDOMNode().getBoundingClientRect();
assert.equal(height, 200);
});
it('should transition to expanded if `visible` changes to `true`', () => {
const wrapper = createSlider({ visible: false });
wrapper.setProps({ visible: true });
const containerStyle = wrapper.getDOMNode().style;
assert.equal(containerStyle.height, '200px');
});
it('should transition to collapsed if `visible` changes to `false`', done => {
const wrapper = createSlider({ visible: true });
wrapper.setProps({ visible: false });
setTimeout(() => {
const { height } = wrapper.getDOMNode().getBoundingClientRect();
assert.equal(height, 0);
done();
}, 1);
});
it('should set the container height to "auto" once the transition finishes', () => {
const wrapper = createSlider({ visible: false });
wrapper.setProps({ visible: true });
let containerStyle = wrapper.getDOMNode().style;
assert.equal(containerStyle.height, '200px');
wrapper
.find('div')
.first()
.simulate('transitionend');
containerStyle = wrapper.getDOMNode().style;
assert.equal(containerStyle.height, 'auto');
});
[true, false].forEach(visible => {
it('should handle unmounting while expanding or collapsing', () => {
const wrapper = createSlider({ visible });
wrapper.setProps({ visible: !visible });
wrapper.unmount();
});
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment