joyent-portal/packages/ui-toolkit/src/slider/react-input-range/input-range/input-range.jsx

726 lines
16 KiB
JavaScript
Executable File

import React, { Component } from 'react';
import PropTypes from 'prop-types';
import * as valueTransformer from './value-transformer';
import rangePropType from './range-prop-type';
import valuePropType from './value-prop-type';
import Slider from './slider';
import Track from './track';
import { captialize, distanceTo, isDefined, isObject, length } from '../utils';
import styled from 'styled-components';
import remcalc from 'remcalc';
export const RangeStyled = styled.div`
position: relative;
min-height: ${remcalc(10)};
`;
/**
* A React component that allows users to input numeric values within a range
* by dragging its sliders.
*/
export default class InputRange extends Component {
/**
* @ignore
* @override
* @return {Object}
*/
static get propTypes() {
return {
ariaLabelledby: PropTypes.string,
ariaControls: PropTypes.string,
classNames: PropTypes.objectOf(PropTypes.string),
disabled: PropTypes.bool,
draggableTrack: PropTypes.bool,
formatLabel: PropTypes.func,
maxValue: rangePropType,
minValue: rangePropType,
name: PropTypes.string,
onChangeStart: PropTypes.func,
onChange: PropTypes.func.isRequired,
onChangeComplete: PropTypes.func,
step: PropTypes.number,
value: valuePropType
};
}
/**
* @ignore
* @override
* @return {Object}
*/
static get defaultProps() {
return {
disabled: false,
maxValue: 10,
minValue: 0,
step: 1
};
}
/**
* @param {Object} props
* @param {string} [props.ariaLabelledby]
* @param {string} [props.ariaControls]
* @param {InputRangeClassNames} [props.classNames]
* @param {boolean} [props.disabled = false]
* @param {Function} [props.formatLabel]
* @param {number|Range} [props.maxValue = 10]
* @param {number|Range} [props.minValue = 0]
* @param {string} [props.name]
* @param {string} props.onChange
* @param {Function} [props.onChangeComplete]
* @param {Function} [props.onChangeStart]
* @param {number} [props.step = 1]
* @param {number|Range} props.value
*/
constructor(props) {
super(props);
/**
* @private
* @type {?number}
*/
this.startValue = null;
/**
* @private
* @type {?Component}
*/
this.node = null;
/**
* @private
* @type {?Component}
*/
this.trackNode = null;
/**
* @private
* @type {bool}
*/
this.isSliderDragging = false;
this.handleSliderDrag = this.handleSliderDrag.bind(this);
this.handleTrackDrag = this.handleTrackDrag.bind(this);
this.handleTrackMouseDown = this.handleTrackMouseDown.bind(this);
this.handleInteractionStart = this.handleInteractionStart.bind(this);
this.handleInteractionEnd = this.handleInteractionEnd.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleMouseDown = this.handleMouseDown.bind(this);
this.handleMouseUp = this.handleMouseUp.bind(this);
this.handleKeyUp = this.handleKeyUp.bind(this);
this.handleTouchStart = this.handleTouchStart.bind(this);
this.handleTouchEnd = this.handleTouchEnd.bind(this);
}
/**
* @ignore
* @override
* @return {void}
*/
componentWillUnmount() {
this.removeDocumentMouseUpListener();
this.removeDocumentTouchEndListener();
}
/**
* Return the bounding rect of the track
* @private
* @return {ClientRect}
*/
getTrackClientRect() {
return this.trackNode.getClientRect();
}
/**
* Return the slider key closest to a point
* @private
* @param {Point} position
* @return {string}
*/
getKeyByPosition(position) {
const values = valueTransformer.getValueFromProps(
this.props,
this.isMultiValue()
);
const positions = valueTransformer.getPositionsFromValues(
values,
this.props.minValue,
this.props.maxValue,
this.getTrackClientRect()
);
if (this.isMultiValue()) {
const distanceToMin = distanceTo(position, positions.min);
const distanceToMax = distanceTo(position, positions.max);
if (distanceToMin < distanceToMax) {
return 'min';
}
}
return 'max';
}
/**
* Return all the slider keys
* @private
* @return {string[]}
*/
getKeys() {
if (this.isMultiValue()) {
return ['min', 'max'];
}
return ['max'];
}
/**
* Return true if the difference between the new and the current value is
* greater or equal to the step amount of the component
* @private
* @param {Range} values
* @return {boolean}
*/
hasStepDifference(values) {
const currentValues = valueTransformer.getValueFromProps(
this.props,
this.isMultiValue()
);
return (
length(values.min, currentValues.min) >= this.props.step ||
length(values.max, currentValues.max) >= this.props.step
);
}
/**
* Return true if the component accepts a min and max value
* @private
* @return {boolean}
*/
isMultiValue() {
return isObject(this.props.value);
}
/**
* Return true if the range is within the max and min value of the component
* @private
* @param {Range} values
* @return {boolean}
*/
isWithinRange(values) {
if (this.isMultiValue()) {
return (
values.min >= this.props.minValue &&
values.max <= this.props.maxValue &&
values.min < values.max
);
}
return (
values.max >= this.props.minValue && values.max <= this.props.maxValue
);
}
/**
* Return true if the new value should trigger a render
* @private
* @param {Range} values
* @return {boolean}
*/
shouldUpdate(values) {
return this.isWithinRange(values) && this.hasStepDifference(values);
}
/**
* Update the position of a slider
* @private
* @param {string} key
* @param {Point} position
* @return {void}
*/
updatePosition(key, position) {
const values = valueTransformer.getValueFromProps(
this.props,
this.isMultiValue()
);
const positions = valueTransformer.getPositionsFromValues(
values,
this.props.minValue,
this.props.maxValue,
this.getTrackClientRect()
);
positions[key] = position;
this.updatePositions(positions);
}
/**
* Update the positions of multiple sliders
* @private
* @param {Object} positions
* @param {Point} positions.min
* @param {Point} positions.max
* @return {void}
*/
updatePositions(positions) {
const values = {
min: valueTransformer.getValueFromPosition(
positions.min,
this.props.minValue,
this.props.maxValue,
this.getTrackClientRect()
),
max: valueTransformer.getValueFromPosition(
positions.max,
this.props.minValue,
this.props.maxValue,
this.getTrackClientRect()
)
};
const transformedValues = {
min: valueTransformer.getStepValueFromValue(values.min, this.props.step),
max: valueTransformer.getStepValueFromValue(values.max, this.props.step)
};
this.updateValues(transformedValues);
}
/**
* Update the value of a slider
* @private
* @param {string} key
* @param {number} value
* @return {void}
*/
updateValue(key, value) {
const values = valueTransformer.getValueFromProps(
this.props,
this.isMultiValue()
);
values[key] = value;
this.updateValues(values);
}
/**
* Update the values of multiple sliders
* @private
* @param {Range|number} values
* @return {void}
*/
updateValues(values) {
if (!this.shouldUpdate(values)) {
return;
}
this.props.onChange(this.isMultiValue() ? values : values.max);
}
/**
* Increment the value of a slider by key name
* @private
* @param {string} key
* @return {void}
*/
incrementValue(key) {
const values = valueTransformer.getValueFromProps(
this.props,
this.isMultiValue()
);
const value = values[key] + this.props.step;
this.updateValue(key, value);
}
/**
* Decrement the value of a slider by key name
* @private
* @param {string} key
* @return {void}
*/
decrementValue(key) {
const values = valueTransformer.getValueFromProps(
this.props,
this.isMultiValue()
);
const value = values[key] - this.props.step;
this.updateValue(key, value);
}
/**
* Listen to mouseup event
* @private
* @return {void}
*/
addDocumentMouseUpListener() {
this.removeDocumentMouseUpListener();
this.node.ownerDocument.addEventListener('mouseup', this.handleMouseUp);
}
/**
* Listen to touchend event
* @private
* @return {void}
*/
addDocumentTouchEndListener() {
this.removeDocumentTouchEndListener();
this.node.ownerDocument.addEventListener('touchend', this.handleTouchEnd);
}
/**
* Stop listening to mouseup event
* @private
* @return {void}
*/
removeDocumentMouseUpListener() {
this.node.ownerDocument.removeEventListener('mouseup', this.handleMouseUp);
}
/**
* Stop listening to touchend event
* @private
* @return {void}
*/
removeDocumentTouchEndListener() {
this.node.ownerDocument.removeEventListener(
'touchend',
this.handleTouchEnd
);
}
/**
* Handle any "mousemove" event received by the slider
* @private
* @param {SyntheticEvent} event
* @param {string} key
* @return {void}
*/
handleSliderDrag(event, key) {
if (this.props.disabled) {
return;
}
const position = valueTransformer.getPositionFromEvent(
event,
this.getTrackClientRect()
);
this.isSliderDragging = true;
requestAnimationFrame(() => this.updatePosition(key, position));
}
/**
* Handle any "mousemove" event received by the track
* @private
* @param {SyntheticEvent} event
* @return {void}
*/
handleTrackDrag(event, prevEvent) {
if (
this.props.disabled ||
!this.props.draggableTrack ||
this.isSliderDragging
) {
return;
}
const { maxValue, minValue, value: { max, min } } = this.props;
const position = valueTransformer.getPositionFromEvent(
event,
this.getTrackClientRect()
);
const value = valueTransformer.getValueFromPosition(
position,
minValue,
maxValue,
this.getTrackClientRect()
);
const stepValue = valueTransformer.getStepValueFromValue(
value,
this.props.step
);
const prevPosition = valueTransformer.getPositionFromEvent(
prevEvent,
this.getTrackClientRect()
);
const prevValue = valueTransformer.getValueFromPosition(
prevPosition,
minValue,
maxValue,
this.getTrackClientRect()
);
const prevStepValue = valueTransformer.getStepValueFromValue(
prevValue,
this.props.step
);
const offset = prevStepValue - stepValue;
const transformedValues = {
min: min - offset,
max: max - offset
};
this.updateValues(transformedValues);
}
/**
* Handle any "mousedown" event received by the track
* @private
* @param {SyntheticEvent} event
* @param {Point} position
* @return {void}
*/
handleTrackMouseDown(event, position) {
if (this.props.disabled) {
return;
}
const { maxValue, minValue, value: { max, min } } = this.props;
event.preventDefault();
const value = valueTransformer.getValueFromPosition(
position,
minValue,
maxValue,
this.getTrackClientRect()
);
const stepValue = valueTransformer.getStepValueFromValue(
value,
this.props.step
);
if (!this.props.draggableTrack || stepValue > max || stepValue < min) {
this.updatePosition(this.getKeyByPosition(position), position);
}
}
/**
* Handle the start of any mouse/touch event
* @private
* @return {void}
*/
handleInteractionStart() {
if (this.props.onChangeStart) {
this.props.onChangeStart(this.props.value);
}
if (this.props.onChangeComplete && !isDefined(this.startValue)) {
this.startValue = this.props.value;
}
}
/**
* Handle the end of any mouse/touch event
* @private
* @return {void}
*/
handleInteractionEnd() {
if (this.isSliderDragging) {
this.isSliderDragging = false;
}
if (!this.props.onChangeComplete || !isDefined(this.startValue)) {
return;
}
if (this.startValue !== this.props.value) {
this.props.onChangeComplete(this.props.value);
}
this.startValue = null;
}
/**
* Handle any "keydown" event received by the component
* @private
* @param {SyntheticEvent} event
* @return {void}
*/
handleKeyDown(event) {
this.handleInteractionStart(event);
}
/**
* Handle any "keyup" event received by the component
* @private
* @param {SyntheticEvent} event
* @return {void}
*/
handleKeyUp(event) {
this.handleInteractionEnd(event);
}
/**
* Handle any "mousedown" event received by the component
* @private
* @param {SyntheticEvent} event
* @return {void}
*/
handleMouseDown(event) {
this.handleInteractionStart(event);
this.addDocumentMouseUpListener();
}
/**
* Handle any "mouseup" event received by the component
* @private
* @param {SyntheticEvent} event
*/
handleMouseUp(event) {
this.handleInteractionEnd(event);
this.removeDocumentMouseUpListener();
}
/**
* Handle any "touchstart" event received by the component
* @private
* @param {SyntheticEvent} event
* @return {void}
*/
handleTouchStart(event) {
this.handleInteractionStart(event);
this.addDocumentTouchEndListener();
}
/**
* Handle any "touchend" event received by the component
* @private
* @param {SyntheticEvent} event
*/
handleTouchEnd(event) {
this.handleInteractionEnd(event);
this.removeDocumentTouchEndListener();
}
/**
* Return JSX of sliders
* @private
* @return {JSX.Element}
*/
renderSliders() {
const values = valueTransformer.getValueFromProps(
this.props,
this.isMultiValue()
);
const percentages = valueTransformer.getPercentagesFromValues(
values,
this.props.minValue,
this.props.maxValue
);
return this.getKeys().map(key => {
const value = values[key];
const percentage = percentages[key];
let { maxValue, minValue } = this.props;
if (key === 'min') {
maxValue = values.max;
} else {
minValue = values.min;
}
const slider = (
<Slider
ariaLabelledby={this.props.ariaLabelledby}
ariaControls={this.props.ariaControls}
classNames={this.props.classNames}
formatLabel={this.props.formatLabel}
key={key}
maxValue={maxValue}
minValue={minValue}
onSliderDrag={this.handleSliderDrag}
percentage={percentage}
type={key}
value={value}
/>
);
return slider;
});
}
/**
* Return JSX of hidden inputs
* @private
* @return {JSX.Element}
*/
renderHiddenInputs() {
if (!this.props.name) {
return [];
}
const isMultiValue = this.isMultiValue();
const values = valueTransformer.getValueFromProps(this.props, isMultiValue);
return this.getKeys().map(key => {
const value = values[key];
const name = isMultiValue
? `${this.props.name}${captialize(key)}`
: this.props.name;
return <input key={key} type="hidden" name={name} value={value} />;
});
}
/**
* @ignore
* @override
* @return {JSX.Element}
*/
render() {
const values = valueTransformer.getValueFromProps(
this.props,
this.isMultiValue()
);
const percentages = valueTransformer.getPercentagesFromValues(
values,
this.props.minValue,
this.props.maxValue
);
return (
<RangeStyled
aria-disabled={this.props.disabled}
ref={node => {
this.node = node;
}}
onKeyDown={this.handleKeyDown}
onKeyUp={this.handleKeyUp}
onMouseDown={this.handleMouseDown}
onTouchStart={this.handleTouchStart}
>
<Track
classNames={this.props.classNames}
draggableTrack={this.props.draggableTrack}
ref={trackNode => {
this.trackNode = trackNode;
}}
percentages={percentages}
onTrackDrag={this.handleTrackDrag}
onTrackMouseDown={this.handleTrackMouseDown}
>
{this.renderSliders()}
</Track>
{this.renderHiddenInputs()}
</RangeStyled>
);
}
}