@wfp/ui
Version:
WFP UI Kit
476 lines (418 loc) • 11.9 kB
JavaScript
import React, { Component } from 'react';
import { sliderValuePropSync } from '../../internal/FeatureFlags';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import isEqual from 'lodash.isequal';
import TextInput from '../TextInput';
import settings from '../../globals/js/settings';
const { prefix } = settings;
const defaultFormatLabel = (value, label) => {
return typeof label === 'function' ? label(value) : `${value}${label}`;
};
/** Sliders provide a visual indication of adjustable content, where the user can move the handle along a horizontal track to increase or decrease the value. */
export default class Slider extends Component {
static propTypes = {
/**
* The CSS class name for the slider.
*/
className: PropTypes.string,
/**
* `true` to hide the number input box.
*/
hideTextInput: PropTypes.bool,
/**
* The ID of the `<input>`.
*/
id: PropTypes.string,
/**
* The callback to get notified of change in value.
*/
onChange: PropTypes.func,
/**
* The value.
*/
value: PropTypes.number.isRequired,
/**
* The minimum value.
*/
min: PropTypes.number.isRequired,
/**
* The label associated with the minimum value.
*/
minLabel: PropTypes.string,
/**
* The maximum value.
*/
max: PropTypes.number.isRequired,
/**
* The label associated with the maximum value.
*/
maxLabel: PropTypes.string,
/**
* The callback to format the label associated with the minimum/maximum value.
*/
formatLabel: PropTypes.func,
/**
* The label for the slider.
*/
labelText: PropTypes.node,
/**
* A value determining how much the value should increase/decrease by moving the thumb by mouse.
*/
step: PropTypes.number,
/**
* A value determining how much the value should increase/decrease by Shift+arrow keys, which will be `(max - min) / stepMuliplier`.
*/
stepMuliplier: PropTypes.number,
/**
* The child nodes.
*/
children: PropTypes.node,
/**
* `true` to disable this slider.
*/
disabled: PropTypes.bool,
/**
* The `name` attribute of the `<input>`.
*/
name: PropTypes.string,
/**
* The `type` attribute of the `<input>`.
*/
inputType: PropTypes.string,
/**
* The `ariaLabel` for the `<input>`.
*/
ariaLabelInput: PropTypes.string,
/**
* `true` to use the light version. (experimental)
*/
light: PropTypes.bool,
/**
* Use the width of the parent element
*/
fullWidth: PropTypes.bool,
};
static defaultProps = {
fullWidth: false,
hideTextInput: false,
step: 1,
stepMuliplier: 4,
disabled: false,
minLabel: '',
maxLabel: '',
inputType: 'number',
ariaLabelInput: 'Slider number input',
light: false,
};
state = {
dragging: false,
value: this.props.value,
left: 0,
};
componentDidMount() {
this.updatePosition();
}
static getDerivedStateFromProps({ value, min, max }, state) {
const { value: currentValue, prevValue, prevMin, prevMax } = state;
if (
!sliderValuePropSync ||
(prevValue === value && prevMin === min && prevMax === max)
) {
return null;
}
const effectiveValue = Math.min(
Math.max(prevValue === value ? currentValue : value, min),
max
);
return {
value: effectiveValue,
left: ((effectiveValue - min) / (max - min)) * 100,
prevValue: value,
prevMin: min,
prevMax: max,
};
}
shouldComponentUpdate(nextProps) {
if (!sliderValuePropSync && !isEqual(nextProps, this.props)) {
this.updatePosition();
}
return true;
}
updatePosition = (evt) => {
if (evt && this.props.disabled) {
return;
}
if (evt && evt.dispatchConfig) {
evt.persist();
}
if (this.state.dragging) {
return;
}
this.setState({ dragging: true });
requestAnimationFrame(() => {
this.setState((prevState, props) => {
// Note: In FF, `evt.target` of `mousemove` event can be `HTMLDocument` which doesn't have `classList`.
// One example is dragging out of browser viewport.
const fromInput =
evt &&
evt.target &&
evt.target.classList &&
evt.target.classList.contains('wfp-slider-text-input');
const { left, newValue: newSliderValue } = this.calcValue(
evt,
prevState,
props
);
const newValue = fromInput ? Number(evt.target.value) : newSliderValue;
if (prevState.left === left && prevState.value === newValue) {
return { dragging: false };
}
if (typeof props.onChange === 'function') {
props.onChange(newValue);
}
return {
dragging: false,
left,
value: newValue,
};
});
});
};
calcValue = (evt, prevState, props) => {
const { min, max, step, stepMuliplier } = props;
const { value } = prevState;
const range = max - min;
const valuePercentage = ((value - min) / range) * 100;
let left;
let newValue;
left = valuePercentage;
newValue = value;
if (evt) {
const { type } = evt;
if (type === 'keydown') {
const direction = {
40: -1, // decreasing
37: -1, // decreasing
38: 1, // increasing
39: 1, // increasing
}[evt.which];
if (direction !== undefined) {
const multiplier =
evt.shiftKey === true ? range / step / stepMuliplier : 1;
const stepMultiplied = step * multiplier;
const stepSize = (stepMultiplied / range) * 100;
left = valuePercentage + stepSize * direction;
newValue = Number(value) + stepMultiplied * direction;
}
}
if (type === 'mousemove' || type === 'click' || type === 'touchmove') {
const clientX = evt.touches ? evt.touches[0].clientX : evt.clientX;
const track = this.track.getBoundingClientRect();
const ratio = (clientX - track.left) / track.width;
const rounded = min + Math.round((range * ratio) / step) * step;
left = ((rounded - min) / range) * 100;
newValue = rounded;
}
}
if (newValue <= Number(min)) {
left = 0;
newValue = min;
}
if (newValue >= Number(max)) {
left = 100;
newValue = max;
}
return { left, newValue };
};
handleMouseStart = () => {
this.element.ownerDocument.addEventListener(
'mousemove',
this.updatePosition
);
this.element.ownerDocument.addEventListener('mouseup', this.handleMouseEnd);
};
handleMouseEnd = () => {
this.element.ownerDocument.removeEventListener(
'mousemove',
this.updatePosition
);
this.element.ownerDocument.removeEventListener(
'mouseup',
this.handleMouseEnd
);
};
handleTouchStart = () => {
this.element.ownerDocument.addEventListener(
'touchmove',
this.updatePosition
);
this.element.ownerDocument.addEventListener('touchup', this.handleTouchEnd);
this.element.ownerDocument.addEventListener(
'touchend',
this.handleTouchEnd
);
this.element.ownerDocument.addEventListener(
'touchcancel',
this.handleTouchEnd
);
};
handleTouchEnd = () => {
this.element.ownerDocument.removeEventListener(
'touchmove',
this.updatePosition
);
this.element.ownerDocument.removeEventListener(
'touchup',
this.handleTouchEnd
);
this.element.ownerDocument.removeEventListener(
'touchend',
this.handleTouchEnd
);
this.element.ownerDocument.removeEventListener(
'touchcancel',
this.handleTouchEnd
);
};
handleChange = (evt) => {
this.setState({ value: evt.target.value });
this.updatePosition(evt);
};
render() {
const {
ariaLabelInput,
className,
hideTextInput,
id = (this.inputId =
this.inputId || `__wfp-slider_${Math.random().toString(36).substr(2)}`),
min,
minLabel,
max,
maxLabel,
formatLabel = defaultFormatLabel,
fullWidth,
labelText,
step,
stepMuliplier, // eslint-disable-line no-unused-vars
inputType,
required,
disabled,
helperText,
invalid,
invalidText,
hideLabel,
name,
light,
...other
} = this.props;
const { value, left } = this.state;
const sliderClasses = classNames(
'wfp--slider',
{ 'wfp--slider--disabled': disabled },
className
);
const sliderContainerClasses = classNames('wfp--slider-container', {
'wfp--slider-container--full-width': fullWidth,
});
const inputClasses = classNames('wfp--slider-text-input', {
'wfp--text-input--light': light,
});
const filledTrackStyle = {
transform: `translate(0%, -50%) scaleX(${left / 100})`,
};
const thumbStyle = {
left: `${left}%`,
};
const errorId = id + '-error-msg';
const labelClasses = classNames(`${prefix}--label`, {
[`${prefix}--visually-hidden`]: hideLabel,
[`${prefix}--label--disabled`]: other.disabled,
});
const label = labelText ? (
<label htmlFor={id} className={labelClasses}>
{labelText}
</label>
) : null;
const error = invalid ? (
<div className="wfp--form-requirement" id={errorId}>
{invalidText}
</div>
) : null;
const helper = helperText ? (
<div className="wfp--form__helper-text">{helperText}</div>
) : null;
return (
<div className="wfp--form-item">
{label}
{helper}
<div className={sliderContainerClasses}>
<span className="wfp--slider__range-label">
{formatLabel(min, minLabel)}
</span>
<div
className={sliderClasses}
ref={(node) => {
this.element = node;
}}
onClick={this.updatePosition}
onKeyPress={this.updatePosition}
role="presentation"
tabIndex={-1}
{...other}
>
<div
className="wfp--slider__track"
ref={(node) => {
this.track = node;
}}
/>
<div
className="wfp--slider__filled-track"
style={filledTrackStyle}
/>
<div
className="wfp--slider__thumb"
role="slider"
id={id}
tabIndex={0}
aria-valuemax={max}
aria-valuemin={min}
aria-valuenow={value}
style={thumbStyle}
onMouseDown={this.handleMouseStart}
onTouchStart={this.handleTouchStart}
onKeyDown={this.updatePosition}
/>
<input
type="hidden"
name={name}
value={value}
required={required}
min={min}
max={max}
step={step}
onChange={this.handleChange}
/>
</div>
<span className="wfp--slider__range-label">
{formatLabel(max, maxLabel)}
</span>
{!hideTextInput && (
<TextInput
disabled={disabled}
type={inputType}
id="input-for-slider"
className={inputClasses}
value={value}
onChange={this.handleChange}
labelText=""
aria-label={ariaLabelInput}
/>
)}
</div>
{error}
</div>
);
}
}