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"