|
import React, { Component } from 'react'; |
|
import classNames from 'classnames'; |
|
import './CounterButton.css'; |
|
|
|
const WIDTH_DIFF_THRESHOLD = 2.5; |
|
|
|
const PARSABLE_PROPERTIES = ['margin-left', 'animation-duration']; |
|
const getComputedProperty = (node, property) => { |
|
const value = window.getComputedStyle(node)[property]; |
|
if (PARSABLE_PROPERTIES.includes(property)) { |
|
return parseFloat(value); |
|
} |
|
return value; |
|
} |
|
|
|
class CounterButton extends Component { |
|
deferredUpdates = []; |
|
|
|
state = { |
|
count: this.props.count, |
|
next: this.props.count + 1, |
|
prev: this.props.count - 1, |
|
animation: null, |
|
width: null, |
|
} |
|
|
|
componentDidMount() { |
|
this.ANIMATION_DURATION = |
|
getComputedProperty(this.currentNode, 'animation-duration') * 1000; |
|
|
|
this.setState({ |
|
width: (() => { |
|
// @todo I should probbarly avoid a second call here |
|
const margin = getComputedProperty(this.counterNode, 'margin-left'); |
|
const initial = this.counter.getBoundingClientRect().width; |
|
const current = this.currentNode.getBoundingClientRect().width; |
|
return initial + margin + current; |
|
})(), |
|
}) |
|
} |
|
|
|
componentWillReceiveProps(nextProps) { |
|
if (this.state.animation === null) { |
|
this.animateNumber(nextProps.count); |
|
} else { |
|
this.deferredUpdates.push(nextProps.count); |
|
} |
|
} |
|
|
|
animateNumber(nextCount) { |
|
if (nextCount > this.state.count) { |
|
this.setState({ animation: 'inc', next: nextCount }, () => { |
|
this.setState({ width: this.getWidth(this.nextNode) }); |
|
this.updateCount(nextCount) |
|
}); |
|
} else { |
|
this.setState({ animation: 'dec', prev: nextCount }, () => { |
|
this.setState({ width: this.getWidth(this.prevNode) }); |
|
this.updateCount(nextCount) |
|
}); |
|
} |
|
} |
|
|
|
updateCount(number) { |
|
setTimeout(() => { |
|
this.setState({ count: number, animation: null }, this.handleDeferredItems); |
|
}, this.ANIMATION_DURATION); |
|
} |
|
|
|
handleDeferredItems() { |
|
const deferredLength = this.deferredUpdates.length; |
|
if (deferredLength > 0) { |
|
const lastDeferredNumber = this.deferredUpdates[deferredLength - 1]; |
|
if (lastDeferredNumber !== this.state.count) { |
|
this.animateNumber(lastDeferredNumber); |
|
} |
|
this.deferredUpdates = []; |
|
} |
|
} |
|
|
|
getWidth(nextNode) { |
|
const totalWidth = this.counter.getBoundingClientRect().width; |
|
const currentWidth = this.currentNode.getBoundingClientRect().width; |
|
const nextWidth = nextNode.getBoundingClientRect().width; |
|
if (Math.abs(currentWidth - nextWidth) < WIDTH_DIFF_THRESHOLD) { |
|
return totalWidth; |
|
}; |
|
const newWidth = totalWidth - currentWidth + nextWidth; |
|
return newWidth; |
|
} |
|
|
|
get buttonClassName() { |
|
return classNames('counter', { |
|
incrementing: this.state.animation === 'inc', |
|
decrementing: this.state.animation === 'dec', |
|
}) |
|
} |
|
|
|
render() { |
|
return ( |
|
<button |
|
className={this.buttonClassName} |
|
ref={n => { this.counter = n; }} |
|
style={{ width: this.state.width }} |
|
> |
|
{this.props.children} |
|
<div |
|
className="counter-counts" |
|
ref={n => { this.counterNode = n; }} |
|
> |
|
<span |
|
className="counter-count counter-count--next" |
|
ref={n => { this.nextNode = n; }} |
|
children={this.state.next} |
|
/> |
|
<span |
|
className="counter-count counter-count--active" |
|
ref={n => { this.currentNode = n; }} |
|
children={this.state.count} |
|
/> |
|
<span |
|
className="counter-count counter-count--prev" |
|
ref={n => { this.prevNode = n; }} |
|
children={this.state.prev} |
|
/> |
|
</div> |
|
</button> |
|
); |
|
} |
|
} |
|
|
|
export default CounterButton; |
See live demo: https://react-counter-button.herokuapp.com/