diff --git a/packages/ui-toolkit/src/components/atoms/Slider/ReactSlider.jsx b/packages/ui-toolkit/src/components/atoms/Slider/ReactSlider.jsx index 084c7619..c73ca983 100644 --- a/packages/ui-toolkit/src/components/atoms/Slider/ReactSlider.jsx +++ b/packages/ui-toolkit/src/components/atoms/Slider/ReactSlider.jsx @@ -9,32 +9,40 @@ function pauseEvent(e) { if (e && e.stopPropagation) { e.stopPropagation(); } + if (e && e.preventDefault) { e.preventDefault(); } + return false; } + function stopPropagation(e) { if (e.stopPropagation) { e.stopPropagation(); } } + function sanitizeInValue(x) { if (x == null) { return []; } + return Array.isArray(x) ? x.slice() : [ x ]; } + function prepareOutValue(x) { return x !== null && x.length === 1 ? x[ 0 ] : x.slice(); } + function trimSucceeding(length, nextValue, minDistance, max) { for (let i = 0; i < length; i += 1) { const padding = max - i * minDistance; + if (nextValue[ length - 1 - i ] > padding) { // eslint-disable-next-line no-param-reassign nextValue[ length - 1 - i ] = padding; @@ -42,9 +50,11 @@ function trimSucceeding(length, nextValue, minDistance, max) { } } + function trimPreceding(length, nextValue, minDistance, min) { for (let i = 0; i < length; i += 1) { const padding = min + i * minDistance; + if (nextValue[ i ] < padding) { // eslint-disable-next-line no-param-reassign nextValue[ i ] = padding; @@ -52,6 +62,7 @@ function trimPreceding(length, nextValue, minDistance, min) { } } + function addHandlers(eventMap) { Object.keys(eventMap).forEach(key => { if (typeof document !== 'undefined') { @@ -60,6 +71,7 @@ function addHandlers(eventMap) { }); } + function removeHandlers(eventMap) { Object.keys(eventMap).forEach(key => { if (typeof document !== 'undefined') { @@ -68,10 +80,12 @@ function removeHandlers(eventMap) { }); } + function trimAlignValue(val, props) { return alignValue(trimValue(val, props), props); } + function alignValue(val, props) { const valModStep = (val - props.min) % props.step; let alignedValue = val - valModStep; @@ -83,11 +97,14 @@ function alignValue(val, props) { return parseFloat(alignedValue.toFixed(5)); } + function trimValue(val, props) { let trimmed = val; + if (trimmed <= props.min) { trimmed = props.min; } + if (trimmed >= props.max) { trimmed = props.max; } @@ -98,6 +115,7 @@ function trimValue(val, props) { class ReactSlider extends React.Component { static displayName = 'ReactSlider'; + static propTypes = { /** * The minimum value of the slider. @@ -313,6 +331,7 @@ class ReactSlider extends React.Component { renderMark: PropTypes.func }; + static defaultProps = { min: 0, max: 100, @@ -337,10 +356,12 @@ class ReactSlider extends React.Component { renderMark: props => }; + constructor(props) { super(props); let value = sanitizeInValue(props.value); + if (!value.length) { value = sanitizeInValue(props.defaultValue); } @@ -349,6 +370,7 @@ class ReactSlider extends React.Component { this.pendingResizeTimeouts = []; const zIndices = []; + for (let i = 0; i < value.length; i += 1) { value[ i ] = trimAlignValue(value[ i ], props); zIndices.push(i); @@ -363,6 +385,7 @@ class ReactSlider extends React.Component { }; } + componentDidMount() { if (typeof window !== 'undefined') { window.addEventListener('resize', this.handleResize); @@ -374,6 +397,7 @@ class ReactSlider extends React.Component { // This basically allows the slider to be a controlled component. static getDerivedStateFromProps(props, state) { const value = sanitizeInValue(props.value); + if (!value.length) { return null; } @@ -388,6 +412,7 @@ class ReactSlider extends React.Component { }; } + componentDidUpdate() { // If an upperBound has not yet been determined (due to the component being hidden // during the mount event, or during the last resize), then calculate it now @@ -396,6 +421,7 @@ class ReactSlider extends React.Component { } } + componentWillUnmount() { this.clearPendingResizeTimeouts(); if (typeof window !== 'undefined') { @@ -403,26 +429,32 @@ class ReactSlider extends React.Component { } } + onKeyUp = () => { this.onEnd(); }; + onMouseUp = () => { this.onEnd(this.getMouseEventMap()); }; + onTouchEnd = () => { this.onEnd(this.getTouchEventMap()); }; + onBlur = () => { this.setState({ index: -1 }, this.onEnd(this.getKeyDownEventMap())); }; + onEnd(eventMap) { if (eventMap) { removeHandlers(eventMap); } + if (this.hasMoved) { this.fireChangeEvent('onAfterChange'); } @@ -433,6 +465,7 @@ class ReactSlider extends React.Component { this.hasMoved = false; } + onMouseMove = e => { // Prevent controlled updates from happening while mouse is moving this.setState({ pending: true }); @@ -440,9 +473,11 @@ class ReactSlider extends React.Component { const position = this.getMousePosition(e); const diffPosition = this.getDiffPosition(position[ 0 ]); const newValue = this.getValueFromPosition(diffPosition); + this.move(newValue); }; + onTouchMove = e => { if (e.touches.length > 1) { return; @@ -456,6 +491,7 @@ class ReactSlider extends React.Component { if (typeof this.isScrolling === 'undefined') { const diffMainDir = position[ 0 ] - this.startPosition[ 0 ]; const diffScrollDir = position[ 1 ] - this.startPosition[ 1 ]; + this.isScrolling = Math.abs(diffScrollDir) > Math.abs(diffMainDir); } @@ -470,6 +506,7 @@ class ReactSlider extends React.Component { this.move(newValue); }; + onKeyDown = e => { if (e.ctrlKey || e.shiftKey || e.altKey) { return; @@ -480,39 +517,52 @@ class ReactSlider extends React.Component { switch (e.key) { case 'ArrowLeft': + case 'ArrowDown': + case 'Left': + case 'Down': e.preventDefault(); this.moveDownByStep(); break; + case 'ArrowRight': + case 'ArrowUp': + case 'Right': + case 'Up': e.preventDefault(); this.moveUpByStep(); break; + case 'Home': e.preventDefault(); this.move(this.props.min); break; + case 'End': e.preventDefault(); this.move(this.props.max); break; + case 'PageDown': e.preventDefault(); this.moveDownByStep(this.props.pageFn(this.props.step)); break; + case 'PageUp': e.preventDefault(); this.moveUpByStep(this.props.pageFn(this.props.step)); break; + default: } }; + onSliderMouseDown = e => { // do nothing if disabled or right click if (this.props.disabled || e.button === 2) { @@ -524,6 +574,7 @@ class ReactSlider extends React.Component { if (!this.props.snapDragDisabled) { const position = this.getMousePosition(e); + this.forceValueFromPosition(position[ 0 ], i => { this.start(i, position[ 0 ]); addHandlers(this.getMouseEventMap()); @@ -533,6 +584,7 @@ class ReactSlider extends React.Component { pauseEvent(e); }; + onSliderClick = e => { if (this.props.disabled) { return; @@ -544,14 +596,17 @@ class ReactSlider extends React.Component { this.calcValue(this.calcOffsetFromPosition(position[ 0 ])), this.props ); + this.props.onSliderClick(valueAtPos); } }; + getValue() { return prepareOutValue(this.state.value); } + getClosestIndex(pixelOffset) { let minDist = Number.MAX_VALUE; let closestIndex = -1; @@ -562,6 +617,7 @@ class ReactSlider extends React.Component { for (let i = 0; i < l; i += 1) { const offset = this.calcOffset(value[ i ]); const dist = Math.abs(pixelOffset - offset); + if (dist < minDist) { minDist = dist; closestIndex = i; @@ -571,15 +627,19 @@ class ReactSlider extends React.Component { return closestIndex; } + getMousePosition(e) { return [ e[ `page${this.axisKey()}` ], e[ `page${this.orthogonalAxisKey()}` ] ]; } + getTouchPosition(e) { const touch = e.touches[ 0 ]; + return [ touch[ `page${this.axisKey()}` ], touch[ `page${this.orthogonalAxisKey()}` ] ]; } + getKeyDownEventMap() { return { keydown: this.onKeyDown, @@ -588,6 +648,7 @@ class ReactSlider extends React.Component { }; } + getMouseEventMap() { return { mousemove: this.onMouseMove, @@ -595,6 +656,7 @@ class ReactSlider extends React.Component { }; } + getTouchEventMap() { return { touchmove: this.onTouchMove, @@ -602,18 +664,23 @@ class ReactSlider extends React.Component { }; } + getValueFromPosition(position) { const diffValue = (position / (this.state.sliderLength - this.state.thumbSize)) * (this.props.max - this.props.min); + return trimAlignValue(this.state.startValue + diffValue, this.props); } + getDiffPosition(position) { let diffPosition = position - this.state.startPosition; + if (this.props.invert) { diffPosition *= -1; } + return diffPosition; } @@ -622,6 +689,7 @@ class ReactSlider extends React.Component { if (this.props.disabled) { return; } + this.start(i); addHandlers(this.getKeyDownEventMap()); pauseEvent(e); @@ -638,6 +706,7 @@ class ReactSlider extends React.Component { this.setState({ pending: true }); const position = this.getMousePosition(e); + this.start(i, position[ 0 ]); addHandlers(this.getMouseEventMap()); pauseEvent(e); @@ -653,6 +722,7 @@ class ReactSlider extends React.Component { this.setState({ pending: true }); const position = this.getTouchPosition(e); + this.startPosition = position; // don't know yet if the user is trying to scroll this.isScrolling = undefined; @@ -661,6 +731,7 @@ class ReactSlider extends React.Component { stopPropagation(e); }; + handleResize = () => { // setTimeout of 0 gives element enough time to have assumed its new size if // it is being resized @@ -673,8 +744,10 @@ class ReactSlider extends React.Component { this.pendingResizeTimeouts.push(resizeTimeout); }; + resize() { const { slider, thumb0: thumb } = this; + if (!slider || !thumb) { return; } @@ -710,19 +783,24 @@ class ReactSlider extends React.Component { // calculates the offset of a thumb in pixels based on its value. calcOffset(value) { const range = this.props.max - this.props.min; + if (range === 0) { return 0; } + const ratio = (value - this.props.min) / range; + return ratio * this.state.upperBound; } // calculates the value corresponding to a given pixel offset, i.e. the inverse of `calcOffset`. calcValue(offset) { const ratio = offset / this.state.upperBound; + return ratio * (this.props.max - this.props.min) + this.props.min; } + calcOffsetFromPosition(position) { const { slider } = this; @@ -737,9 +815,11 @@ class ReactSlider extends React.Component { const sliderStart = windowOffset + (this.props.invert ? sliderMax : sliderMin); let pixelOffset = position - sliderStart; + if (this.props.invert) { pixelOffset = this.state.sliderLength - pixelOffset; } + pixelOffset -= this.state.thumbSize / 2; return pixelOffset; } @@ -754,6 +834,7 @@ class ReactSlider extends React.Component { // Clone this.state.value since we'll modify it temporarily // eslint-disable-next-line zillow/react/no-access-state-in-setstate const value = this.state.value.slice(); + value[ closestIndex ] = nextValue; // Prevents the slider from shrinking below `props.minDistance` @@ -780,13 +861,16 @@ class ReactSlider extends React.Component { } while (this.pendingResizeTimeouts.length); } + start(i, position) { const thumbRef = this[ `thumb${i}` ]; + if (thumbRef) { thumbRef.focus(); } const { zIndices } = this.state; + // remove wherever the element is zIndices.splice(zIndices.indexOf(i), 1); // add to end @@ -800,24 +884,30 @@ class ReactSlider extends React.Component { })); } + moveUpByStep(step = this.props.step) { const oldValue = this.state.value[ this.state.index ]; const newValue = trimAlignValue(oldValue + step, this.props); + this.move(Math.min(newValue, this.props.max)); } + moveDownByStep(step = this.props.step) { const oldValue = this.state.value[ this.state.index ]; const newValue = trimAlignValue(oldValue - step, this.props); + this.move(Math.max(newValue, this.props.min)); } + move(newValue) { const { index, value } = this.state; const { length } = value; // Short circuit if the value is not changing const oldValue = value[ index ]; + if (newValue === oldValue) { return; } @@ -826,14 +916,17 @@ class ReactSlider extends React.Component { if (!this.hasMoved) { this.fireChangeEvent('onBeforeChange'); } + this.hasMoved = true; // if "pearling" (= thumbs pushing each other) is disabled, // prevent the thumb from getting closer than `minDistance` to the previous or next thumb. const { pearling, max, min, minDistance } = this.props; + if (!pearling) { if (index > 0) { const valueBefore = value[ index - 1 ]; + if (newValue < valueBefore + minDistance) { // eslint-disable-next-line no-param-reassign newValue = valueBefore + minDistance; @@ -842,6 +935,7 @@ class ReactSlider extends React.Component { if (index < length - 1) { const valueAfter = value[ index + 1 ]; + if (newValue > valueAfter - minDistance) { // eslint-disable-next-line no-param-reassign newValue = valueAfter - minDistance; @@ -856,6 +950,7 @@ class ReactSlider extends React.Component { if (newValue > oldValue) { this.pushSucceeding(value, minDistance, index); trimSucceeding(length, value, minDistance, max); + } else if (newValue < oldValue) { this.pushPreceding(value, minDistance, index); trimPreceding(length, value, minDistance, min); @@ -868,9 +963,11 @@ class ReactSlider extends React.Component { this.setState({ value }, this.fireChangeEvent.bind(this, 'onChange')); } + pushSucceeding(value, minDistance, index) { let i; let padding; + for ( i = index, padding = value[ i ] + minDistance; value[ i + 1 ] !== null && padding > value[ i + 1 ]; @@ -881,6 +978,7 @@ class ReactSlider extends React.Component { } } + pushPreceding(value, minDistance, index) { for ( let i = index, padding = value[ i ] - minDistance; @@ -892,52 +990,64 @@ class ReactSlider extends React.Component { } } + axisKey() { if (this.props.orientation === 'vertical') { return 'Y'; } + // Defaults to 'horizontal'; return 'X'; } + orthogonalAxisKey() { if (this.props.orientation === 'vertical') { return 'X'; } + // Defaults to 'horizontal' return 'Y'; } + posMinKey() { if (this.props.orientation === 'vertical') { return this.props.invert ? 'bottom' : 'top'; } + // Defaults to 'horizontal' return this.props.invert ? 'right' : 'left'; } + posMaxKey() { if (this.props.orientation === 'vertical') { return this.props.invert ? 'top' : 'bottom'; } + // Defaults to 'horizontal' return this.props.invert ? 'left' : 'right'; } + sizeKey() { if (this.props.orientation === 'vertical') { return 'clientHeight'; } + // Defaults to 'horizontal' return 'clientWidth'; } + fireChangeEvent(event) { if (this.props[ event ]) { this.props[ event ](prepareOutValue(this.state.value)); } } + buildThumbStyle(offset, i) { const style = { position: 'absolute', @@ -945,20 +1055,24 @@ class ReactSlider extends React.Component { willChange: this.state.index >= 0 ? this.posMinKey() : '', zIndex: this.state.zIndices.indexOf(i) + 1 }; + style[ this.posMinKey() ] = `${offset}px`; return style; } + buildTrackStyle(min, max) { const obj = { position: 'absolute', willChange: this.state.index >= 0 ? `${this.posMinKey()},${this.posMaxKey()}` : '' }; + obj[ this.posMinKey() ] = min; obj[ this.posMaxKey() ] = max; return obj; } + renderThumb = (style, i) => { const className = `${this.props.thumbClassName} ${this.props.thumbClassName}-${i} ${this.state.index === i ? this.props.thumbActiveClassName : ''}`; @@ -999,21 +1113,26 @@ class ReactSlider extends React.Component { return this.props.renderThumb(props, state); }; + renderThumbs(offset) { const { length } = offset; const styles = []; + for (let i = 0; i < length; i += 1) { styles[ i ] = this.buildThumbStyle(offset[ i ], i); } const res = []; + for (let i = 0; i < length; i += 1) { res[ i ] = this.renderThumb(styles[ i ], i); } + return res; } + renderTrack = (i, offsetFrom, offsetTo) => { const props = { key: `${this.props.trackClassName}-${i}`, @@ -1024,9 +1143,11 @@ class ReactSlider extends React.Component { index: i, value: prepareOutValue(this.state.value) }; + return this.props.renderTrack(props, state); }; + renderTracks(offset) { const tracks = []; const lastIndex = offset.length - 1; @@ -1042,6 +1163,7 @@ class ReactSlider extends React.Component { return tracks; } + renderMarks() { let { marks } = this.props; @@ -1049,6 +1171,7 @@ class ReactSlider extends React.Component { if (typeof marks === 'boolean') { marks = Array.from({ length: range }).map((_, key) => key); + } else if (typeof marks === 'number') { marks = Array.from({ length: range }) .map((_, key) => key) @@ -1075,10 +1198,12 @@ class ReactSlider extends React.Component { }); } + render() { const offset = []; const { value } = this.state; const l = value.length; + for (let i = 0; i < l; i += 1) { offset[ i ] = this.calcOffset(value[ i ], i); } diff --git a/packages/ui-toolkit/src/components/atoms/Slider/Slider.tsx b/packages/ui-toolkit/src/components/atoms/Slider/Slider.tsx index c05f8f24..eb3f4a3a 100644 --- a/packages/ui-toolkit/src/components/atoms/Slider/Slider.tsx +++ b/packages/ui-toolkit/src/components/atoms/Slider/Slider.tsx @@ -62,7 +62,7 @@ type DefaultProps = { } -type Props = SliderProps & DefaultProps; +export type Props = SliderProps & DefaultProps; Slider.defaultProps = { sliderWrapperClass: '', diff --git a/packages/ui-toolkit/stories/Slider.stories.tsx b/packages/ui-toolkit/stories/Slider.stories.tsx new file mode 100644 index 00000000..0f8ad101 --- /dev/null +++ b/packages/ui-toolkit/stories/Slider.stories.tsx @@ -0,0 +1,65 @@ +import React, { useState } from 'react'; +import { Story } from '@storybook/react'; + +import Slider, { Props as SliderProps } from '../src/components/atoms/Slider/Slider'; + +import './style.css'; + +export default { + title: 'Slider', + component: Slider +}; + + +const Template: Story = (args) => { + + return ( +
+ console.log('Slider Value: ', e)} + /> +
+ ); +}; + +export const Default = Template.bind({}); + +Default.args = { + min: 0, + max: 100, + sliderWrapperClass: 'sliderWrapper', + thumbClassName: 'sliderThumb', + trackClassName: 'sliderTrack' +}; + +export const WithSteps = Template.bind({}); + +WithSteps.args = { + ...Default.args, + step: 10 +}; + +export const WithMarks = Template.bind({}); + +WithMarks.args = { + ...Default.args, + marks: 10, + markClassName: 'sliderMark' +}; + +export const DefaultValue = Template.bind({}); + +DefaultValue.args = { + ...Default.args, + defaultValue: 25 +}; + +export const MarkedSteps = Template.bind({}); + +MarkedSteps.args = { + ...Default.args, + step: 10, + marks: 10, + markClassName: 'sliderMark' +}; diff --git a/packages/ui-toolkit/stories/style.css b/packages/ui-toolkit/stories/style.css index bc32e6e7..32afdc75 100644 --- a/packages/ui-toolkit/stories/style.css +++ b/packages/ui-toolkit/stories/style.css @@ -90,3 +90,39 @@ cursor: pointer; border: 2px solid var(--gray900); } + +/* Slider */ + +.scalc101MainContainer .sliderTrack { + height: 8px; + position: relative; + background: var(--green500); + border-radius: 5px; + margin: 16px 0; +} + +.scalc101MainContainer .sliderThumb { + width: 40px; + height: 40px; + border-radius: 50%; + outline: none; + background: var(--gray50); + border: 2px solid var(--green500); + /* This is required, In min code we still dont have this so visually wrong values might appear up! */ + /* transform: translateX(-50%); */ + cursor: pointer; +} + +.scalc101MainContainer .sliderTrack.sliderTrack-1 { + background: var(--gray150); +} + +.scalc101MainContainer .sliderMark { + height: 12px; + width: 4px; + border-radius: 20px; + position: absolute; + background-color: var(--gray150); + /* Should be moved and handled inside the original mark */ + transform: translateX(425%); +} \ No newline at end of file