From f506dd9bc2d34851511575c4fb295815ffb78d6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= Date: Tue, 4 Jun 2019 09:19:03 +0200 Subject: [PATCH] fix: make #progress-indicator run more smoothly on `on_complete` usage (#194) * chore: move #progress-indicator to own story file * WIP: use requestAnimationFrame to watch the onComplete animation usage * WIP: make requestAnimationFrame work with onComplete animation usage * fix: enhance #progress-indicator to run more smoothly on `on_complete` usage --- .../components/progress-indicator/Examples.js | 5 +- .../progress-indicator/ProgressIndicator.js | 64 +++----- .../ProgressIndicatorCircular.js | 151 +++++++++++++++++- .../ProgressIndicator.test.js.snap | 20 ++- .../style/_progress-indicator.scss | 5 +- .../stories/components/ProgressIndicator.js | 44 +++++ .../dnb-ui-lib/stories/componentsStories.js | 41 +---- 7 files changed, 227 insertions(+), 103 deletions(-) create mode 100644 packages/dnb-ui-lib/stories/components/ProgressIndicator.js diff --git a/packages/dnb-design-system-portal/src/pages/uilib/components/progress-indicator/Examples.js b/packages/dnb-design-system-portal/src/pages/uilib/components/progress-indicator/Examples.js index 3803feae491..b86b3591bc3 100644 --- a/packages/dnb-design-system-portal/src/pages/uilib/components/progress-indicator/Examples.js +++ b/packages/dnb-design-system-portal/src/pages/uilib/components/progress-indicator/Examples.js @@ -50,14 +50,15 @@ class Example extends PureComponent { `} {/* @jsx */ ` () => { + const random = (min, max) => (Math.floor( Math.random () * (max - min + 1)) + min) const [visible, setVisibe] = useState(true) useEffect(() => { - const timer = setInterval(() => setVisibe(!visible), 2400) + const timer = setInterval(() => setVisibe(!visible), random(2400, 4200)) return () => clearTimeout(timer) }) return ( diff --git a/packages/dnb-ui-lib/src/components/progress-indicator/ProgressIndicator.js b/packages/dnb-ui-lib/src/components/progress-indicator/ProgressIndicator.js index 21b303b0edb..ca6c0d4ae68 100644 --- a/packages/dnb-ui-lib/src/components/progress-indicator/ProgressIndicator.js +++ b/packages/dnb-ui-lib/src/components/progress-indicator/ProgressIndicator.js @@ -7,6 +7,7 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' import classnames from 'classnames' import { + isTrue, registerElement, validateDOMAttributes, dispatchCustomElementEvent @@ -74,10 +75,9 @@ export default class ProgressIndicator extends PureComponent { static getDerivedStateFromProps(props, state) { if (state._listenForPropChanges) { - state.visible = Boolean(props.visible) + state.visible = isTrue(props.visible) if (state.visible) { state.complete = false - state.startTime = new Date().getTime() } if (parseFloat(props.progress) > -1) { state.progress = props.progress @@ -87,20 +87,8 @@ export default class ProgressIndicator extends PureComponent { return state } - constructor(props) { - super(props) - - // this._id = - // props.id || `dnb-progress-indicator-${Math.round(Math.random() * 999)}` // cause we need an id anyway - - this.state = { - _listenForPropChanges: true, - visible: Boolean(props.visible), - complete: false, - progress: props.progress - } - - this.firstDelay = 300 // wait for the rest time + 200 start delay + state = { + _listenForPropChanges: true } componentWillUnmount() { @@ -108,31 +96,17 @@ export default class ProgressIndicator extends PureComponent { clearTimeout(this.fadeOutTimeout) } - callOnCompleteHandler() { - if (typeof this.props.on_complete === 'function') { - this.fadeOutTimeout = setTimeout(() => { - dispatchCustomElementEvent(this, 'on_complete') - }, 600) // wait for CSS fade out, defined in "progress-indicator-fade-out" - } - } - - delayVisibility() { - if (this.state.complete) { - return - } - - const duration = 1e3 // the duration, defined in CSS - const difference = new Date().getTime() - this.state.startTime - const ceil = Math.ceil(difference / duration) * duration - const timeToWait = ceil - difference - - this.fadeOutTimeout = setTimeout(() => { - this.firstDelay = 0 + callOnCompleteHandler = () => { + this.completeTimeout = setTimeout(() => { this.setState({ complete: true }) - this.callOnCompleteHandler() - }, timeToWait + this.firstDelay) + if (typeof this.props.on_complete === 'function') { + this.fadeOutTimeout = setTimeout(() => { + dispatchCustomElementEvent(this, 'on_complete') + }, 600) // wait for CSS fade out, defined in "progress-indicator-fade-out" + } + }, 200) } render() { @@ -140,8 +114,10 @@ export default class ProgressIndicator extends PureComponent { type, size, no_animation, + on_complete, progress: _progress, //eslint-disable-line visible: _visible, //eslint-disable-line + complete: _complete, //eslint-disable-line ...props } = this.props @@ -157,20 +133,13 @@ export default class ProgressIndicator extends PureComponent { validateDOMAttributes(this.props, params) - const isComplete = - visible === false || - (hasProgressIndicator && parseFloat(progress) >= 100) - if (isComplete) { - this.delayVisibility() - } - return (
@@ -178,7 +147,10 @@ export default class ProgressIndicator extends PureComponent { )}
diff --git a/packages/dnb-ui-lib/src/components/progress-indicator/ProgressIndicatorCircular.js b/packages/dnb-ui-lib/src/components/progress-indicator/ProgressIndicatorCircular.js index 7c08bae19a1..e7035260008 100644 --- a/packages/dnb-ui-lib/src/components/progress-indicator/ProgressIndicatorCircular.js +++ b/packages/dnb-ui-lib/src/components/progress-indicator/ProgressIndicatorCircular.js @@ -10,26 +10,155 @@ import { validateDOMAttributes } from '../../shared/component-helper' export const propTypes = { size: PropTypes.string, + visible: PropTypes.bool, complete: PropTypes.bool, progress: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - maxOffset: PropTypes.number + maxOffset: PropTypes.number, + onComplete: PropTypes.func, + callOnCompleteHandler: PropTypes.func } export const defaultProps = { size: null, + visible: true, complete: false, progress: null, - maxOffset: 88 + maxOffset: 88, + onComplete: null, + callOnCompleteHandler: null } export default class ProgressIndicatorCircular extends PureComponent { static propTypes = propTypes static defaultProps = defaultProps + static getDerivedStateFromProps(props, state) { + state.progress = parseFloat(props.progress) + state.visible = props.visible + state.complete = props.complete + return state + } + constructor(props) { + super(props) + this.useAnimationFrame = typeof props.onComplete === 'function' + this._refDark = React.createRef() + this._refLight = React.createRef() + this.state = { animate: false } + } + componentDidMount() { + if (this.useAnimationFrame) { + this.startAnimationFirstTime() + } + } + componentWillUnmount() { + this.stopAnimation() + } + stopAnimation() { + this.setState({ animate: false }) + if (this.startupTimeout) { + clearTimeout(this.startupTimeout) + } + } + startAnimationFirstTime() { + this.setState({ animate: false }) + this.startupTimeout = setTimeout(() => this.startAnimation(), 300) + } + startAnimation() { + this.setState({ animate: true }, () => { + if (this._refDark.current) { + this.animate( + this._refDark.current, + true, + this.props.callOnCompleteHandler + ) + } + if (this._refLight.current) { + this.animate(this._refLight.current, false) + } + }) + } + animate(element, animateOnStart = true, callback = null) { + const min = 1 + const max = 88 + let start = 0, + ms = 0, + prog = max, + setProg = animateOnStart, + animate = true, + completeCalled = false, + stopNextRound = false + + const step = timestamp => { + if (!start) { + start = timestamp + } + + // milliseconds + ms = timestamp - start + + if (animate) { + if (!this.state.visible && prog < 5) { + prog = min + } + if (setProg) { + element.style['stroke-dashoffset'] = prog + } else if (!animateOnStart) { + element.style['stroke-dashoffset'] = max + } + } + + // if complete + if (stopNextRound) { + animate = false + if (!completeCalled) { + completeCalled = true + if (animateOnStart && typeof callback === 'function') { + callback() + } + } else if (this.state.visible && ms % 1e3 > 950) { + // this.startAnimationFirstTime() // will not start completely from scratch + stopNextRound = false + } + } else { + // make sure we stop next round + stopNextRound = !this.state.visible && prog === min + animate = true + completeCalled = false + } + + // sice we have 1sec as duration, and we want always a max of 1000ms + prog = Math.round(max - (max / 1e3) * (ms % 1e3)) + + // calc if we want to animate + setProg = animateOnStart + ? Math.ceil(ms / 1e3) % 2 === 1 || ms === 0 + : Math.ceil(ms / 1e3) % 2 === 0 && ms !== 0 + + if (this.state.animate) { + window.requestAnimationFrame(step) + } + } + if (typeof window !== 'undefined' && window.requestAnimationFrame) { + window.requestAnimationFrame(step) + } + } render() { - const { size, complete, maxOffset, progress } = this.props + const { + size, + maxOffset, + progress: _progress, // eslint-disable-line + visible, // eslint-disable-line + complete, // eslint-disable-line + onComplete, // eslint-disable-line + callOnCompleteHandler, // eslint-disable-line + + ...rest + } = this.props + + const { progress } = this.state + const strokeDashoffset = maxOffset - (maxOffset / 100) * progress const hasProgressIndicator = parseFloat(progress) > -1 - const params = {} + const params = { ...rest } if (hasProgressIndicator) { params['title'] = `${progress}%` params['aria-label'] = `${progress}%` @@ -61,17 +190,22 @@ export default class ProgressIndicatorCircular extends PureComponent { className={classnames( 'dnb-progress-indicator__circular__line', 'dark', - hasProgressIndicator || complete ? 'paused' : null + 'dark', + hasProgressIndicator || this.useAnimationFrame + ? 'paused' + : null )} style={hasProgressIndicator ? { strokeDashoffset } : {}} + ref={this._refDark} /> {!hasProgressIndicator && ( )} @@ -79,12 +213,13 @@ export default class ProgressIndicatorCircular extends PureComponent { } } -const Circle = ({ className, ...rest }) => { +const Circle = React.forwardRef(({ className, ...rest }, ref) => { return ( { /> ) -} +}) Circle.propTypes = { className: PropTypes.string.isRequired } diff --git a/packages/dnb-ui-lib/src/components/progress-indicator/__tests__/__snapshots__/ProgressIndicator.test.js.snap b/packages/dnb-ui-lib/src/components/progress-indicator/__tests__/__snapshots__/ProgressIndicator.test.js.snap index d7efebef106..c388d246b91 100644 --- a/packages/dnb-ui-lib/src/components/progress-indicator/__tests__/__snapshots__/ProgressIndicator.test.js.snap +++ b/packages/dnb-ui-lib/src/components/progress-indicator/__tests__/__snapshots__/ProgressIndicator.test.js.snap @@ -26,17 +26,20 @@ exports[`Circular ProgressIndicator component have to match snapshot 1`] = ` className="dnb-progress-indicator dnb-progress-indicator--visible" >
- - - + - +
@@ -184,6 +187,7 @@ exports[`ProgressIndicator scss have to match snapshot 1`] = ` * */ :root { + --progress-indicator-timing: cubic-bezier(0.5, 0, 0.5, 0.99); --progress-indicator-circular-circle: 88; --progress-indicator-circular-circle-offset--min: 88; --progress-indicator-circular-circle-offset--max: 1; } @@ -202,7 +206,7 @@ exports[`ProgressIndicator scss have to match snapshot 1`] = ` .dnb-progress-indicator__circular__line { animation-duration: 2s; animation-delay: 200ms; - animation-timing-function: cubic-bezier(0.5, 0, 0.5, 0.99); + animation-timing-function: var(--progress-indicator-timing); animation-iteration-count: infinite; } .dnb-progress-indicator__circular__line.background { stroke-dashoffset: var(--progress-indicator-circular-circle-offset--max); } @@ -217,7 +221,7 @@ exports[`ProgressIndicator scss have to match snapshot 1`] = ` .dnb-progress-indicator__circular__line.paused { animation-play-state: paused; } .dnb-progress-indicator__circular--has-progress-indicator .dnb-progress-indicator__circular__line.dark { - transition: stroke-dashoffset 600ms cubic-bezier(0.5, 0, 0.5, 0.99); } + transition: stroke-dashoffset 600ms var(--progress-indicator-timing); } .dnb-progress-indicator__circular__circle { stroke-linecap: round; } .dnb-progress-indicator__circular__line.light .dnb-progress-indicator__circular__circle { diff --git a/packages/dnb-ui-lib/src/components/progress-indicator/style/_progress-indicator.scss b/packages/dnb-ui-lib/src/components/progress-indicator/style/_progress-indicator.scss index b67b5bf483b..a2dc403defa 100644 --- a/packages/dnb-ui-lib/src/components/progress-indicator/style/_progress-indicator.scss +++ b/packages/dnb-ui-lib/src/components/progress-indicator/style/_progress-indicator.scss @@ -4,6 +4,7 @@ */ :root { + --progress-indicator-timing: cubic-bezier(0.5, 0, 0.5, 0.99); --progress-indicator-circular-circle: 88; --progress-indicator-circular-circle-offset--min: 88; --progress-indicator-circular-circle-offset--max: 1; @@ -32,7 +33,7 @@ &__line { animation-duration: 2s; animation-delay: 200ms; - animation-timing-function: cubic-bezier(0.5, 0, 0.5, 0.99); + animation-timing-function: var(--progress-indicator-timing); animation-iteration-count: infinite; } @@ -62,7 +63,7 @@ } // for static progress-indicator animation &--has-progress-indicator &__line.dark { - transition: stroke-dashoffset 600ms cubic-bezier(0.5, 0, 0.5, 0.99); + transition: stroke-dashoffset 600ms var(--progress-indicator-timing); } &__circle { diff --git a/packages/dnb-ui-lib/stories/components/ProgressIndicator.js b/packages/dnb-ui-lib/stories/components/ProgressIndicator.js new file mode 100644 index 00000000000..0036b05ec6a --- /dev/null +++ b/packages/dnb-ui-lib/stories/components/ProgressIndicator.js @@ -0,0 +1,44 @@ +/** + * dnb-ui-lib Component Story + * + */ + +import React, { useState, useEffect } from 'react' +import { Wrapper, Box } from '../helpers' +// import styled from '@emotion/styled' + +import { ProgressIndicator } from '../../src/components' + +export default [ + 'ProgressIndicator', + () => ( + + + + + + + + + + + + ) +] +const ProgressIndicatorCircular = () => { + const [visible, setVisibe] = useState(true) + useEffect(() => { + const timer = setInterval(() => setVisibe(!visible), 3400) + return () => clearInterval(timer) + }) + return ( + { + console.log('on_complete') + }} + /> + ) +} diff --git a/packages/dnb-ui-lib/stories/componentsStories.js b/packages/dnb-ui-lib/stories/componentsStories.js index 3bdcf124190..8ed68d24526 100644 --- a/packages/dnb-ui-lib/stories/componentsStories.js +++ b/packages/dnb-ui-lib/stories/componentsStories.js @@ -3,7 +3,7 @@ * */ -import React, { useState, useEffect } from 'react' +import React from 'react' import { Wrapper, Box } from './helpers' import styled from '@emotion/styled' @@ -11,6 +11,7 @@ import styled from '@emotion/styled' import Radio from './components/Radio' import DatePicker from './components/DatePicker' import Textarea from './components/Textarea' +import ProgressIndicator from './components/ProgressIndicator' import { Button, Tabs, @@ -24,8 +25,7 @@ import { Switch, Checkbox, Logo, - StepIndicator, - ProgressIndicator + StepIndicator } from '../src/components' import { H2, P, Hr } from '../src/elements' @@ -35,6 +35,7 @@ export default stories stories.push(Radio) stories.push(DatePicker) stories.push(Textarea) +stories.push(ProgressIndicator) const CustomStyle = styled.div` p { @@ -517,40 +518,6 @@ stories.push([ ) ]) -stories.push([ - 'ProgressIndicator', - () => ( - - - - - - - - - - - - ) -]) -const ProgressIndicatorCircular = () => { - const [visible, setVisibe] = useState(true) - useEffect(() => { - const timer = setInterval(() => setVisibe(!visible), 2400) - return () => clearInterval(timer) - }) - return ( - { - console.log('on_complete') - }} - /> - ) -} - stories.push([ 'Dropdown', () => (