diff --git a/source/components/StyledImage/README.md b/source/components/StyledImage/README.md new file mode 100644 index 00000000..c103ad86 --- /dev/null +++ b/source/components/StyledImage/README.md @@ -0,0 +1,11 @@ +Click the theme buttons to re-initialize examples. + +```js + console.log("Countdown: ", value)} /> +``` + +This timer is always stopped: + +```js + console.log("Stopped countdown: ", value)} /> +``` diff --git a/source/components/StyledImage/__snapshots__/index.spec.js.snap b/source/components/StyledImage/__snapshots__/index.spec.js.snap new file mode 100644 index 00000000..a961c973 --- /dev/null +++ b/source/components/StyledImage/__snapshots__/index.spec.js.snap @@ -0,0 +1,4035 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`StyledCountdown calls callback 10 times with proper values Callback #0 1`] = ` + + + + +
+ + + + 9 + + + + + + + + + + + + + + +
+
+
+
+
+`; + +exports[`StyledCountdown calls callback 10 times with proper values Callback #1 1`] = ` + + + + +
+ + + + 8 + + + + + + + + + + + + + + +
+
+
+
+
+`; + +exports[`StyledCountdown calls callback 10 times with proper values Callback #2 1`] = ` + + + + +
+ + + + 7 + + + + + + + + + + + + + + +
+
+
+
+
+`; + +exports[`StyledCountdown calls callback 10 times with proper values Callback #3 1`] = ` + + + + +
+ + + + 6 + + + + + + + + + + + + + + +
+
+
+
+
+`; + +exports[`StyledCountdown calls callback 10 times with proper values Callback #4 1`] = ` + + + + +
+ + + + 5 + + + + + + + + + + + + + + +
+
+
+
+
+`; + +exports[`StyledCountdown calls callback 10 times with proper values Callback #5 1`] = ` + + + + +
+ + + + 4 + + + + + + + + + + + + + + +
+
+
+
+
+`; + +exports[`StyledCountdown calls callback 10 times with proper values Callback #6 1`] = ` + + + + +
+ + + + 3 + + + + + + + + + + + + + + +
+
+
+
+
+`; + +exports[`StyledCountdown calls callback 10 times with proper values Callback #7 1`] = ` + + + + +
+ + + + 2 + + + + + + + + + + + + + + +
+
+
+
+
+`; + +exports[`StyledCountdown calls callback 10 times with proper values Callback #8 1`] = ` + + + + +
+ + + + 1 + + + + + + + + + + + + + + +
+
+
+
+
+`; + +exports[`StyledCountdown calls callback 10 times with proper values Callback #9 1`] = ` + + + + +
+ + + + 0 + + + +
+
+
+
+
+`; + +exports[`StyledCountdown renders correctly 1`] = ` + + + + +
+ + + + 10 + + + + + + + + + + + + + + +
+
+
+
+
+`; + +exports[`StyledCountdown renders correctly when stopped 1`] = ` + + + + +
+ + + + 10 + + + + + + + + + + + + + + +
+
+
+
+
+`; diff --git a/source/components/StyledImage/index.js b/source/components/StyledImage/index.js new file mode 100644 index 00000000..c3a21609 --- /dev/null +++ b/source/components/StyledImage/index.js @@ -0,0 +1,102 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { vignette } from '../../utils/vignette'; + +const LOADING_STATES = Object.freeze({ + PENDING: 'pending', + SUCCESS: 'success', + ERROR: 'error', + INITIAL: 'initial', +}); + +function usePreloadedImage(src, srcSet) { + const [loadStatus, setLoadStatus] = React.useState(LOADING_STATES.INITIAL); + const [requestId, setRequestId] = React.useState(null); + + const handleSuccess = () => { + setLoadStatus(LOADING_STATES.SUCCESS); + setRequestId(null); + }; + + const handleError = () => { + setLoadStatus(LOADING_STATES.ERROR); + setRequestId(null); + }; + + React.useEffect(() => { + if (requestId !== null) { + cancelAnimationFrame(requestId); + } + + const newRequestId = requestAnimationFrame(() => { + const image = document.createElement('img'); + + image.onload = handleSuccess; + image.onerror = handleError; + image.src = src; + image.srcset = srcSet || src; + + if (image.complete) { + handleSuccess(); + } + + this.image = image; + }); + + setRequestId(newRequestId); + }, [src]); + + + return { + loadStatus, + }; +} + +const LAZY_IMAGE_SIZE = 5; +function createLowResolutionSrc(src) { + return vignette(src).withSmart(LAZY_IMAGE_SIZE, LAZY_IMAGE_SIZE).get(); +} + +// todo: get a placeholder image to swap out instead of empty string +const LIMBO_IMAGE = ''; + +const StyledImage = ({ src, srcSet, disableLazy, alt, className, ...rest }) => { + const [isLimbo, setIsLimbo] = React.useState(false); + const { loadStatus } = usePreloadedImage(src, srcSet); + + // limbo is intended to remove the image when the src changes but the image is not yet loaded. + React.useEffect(() => { + setIsLimbo(true); + }, [src]); + // this is odd but it allows us to quickly flush the src when an image src is swapped but react is able to use + // the same DOM element. The other option would be to use keys but then react can't be as efficient. + // todo maybe wrap in a timeout to make sure it doesn't get batched + setIsLimbo(false); + + // Show low resolution image + if (loadStatus === LOADING_STATES.PENDING) { + const lowResolutionSrc = createLowResolutionSrc(src); + return {alt}; + } + + return ( + {alt} + ); +}; + +StyledImage.propTypes = { + alt: PropTypes.string.isRequired, + className: PropTypes.string, + disableLazy: PropTypes.bool, + src: PropTypes.string.isRequired, + srcSet: PropTypes.string, +}; + +StyledImage.defaultProps = { + className: undefined, + disableLazy: false, + srcSet: undefined, +}; + +export default StyledCountdown; diff --git a/source/components/StyledImage/index.spec.js b/source/components/StyledImage/index.spec.js new file mode 100644 index 00000000..cc79949c --- /dev/null +++ b/source/components/StyledImage/index.spec.js @@ -0,0 +1,43 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import sinon from 'sinon'; + +import StyledCountdown from './index'; + +jest.useFakeTimers(); + +test('StyledCountdown renders correctly', () => { + const callback = sinon.spy(); + const component = mountWithThemeProvider( + + ); + expect(component).toMatchSnapshot(); +}); + +test('StyledCountdown renders correctly when stopped', () => { + const callback = sinon.spy(); + const component = mountWithThemeProvider( + + ); + expect(component).toMatchSnapshot(); +}); + + +describe('StyledCountdown calls callback 10 times with proper values', () => { + const callback = sinon.spy(); + const component = mountWithThemeProvider(); + + for (let value = 9; value >= 0; value -= 1) { + const index = 9 - value; + test(`Callback #${index}`, () => { + act(() => { + jest.advanceTimersByTime(1000); + }); + component.update(); + + expect(callback.callCount).toEqual(index + 1); + expect(callback.getCall(index).args[0]).toEqual(value); + expect(component).toMatchSnapshot(); + }); + } +});