@kiwicom/orbit-components
Version:
Orbit-components is a React component library which provides developers with the easiest possible way of building Kiwi.com’s products.
567 lines (496 loc) • 18.1 kB
JavaScript
import * as React from "react";
import styled, { css, withTheme } from "styled-components";
import { warning } from "@adeira/js";
import transition from "../utils/transition";
import Text from "../Text";
import Heading from "../Heading";
import Stack from "../Stack";
import Hide from "../Hide";
import Handle from "./components/Handle";
import Bar from "./components/Bar";
import KEY_CODE_MAP from "../common/keyMaps";
import DEFAULT_VALUES from "./consts";
import Histogram from "./components/Histogram";
import defaultTheme from "../defaultTheme";
import mq from "../utils/mediaQuery";
import boundingClientRect from "../utils/boundingClientRect";
const StyledSlider = styled.div.withConfig({
displayName: "Slider__StyledSlider",
componentId: "sc-lemjaf-0"
})(["position:relative;"]); // $FlowFixMe: https://github.com/flow-typed/flow-typed/issues/3653#issuecomment-568539198
StyledSlider.defaultProps = {
theme: defaultTheme
};
const StyledSliderContent = styled.div.withConfig({
displayName: "Slider__StyledSliderContent",
componentId: "sc-lemjaf-1"
})(["display:block;width:100%;box-sizing:border-box;padding-bottom:", ";", ";"], ({
theme
}) => theme.orbit.spaceXSmall, mq.tablet(css(["width:calc(100% + 48px);position:absolute;bottom:-16px;left:-24px;right:-24px;opacity:0;visibility:hidden;padding:12px 24px 48px 24px;border-radius:", ";transition:", ";background:transparent;", ";"], ({
theme
}) => theme.orbit.borderRadiusNormal, transition(["all"], "fast", "ease-in-out"), ({
focused,
theme
}) => focused && css(["visibility:visible;opacity:1;background:", ";box-shadow:", ";"], theme.orbit.paletteWhite, theme.orbit.boxShadowRaised)))); // $FlowFixMe: https://github.com/flow-typed/flow-typed/issues/3653#issuecomment-568539198
StyledSliderContent.defaultProps = {
theme: defaultTheme
};
const StyledSliderInput = styled.div.withConfig({
displayName: "Slider__StyledSliderInput",
componentId: "sc-lemjaf-2"
})(["display:flex;align-items:center;width:100%;height:24px;"]);
export class PureSlider extends React.PureComponent {
constructor(...args) {
super(...args);
this.bar = /*#__PURE__*/React.createRef();
this.state = {
value: this.props.defaultValue || DEFAULT_VALUES.VALUE,
handleIndex: null,
focused: false
};
this.pauseEvent = event => {
// $FlowFixMe[method-unbinding]
if (typeof event.stopPropagation === "function") {
event.stopPropagation();
}
if ( // $FlowFixMe[method-unbinding]
typeof event.preventDefault === "function" && (typeof event.cancelable !== "boolean" || event.cancelable)) {
event.preventDefault();
}
};
this.stopPropagation = event => {
// $FlowFixMe[method-unbinding]
if (typeof event.stopPropagation === "function") event.stopPropagation();
};
this.isNotEqual = (a, b) => {
if (Array.isArray(a) && Array.isArray(b)) {
return a.toString() !== b.toString();
}
return a !== b;
};
this.calculateValue = (ratio, addition, deduction) => {
const {
maxValue = DEFAULT_VALUES.MAX,
minValue = DEFAULT_VALUES.MIN
} = this.props;
return Math.round((maxValue - minValue + (addition ? 1 : 0)) * ratio + minValue - (deduction ? 1 : 0));
};
this.calculateValueFromPosition = (pageX, throughClick) => {
const barRect = boundingClientRect(this.bar);
if (barRect) {
const {
histogramData,
histogramLoading,
theme: {
rtl
}
} = this.props;
const {
handleIndex,
value
} = this.state;
const mousePosition = (rtl ? barRect.right : pageX) - (rtl ? pageX : barRect.left);
const positionRatio = mousePosition / barRect.width;
const hasHistogram = histogramLoading || !!histogramData; // when range slider
if (Array.isArray(value)) {
if (value[0] === value[value.length - 1]) {
if (this.calculateValue(positionRatio, true, true) >= value[value.length - 1]) {
return this.calculateValue(positionRatio, true, true);
}
return this.calculateValue(positionRatio, true);
}
if (this.isNotEqual(this.sortArray(value), value)) {
if (handleIndex === 0) {
return this.calculateValue(positionRatio, true, true);
}
return this.calculateValue(positionRatio, true);
}
const closestKey = this.findClosestKey(this.calculateValue(positionRatio), this.sortArray(value)); // when first handle of range slider or when clicked and it should move the first handle
if (handleIndex === 0 || throughClick && closestKey === 0) {
return this.calculateValue(positionRatio, true);
}
} // simple slider without histogram
if (handleIndex === null && !hasHistogram) {
return this.calculateValue(positionRatio);
}
return this.calculateValue(positionRatio, true, true);
}
return null;
};
this.sortArray = arr => {
if (Array.isArray(arr)) {
return arr.slice().sort((a, b) => a - b);
}
return arr;
};
this.findClosestKey = (goal, value) => {
return Array.isArray(value) ? value.reduce((acc, curr, index) => {
return Array.isArray(value) && Math.abs(curr - goal) < Math.abs(value[acc] - goal) ? index : acc;
}, 0) : null;
};
this.moveValueByStep = (step, forcedValue) => {
const {
value,
handleIndex
} = this.state;
if (Array.isArray(value)) {
return this.replaceValue(forcedValue || this.alignValue(value[Number(handleIndex)] + step), Number(handleIndex));
}
return forcedValue || this.alignValue(value + step);
};
this.handleKeyDown = event => {
if (event.ctrlKey || event.shiftKey || event.altKey) return;
const {
step = DEFAULT_VALUES.STEP,
minValue = DEFAULT_VALUES.MIN,
maxValue = DEFAULT_VALUES.MAX,
theme: {
rtl
}
} = this.props;
if (event.keyCode === KEY_CODE_MAP.ARROW_UP) {
this.pauseEvent(event);
this.injectCallbackAndSetState(this.props.onChange, this.moveValueByStep(step));
}
if (event.keyCode === KEY_CODE_MAP.ARROW_DOWN) {
this.pauseEvent(event);
this.injectCallbackAndSetState(this.props.onChange, this.moveValueByStep(-step));
}
if (event.keyCode === KEY_CODE_MAP.ARROW_RIGHT) {
const switchStep = rtl ? -step : step;
this.pauseEvent(event);
this.injectCallbackAndSetState(this.props.onChange, this.moveValueByStep(switchStep));
}
if (event.keyCode === KEY_CODE_MAP.ARROW_LEFT) {
const switchStep = rtl ? step : -step;
this.pauseEvent(event);
this.injectCallbackAndSetState(this.props.onChange, this.moveValueByStep(switchStep));
}
if (event.keyCode === KEY_CODE_MAP.HOME) {
this.pauseEvent(event);
this.injectCallbackAndSetState(this.props.onChange, this.moveValueByStep(0, minValue));
}
if (event.keyCode === KEY_CODE_MAP.END) {
this.pauseEvent(event);
this.injectCallbackAndSetState(this.props.onChange, this.moveValueByStep(0, maxValue));
}
};
this.handleBlur = () => {
this.setState({
focused: false
});
const {
value
} = this.state;
window.removeEventListener("keydown", this.handleKeyDown);
window.removeEventListener("focusout", this.handleBlur);
this.injectCallbackAndSetState(this.props.onChangeAfter, value, true);
};
this.handleOnFocus = i => event => {
if (typeof i === "number") this.setState({
handleIndex: i
});
const {
value
} = this.state;
this.setState({
focused: true
});
this.pauseEvent(event);
window.addEventListener("keydown", this.handleKeyDown);
window.addEventListener("focusout", this.handleBlur);
this.injectCallbackAndSetState(this.props.onChangeBefore, value, true);
};
this.handleMove = newValue => {
const {
value,
handleIndex
} = this.state;
if (newValue != null) {
if (Array.isArray(value)) {
return this.replaceValue(this.alignValue(newValue), Number(handleIndex));
}
return this.alignValue(newValue);
}
return null;
};
this.handleBarMouseDown = event => {
const {
value
} = this.state;
this.setState({
handleIndex: null
});
const newValue = this.calculateValueFromPosition(event.pageX, true);
if (newValue) {
if (Array.isArray(value)) {
const index = this.findClosestKey(newValue, value);
const replacedValue = this.replaceValue(this.alignValue(newValue), index);
this.injectCallbackAndSetState(this.props.onChangeBefore, value, true);
this.injectCallbackAndSetState(this.props.onChange, replacedValue);
this.injectCallbackAndSetState(this.props.onChangeAfter, replacedValue);
} else {
const alignedValue = this.alignValue(newValue);
this.injectCallbackAndSetState(this.props.onChangeBefore, value, true);
this.injectCallbackAndSetState(this.props.onChange, alignedValue);
this.injectCallbackAndSetState(this.props.onChangeAfter, alignedValue);
}
}
};
this.injectCallbackAndSetState = (callback, newValue, forced = false) => {
const {
value
} = this.state;
if (newValue != null) {
if (this.isNotEqual(newValue, value) || forced) {
this.setState({
value: newValue
});
if (callback) {
callback(this.sortArray(newValue));
}
}
}
};
this.handleMouseMove = event => {
const newValue = this.calculateValueFromPosition(event.pageX);
this.pauseEvent(event);
this.injectCallbackAndSetState(this.props.onChange, this.handleMove(newValue));
};
this.handleMouseUp = () => {
const {
value
} = this.state;
this.setState({
focused: false
});
window.removeEventListener("mousemove", this.handleMouseMove);
window.removeEventListener("mouseup", this.handleMouseUp);
this.injectCallbackAndSetState(this.props.onChangeAfter, value, true);
};
this.handleMouseDown = i => event => {
// just allow left-click
if (event.button === 0 && event.buttons !== 2) {
const {
value
} = this.state;
this.setState({
focused: true
});
if (typeof i === "number") this.setState({
handleIndex: i
});
window.addEventListener("mousemove", this.handleMouseMove);
window.addEventListener("mouseup", this.handleMouseUp);
this.pauseEvent(event);
this.injectCallbackAndSetState(this.props.onChangeBefore, value, true);
}
};
this.handleOnTouchMove = event => {
if (event.touches.length > 1) return;
const newValue = this.calculateValueFromPosition(event.touches[0].pageX);
this.pauseEvent(event);
this.injectCallbackAndSetState(this.props.onChange, this.handleMove(newValue));
};
this.handleTouchEnd = () => {
const {
value
} = this.state;
this.setState({
focused: false
});
window.removeEventListener("touchmove", this.handleOnTouchMove);
window.removeEventListener("touchend", this.handleTouchEnd);
this.injectCallbackAndSetState(this.props.onChangeAfter, value, true);
};
this.handleOnTouchStart = i => event => {
if (event.touches.length <= 1) {
this.setState({
focused: true
});
const {
value
} = this.state;
if (typeof i === "number") this.setState({
handleIndex: i
});
window.addEventListener("touchmove", this.handleOnTouchMove, {
passive: false
});
window.addEventListener("touchend", this.handleTouchEnd);
this.stopPropagation(event);
this.injectCallbackAndSetState(this.props.onChangeBefore, value, true);
}
};
this.alignValueToStep = value => {
const {
step = DEFAULT_VALUES.STEP
} = this.props;
if (step === 1) return value;
const gap = value % step;
if (gap === 0) return value;
if (gap * 2 >= step) {
return value - gap + step;
}
return value - gap;
};
this.alignValueToMaxMin = value => {
const {
maxValue = DEFAULT_VALUES.MAX,
minValue = DEFAULT_VALUES.MIN
} = this.props;
if (value > maxValue) {
return maxValue;
}
if (value < minValue) {
return minValue;
}
return value;
};
this.alignValue = value => this.alignValueToMaxMin(this.alignValueToStep(value));
this.replaceValue = (newValue, index) => {
const {
value
} = this.state;
if (index == null || !Array.isArray(value)) return newValue;
return value.map((item, key) => key === index ? newValue : item);
};
this.renderHandle = (valueNow, i) => {
const {
minValue = DEFAULT_VALUES.MIN,
maxValue = DEFAULT_VALUES.MAX,
histogramData,
histogramLoading,
ariaValueText,
ariaLabel
} = this.props;
const {
handleIndex,
value
} = this.state;
const key = i && encodeURIComponent(i.toString());
const index = i || 0;
return /*#__PURE__*/React.createElement(Handle, {
tabIndex: "0",
onTop: handleIndex === i,
valueMax: maxValue,
valueMin: minValue,
onMouseDown: this.handleMouseDown(i),
onFocus: this.handleOnFocus(i),
onTouchStart: this.handleOnTouchStart(i),
value: value,
ariaValueText: ariaValueText,
ariaLabel: ariaLabel,
hasHistogram: histogramLoading || !!histogramData,
index: index,
key: key,
dataTest: `SliderHandle-${index}`
});
};
this.renderHandles = () => {
const {
value
} = this.state;
return Array.isArray(value) ? value.map((valueNow, i) => this.renderHandle(valueNow, i)) : this.renderHandle(value);
};
this.renderSliderTexts = biggerSpace => {
const {
label,
valueDescription,
histogramDescription
} = this.props;
if (!(label || valueDescription || histogramDescription)) return null;
return /*#__PURE__*/React.createElement(Stack, {
direction: "row",
spacing: "none",
spaceAfter: biggerSpace ? "medium" : "small"
}, (label || histogramDescription) && /*#__PURE__*/React.createElement(Stack, {
direction: "column",
spacing: "none",
basis: "60%",
grow: true
}, label && /*#__PURE__*/React.createElement(Heading, {
type: "title4"
}, label), valueDescription && /*#__PURE__*/React.createElement(Text, {
type: "secondary",
size: "small"
}, valueDescription)), histogramDescription && /*#__PURE__*/React.createElement(Stack, {
shrink: true,
justify: "end",
grow: false
}, /*#__PURE__*/React.createElement(Text, {
type: "primary",
size: "small"
}, histogramDescription)));
};
this.renderHeading = hasHistogram => {
if (hasHistogram) {
return /*#__PURE__*/React.createElement(Hide, {
on: ["smallMobile", "mediumMobile", "largeMobile"],
block: true
}, this.renderSliderTexts(true));
}
return this.renderSliderTexts(true);
};
}
componentDidUpdate(prevProps) {
const {
defaultValue = DEFAULT_VALUES.VALUE
} = this.props;
const {
defaultValue: prevDefaultValue = DEFAULT_VALUES.VALUE
} = prevProps;
if (this.isNotEqual(prevDefaultValue, defaultValue)) {
const newValue = Array.isArray(defaultValue) ? defaultValue.map(item => Number(item)) : Number(defaultValue); // eslint-disable-next-line react/no-did-update-set-state
this.setState({
value: newValue
});
}
}
render() {
const {
minValue = DEFAULT_VALUES.MIN,
maxValue = DEFAULT_VALUES.MAX,
histogramData,
histogramLoading = false,
histogramLoadingText,
dataTest,
step = DEFAULT_VALUES.STEP
} = this.props;
if (histogramData) {
const properHistogramLength = (maxValue - minValue + step) / step;
process.env.NODE_ENV !== "production" ? warning(histogramData.length === properHistogramLength, `Warning: Length of histogramData array is ${histogramData.length}, but should be ${properHistogramLength}. This will cause broken visuals of the whole Histogram.`) : void 0;
}
const {
value,
focused
} = this.state;
const sortedValue = this.sortArray(value);
const hasHistogram = histogramLoading || !!histogramData;
return /*#__PURE__*/React.createElement(StyledSlider, {
"data-test": dataTest
}, this.renderHeading(hasHistogram), hasHistogram && /*#__PURE__*/React.createElement(StyledSliderContent, {
focused: focused
}, this.renderSliderTexts(false), /*#__PURE__*/React.createElement(Histogram, {
data: histogramData,
value: sortedValue,
min: minValue,
step: step,
loading: histogramLoading,
loadingText: histogramLoadingText
})), /*#__PURE__*/React.createElement(StyledSliderInput, null, /*#__PURE__*/React.createElement(Bar, {
ref: this.bar,
onMouseDown: this.handleBarMouseDown,
value: sortedValue,
max: maxValue,
min: minValue,
hasHistogram: hasHistogram
}), this.renderHandles()));
}
}
PureSlider.defaultProps = {
theme: defaultTheme
};
const ThemedSlider = withTheme(PureSlider);
ThemedSlider.displayName = "Slider";
export default ThemedSlider;