UNPKG

@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.

564 lines (493 loc) 18 kB
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 => { if (typeof event.stopPropagation === "function") { event.stopPropagation(); } if (typeof event.preventDefault === "function" && (typeof event.cancelable !== "boolean" || event.cancelable)) { event.preventDefault(); } }; this.stopPropagation = event => { 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;