From f38887d8db4a84f9aced80b9bb493f379d179824 Mon Sep 17 00:00:00 2001 From: Richard Palmer Date: Fri, 27 Jan 2017 15:24:00 +0000 Subject: [PATCH] Add `` component - Custom wrapper for `react-input-range` - Higher order function to wrap `` to add support for non-linear scales --- components/Forms/InputRange/InputRange.css | 58 ++++++ components/Forms/InputRange/InputRange.js | 92 +++++++++ .../Forms/InputRange/InputRange.story.js | 70 +++++++ .../Forms/InputRange/InputRange.test.js | 37 ++++ .../Forms/InputRange/transformStepValues.js | 77 +++++++ .../InputRange/transformStepValues.test.js | 189 ++++++++++++++++++ package.json | 1 + yarn.lock | 10 +- 8 files changed, 529 insertions(+), 5 deletions(-) create mode 100644 components/Forms/InputRange/InputRange.css create mode 100644 components/Forms/InputRange/InputRange.js create mode 100644 components/Forms/InputRange/InputRange.story.js create mode 100644 components/Forms/InputRange/InputRange.test.js create mode 100644 components/Forms/InputRange/transformStepValues.js create mode 100644 components/Forms/InputRange/transformStepValues.test.js diff --git a/components/Forms/InputRange/InputRange.css b/components/Forms/InputRange/InputRange.css new file mode 100644 index 000000000..857fec0fd --- /dev/null +++ b/components/Forms/InputRange/InputRange.css @@ -0,0 +1,58 @@ +:root { + --inputRange-track-height: 0.3rem; +} + +.root { + position: relative; + composes: fontSmallI from '../../../globals/typography.css'; +} + +.input { + width: 100%; + height: calc(2 * var(--size-large)); + transform: translateY(calc(var(--size-large) - var(--inputRange-track-height))); + position: relative; +} + +.slider { + appearance: none; + border-radius: 100%; + height: var(--size-large); + width: var(--size-large); + display: block; + background-color: var(--color-white); + border: 1px solid var(--color-greyLighter); + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2); + left: calc(-0.5 * var(--size-large)); + top: calc((var(--size-large) - var(--inputRange-track-height)) * -0.5); + position: absolute; +} + +.slider:active { + transform: scale(1.1); + box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.2); + background-color: var(--color-greyLightest); +} + +.slider:focus { + transform: scale(1.1); +} + +.trackContainer, +.trackActive { + cursor: pointer; + display: block; + height: var(--inputRange-track-height); + border-radius: calc(var(--inputRange-track-height) / 2); + position: absolute; + width: 100%; + left: 0; +} + +.trackContainer { + background: var(--color-greyLightest); +} + +.trackActive { + background-color: var(--color-black); +} \ No newline at end of file diff --git a/components/Forms/InputRange/InputRange.js b/components/Forms/InputRange/InputRange.js new file mode 100644 index 000000000..6f482badd --- /dev/null +++ b/components/Forms/InputRange/InputRange.js @@ -0,0 +1,92 @@ +import React, { PropTypes, Component } from 'react'; + +import noop from '../../../utils/noop'; +import ReactInputRange from '@appearhere/react-input-range'; +import css from './InputRange.css'; + +export const defaultClassNames = { + component: css.input, + labelContainer: css.labelContainer, + labelMax: css.labelMax, + labelMin: css.labelMin, + labelValue: css.labelValue, + slider: css.slider, + sliderContainer: css.sliderContainer, + trackActive: css.trackActive, + trackContainer: css.trackContainer, + disabled: css.disabled, +}; + +export default class InputRange extends Component { + static propTypes = { + name: PropTypes.string.isRequired, + id: PropTypes.string, + value: PropTypes.oneOfType([ + PropTypes.number, + PropTypes.shape({ + min: PropTypes.number, + max: PropTypes.number, + }), + ]).isRequired, + onChange: PropTypes.func, + onChangeComplete: PropTypes.func, + classNames: PropTypes.shape({ + component: PropTypes.string, + labelContainer: PropTypes.string, + labelMax: PropTypes.string, + labelMin: PropTypes.string, + labelValue: PropTypes.string, + slider: PropTypes.string, + sliderContainer: PropTypes.string, + trackActive: PropTypes.string, + trackContainer: PropTypes.string, + disabled: PropTypes.string, + }), + minValue: PropTypes.number.isRequired, + maxValue: PropTypes.number.isRequired, + }; + + static defaultProps = { + onChange: noop, + onChangeComplete: noop, + classNames: defaultClassNames, + }; + + handleChange = (val) => { + const { name, onChange } = this.props; + onChange(null, name, val); + }; + + handleChangeComplete = (val) => { + const { name, onChangeComplete } = this.props; + onChangeComplete(null, name, val); + }; + + render() { + const { + classNames, + id, + value, + minValue, + maxValue, + ...rest, + } = this.props; + + const defaultValue = typeof value === 'object' ? { minValue, maxValue } : maxValue; + + return ( + + ); + } +} \ No newline at end of file diff --git a/components/Forms/InputRange/InputRange.story.js b/components/Forms/InputRange/InputRange.story.js new file mode 100644 index 000000000..7cb25cd57 --- /dev/null +++ b/components/Forms/InputRange/InputRange.story.js @@ -0,0 +1,70 @@ +import React from 'react'; +import { storiesOf, action } from '@kadira/storybook'; +import InputRange from './InputRange'; +import transformStepValues from './transformStepValues'; + +const bucket = [ + 0, + 2, + 4, + 8, + 16, + 32, + 64, +]; + +storiesOf('InputRange', module) + .add('Default InputRange', () => ( + + )) + .add('Multi InputRange', () => ( + + )) + .add('Non-linear default InputRange', () => { + const NonLinearRangeInput = transformStepValues(InputRange)(bucket); + + return ( + + ); + }) + .add('Non-linear multi InputRange', () => { + const NonLinearRangeInput = transformStepValues(InputRange)(bucket); + + return ( + + ); + }); \ No newline at end of file diff --git a/components/Forms/InputRange/InputRange.test.js b/components/Forms/InputRange/InputRange.test.js new file mode 100644 index 000000000..64639d2d1 --- /dev/null +++ b/components/Forms/InputRange/InputRange.test.js @@ -0,0 +1,37 @@ +import React from 'react'; +import { render } from 'react-dom'; + +import InputRange from './InputRange'; + +describe('Default InputRange', () => { + it('renders without crashing', () => { + const div = document.createElement('div'); + render( + , + div + ); + }); +}); + +describe('Multi InputRange', () => { + it('renders without crashing', () => { + const div = document.createElement('div'); + render( + , + div + ); + }); +}); \ No newline at end of file diff --git a/components/Forms/InputRange/transformStepValues.js b/components/Forms/InputRange/transformStepValues.js new file mode 100644 index 000000000..4539e2032 --- /dev/null +++ b/components/Forms/InputRange/transformStepValues.js @@ -0,0 +1,77 @@ +import React, { PropTypes, Component } from 'react'; +import noop from '../../../utils/noop'; + +export const getDomainValue = (rawValue, steps) => { + if (typeof rawValue === 'object') { + return { + min: steps[rawValue.min], + max: steps[rawValue.max], + }; + } + + return steps[rawValue]; +}; + +export const getRawValue = (domainValue, steps) => { + if (typeof domainValue === 'object') { + return { + min: steps.indexOf(domainValue.min), + max: steps.indexOf(domainValue.max), + }; + } + + return steps.indexOf(domainValue); +}; + +const transformStepValues = WrappedComponent => steps => class extends Component { + static propTypes = { + onChange: PropTypes.func, + onChangeComplete: PropTypes.func, + value: PropTypes.oneOfType([ + PropTypes.number, + PropTypes.shape({ + min: PropTypes.number, + max: PropTypes.number, + }), + ]), + maxValue: PropTypes.number, + minValue: PropTypes.number, + }; + + static defaultProps = { + onChange: noop, + onChangeComplete: noop, + }; + + handleChange = (e, name, rawValue) => { + const { onChange } = this.props; + onChange(e, name, getDomainValue(rawValue, steps)); + }; + + handleChangeComplete = (e, name, rawValue) => { + const { onChangeComplete } = this.props; + onChangeComplete(e, name, getDomainValue(rawValue, steps)); + }; + + render() { + const { maxValue, minValue, value } = this.props; + + const rawMaxValue = getRawValue(maxValue, steps); + const rawMinValue = getRawValue(minValue, steps); + const rawValues = getRawValue(value, steps); + + return ( + { this.component = c; } } + onChange={ this.handleChange } + onChangeComplete={ this.handleChangeComplete } + /> + ); + } +}; + +export default transformStepValues; \ No newline at end of file diff --git a/components/Forms/InputRange/transformStepValues.test.js b/components/Forms/InputRange/transformStepValues.test.js new file mode 100644 index 000000000..619ef38d7 --- /dev/null +++ b/components/Forms/InputRange/transformStepValues.test.js @@ -0,0 +1,189 @@ +/* global jasmine:true */ +import React from 'react'; +import { render } from 'react-dom'; + +import InputRange from './InputRange'; +import transformStepValues, { + getDomainValue, + getRawValue, +} from './transformStepValues'; + +const steps = [ + 0, + 2, + 4, + 8, + 16, + 32, + 64, +]; + +describe('getDomainValue', () => { + it('maps a value to a step', () => { + const rawValue = 4; + const domainValue = getDomainValue(rawValue, steps); + expect(domainValue).toEqual(16); + }); + + it('maps a min and max to steps', () => { + const rawValue = { + min: 0, + max: 6, + }; + + const domainValue = getDomainValue(rawValue, steps); + + expect(domainValue).toEqual({ + min: 0, + max: 64, + }); + }); +}); + +describe('getRawValue', () => { + it('maps domain value (step) to a raw value', () => { + const domainValue = 16; + const rawValue = getRawValue(domainValue, steps); + expect(rawValue).toEqual(4); + }); + + it('maps min and max domain values (steps) to their raw values', () => { + const domainValue = { + min: 0, + max: 16, + }; + + const rawValue = getRawValue(domainValue, steps); + expect(rawValue).toEqual({ + min: 0, + max: 4, + }); + }); +}); + +describe('transformStepValues higher order function', () => { + it('renders without crashing', () => { + const WrappedInputRange = transformStepValues(InputRange)(steps); + + const div = document.createElement('div'); + render( + , + div + ); + }); + + it('onChange returns a single mapped domain value', () => { + const value = 4; + let wrapperComponent; + + const WrappedInputRange = transformStepValues(InputRange)(steps); + const spy = jasmine.createSpy(); + const div = document.createElement('div'); + + render( + { wrapperComponent = c; } } + name="" + minValue={ steps[0] } + maxValue={ steps[6] } + value={ steps[value] } + onChange={ spy } + />, + div + ); + + wrapperComponent.component.handleChange(value); + expect(spy.calls.count()).toEqual(1); + expect(spy.calls.mostRecent().args[2]).toEqual(16); + }); + + it('onChange returns the mapped min and max domain values', () => { + const min = 1; + const max = 6; + let wrapperComponent; + + const WrappedInputRange = transformStepValues(InputRange)(steps); + + const changeSpy = jasmine.createSpy(); + + const div = document.createElement('div'); + render( + { wrapperComponent = c; } } + name="" + minValue={ steps[0] } + maxValue={ steps[6] } + value={ { min, max } } + onChange={ changeSpy } + />, + div + ); + + wrapperComponent.component.handleChange({ min, max }); + expect(changeSpy.calls.count()).toEqual(1); + expect(changeSpy.calls.mostRecent().args[2]).toEqual({ + min: 2, + max: 64, + }); + }); + + it('onChangeComplete returns a single mapped domain value', () => { + const value = 4; + let wrapperComponent; + + const WrappedInputRange = transformStepValues(InputRange)(steps); + const changeCompleteSpy = jasmine.createSpy(); + const div = document.createElement('div'); + + render( + { wrapperComponent = c; } } + name="" + minValue={ steps[0] } + maxValue={ steps[6] } + value={ value } + onChangeComplete={ changeCompleteSpy } + />, + div + ); + + wrapperComponent.component.handleChangeComplete(value); + expect(changeCompleteSpy.calls.count()).toEqual(1); + expect(changeCompleteSpy.calls.mostRecent().args[2]).toEqual(16); + }); + + it('onChangeComplete returns the mapped min and max domain values', () => { + const min = 1; + const max = 6; + let wrapperComponent; + + const WrappedInputRange = transformStepValues(InputRange)(steps); + + const changeCompleteSpy = jasmine.createSpy(); + + const div = document.createElement('div'); + render( + { wrapperComponent = c; } } + name="" + minValue={ steps[0] } + maxValue={ steps[6] } + value={ { min, max } } + onChangeComplete={ changeCompleteSpy } + />, + div + ); + + wrapperComponent.component.handleChangeComplete({ min, max }); + expect(changeCompleteSpy.calls.count()).toEqual(1); + expect(changeCompleteSpy.calls.mostRecent().args[2]).toEqual({ + min: 2, + max: 64, + }); + }); +}); \ No newline at end of file diff --git a/package.json b/package.json index 473527a2f..79aede144 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "whatwg-fetch": "1.0.0" }, "dependencies": { + "@appearhere/react-input-range": "^1.0.0", "array-from": "^2.1.1", "classnames": "^2.2.5", "commonmark": "^0.26.0", diff --git a/yarn.lock b/yarn.lock index e8d6d91ed..e063c732a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,10 @@ # yarn lockfile v1 +"@appearhere/react-input-range@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@appearhere/react-input-range/-/react-input-range-1.0.0.tgz#c4cf748211de55e9602053e414e63744315b612c" + "@kadira/react-split-pane@^1.4.0": version "1.4.7" resolved "https://registry.yarnpkg.com/@kadira/react-split-pane/-/react-split-pane-1.4.7.tgz#6d753d4a9fe62fe82056e323a6bcef7f026972b5" @@ -323,7 +327,7 @@ async-each@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d" -async@0.9.0: +async@0.9.0, async@^0.9.0: version "0.9.0" resolved "https://registry.yarnpkg.com/async/-/async-0.9.0.tgz#ac3613b1da9bed1b47510bb4651b8931e47146c7" @@ -331,10 +335,6 @@ async@1.x, async@^1.3.0, async@^1.4.0, async@^1.4.2, async@^1.5.0: version "1.5.2" resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" -async@^0.9.0: - version "0.9.2" - resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d" - async@~0.2.6: version "0.2.10" resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1"