From 296b4555a42f91d9a0046af06b031c8051f5256c Mon Sep 17 00:00:00 2001 From: Austin Green Date: Wed, 16 Oct 2019 15:34:19 -0700 Subject: [PATCH] Revert "feat(loaders): deprecate ScheduleContainer BREAKING (#462)" This reverts commit e62d6f728469356fbb3a9a8f2a409aeda6a25b76. --- packages/loaders/package.json | 1 - packages/loaders/src/Dots.example.md | 16 +- packages/loaders/src/Dots.js | 171 ++++---- packages/loaders/src/Dots.spec.js | 393 ++++++++---------- packages/loaders/src/Progress.example.md | 6 +- packages/loaders/src/Skeleton.js | 1 + packages/loaders/src/Spinner.js | 204 +++++---- packages/loaders/src/Spinner.spec.js | 27 +- .../containers/ScheduleContainer.example.md | 9 + .../src/containers/ScheduleContainer.js | 79 ++++ .../src/containers/ScheduleContainer.spec.js | 68 +++ .../loaders/src/hooks/useCSSSVGAnimation.js | 39 -- packages/loaders/src/styled-elements.js | 41 +- packages/loaders/src/styled-elements.spec.js | 42 +- packages/loaders/src/utils/animations.js | 109 +---- packages/loaders/src/utils/animations.spec.js | 32 ++ packages/loaders/src/utils/dot-coordinates.js | 85 ++++ .../loaders/src/utils/dot-coordinates.spec.js | 58 +++ packages/loaders/styleguide.config.js | 4 + 19 files changed, 784 insertions(+), 601 deletions(-) create mode 100644 packages/loaders/src/containers/ScheduleContainer.example.md create mode 100644 packages/loaders/src/containers/ScheduleContainer.js create mode 100644 packages/loaders/src/containers/ScheduleContainer.spec.js delete mode 100644 packages/loaders/src/hooks/useCSSSVGAnimation.js create mode 100644 packages/loaders/src/utils/animations.spec.js create mode 100644 packages/loaders/src/utils/dot-coordinates.js create mode 100644 packages/loaders/src/utils/dot-coordinates.spec.js diff --git a/packages/loaders/package.json b/packages/loaders/package.json index 8aff8e88236..d26af7cb6c5 100644 --- a/packages/loaders/package.json +++ b/packages/loaders/package.json @@ -19,7 +19,6 @@ "start": "../../utils/scripts/start.sh" }, "dependencies": { - "@zendeskgarden/container-schedule": "^1.1.0", "@zendeskgarden/css-variables": "^6.1.0", "classnames": "^2.2.5", "polished": "^2.3.3" diff --git a/packages/loaders/src/Dots.example.md b/packages/loaders/src/Dots.example.md index 5a10e45ce2f..a939af6d11a 100644 --- a/packages/loaders/src/Dots.example.md +++ b/packages/loaders/src/Dots.example.md @@ -82,7 +82,7 @@ const Color = ({ name, color, includeSample }) => @@ -105,13 +105,13 @@ const Color = ({ name, color, includeSample }) => - + setState({ duration: parseFloat(event.target.value) })} - min={625} - max={2500} - step={625} + value={state.velocity} + onChange={event => setState({ velocity: parseFloat(event.target.value) })} + min={-0.5} + max={1} + step={0.05} /> @@ -139,7 +139,7 @@ const Color = ({ name, color, includeSample }) => - + diff --git a/packages/loaders/src/Dots.js b/packages/loaders/src/Dots.js index d25747d1383..56cada58653 100644 --- a/packages/loaders/src/Dots.js +++ b/packages/loaders/src/Dots.js @@ -5,87 +5,112 @@ * found at http://www.apache.org/licenses/LICENSE-2.0. */ -import React, { useEffect, useRef } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; -import { useSchedule } from '@zendeskgarden/container-schedule'; -import { - DotsOneCircle, - DotsTwoCircle, - DotsThreeCircle, - StyledSVG, - LoadingPlaceholder -} from './styled-elements'; -import { useCSSSVGAnimation } from './hooks/useCSSSVGAnimation'; +import { retrieveXCoordinate, retrieveYCoordinate, KEYFRAME_MAX } from './utils/dot-coordinates'; +import { DotsCircle, StyledSVG } from './styled-elements'; +import ScheduleContainer from './containers/ScheduleContainer'; const COMPONENT_ID = 'loaders.dots'; -/** @component */ -export default function Dots({ - size = 'inherit', - color = 'inherit', - duration = 1250, - delayMS = 750, - ...other -}) { - const { delayComplete } = useSchedule({ duration, delayMS }); - const noAnimatedSVGSupport = useCSSSVGAnimation(); - const dotOne = useRef(null); - const dotTwo = useRef(null); - const dotThree = useRef(null); +export default class Dots extends React.Component { + static propTypes = { + /** + * Size of the loader. Can inherit from `font-size` styling. + **/ + size: PropTypes.any, + /** + * Velocity (speed) of the animation. Between -1 and 1. + * This should only be maniuplated at extreme sizes. + **/ + velocity: PropTypes.number, + /** + * Color of the loader. Can inherit from `color` styling. + **/ + color: PropTypes.string, + /** + * Delay in MS to begin loader rendering. This helps prevent + * quick flashes of the loader during normal loading times. + **/ + delayMS: PropTypes.number + }; - useEffect(() => { - if (noAnimatedSVGSupport && delayComplete) { - const transforms = [ - window.getComputedStyle(dotOne.current).getPropertyValue('transform'), - window.getComputedStyle(dotTwo.current).getPropertyValue('transform'), - window.getComputedStyle(dotThree.current).getPropertyValue('transform') - ]; + static defaultProps = { + size: 'inherit', + color: 'inherit', + velocity: 0.05, + delayMS: 750 + }; - dotOne.current.setAttribute('transform', transforms[0]); - dotTwo.current.setAttribute('transform', transforms[1]); - dotThree.current.setAttribute('transform', transforms[2]); + state = { + frame: 0, + timestamp: 0 + }; + + performAnimationFrame = (timestamp = 0) => { + const { velocity } = this.props; + + let pinnedVelocity = velocity; + + if (velocity < -1) { + pinnedVelocity = -0.9; + } else if (velocity > 1) { + pinnedVelocity = 1; } - }); - if (!delayComplete && delayMS !== 0) { - return  ; - } + this.setState(prevState => { + const factor = 1000 + 1000 * pinnedVelocity; + const elapsed = (timestamp - prevState.timestamp) / factor; + const frame = prevState.frame + (elapsed % KEYFRAME_MAX); - return ( - - - - - - - - ); -} + return { frame, timestamp }; + }); + }; -Dots.propTypes = { - /** - * Size of the loader. Can inherit from `font-size` styling. - **/ - size: PropTypes.any, - /** - * Duration (ms) of the animation. Default is 1250ms. - **/ - duration: PropTypes.number, - /** - * Color of the loader. Can inherit from `color` styling. - **/ - color: PropTypes.string, - /** - * Delay in MS to begin loader rendering. This helps prevent - * quick flashes of the loader during normal loading times. - **/ - delayMS: PropTypes.number -}; + retrieveFrame = offset => { + const loop = KEYFRAME_MAX * 2; + + return (this.state.frame + offset * loop) % loop; + }; + + render() { + const { size, color, delayMS, ...other } = this.props; + const dotOneFrame = this.retrieveFrame(0); + const dotTwoFrame = this.retrieveFrame(1 / 3); + const dotThreeFrame = this.retrieveFrame(2 / 3); + + return ( + + {() => ( + + + + + + + + )} + + ); + } +} diff --git a/packages/loaders/src/Dots.spec.js b/packages/loaders/src/Dots.spec.js index d87eadd4c8c..e9986e98778 100644 --- a/packages/loaders/src/Dots.spec.js +++ b/packages/loaders/src/Dots.spec.js @@ -6,25 +6,16 @@ */ import React from 'react'; -import { render, act } from 'garden-test-utils'; -import mockDate from 'mockdate'; +import { render } from 'garden-test-utils'; import Dots from './Dots'; jest.useFakeTimers(); -const DEFAULT_DATE = new Date(2019, 1, 5, 1, 1, 1); - describe('Dots', () => { beforeEach(() => { clearTimeout.mockClear(); global.cancelAnimationFrame = jest.fn(); global.requestAnimationFrame = jest.fn(); - global.document.elementFromPoint = jest.fn(); - mockDate.set(DEFAULT_DATE); - }); - - afterEach(() => { - mockDate.reset(); }); describe('Loading delay', () => { @@ -37,9 +28,7 @@ describe('Dots', () => { it('shows loader after initial delay', () => { const { queryByTestId } = render(); - act(() => { - jest.runOnlyPendingTimers(); - }); + jest.runOnlyPendingTimers(); expect(queryByTestId('dots')).not.toBeNull(); }); @@ -49,238 +38,196 @@ describe('Dots', () => { it('updates animation after request animation frame', () => { const { container } = render(); - act(() => { - jest.runOnlyPendingTimers(); - }); + jest.runOnlyPendingTimers(); expect(container.querySelector('g')).toMatchInlineSnapshot(` - .c0 { - -webkit-animation: dMEWJg 1250ms linear infinite; - animation: dMEWJg 1250ms linear infinite; - } - - .c1 { - -webkit-animation: hiIgQS 1250ms linear infinite; - animation: hiIgQS 1250ms linear infinite; - } - - .c2 { - -webkit-animation: jfCXbz 1250ms linear infinite; - animation: jfCXbz 1250ms linear infinite; - } - - - - - - - `); - - act(() => { - // move time forward 1 second - mockDate.set(DEFAULT_DATE.setSeconds(2)); - requestAnimationFrame.mock.calls[0][0](); - }); + + + + + +`); + + // Requestion animation with 1000 MS delay + requestAnimationFrame.mock.calls[0][0](1000); expect(container.querySelector('g')).toMatchInlineSnapshot(` - - - - - - `); + + + + + +`); }); it('updates animation after request animation frame with negative bound velocity', () => { const { container } = render(); - act(() => { - jest.runOnlyPendingTimers(); - }); + jest.runOnlyPendingTimers(); expect(container.querySelector('g')).toMatchInlineSnapshot(` - .c0 { - -webkit-animation: dMEWJg 1250ms linear infinite; - animation: dMEWJg 1250ms linear infinite; - } - - .c1 { - -webkit-animation: hiIgQS 1250ms linear infinite; - animation: hiIgQS 1250ms linear infinite; - } - - .c2 { - -webkit-animation: jfCXbz 1250ms linear infinite; - animation: jfCXbz 1250ms linear infinite; - } - - - - - - - `); - - act(() => { - // move time forward 1 second - mockDate.set(DEFAULT_DATE.setSeconds(2)); - requestAnimationFrame.mock.calls[0][0](); - }); + + + + + +`); + + // Requestion animation with 1000 MS delay + requestAnimationFrame.mock.calls[0][0](1000); expect(container.querySelector('g')).toMatchInlineSnapshot(` - - - - - - `); + + + + + +`); }); it('updates animation after request animation frame with positive bound velocity', () => { const { container } = render(); - act(() => { - jest.runOnlyPendingTimers(); - }); + jest.runOnlyPendingTimers(); expect(container.querySelector('g')).toMatchInlineSnapshot(` - .c0 { - -webkit-animation: dMEWJg 1250ms linear infinite; - animation: dMEWJg 1250ms linear infinite; - } - - .c1 { - -webkit-animation: hiIgQS 1250ms linear infinite; - animation: hiIgQS 1250ms linear infinite; - } - - .c2 { - -webkit-animation: jfCXbz 1250ms linear infinite; - animation: jfCXbz 1250ms linear infinite; - } - - - - - - - `); - - act(() => { - // move time forward 1 second - mockDate.set(DEFAULT_DATE.setSeconds(2)); - requestAnimationFrame.mock.calls[0][0](); - }); + + + + + +`); + + // Requestion animation with 1000 MS delay + requestAnimationFrame.mock.calls[0][0](1000); expect(container.querySelector('g')).toMatchInlineSnapshot(` - - - - - - `); + + + + + +`); }); }); }); diff --git a/packages/loaders/src/Progress.example.md b/packages/loaders/src/Progress.example.md index 242f42a16cb..483c9353b8b 100644 --- a/packages/loaders/src/Progress.example.md +++ b/packages/loaders/src/Progress.example.md @@ -78,7 +78,7 @@ const { zdColorGreen600, zdColorRed600 } = require('@zendeskgarden/css-variables'); -const { Field: FormField, Label, Range } = require('@zendeskgarden/react-forms/src'); +const { RangeField, Label, Range } = require('@zendeskgarden/react-ranges/src'); const { Dropdown, Field, @@ -208,7 +208,7 @@ const Color = ({ name, color, includeSample }) => - + max={100} step={1} /> - + diff --git a/packages/loaders/src/Skeleton.js b/packages/loaders/src/Skeleton.js index c94bf0ed274..56480e348f0 100644 --- a/packages/loaders/src/Skeleton.js +++ b/packages/loaders/src/Skeleton.js @@ -85,6 +85,7 @@ const StyledSkeleton = styled.div.attrs({ ${props => retrieveTheme(COMPONENT_ID, props)}; `; +/* eslint-enable */ /** * Loader used to create Skeleton objects diff --git a/packages/loaders/src/Spinner.js b/packages/loaders/src/Spinner.js index 30079bb6ecb..739ae981bd5 100644 --- a/packages/loaders/src/Spinner.js +++ b/packages/loaders/src/Spinner.js @@ -13,95 +13,125 @@ import { DASHARRAY_FRAMES, ROTATION_FRAMES } from './utils/spinner-coordinates'; -import { SpinnerCircle, StyledSVG, LoadingPlaceholder } from './styled-elements'; -import { useSchedule } from '@zendeskgarden/container-schedule'; +import { SpinnerCircle, StyledSVG } from './styled-elements'; +import ScheduleContainer from './containers/ScheduleContainer'; const COMPONENT_ID = 'loaders.spinner'; -const totalFrames = 100; - -const computeFrames = (frames, duration) => { - return Object.entries(frames).reduce((acc, item, index, arr) => { - const [frame, value] = item; - const [nextFrame, nextValue] = arr[index + 1] || [totalFrames, arr[0][1]]; - const diff = nextFrame - frame; - const frameHz = 1000 / 60; - - let subDuration = (duration / (totalFrames - 1)) * diff; - let lastValue = value; - - acc[frame] = value; - for (let idx = 0; idx < diff; idx++) { - lastValue = lastValue + (nextValue - lastValue) * (frameHz / subDuration); - subDuration = (duration / (totalFrames - 1)) * (diff - idx); - - acc[parseInt(frame, 10) + idx + 1] = lastValue; - } - acc[nextFrame] = nextValue; - - return acc; - }, {}); -}; - -/** @component */ -export default function Spinner({ - size = 'inherit', - duration = 1250, - color = 'inherit', - delayMS = 750, - ...other -}) { - const strokeWidthValues = computeFrames(STROKE_WIDTH_FRAMES, duration); - const rotationValues = computeFrames(ROTATION_FRAMES, duration); - const dasharrayValues = computeFrames(DASHARRAY_FRAMES, duration); - - const { elapsed, delayComplete } = useSchedule({ duration, delayMS }); - const frame = (elapsed * 100).toFixed(0); - - const strokeWidthValue = strokeWidthValues[frame]; - const rotationValue = rotationValues[frame]; - const dasharrayValue = dasharrayValues[frame]; - - const WIDTH = 80; - const HEIGHT = 80; - - if (!delayComplete && delayMS !== 0) { - return  ; + +export default class Spinner extends React.Component { + constructor(props) { + super(props); + + this.strokeWidthValues = this.computeFrames(STROKE_WIDTH_FRAMES); + this.rotationValues = this.computeFrames(ROTATION_FRAMES); + this.dasharrayValues = this.computeFrames(DASHARRAY_FRAMES); } - return ( - - - - ); -} + static propTypes = { + /** + * Size of the loader. Can inherit from `font-size` styling. + **/ + size: PropTypes.any, + /** + * Duration (ms) of the animation. Default is 1250ms. + **/ + duration: PropTypes.number, + /** + * Color of the loader. Can inherit from `color` styling. + **/ + color: PropTypes.string, + /** + * Delay in MS to begin loader rendering. This helps prevent + * quick flashes of the loader during normal loading times. + **/ + delayMS: PropTypes.number + }; + + static defaultProps = { + size: 'inherit', + color: 'inherit', + delayMS: 750, + duration: 1250 + }; + + state = { + frame: 0, + rawFrame: 0, + totalFrames: 100, + delayComplete: false, + timestamp: 0 + }; + + computeFrames = frames => { + const { duration } = this.props; + const { totalFrames } = this.state; + + return Object.entries(frames).reduce((acc, item, index, arr) => { + const [frame, value] = item; + const [nextFrame, nextValue] = arr[index + 1] || [totalFrames, arr[0][1]]; + const diff = nextFrame - frame; + const frameHz = 1000 / 60; + + let subDuration = (duration / (totalFrames - 1)) * diff; + let lastValue = value; + + acc[frame] = value; + for (let idx = 0; idx < diff; idx++) { + lastValue = lastValue + (nextValue - lastValue) * (frameHz / subDuration); + subDuration = (duration / (totalFrames - 1)) * (diff - idx); -Spinner.propTypes = { - /** - * Size of the loader. Can inherit from `font-size` styling. - **/ - size: PropTypes.any, - /** - * Duration (ms) of the animation. Default is 1250ms. - **/ - duration: PropTypes.number, - /** - * Color of the loader. Can inherit from `color` styling. - **/ - color: PropTypes.string, - /** - * Delay in MS to begin loader rendering. This helps prevent - * quick flashes of the loader during normal loading times. - **/ - delayMS: PropTypes.number -}; + acc[parseInt(frame, 10) + idx + 1] = lastValue; + } + acc[nextFrame] = nextValue; + + return acc; + }, {}); + }; + + performAnimationFrame = (nowTime = 0) => { + const { totalFrames, rawFrame, timestamp } = this.state; + const { duration } = this.props; + const elapsedTime = nowTime - timestamp; + + this.setState(() => { + const frameMultiplier = (totalFrames + 1) / duration; + const nextValue = rawFrame + elapsedTime * frameMultiplier; + const actualFrame = Math.floor(nextValue); + const frame = actualFrame % totalFrames; + const currentRawFrame = nextValue % totalFrames; + + return { frame, rawFrame: currentRawFrame, timestamp: nowTime }; + }); + }; + + render() { + const { size, color, delayMS, ...other } = this.props; + const { frame } = this.state; + const strokeWidthValue = this.strokeWidthValues[frame]; + const rotationValue = this.rotationValues[frame]; + const dasharrayValue = this.dasharrayValues[frame]; + const WIDTH = 80; + const HEIGHT = 80; + + return ( + + {() => ( + + + + )} + + ); + } +} diff --git a/packages/loaders/src/Spinner.spec.js b/packages/loaders/src/Spinner.spec.js index 005e49f745e..7242d320e1f 100644 --- a/packages/loaders/src/Spinner.spec.js +++ b/packages/loaders/src/Spinner.spec.js @@ -6,24 +6,16 @@ */ import React from 'react'; -import { render, act } from 'garden-test-utils'; -import mockDate from 'mockdate'; +import { render } from 'garden-test-utils'; import Spinner from './Spinner'; jest.useFakeTimers(); -const DEFAULT_DATE = new Date(2019, 1, 5, 1, 1, 1); - describe('Spinner', () => { beforeEach(() => { clearTimeout.mockClear(); global.cancelAnimationFrame = jest.fn(); global.requestAnimationFrame = jest.fn(); - mockDate.set(DEFAULT_DATE); - }); - - afterEach(() => { - mockDate.reset(); }); describe('Loading delay', () => { @@ -36,9 +28,7 @@ describe('Spinner', () => { it('shows loader after initial delay', () => { const { queryByTestId } = render(); - act(() => { - jest.runOnlyPendingTimers(); - }); + jest.runOnlyPendingTimers(); expect(queryByTestId('spinner')).not.toBeNull(); }); @@ -48,9 +38,7 @@ describe('Spinner', () => { it('updates animation after request animation frame', () => { const { container } = render(); - act(() => { - jest.runOnlyPendingTimers(); - }); + jest.runOnlyPendingTimers(); expect(container.firstChild.firstChild).toMatchInlineSnapshot( ` @@ -69,16 +57,13 @@ describe('Spinner', () => { ` ); - act(() => { - // move time forward 1 second - mockDate.set(DEFAULT_DATE.setSeconds(2)); - requestAnimationFrame.mock.calls[0][0](); - }); + // Requestion animation with 1000 MS delay + requestAnimationFrame.mock.calls[0][0](1000); expect(container.firstChild.firstChild).toMatchInlineSnapshot( ` setState({ count: state.count + 1 })}> + {() =>

