react-toolbox/components/ripple/Ripple.js

266 lines
10 KiB
JavaScript
Raw Normal View History

import React, { Component, PropTypes } from 'react';
import ReactDOM from 'react-dom';
2016-05-16 15:17:26 +03:00
import classnames from 'classnames';
import update from 'immutability-helper';
2016-05-16 15:17:26 +03:00
import { themr } from 'react-css-themr';
2016-05-28 16:37:10 +03:00
import { RIPPLE } from '../identifiers.js';
2016-04-12 22:41:28 +03:00
import events from '../utils/events';
2015-12-07 04:34:12 +03:00
import prefixer from '../utils/prefixer';
2016-08-21 19:44:04 +03:00
import utils from '../utils/utils';
2015-12-07 04:34:12 +03:00
const defaults = {
centered: false,
className: '',
2016-08-06 15:24:40 +03:00
multiple: true,
2016-05-28 16:37:10 +03:00
spread: 2,
theme: {}
2015-12-07 04:34:12 +03:00
};
2016-05-28 16:37:10 +03:00
const rippleFactory = (options = {}) => {
2015-12-07 04:34:12 +03:00
const {
centered: defaultCentered,
className: defaultClassName,
2016-08-06 15:24:40 +03:00
multiple: defaultMultiple,
2016-01-26 16:58:28 +03:00
spread: defaultSpread,
2016-05-28 16:37:10 +03:00
theme: defaultTheme,
2016-01-26 16:58:28 +03:00
...props
2015-12-07 04:34:12 +03:00
} = {...defaults, ...options};
2015-12-07 04:34:12 +03:00
return ComposedComponent => {
class RippledComponent extends Component {
2015-12-07 04:34:12 +03:00
static propTypes = {
children: PropTypes.any,
disabled: PropTypes.bool,
onRippleEnded: PropTypes.func,
ripple: PropTypes.bool,
rippleCentered: PropTypes.bool,
rippleClassName: PropTypes.string,
2016-08-06 15:24:40 +03:00
rippleMultiple: PropTypes.bool,
rippleSpread: PropTypes.number,
theme: PropTypes.shape({
ripple: PropTypes.string,
rippleActive: PropTypes.string,
rippleRestarting: PropTypes.string,
rippleWrapper: PropTypes.string
2016-05-16 15:17:26 +03:00
})
2015-12-07 04:34:12 +03:00
};
2015-12-07 04:34:12 +03:00
static defaultProps = {
disabled: false,
ripple: true,
rippleCentered: defaultCentered,
rippleClassName: defaultClassName,
2016-08-06 15:24:40 +03:00
rippleMultiple: defaultMultiple,
2015-12-07 04:34:12 +03:00
rippleSpread: defaultSpread
};
2015-12-07 04:34:12 +03:00
state = {
2016-08-06 15:24:40 +03:00
ripples: {}
2015-12-07 04:34:12 +03:00
};
componentDidUpdate (prevProps, prevState) {
2016-08-06 15:24:40 +03:00
// If a new ripple was just added, add a remove event listener to its animation
if (Object.keys(prevState.ripples).length < Object.keys(this.state.ripples).length) {
this.addRippleRemoveEventListener(this.getLastKey());
2016-04-12 22:41:28 +03:00
}
}
2016-08-06 21:40:24 +03:00
componentWillUnmount () {
// Remove document event listeners for ripple if they still exists
Object.keys(this.state.ripples).forEach(key => {
this.state.ripples[key].endRipple();
});
}
2016-08-06 15:24:40 +03:00
/**
* Add an event listener to the reference with given key so when the animation transition
* ends we can be sure that it finished and it can be safely removed from the state.
* This function is called whenever a new ripple is added to the component.
*
* @param {String} rippleKey Is the key of the ripple to add the event.
*/
addRippleRemoveEventListener (rippleKey) {
const self = this;
events.addEventListenerOnTransitionEnded(this.refs[rippleKey], function onOpacityEnd (e) {
if (e.propertyName === 'opacity') {
if (self.props.onRippleEnded) self.props.onRippleEnded(e);
events.removeEventListenerOnTransitionEnded(self.refs[rippleKey], onOpacityEnd);
2016-08-21 19:44:04 +03:00
self.setState({ ripples: utils.removeObjectKey(rippleKey, self.state.ripples) });
2016-08-06 15:24:40 +03:00
}
});
2016-04-12 22:41:28 +03:00
}
2016-08-06 15:24:40 +03:00
/**
* Start a ripple animation on an specific point with touch or mouse events. First
* decides if the animation should trigger. If the ripple is multiple or there is no
* ripple present, it creates a new key. If it's a simple ripple and already exists,
* it just restarts the current ripple. The animation happens in two state changes
* to allow triggering via css.
*
* @param {Number} x Coordinate X on the screen where animation should start
* @param {Number} y Coordinate Y on the screen where animation should start
* @param {Boolean} isTouch Use events from touch or mouse.
*/
animateRipple (x, y, isTouch) {
if (this.rippleShouldTrigger(isTouch)) {
const { top, left, width } = this.getDescriptor(x, y);
const noRipplesActive = Object.keys(this.state.ripples).length === 0;
const key = this.props.rippleMultiple || noRipplesActive ? this.getNextKey() : this.getLastKey();
2016-08-06 21:40:24 +03:00
const endRipple = this.addRippleDeactivateEventListener(isTouch, key);
const initialState = { active: false, restarting: true, top, left, width, endRipple };
2016-08-06 15:24:40 +03:00
const runningState = { active: true, restarting: false };
this.setState(update(this.state, { ripples: { [key]: { $set: initialState } } }), () => {
this.refs[key].offsetWidth; //eslint-disable-line no-unused-expressions
this.setState(update(this.state, { ripples: { [key]: { $merge: runningState } } }));
2015-12-07 04:34:12 +03:00
});
}
2016-08-06 15:24:40 +03:00
}
2015-11-29 14:40:09 +03:00
2016-08-06 15:24:40 +03:00
/**
* Determine if a ripple should start depending if its a touch event. For mobile both
* touchStart and mouseDown are launched so in case is touch we should always trigger
* but if its not we should check if a touch was already triggered to decide.
*
* @param {Boolean} isTouch True in case a touch event triggered the ripple false otherwise.
* @return {Boolean} True in case the ripple should trigger or false if it shouldn't.
*/
rippleShouldTrigger (isTouch) {
const shouldStart = isTouch ? true : !this.touchCache;
this.touchCache = isTouch;
return shouldStart;
2015-12-07 04:34:12 +03:00
}
2016-08-06 15:24:40 +03:00
/**
* Find out a descriptor object for the ripple element being created depending on
* the position where the it was triggered and the component's dimensions.
*
* @param {Number} x Coordinate x in the viewport where ripple was triggered
* @param {Number} y Coordinate y in the viewport where ripple was triggered
* @return {Object} Descriptor element including position and size of the element
*/
getDescriptor (x, y) {
const { left, top, height, width } = ReactDOM.findDOMNode(this).getBoundingClientRect();
const { rippleCentered: centered, rippleSpread: spread } = this.props;
2015-12-07 04:34:12 +03:00
return {
2016-08-06 15:24:40 +03:00
left: centered ? 0 : x - left - width / 2,
top: centered ? 0 : y - top - height / 2,
2015-12-07 04:34:12 +03:00
width: width * spread
};
}
2016-08-06 15:24:40 +03:00
/**
* Increments and internal counter and returns the next value as a string. It
* is used to assign key references to new ripple elements.
*
* @return {String} Key to be assigned to a ripple.
*/
getNextKey () {
this.currentCount = this.currentCount ? this.currentCount + 1 : 1;
2016-08-21 19:44:04 +03:00
return `ripple${this.currentCount}`;
2016-08-06 15:24:40 +03:00
}
/**
* Return the last generated key for a ripple element. When there is only one ripple
* and to get the reference when a ripple was just created.
*
* @return {String} The last generated ripple key.
*/
getLastKey () {
2016-08-21 19:44:04 +03:00
return `ripple${this.currentCount}`;
2016-08-06 15:24:40 +03:00
}
/**
2016-08-06 21:40:24 +03:00
* Add an event listener to the document needed to deactivate a ripple and make it dissappear.
* Deactivation can happen with a touchend or mouseup depending on the trigger type. The
* ending function is created from a factory function and returned.
2016-08-06 15:24:40 +03:00
*
* @param {Boolean} isTouch True in case the trigger was a touch event false otherwise.
* @param {String} rippleKey It's a key to identify the ripple that should be deactivated.
2016-08-06 21:40:24 +03:00
* @return {Function} Callback function that deactivates the ripple and removes the event listener
2016-08-06 15:24:40 +03:00
*/
addRippleDeactivateEventListener (isTouch, rippleKey) {
const eventType = isTouch ? 'touchend' : 'mouseup';
2016-08-06 21:40:24 +03:00
const endRipple = this.createRippleDeactivateCallback(eventType, rippleKey);
document.addEventListener(eventType, endRipple);
return endRipple;
}
/**
* Generates a function that can be called to deactivate a given ripple and remove its finishing
* event listener. If is generated because we need to store it to be called on unmount in case
* the ripple is still running.
*
* @param {String} eventType Is the event type that can be touchend or mouseup
* @param {String} rippleKey Is the key representing the ripple
* @return {Function} Callback function that deactivates the ripple and removes the listener
*/
createRippleDeactivateCallback (eventType, rippleKey) {
const self = this;
return function endRipple () {
2016-08-06 15:24:40 +03:00
document.removeEventListener(eventType, endRipple);
self.setState({ ripples: update(self.state.ripples, {
[rippleKey]: { $merge: { active: false } }
}) });
2016-08-06 21:40:24 +03:00
};
2016-08-06 15:24:40 +03:00
}
2015-12-07 04:34:12 +03:00
handleMouseDown = (event) => {
if (this.props.onMouseDown) this.props.onMouseDown(event);
2016-08-06 15:24:40 +03:00
if (!this.props.disabled) {
const { x, y } = events.getMousePosition(event);
this.animateRipple(x, y, false);
}
};
handleTouchStart = (event) => {
if (this.props.onTouchStart) this.props.onTouchStart(event);
if (!this.props.disabled) {
const { x, y } = events.getTouchPosition(event);
this.animateRipple(x, y, true);
}
2015-12-07 04:34:12 +03:00
};
2016-08-06 15:24:40 +03:00
renderRipple (key, className, { active, left, restarting, top, width }) {
const scale = restarting ? 0 : 1;
const transform = `translate3d(${-width / 2 + left}px, ${-width / 2 + top}px, 0) scale(${scale})`;
const _className = classnames(this.props.theme.ripple, {
[this.props.theme.rippleActive]: active,
[this.props.theme.rippleRestarting]: restarting
}, className);
return (
<span key={key} data-react-toolbox='ripple' className={this.props.theme.rippleWrapper} {...props}>
<span
role='ripple'
ref={key}
className={_className}
style={prefixer({ transform }, {width, height: width})}
/>
</span>
);
}
2015-12-07 04:34:12 +03:00
render () {
if (!this.props.ripple) {
return <ComposedComponent {...this.props} />;
} else {
2016-08-06 15:24:40 +03:00
const { ripples } = this.state;
2015-12-07 04:34:12 +03:00
const {
2016-08-06 15:24:40 +03:00
ripple, onRippleEnded, rippleCentered, rippleMultiple, rippleSpread, //eslint-disable-line no-unused-vars
children, rippleClassName: className, ...other
2015-12-07 04:34:12 +03:00
} = this.props;
return (
2016-08-06 15:24:40 +03:00
<ComposedComponent {...other} onMouseDown={this.handleMouseDown} onTouchStart={this.handleTouchStart}>
2015-12-07 04:34:12 +03:00
{children ? children : null}
2016-08-06 15:24:40 +03:00
{Object.keys(ripples).map(key => this.renderRipple(key, className, ripples[key]))}
2015-12-07 04:34:12 +03:00
</ComposedComponent>
);
}
}
2016-05-16 15:17:26 +03:00
}
2016-05-28 16:37:10 +03:00
return themr(RIPPLE, defaultTheme)(RippledComponent);
2015-12-07 04:34:12 +03:00
};
};
2016-05-28 16:37:10 +03:00
export default rippleFactory;