From 8b571fbc4d64f2022675dd1fc2c4135e9aa43f05 Mon Sep 17 00:00:00 2001 From: Ian Fleming Date: Wed, 14 Jun 2017 10:43:52 -0500 Subject: [PATCH] feat(slider): add slider component (#146) * feat(slider): WIP adding slider component * feat(slider): add correct styling * feat(slider): make filled track follow the thumb position * feat(slider): WIP math * feat(slider): WIP math, and add support for track click * feat(slider): WIP rounding math * feat(slider): fix rounding math * feat(slider): styling and text input option * feat(slider): add eventedState * feat(slider): refactor js * feat(slider): add support for shiftKey as a modifier * feat(slider): js cleanup * feat(slider): make stepMulitplier a configurable option * test(slider): WIP slider tests * feat(slider): fix focus/active states, and update tests * feat(slider): fix shiftKey multiplied math, tests * test(slider): fix slider tests raf bug * feat(slider): add min/max labels * test(slider): WIP fix raf conflict * test(slider): remove raf stub conflict * feat(slider): fix misaligned track * feat(slider): fix support for disabled slider * fix(slider): fix merge conflict * docs(slider): re-add README * fix(slider): fix typo failing tests * fix(slider): PR fixes * fix(slider): fix visual PR bug --- package.json | 1 + src/components/slider/README.md | 34 +++++ src/components/slider/_slider.scss | 115 ++++++++++++++++ src/components/slider/slider.html | 16 +++ src/components/slider/slider.js | 206 +++++++++++++++++++++++++++++ src/globals/scss/styles.scss | 1 + src/index.js | 7 + tests/spec/slider_spec.js | 159 ++++++++++++++++++++++ yarn.lock | 10 +- 9 files changed, 547 insertions(+), 2 deletions(-) create mode 100644 src/components/slider/README.md create mode 100644 src/components/slider/_slider.scss create mode 100644 src/components/slider/slider.html create mode 100644 src/components/slider/slider.js create mode 100644 tests/spec/slider_spec.js diff --git a/package.json b/package.json index 7d24a5887628..f69d436d9447 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "minimatch": "^3.0.0", "minimist": "~1.2.0", "mocha": "~2.4.4", + "mock-raf": "^1.0.0", "nodemon": "1.9.1", "phantomjs-prebuilt": "^2.1.3", "pump": "^1.0.2", diff --git a/src/components/slider/README.md b/src/components/slider/README.md new file mode 100644 index 000000000000..80a7bed1b5a9 --- /dev/null +++ b/src/components/slider/README.md @@ -0,0 +1,34 @@ +### SCSS + +#### Modifiers + +Use these modifiers with `.bx--slider` class. + +| Selector | Description | +|--------------------------|---------------------------------------------------------------------| +| .bx--slider--disabled | Applies disabled styling and prevents JS from running on user input | + +### Javascript + +#### Options + +| Option | Default Selector | Description | +|--------------------------------|------------------------------|---------------------------------------------------------------------------| +| `selectorInit` | `[data-slider]` | The selector to find the Slider element. | +| `selectorTrack` | `.bx--slider__track` | The selector to find the Slider track element. | +| `selectorFilledTrack` | `.bx--slider__filled-track` | The selector to find the Slider filled track element. | +| `selectorThumb` | `.bx--slider__thumb` | The selector to find the Slider thumb element. | +| `selectorInput` | `.bx--slider__input` | The selector to find the Slider input element. | +| `eventBeforeSliderValueChange` | `slider-before-value-change` | The event emitted before the Slider value changes. | +| `eventAfterSliderValueChange` | `slider-after-value-change` | The event emitted when the Slider value changes. | +| `stepMultiplier` | `4` | The multiplier applied to arrow key inputs when the shift key is pressed. | + +### FAQ + +#### Keydown event + +Once `Slider` instance is initialized a user can use the following keys to control the slider. + +- `up` and `right` arrow keys increase the slider value by one step +- `down` and `left` arrow keys decrease the slider value by one step +- Pressing `shift` while changing the value of the slider will multiple the change by `Slider.options.stepMultiplier` (the default is 4) diff --git a/src/components/slider/_slider.scss b/src/components/slider/_slider.scss new file mode 100644 index 000000000000..306992477223 --- /dev/null +++ b/src/components/slider/_slider.scss @@ -0,0 +1,115 @@ +//----------------------------- +// Slider +//----------------------------- + +@import '../../globals/scss/colors'; +@import '../../globals/scss/helper-mixins'; +@import '../../globals/scss/layer'; +@import '../../globals/scss/typography'; +@import '../../globals/scss/layout'; +@import '../../globals/scss/import-once'; +@import '../form/form'; +@import '../text-input/text-input'; + +@include exports('slider') { + .bx--slider-container { + max-width: rem(600px); + min-width: rem(200px); + display: flex; + align-items: center; + user-select: none; + + @media screen and (min-width: 768px) { + min-width: rem(350px); + } + } + + .bx--slider { + position: relative; + width: 100%; + margin: 0 1rem; + } + + .bx--slider--disabled { + opacity: .5; + } + + .bx--slider--disabled .bx--slider__thumb { + &:hover { + transform: translate(-50%, -50%); + } + &:focus { + box-shadow: none; + outline: none; + } + &:active { + background: $brand-01; + transform: translate(-50%, -50%); + } + } + + .bx--slider__range-label { + @include font-size('14'); + color: $text-02; + + &:last-of-type { + margin-right: 1rem; + } + } + + .bx--slider__track { + position: absolute; + width: 100%; + height: rem(4px); + background: $ui-05; + cursor: pointer; + transform: translate(0%, -50%); + } + + .bx--slider__filled-track { + position: absolute; + width: 100%; + height: rem(4px); + background: $brand-01; + transform-origin: left; + pointer-events: none; + transform: translate(0%, -50%); + } + + .bx--slider__thumb { + position: absolute; + height: rem(24px); + width: rem(24px); + background: $brand-01; + border-radius: 50%; + top: 0; + transform: translate(-50%, -50%); + transition: transform 100ms $bx--standard-easing, background 100ms $bx--standard-easing; + cursor: pointer; + outline: none; + + &:hover { + transform: translate(-50%, -50%) scale(1.05); + } + &:focus { + @include focus-outline('blurred'); + } + &:active { + background: darken($brand-01, 5%); + transform: translate(-50%, -50%) scale(1.25); + } + } + + .bx--slider__input { + display: none; + } + + .bx-slider-text-input { + max-width: rem(32px); + min-width: 0; + height: 2rem; + padding: 0; + text-align: center; + font-weight: 700; + } +} diff --git a/src/components/slider/slider.html b/src/components/slider/slider.html new file mode 100644 index 000000000000..6da828ff34e3 --- /dev/null +++ b/src/components/slider/slider.html @@ -0,0 +1,16 @@ +
+ +
+
+ 0 +
+
+
+
+ +
+ 100 + +
+
+
diff --git a/src/components/slider/slider.js b/src/components/slider/slider.js new file mode 100644 index 000000000000..ff67ff1b0048 --- /dev/null +++ b/src/components/slider/slider.js @@ -0,0 +1,206 @@ +import mixin from '../../globals/js/misc/mixin'; +import createComponent from '../../globals/js/mixins/create-component'; +import initComponentBySearch from '../../globals/js/mixins/init-component-by-search'; +import eventedState from '../../globals/js/mixins/evented-state'; +import on from '../../globals/js/misc/on'; + +class Slider extends mixin(createComponent, initComponentBySearch, eventedState) { + /** + * Slider. + * @extends CreateComponent + * @extends InitComponentBySearch + * @param {HTMLElement} element The element working as an slider. + */ + constructor(element, options) { + super(element, options); + + this.sliderActive = false; + this.dragging = false; + + this.track = this.element.querySelector(this.options.selectorTrack); + this.filledTrack = this.element.querySelector(this.options.selectorFilledTrack); + this.thumb = this.element.querySelector(this.options.selectorThumb); + this.input = this.element.querySelector(this.options.selectorInput); + + if (this.element.dataset.sliderInputBox) { + this.boundInput = this.element.ownerDocument.querySelector(this.element.dataset.sliderInputBox); + this._updateInput(); + this.boundInput.addEventListener('change', (evt) => { this.setValue(evt.target.value); }); + } + + this._updatePosition(); + + this.thumb.addEventListener('mousedown', () => { this.sliderActive = true; }); + this.hDocumentMouseUp = on(this.element.ownerDocument, 'mouseup', () => { this.sliderActive = false; }); + this.hDocumentMouseMove = on(this.element.ownerDocument, 'mousemove', (evt) => { + const disabled = this.element.classList.contains('bx--slider--disabled'); + if (this.sliderActive === true && !disabled) { + this._updatePosition(evt); + } + }); + this.thumb.addEventListener('keydown', (evt) => { + const disabled = this.element.classList.contains('bx--slider--disabled'); + if (!disabled) { + this._updatePosition(evt); + } + }); + this.track.addEventListener('click', (evt) => { + const disabled = this.element.classList.contains('bx--slider--disabled'); + if (!disabled) { + this._updatePosition(evt); + } + }); + } + + _changeState = (state, detail, callback) => { + callback(); + } + + + _updatePosition(evt) { + const { + left, + newValue, + } = this._calcValue(evt); + + + if (this.dragging) { + return; + } + + this.dragging = true; + + requestAnimationFrame(() => { + this.dragging = false; + this.thumb.style.left = `${left}%`; + this.filledTrack.style.transform = `translate(0%, -50%) scaleX(${left / 100})`; + this.input.value = newValue; + this._updateInput(); + this.changeState('slider-value-change', { value: newValue }); + }); + } + + _calcValue(evt) { + const { + value, + min, + max, + step, + } = this.getInputProps(); + + const range = max - min; + const valuePercentage = (((value - min) / range) * 100); + + let left; + let newValue; + left = valuePercentage; + newValue = value; + + if (evt) { + const { type } = evt; + + if (type === 'keydown') { + const direction = { + 40: -1, // decreasing + 37: -1, // decreasing + 38: 1, // increasing + 39: 1, // increasing + }[evt.which]; + + if (direction !== undefined) { + const multiplier = evt.shiftKey === true + ? (range / step) / this.options.stepMultiplier + : 1; + const stepMultiplied = step * multiplier; + const stepSize = (stepMultiplied / range) * 100; + left = valuePercentage + (stepSize * direction); + newValue = Number(value) + (stepMultiplied * direction); + } + } + if (type === 'mousemove' || type === 'click') { + const track = this.track.getBoundingClientRect(); + const unrounded = ((evt.clientX - track.left) / track.width); + const rounded = Math.round(((range * unrounded) / step)) * step; + left = (((rounded - min) / range) * 100); + newValue = rounded; + } + } + + if (newValue <= Number(min)) { + left = 0; + newValue = min; + } + if (newValue >= Number(max)) { + left = 100; + newValue = max; + } + + return { left, newValue }; + } + + _updateInput() { + if (this.boundInput) { + this.boundInput.value = this.input.value; + } + } + + getInputProps() { + const values = { + value: this.input.value, + min: this.input.min, + max: this.input.max, + step: this.input.step ? this.input.step : 1, + }; + return values; + } + + setValue(value) { + this.input.value = value; + this._updatePosition(); + } + + stepUp() { + this.input.stepUp(); + this._updatePosition(); + } + + stepDown() { + this.input.stepDown(); + this._updatePosition(); + } + + release() { + if (this.hDocumentMouseUp) { + this.hDocumentMouseUp = this.hDocumentMouseUp.release(); + } + if (this.hDocumentMouseMove) { + this.hDocumentMouseMove = this.hDocumentMouseMove.release(); + } + super.release(); + } + + /** + * The map associating DOM element and Slider UI instance. + * @type {WeakMap} + */ + static components = new WeakMap(); + + /** + * The component options. + * If `options` is specified in the constructor, + * properties in this object are overriden for the instance being created. + * @property {string} selectorInit The CSS selector to find slider instances. + */ + static options = { + selectorInit: '[data-slider]', + selectorTrack: '.bx--slider__track', + selectorFilledTrack: '.bx--slider__filled-track', + selectorThumb: '.bx--slider__thumb', + selectorInput: '.bx--slider__input', + eventBeforeSliderValueChange: 'slider-before-value-change', + eventAfterSliderValueChange: 'slider-after-value-change', + stepMultiplier: 4, + } +} + +export default Slider; diff --git a/src/globals/scss/styles.scss b/src/globals/scss/styles.scss index baaca0906761..176c827cfc70 100644 --- a/src/globals/scss/styles.scss +++ b/src/globals/scss/styles.scss @@ -65,6 +65,7 @@ $css--typography: true !default; @import '../../components/breadcrumb/breadcrumb'; @import '../../components/toolbar/toolbar'; @import '../../components/time-picker/time-picker'; +@import '../../components/slider/slider'; //------------------------------------- // 🙈 Hidden (Not exposed on website) diff --git a/src/index.js b/src/index.js index 32431a5a184c..7669e4ea47e5 100644 --- a/src/index.js +++ b/src/index.js @@ -35,6 +35,7 @@ import Tooltip from './components/tooltip/tooltip'; import ProgressIndicator from './components/progress-indicator/progress-indicator'; import FloatingMenu from './components/floating-menu/floating-menu'; import StructuredList from './components/structured-list/structured-list'; +import Slider from './components/slider/slider'; const settings = {}; @@ -195,6 +196,11 @@ export { * @type DatePicker */ DatePicker, + /** + * Slider. + * @type Slider + */ + Slider, }; /** @@ -235,6 +241,7 @@ const init = () => { ProgressIndicator.init(); StructuredList.init(); DatePicker.init(); + Slider.init(); // Floating menu instances are created by Tooltip, etc. and thus not for automatic instantiation } }; diff --git a/tests/spec/slider_spec.js b/tests/spec/slider_spec.js new file mode 100644 index 000000000000..4c6f8de55ef8 --- /dev/null +++ b/tests/spec/slider_spec.js @@ -0,0 +1,159 @@ +import 'core-js/modules/es6.weak-map'; // For PhantomJS +import createMockRaf from 'mock-raf'; +import Slider from '../../src/components/slider/slider'; +import SliderHTML from '../../src/components/slider/slider.html'; + +describe('Test slider', function () { + describe('Constructor', function () { + let slider; + + it('Should throw if root element is not given', function () { + expect(() => { + slider = new Slider(); + }).to.throw(Error); + }); + + it('Should throw if root element is not a DOM element', function () { + expect(() => { + slider = new Slider(document.createTextNode('')); + }).to.throw(Error); + }); + + it('Should set default options', function () { + const container = document.createElement('div'); + container.innerHTML = SliderHTML; + document.body.appendChild(container); + slider = new Slider(document.querySelector('[data-slider]')); + + expect(slider.options).to.deep.equal({ + selectorInit: '[data-slider]', + selectorTrack: '.bx--slider__track', + selectorFilledTrack: '.bx--slider__filled-track', + selectorThumb: '.bx--slider__thumb', + selectorInput: '.bx--slider__input', + eventBeforeSliderValueChange: 'slider-before-value-change', + eventAfterSliderValueChange: 'slider-after-value-change', + stepMultiplier: 4, + }); + }); + + afterEach(function () { + if (slider) { + slider = slider.release(); + } + }); + }); + describe('Programatic change', function () { + let slider; + let container; + beforeEach(function () { + container = document.createElement('div'); + container.innerHTML = SliderHTML; + document.body.appendChild(container); + slider = new Slider(document.querySelector('[data-slider]')); + }); + it('Should setValue as expected', function () { + slider.setValue(100); + expect(slider.getInputProps().value).to.equal('100'); + }); + it('Should stepUp as expected', function () { + slider.setValue(50); + slider.stepUp(); + expect(slider.getInputProps().value).to.equal('51'); + }); + it('Should stepDown as expected', function () { + slider.setValue(50); + slider.stepDown(); + expect(slider.getInputProps().value).to.equal('49'); + }); + afterEach(function () { + if (slider) { + slider = slider.release(); + document.body.innerHTML = ''; + } + }); + }); + describe('Keydown on slider', function () { + let container; + let slider; + let thumb; + let mockRaf; + let rafStub; + beforeEach(function () { + mockRaf = createMockRaf(); + rafStub = sinon.stub(window, 'requestAnimationFrame').callsFake(mockRaf.raf); + container = document.createElement('div'); + container.innerHTML = SliderHTML; + document.body.appendChild(container); + slider = new Slider(document.querySelector('[data-slider]')); + thumb = document.querySelector('.bx--slider__thumb'); + mockRaf.step({ count: 1 }); + }); + it('Should stepUp value on up/right key', function () { + const event = new CustomEvent('keydown', { bubbles: true }); + event.which = 39; + thumb.dispatchEvent(event); + mockRaf.step({ count: 1 }); + expect(slider.getInputProps().value).to.equal('51'); + event.which = 38; + thumb.dispatchEvent(event); + mockRaf.step({ count: 1 }); + expect(slider.getInputProps().value).to.equal('52'); + }); + it('Should stepDown value on down/left key', function () { + const event = new CustomEvent('keydown', { bubbles: true }); + event.which = 40; + thumb.dispatchEvent(event); + mockRaf.step({ count: 1 }); + expect(slider.getInputProps().value).to.equal('49'); + event.which = 37; + thumb.dispatchEvent(event); + mockRaf.step({ count: 1 }); + expect(slider.getInputProps().value).to.equal('48'); + }); + afterEach(function () { + if (mockRaf) { + rafStub.restore(); + rafStub = null; + } + if (slider) { + slider = slider.release(); + document.body.innerHTML = ''; + } + }); + }); + describe('Click on slider', function () { + let container; + let slider; + let track; + let mockRaf; + let rafStub; + beforeEach(function () { + mockRaf = createMockRaf(); + rafStub = sinon.stub(window, 'requestAnimationFrame').callsFake(mockRaf.raf); + container = document.createElement('div'); + container.innerHTML = SliderHTML; + document.body.appendChild(container); + slider = new Slider(document.querySelector('[data-slider]')); + track = document.querySelector('.bx--slider__track'); + mockRaf.step({ count: 1 }); + }); + it('Should change value on click', function () { + const event = new CustomEvent('click', { bubbles: true }); + event.clientX = 0; + track.dispatchEvent(event); + mockRaf.step({ count: 1 }); + expect(slider.getInputProps().value).to.equal('0'); + }); + afterEach(function () { + if (mockRaf) { + rafStub.restore(); + rafStub = null; + } + if (slider) { + slider = slider.release(); + document.body.innerHTML = ''; + } + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index cf5b8a70a199..77c351ea31cd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3491,8 +3491,8 @@ jsprim@^1.2.2: verror "1.3.6" karma-chrome-launcher@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/karma-chrome-launcher/-/karma-chrome-launcher-2.0.0.tgz#c2790c5a32b15577d0fff5a4d5a2703b3b439c25" + version "2.1.1" + resolved "https://registry.yarnpkg.com/karma-chrome-launcher/-/karma-chrome-launcher-2.1.1.tgz#216879c68ac04d8d5140e99619ba04b59afd46cf" dependencies: fs-access "^1.0.0" which "^1.2.1" @@ -4185,6 +4185,12 @@ mocha@~2.4.4: mkdirp "0.5.1" supports-color "1.2.0" +mock-raf@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/mock-raf/-/mock-raf-1.0.0.tgz#a288145178893e2040b230f2182fee2049f16f25" + dependencies: + object-assign "^3.0.0" + moment@^2.14.1: version "2.18.1" resolved "https://registry.yarnpkg.com/moment/-/moment-2.18.1.tgz#c36193dd3ce1c2eed2adb7c802dbbc77a81b1c0f"