{state.count}

} +; +``` diff --git a/packages/loaders/src/containers/ScheduleContainer.js b/packages/loaders/src/containers/ScheduleContainer.js new file mode 100644 index 00000000000..f0f9d697ae1 --- /dev/null +++ b/packages/loaders/src/containers/ScheduleContainer.js @@ -0,0 +1,79 @@ +/** + * Copyright Zendesk, Inc. + * + * Use of this source code is governed under the Apache License, Version 2.0 + * found at http://www.apache.org/licenses/LICENSE-2.0. + */ + +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +import { LoadingPlaceholder } from '../styled-elements'; + +export default class ScheduleContainer extends Component { + static propTypes = { + /** + * Size of the loader. Can inherit from `font-size` styling. + **/ + size: PropTypes.any, + /** + * Delay in MS to begin loader rendering. This helps prevent + * quick flashes of the loader during normal loading times. + **/ + delayMS: PropTypes.number, + /** + * Function to call on each animation frame. + **/ + tick: PropTypes.func, + children: PropTypes.func, + /** + * Identical to children + */ + render: PropTypes.func + }; + + static defaultProps = { + delayMS: 750 + }; + + constructor(props) { + super(props); + + this.state = { + delayComplete: false + }; + } + + componentDidMount() { + const { delayMS } = this.props; + + this.renderingDelayTimeout = setTimeout(() => { + this.setState({ delayComplete: true }, () => { + this.performAnimationFrame(); + }); + }, delayMS); + } + + componentWillUnmount() { + clearTimeout(this.renderingDelayTimeout); + cancelAnimationFrame(this.tick); + } + + performAnimationFrame() { + this.tick = requestAnimationFrame(timestamp => { + this.props.tick(timestamp); + this.performAnimationFrame(); + }); + } + + render() { + const { delayMS, size, children, render = children } = this.props; + const { delayComplete } = this.state; + + if (!delayComplete && delayMS !== 0) { + return  ; + } + + return render(); + } +} diff --git a/packages/loaders/src/containers/ScheduleContainer.spec.js b/packages/loaders/src/containers/ScheduleContainer.spec.js new file mode 100644 index 00000000000..db1ecff188a --- /dev/null +++ b/packages/loaders/src/containers/ScheduleContainer.spec.js @@ -0,0 +1,68 @@ +/** + * Copyright Zendesk, Inc. + * + * Use of this source code is governed under the Apache License, Version 2.0 + * found at http://www.apache.org/licenses/LICENSE-2.0. + */ + +import React from 'react'; +import { render } from 'garden-test-utils'; +import ScheduleContainer from './ScheduleContainer'; + +describe('ScheduleContainer', () => { + const Example = props => ( + + {() =>

Example content

} +
+ ); + + beforeEach(() => { + jest.useFakeTimers(); + global.cancelAnimationFrame = jest.fn(); + global.requestAnimationFrame = jest.fn(); + }); + + it('hides content until default delay time has passed', () => { + const { queryByTestId } = render(); + + expect(queryByTestId('content')).toBeNull(); + jest.runOnlyPendingTimers(); + expect(queryByTestId('content')).not.toBeNull(); + }); + + it('hides content until custom delay time has passed', () => { + const { queryByTestId } = render(); + + expect(queryByTestId('content')).toBeNull(); + jest.runTimersToTime(50); + expect(queryByTestId('content')).not.toBeNull(); + }); + + it('shows content if delayMs is 0', () => { + const { queryByTestId } = render(); + + expect(queryByTestId('content')).not.toBeNull(); + }); + + it('removes events when component is unmounted', () => { + const { unmount } = render(); + + unmount(); + expect(clearTimeout).toHaveBeenCalled(); + expect(cancelAnimationFrame).toHaveBeenCalled(); + }); + + it('calls tick with timestamp when requestAnimationFrame is triggered', () => { + const tickSpy = jest.fn(); + + render(); + + // Run timer to start requestAnimationFrame + jest.runOnlyPendingTimers(); + + // Run first animation frame + requestAnimationFrame.mock.calls[0][0](); + + expect(tickSpy).toHaveBeenCalled(); + }); +}); diff --git a/packages/loaders/src/hooks/useCSSSVGAnimation.js b/packages/loaders/src/hooks/useCSSSVGAnimation.js deleted file mode 100644 index 2911d56e48e..00000000000 --- a/packages/loaders/src/hooks/useCSSSVGAnimation.js +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Copyright Zendesk, Inc. - * - * Use of this source code is governed under the Apache License, Version 2.0 - * found at http://www.apache.org/licenses/LICENSE-2.0. - */ - -import { useEffect, useState } from 'react'; - -/** - * Adapted from https://eprev.org/2017/01/05/how-to-detect-if-css-transforms-are-supported-on-svg/ - */ -export const useCSSSVGAnimation = () => { - const [canAnimateSVG, setAnimateSVG] = useState(true); - - useEffect(() => { - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); - - svg.setAttribute('viewBox', '0 0 2 2'); - svg.setAttribute('style', 'position: fixed; top: 0; left: 0; width: 2px; height: 2px;'); - - rect.setAttribute('style', 'transform: translate(1px, 1px);'); - rect.setAttribute('width', '1'); - rect.setAttribute('height', '1'); - - svg.appendChild(rect); - - document.body.appendChild(svg); - - const result = document.elementFromPoint(1, 1) === svg; - - svg.parentNode.removeChild(svg); - - setAnimateSVG(result); - }, []); - - return canAnimateSVG; -}; diff --git a/packages/loaders/src/styled-elements.js b/packages/loaders/src/styled-elements.js index 110400fb51b..015d572c13e 100644 --- a/packages/loaders/src/styled-elements.js +++ b/packages/loaders/src/styled-elements.js @@ -7,42 +7,19 @@ import React from 'react'; import PropTypes from 'prop-types'; -import styled, { css } from 'styled-components'; +import styled from 'styled-components'; import { retrieveTheme } from '@zendeskgarden/react-theming'; -import { dotOneKeyframes, dotTwoKeyframes, dotThreeKeyframes } from './utils/animations'; - -const DotsCircle = styled.circle.attrs(() => ({ - cy: 36, - r: 9 +export const DotsCircle = styled.circle.attrs(props => ({ + cx: 9, + cy: 9, + r: 9, + transform: props.transform }))``; -export const DotsOneCircle = styled(DotsCircle).attrs(() => ({ - cx: 9 -}))` - animation: ${({ duration }) => - css` - ${dotOneKeyframes} ${duration}ms linear infinite - `}; -`; - -export const DotsTwoCircle = styled(DotsCircle).attrs(() => ({ - cx: 40 -}))` - animation: ${({ duration }) => - css` - ${dotTwoKeyframes} ${duration}ms linear infinite - `}; -`; - -export const DotsThreeCircle = styled(DotsCircle).attrs(() => ({ - cx: 71 -}))` - animation: ${({ duration }) => - css` - ${dotThreeKeyframes} ${duration}ms linear infinite - `}; -`; +DotsCircle.propTypes = { + transform: PropTypes.string +}; export const SpinnerCircle = styled.circle.attrs(props => ({ cx: 40, diff --git a/packages/loaders/src/styled-elements.spec.js b/packages/loaders/src/styled-elements.spec.js index 9d04e13c56f..0df927c92bf 100644 --- a/packages/loaders/src/styled-elements.spec.js +++ b/packages/loaders/src/styled-elements.spec.js @@ -7,28 +7,18 @@ import React from 'react'; import { render } from 'garden-test-utils'; -import { - DotsOneCircle, - DotsTwoCircle, - DotsThreeCircle, - SpinnerCircle, - StyledSVG -} from './styled-elements'; +import { DotsCircle, SpinnerCircle, StyledSVG } from './styled-elements'; describe('Loader styled-elements', () => { - describe('DotsCircles', () => { - const cx = [9, 40, 71]; - - [DotsOneCircle, DotsTwoCircle, DotsThreeCircle].forEach((Circle, index) => { - it(`applies correct cx=${cx[index]} coord`, () => { - const { getByTestId } = render( - - - - ); - - expect(getByTestId('circle')).toHaveAttribute('cx', `${cx[index]}`); - }); + describe('DotsCircle', () => { + it('applies transform correctly', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId('circle')).toHaveAttribute('transform', '2'); }); }); @@ -54,28 +44,26 @@ describe('Loader styled-elements', () => { }); describe('StyledSVG', () => { - const props = { 'data-garden-id': 'StyledSVG' }; - it('applies font-size if provided', () => { - const { container } = render(); + const { container } = render(); expect(container.firstChild).toHaveStyleRule('font-size', '12px'); }); it('defaults font-size to inherit if not provided', () => { - const { container } = render(); + const { container } = render(); expect(container.firstChild).toHaveStyleRule('font-size', 'inherit'); }); it('applies color if provided', () => { - const { container } = render(); + const { container } = render(); expect(container.firstChild).toHaveStyleRule('color', 'red'); }); it('defaults color to inherit if not provided', () => { - const { container } = render(); + const { container } = render(); expect(container.firstChild).toHaveStyleRule('color', 'inherit'); }); @@ -84,7 +72,7 @@ describe('Loader styled-elements', () => { const width = '2em'; const height = '4em'; - const { container } = render(); + const { container } = render(); expect(container.firstChild).toHaveAttribute('width', width); expect(container.firstChild).toHaveAttribute('height', height); diff --git a/packages/loaders/src/utils/animations.js b/packages/loaders/src/utils/animations.js index 60ec3fd768e..31bd615eb22 100644 --- a/packages/loaders/src/utils/animations.js +++ b/packages/loaders/src/utils/animations.js @@ -5,93 +5,28 @@ * found at http://www.apache.org/licenses/LICENSE-2.0. */ -import { keyframes } from 'styled-components'; +/** + * Accelerating from zero velocity + * @param {Number} time Time + */ +export function easeInCubic(time) { + return time * time * time; +} -/* stylelint-disable rule-empty-line-before */ -export const dotOneKeyframes = keyframes` - 0% { transform: translate(0, 5px); } - 3% { transform: translate(1px, -5px); } - 6% { transform: translate(3px, -15px); } - 8% { transform: translate(5px, -18px); } - 9% { transform: translate(7px, -21px); } - 11% { transform: translate(8px, -22px); } - 13% { transform: translate(9px, -23px); } - 16% { transform: translate(12px, -25px); } - 18% { transform: translate(13px, -26px); } - 23% { transform: translate(18px, -26px); } - 24% { transform: translate(19px, -25px); } - 28% { transform: translate(22px, -23px); } - 31% { transform: translate(24px, -21px); } - 33% { transform: translate(26px, -18px); } - 34% { transform: translate(28px, -14px); } - 36% { transform: translate(29px, -12px); } - 38% { transform: translate(30px, -5px); } - 39% { transform: translate(31px, 5px); } - 54% { transform: translate(31px, 3px); } - 59% { transform: translate(33px); } - 61% { transform: translate(43px); } - 63% { transform: translate(48px); } - 64% { transform: translate(51px); } - 66% { transform: translate(53px); } - 68% { transform: translate(55px); } - 69% { transform: translate(57px); } - 76% { transform: translate(60px); } - 81% { transform: translate(61px); } - 83%, 100% { transform: translate(62px); } -`; +/** + * Decelerating to zero velocity + * @param {Number} time Time + */ +export function easeOutCubic(time) { + const value = time - 1; -export const dotTwoKeyframes = keyframes` - 4% { transform: translate(0); } - 6% { transform: translate(-1px); } - 8% { transform: translate(-2px); } - 9% { transform: translate(-5px); } - 11% { transform: translate(-7px); } - 13% { transform: translate(-12px); } - 14% { transform: translate(-17px); } - 16% { transform: translate(-19px); } - 18% { transform: translate(-22px); } - 19% { transform: translate(-25px); } - 21% { transform: translate(-26px); } - 23% { transform: translate(-27px); } - 24% { transform: translate(-28px); } - 26% { transform: translate(-29px); } - 29% { transform: translate(-30px); } - 33%, 89% { transform: translate(-31px); } - 91% { transform: translate(-31px, 1px); } - 94% { transform: translate(-31px, 2px); } - 98% { transform: translate(-31px, 3px); } - 99% { transform: translate(-31px, 4px); } - 100% { transform: translate(-31px, 5px); } -`; + return value * value * value + 1; +} -export const dotThreeKeyframes = keyframes` - 39% { transform: translate(0); } - 44% { transform: translate(0, 1px); } - 46% { transform: translate(0, 2px); } - 48% { transform: translate(0, 3px); } - 49% { transform: translate(0, 4px); } - 51% { transform: translate(0, 5px); } - 53% { transform: translate(-1px, -6px); } - 54% { transform: translate(-2px, -13px); } - 56% { transform: translate(-3px, -15px); } - 58% { transform: translate(-5px, -19px); } - 59% { transform: translate(-7px, -21px); } - 61% { transform: translate(-8px, -22px); } - 63% { transform: translate(-9px, -24px); } - 64% { transform: translate(-11px, -25px); } - 66% { transform: translate(-12px, -26px); } - 74% { transform: translate(-19px, -26px); } - 76% { transform: translate(-20px, -25px); } - 78% { transform: translate(-22px, -24px); } - 81% { transform: translate(-24px, -21px); } - 83% { transform: translate(-26px, -19px); } - 84% { transform: translate(-28px, -15px); } - 86% { transform: translate(-29px, -13px); } - 88% { transform: translate(-30px, -6px); } - 89% { transform: translate(-31px, 5px); } - 91% { transform: translate(-31px, 4px); } - 93% { transform: translate(-31px, 3px); } - 94% { transform: translate(-31px, 2px); } - 98% { transform: translate(-31px, 1px); } - 100% { transform: translate(-31px); } -`; +/** + * Acceleration until halfway, then deceleration + * @param {Number} time Time + */ +export function easeInOutCubic(time) { + return time < 0.5 ? 4 * time * time * time : (time - 1) * (2 * time - 2) * (2 * time - 2) + 1; +} diff --git a/packages/loaders/src/utils/animations.spec.js b/packages/loaders/src/utils/animations.spec.js new file mode 100644 index 00000000000..0dd3622c78f --- /dev/null +++ b/packages/loaders/src/utils/animations.spec.js @@ -0,0 +1,32 @@ +/** + * Copyright Zendesk, Inc. + * + * Use of this source code is governed under the Apache License, Version 2.0 + * found at http://www.apache.org/licenses/LICENSE-2.0. + */ + +import { easeInCubic, easeOutCubic, easeInOutCubic } from './animations'; + +describe('animations', () => { + describe('easeInCubic()', () => { + it('returns correct value based on time', () => { + expect(easeInCubic(2)).toBe(8); + }); + }); + + describe('easeOutCubic()', () => { + it('returns correct value based on time', () => { + expect(easeOutCubic(2)).toBe(2); + }); + }); + + describe('easeInOutCubic()', () => { + it('returns correct value when time is less than 0.5', () => { + expect(easeInOutCubic(0.45)).toBeCloseTo(0.3645); + }); + + it('returns correct value when time is greater than 0.5', () => { + expect(easeInOutCubic(3)).toBe(33); + }); + }); +}); diff --git a/packages/loaders/src/utils/dot-coordinates.js b/packages/loaders/src/utils/dot-coordinates.js new file mode 100644 index 00000000000..554cb5d7e95 --- /dev/null +++ b/packages/loaders/src/utils/dot-coordinates.js @@ -0,0 +1,85 @@ +/** + * Copyright Zendesk, Inc. + * + * Use of this source code is governed under the Apache License, Version 2.0 + * found at http://www.apache.org/licenses/LICENSE-2.0. + */ + +import { easeInCubic, easeInOutCubic, easeOutCubic } from './animations'; + +export const KEYFRAME_1 = 0.166666667; +export const KEYFRAME_2 = 0.55; +export const KEYFRAME_3 = 1.166666667; +export const KEYFRAME_4 = 1.333333333; +export const KEYFRAME_5 = 1.533333333; +export const KEYFRAME_MAX = 1.766666667; + +const WIDTH = 80; +const HEIGHT = WIDTH * 0.9; +const CIRCLE_RADIUS = WIDTH * 0.1125; +const MID_X = WIDTH / 2 - CIRCLE_RADIUS; +const MID_Y = HEIGHT / 2 - CIRCLE_RADIUS; +const BOTTOM = MID_Y + 5; + +/** + * Retrieve the X coordinate value + * @param {Number} frame The current frame + */ +export function retrieveXCoordinate(frame) { + let retVal; + + const _frame = frame % KEYFRAME_MAX; + + if (_frame < KEYFRAME_1) { + return MID_X; + } else if (_frame < KEYFRAME_2) { + const frameValue = _frame - KEYFRAME_1; + const frameMaximum = KEYFRAME_2 - KEYFRAME_1; + const easeValue = easeInOutCubic(frameValue / frameMaximum); + + retVal = MID_X - easeValue * MID_X; + } else if (_frame < KEYFRAME_4) { + retVal = 0; + } else { + const frameValue = _frame - KEYFRAME_4; + const frameMaximum = KEYFRAME_MAX - KEYFRAME_4; + + retVal = MID_X * (frameValue / frameMaximum); + } + + if (frame >= KEYFRAME_MAX) { + retVal = MID_X * 2 - retVal; + } + + return retVal; +} + +/** + * Retrieve the Y coordinate value + * @param {Number} frame The current frame + */ +export function retrieveYCoordinate(frame) { + const _frame = frame % KEYFRAME_MAX; + + if (_frame < KEYFRAME_1) { + return (_frame / KEYFRAME_1) * -1 * (BOTTOM - MID_Y) + BOTTOM; + } else if (_frame < KEYFRAME_3) { + return MID_Y; + } else if (_frame < KEYFRAME_4) { + const frameValue = _frame - KEYFRAME_3; + const frameMaximum = KEYFRAME_4 - KEYFRAME_3; + + return (frameValue / frameMaximum) * (BOTTOM - MID_Y) + MID_Y; + } else if (_frame < KEYFRAME_5) { + const frameValue = _frame - KEYFRAME_4; + const frameMaximum = KEYFRAME_5 - KEYFRAME_4; + const easeValue = easeOutCubic(frameValue / frameMaximum); + + return BOTTOM - easeValue * BOTTOM; + } + const frameValue = _frame - KEYFRAME_5; + const frameMaximum = KEYFRAME_MAX - KEYFRAME_5; + const easeValue = easeInCubic(frameValue / frameMaximum); + + return easeValue * BOTTOM; +} diff --git a/packages/loaders/src/utils/dot-coordinates.spec.js b/packages/loaders/src/utils/dot-coordinates.spec.js new file mode 100644 index 00000000000..5d386095680 --- /dev/null +++ b/packages/loaders/src/utils/dot-coordinates.spec.js @@ -0,0 +1,58 @@ +/** + * Copyright Zendesk, Inc. + * + * Use of this source code is governed under the Apache License, Version 2.0 + * found at http://www.apache.org/licenses/LICENSE-2.0. + */ + +import { retrieveXCoordinate, retrieveYCoordinate } from './dot-coordinates'; + +describe('Dot Coordinates', () => { + describe('retrieveXCoordinate()', () => { + it('returns MID_X if sub-frame is within KEYFRAME_1', () => { + expect(retrieveXCoordinate(0.111)).toBe(31); + }); + + it('returns eased MID_X if sub-frame is within KEYFRAME_2', () => { + expect(retrieveXCoordinate(0.5)).toBeCloseTo(0.27517); + }); + + it('returns 0 if sub-frame is within KEYFRAME_4', () => { + expect(retrieveXCoordinate(1.3)).toBe(0); + }); + + it('returns MID_X upper transform if sub-frame is within KEYFRAME_MAX', () => { + expect(retrieveXCoordinate(1.8)).toBe(31); + }); + + it('returns MID_X lower transform if sub-frame otherwise', () => { + expect(retrieveXCoordinate(1.6)).toBeCloseTo(19.0769); + }); + + it('returns correct value if sub-frame is greater than KEYFRAME_MAX', () => { + expect(retrieveXCoordinate(3)).toBe(62); + }); + }); + + describe('retrieveYCoordinate()', () => { + it('returns correct value if sub-frame is within KEYFRAME_1', () => { + expect(retrieveYCoordinate(0.111)).toBeCloseTo(28.67); + }); + + it('returns correct value if sub-frame is within KEYFRAME_3', () => { + expect(retrieveYCoordinate(0.5)).toBe(27); + }); + + it('returns correct value if sub-frame is within KEYFRAME_4', () => { + expect(retrieveYCoordinate(1.3)).toBeCloseTo(31.0); + }); + + it('returns correct value if sub-frame is within KEYFRAME_5', () => { + expect(retrieveYCoordinate(1.5)).toBeCloseTo(0.148); + }); + + it('returns correct value otherwise', () => { + expect(retrieveYCoordinate(1.7)).toBeCloseTo(11.6618); + }); + }); +}); diff --git a/packages/loaders/styleguide.config.js b/packages/loaders/styleguide.config.js index e0bf913c6b2..509292061a4 100644 --- a/packages/loaders/styleguide.config.js +++ b/packages/loaders/styleguide.config.js @@ -18,6 +18,10 @@ module.exports = { { name: 'Components', components: '../../packages/loaders/src/[A-Z]*.js' + }, + { + name: 'Containers', + components: '../../packages/loaders/src/containers/[A-Z]*.js' } ] };